fix: support multi-customer overseas receiving containers

This commit is contained in:
2026-09-09 21:30:39 +08:00
parent 6737c4c327
commit a05b263ca8
25 changed files with 199 additions and 181 deletions
+2 -2
View File
@@ -16,9 +16,9 @@
| 事件类型与区域 | `EventTypes.java`、`RegionCode.java` | | 事件类型与区域 | `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` | | 头程装柜完成 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 解析模型 | `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 | `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 | `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 回传结果,双方以事件与业务幂等键防重,不跨区域同步调用钱包 | | 海外业务资金指令与结果 V1 | `WalletCommandRequestedV1.java`、`WalletCommandCompletedV1.java`;海外业务通过 Outbox 请求国内 Finance 冻结、结算或释放,Finance 执行后再经 Outbox 回传结果,双方以事件与业务幂等键防重,不跨区域同步调用钱包 |
| IAM 安全事件 V1 | `IamSecurityEventsV1.java`;账号、授权快照等安全事件的冻结模型 | | IAM 安全事件 V1 | `IamSecurityEventsV1.java`;账号、授权快照等安全事件的冻结模型 |
@@ -7,7 +7,6 @@ import java.util.Map;
/** Actual carrier milestone recorded by Firstleg for overseas receiving readiness. */ /** Actual carrier milestone recorded by Firstleg for overseas receiving readiness. */
public record FirstLegContainerShippingNodeRecordedV1( public record FirstLegContainerShippingNodeRecordedV1(
String queryId, String queryId,
String customerId,
String oceanBookingId, String oceanBookingId,
@JsonProperty("sourceContainerBookingId") String containerBookingId, @JsonProperty("sourceContainerBookingId") String containerBookingId,
String containerNo, String containerNo,
@@ -29,8 +28,7 @@ public record FirstLegContainerShippingNodeRecordedV1(
"EMPTY_RETURNED", 100); "EMPTY_RETURNED", 100);
public FirstLegContainerShippingNodeRecordedV1 { public FirstLegContainerShippingNodeRecordedV1 {
if (queryId == null || queryId.isBlank() || customerId == null || customerId.isBlank() if (queryId == null || queryId.isBlank() || oceanBookingId == null || oceanBookingId.isBlank()
|| oceanBookingId == null || oceanBookingId.isBlank()
|| containerBookingId == null || containerBookingId.isBlank() || containerBookingId == null || containerBookingId.isBlank()
|| containerNo == null || containerNo.isBlank()) { || containerNo == null || containerNo.isBlank()) {
throw new IllegalArgumentException("船节点交接业务标识不能为空"); throw new IllegalArgumentException("船节点交接业务标识不能为空");
@@ -29,7 +29,7 @@ class FirstLegContainerShippingNodeRecordedV1Test {
@Test @Test
void rejectsNonCanonicalPhysicalContainerNumber() { void rejectsNonCanonicalPhysicalContainerNumber() {
assertThatThrownBy(() -> new FirstLegContainerShippingNodeRecordedV1( 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"), Instant.parse("2026-08-25T01:00:00Z"), Instant.parse("2026-08-25T01:00:00Z"),
null, null, "ZIM", "evt", "AVAILABLE", "ZIM:evt", "a".repeat(64))) null, null, "ZIM", "evt", "AVAILABLE", "ZIM:evt", "a".repeat(64)))
.hasMessageContaining("normalized"); .hasMessageContaining("normalized");
@@ -38,13 +38,13 @@ class FirstLegContainerShippingNodeRecordedV1Test {
@Test @Test
void rejectsEstimatedOnlyOrIncompleteProviderFacts() { void rejectsEstimatedOnlyOrIncompleteProviderFacts() {
assertThatThrownBy(() -> new FirstLegContainerShippingNodeRecordedV1( 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, Instant.parse("2026-08-25T01:00:00Z"), null, null, null,
"ZIM", "evt-1", "Carrier Release", "ZIM:evt-1", "a".repeat(64))) "ZIM", "evt-1", "Carrier Release", "ZIM:evt-1", "a".repeat(64)))
.isInstanceOf(IllegalArgumentException.class).hasMessageContaining("实际时间"); .isInstanceOf(IllegalArgumentException.class).hasMessageContaining("实际时间");
assertThatThrownBy(() -> new FirstLegContainerShippingNodeRecordedV1( 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"), 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))) null, null, "ZIM", "evt-2", "ARRIVED", "ZIM:evt-2", "b".repeat(64)))
.isInstanceOf(IllegalArgumentException.class); .isInstanceOf(IllegalArgumentException.class);
@@ -52,7 +52,7 @@ class FirstLegContainerShippingNodeRecordedV1Test {
private static FirstLegContainerShippingNodeRecordedV1 payload() { private static FirstLegContainerShippingNodeRecordedV1 payload() {
return new FirstLegContainerShippingNodeRecordedV1( return new FirstLegContainerShippingNodeRecordedV1(
"2088000000000000001", "2088000000000000002", "2088000000000000004", "2088000000000000001", "2088000000000000004",
"2088000000000000003", "ZCSU7881970", "PICKUP_READY", 80, "2088000000000000003", "ZCSU7881970", "PICKUP_READY", 80,
Instant.parse("2026-08-25T01:02:00Z"), Instant.parse("2026-08-25T01:00:00Z"), Instant.parse("2026-08-25T01:02:00Z"), Instant.parse("2026-08-25T01:00:00Z"),
"USLAX", "Los Angeles", "ZIM", "evt-1", "AVAILABLE", "USLAX", "Los Angeles", "ZIM", "evt-1", "AVAILABLE",
+1 -1
View File
@@ -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` | | 国内货柜履约海运订舱、资料/打印、关联候选与出仓前船期资料 | `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` | | 海外仓业务国内出仓后船节点定时查询 | `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`;首次选队不发信,换队事务内生成取消与请求两封邮件。 | | 目的港码头拖车与还空预警 | `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` 前缀,按北京时间每日独立编号;具体业务归属以各调用服务为准 | | 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 完成不推进拖车状态 | | 订舱级拖车与整柜出仓调度 | `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` | | 整柜出口报关 | `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` |
@@ -44,7 +44,7 @@ public class CarrierScheduleNodeOutboxService {
} }
ZoneId zone = ZoneId.of(scheduleZone); ZoneId zone = ZoneId.of(scheduleZone);
var payload = new FirstLegContainerShippingNodeRecordedV1( var payload = new FirstLegContainerShippingNodeRecordedV1(
string(query.getId()), string(query.getCustomerId()), string(query.getOceanBookingId()), string(query.getId()), string(query.getOceanBookingId()),
string(query.getContainerBookingId()), normalizedContainerNo, string(query.getContainerBookingId()), normalizedContainerNo,
fact.getStandardNode(), fact.getNodeSequence(), fact.getStandardNode(), fact.getNodeSequence(),
fact.getObservedAt().atZone(zone).toInstant(), fact.getActualAt().atZone(zone).toInstant(), fact.getObservedAt().atZone(zone).toInstant(), fact.getActualAt().atZone(zone).toInstant(),
@@ -57,12 +57,12 @@ public class CarrierScheduleNodeOutboxService {
EventEnvelope envelope = new EventEnvelope(EventIdentifiers.newUuid(), EventEnvelope envelope = new EventEnvelope(EventIdentifiers.newUuid(),
EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED, 1, EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED, 1,
fact.getActualAt().atZone(zone).toInstant(), RegionCode.CN, RegionCode.OVERSEAS, 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, aggregateVersion, aggregateVersion + 1, EventIdentifiers.newUuid(), eventKey, null,
payloadNode, payloadHash).requireValid(); payloadNode, payloadHash).requireValid();
InboundOutboxEntity row = new InboundOutboxEntity(); 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.setAggregateType(envelope.aggregateType()); row.setAggregateId(query.getContainerBookingId());
row.setEventType(envelope.eventType()); row.setEventKey(eventKey); row.setEventVersion(1); row.setEventType(envelope.eventType()); row.setEventKey(eventKey); row.setEventVersion(1);
row.setAggregateSequence(Math.toIntExact(aggregateVersion)); row.setPayloadHash(payloadHash); row.setAggregateSequence(Math.toIntExact(aggregateVersion)); row.setPayloadHash(payloadHash);
@@ -50,6 +50,8 @@ class CarrierScheduleNodeOutboxServiceTest {
assertThat(row.getEventType()).isEqualTo(EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED); assertThat(row.getEventType()).isEqualTo(EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED);
assertThat(envelope.eventVersion()).isEqualTo(1); assertThat(envelope.eventVersion()).isEqualTo(1);
assertThat(envelope.aggregateType()).isEqualTo("CONTAINER_BOOKING"); assertThat(envelope.aggregateType()).isEqualTo("CONTAINER_BOOKING");
assertThat(envelope.tenantId()).isEqualTo("0");
assertThat(row.getTenantId()).isZero();
assertThat(envelope.aggregateId()).isEqualTo("2088000000000000003"); assertThat(envelope.aggregateId()).isEqualTo("2088000000000000003");
assertThat(envelope.aggregateVersion()).isEqualTo(7); assertThat(envelope.aggregateVersion()).isEqualTo(7);
assertThat(envelope.handoverVersion()).isEqualTo(8); assertThat(envelope.handoverVersion()).isEqualTo(8);
@@ -57,6 +59,7 @@ class CarrierScheduleNodeOutboxServiceTest {
assertThat(envelope.payload().path("sourceContainerBookingId").asText()) assertThat(envelope.payload().path("sourceContainerBookingId").asText())
.isEqualTo("2088000000000000003"); .isEqualTo("2088000000000000003");
assertThat(envelope.payload().has("containerBookingId")).isFalse(); assertThat(envelope.payload().has("containerBookingId")).isFalse();
assertThat(envelope.payload().has("customerId")).isFalse();
assertThat(envelope.payload().path("actualAt").asText()).isEqualTo("2026-08-25T01:00:00Z"); assertThat(envelope.payload().path("actualAt").asText()).isEqualTo("2026-08-25T01:00:00Z");
} }
+2 -2
View File
@@ -35,7 +35,7 @@
| 入仓后 SKU 库存余额、库位和流水 | `inventory/`;平台只读查询与非出库库存命令分别从 `inventory/query/controller/InventoryQueryController.java`、`inventory/command/controller/InventoryCommandController.java` 进入 | | 入仓后 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/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 仓内通用扫码与操作历史 | `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`,查询、预排与执行边界见“海外仓全流程开发路由” | | 平台排车列表/详情与智能预排 | `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` | | 排车编辑、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` | | 仓储资源调用 | `inboundplan/pda/gateway/WarehouseResourceGateway.java`、`HttpWarehouseResourceGateway.java` |
@@ -95,7 +95,7 @@
- Lastleg 安全投影只接受 `ACTIVE`/`DISABLED` 两态安全语义;客户建档和审核的 `PROFILE_REQUIRED`/`PENDING`/`REJECTED`/`ARCHIVED` 由 IAM 在发布前统一折叠为 `DISABLED`,Lastleg 不兼容或解释国内工作流状态。 - 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 事件单调覆盖。不得扩大为历史账号运行时补齐或缺失投影默认放行。 - 开发基线只为国内固定 `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 入仓任务完成前必须先通过 `/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 实收、托盘和库存始终使用入仓码所属票的真实客户。未完成会话按平台操作账号和柜号分别全局唯一;平台管理员和客户账号不得进入该作业入口。
## 查询与租户约束 ## 查询与租户约束
@@ -13,7 +13,6 @@ public class ReceivingContainerOperation {
@TableId(type = IdType.INPUT) @TableId(type = IdType.INPUT)
private Long id; private Long id;
private Long sessionId; private Long sessionId;
private String customerId;
private String operatorKey; private String operatorKey;
private String action; private String action;
private String idempotencyKey; private String idempotencyKey;
@@ -15,7 +15,6 @@ import java.time.LocalDateTime;
public class ReceivingContainerSession { public class ReceivingContainerSession {
@TableId(type = IdType.INPUT) @TableId(type = IdType.INPUT)
private Long id; private Long id;
private String customerId;
private String sourceContainerBookingId; private String sourceContainerBookingId;
private String containerFactHash; private String containerFactHash;
private String containerNo; private String containerNo;
@@ -546,33 +546,34 @@ public class InboundPdaWorkflowService {
@Transactional @Transactional
public PdaResponses.Task complete(String customerId, String planNo) { public PdaResponses.Task complete(String customerId, String planNo) {
InboundPlan plan = requirePlan(customerId, 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. */ /** Completes every plan of one physical container and emits exactly one container-level outbox event. */
@Transactional @Transactional
public List<PdaResponses.Task> completeContainer(String customerId, List<InboundPlan> containerPlans) { public List<PdaResponses.Task> completeContainer(List<InboundPlan> containerPlans) {
if (containerPlans == null || containerPlans.isEmpty()) throw conflict("Receiving container has no inbound plans"); if (containerPlans == null || containerPlans.isEmpty()) throw conflict("Receiving container has no inbound plans");
List<InboundPlan> orderedPlans = containerPlans.stream().sorted(Comparator.comparing(InboundPlan::getId)).toList(); List<InboundPlan> orderedPlans = containerPlans.stream().sorted(Comparator.comparing(InboundPlan::getId)).toList();
String containerNo = orderedPlans.get(0).getContainerNo(); String containerNo = orderedPlans.get(0).getContainerNo();
if (containerNo == null || containerNo.isBlank() || orderedPlans.stream().anyMatch(plan -> if (containerNo == null || containerNo.isBlank() || orderedPlans.stream().anyMatch(plan ->
!customerId.equals(plan.getCustomerId()) || !containerNo.equals(plan.getContainerNo()))) { plan.getCustomerId() == null || plan.getCustomerId().isBlank() || !containerNo.equals(plan.getContainerNo()))) {
throw conflict("Inbound plans must belong to one customer and one physical container"); throw conflict("Inbound plans must have an owning customer and belong to one physical container");
} }
List<CompletionContext> contexts = orderedPlans.stream().map(plan -> completionContext(customerId, plan)).toList(); List<CompletionContext> contexts = orderedPlans.stream()
.map(plan -> completionContext(plan.getCustomerId(), plan)).toList();
boolean anyCompleted = contexts.stream().anyMatch(context -> "COMPLETED".equals(context.task().getStatus())); boolean anyCompleted = contexts.stream().anyMatch(context -> "COMPLETED".equals(context.task().getStatus()));
boolean allCompleted = contexts.stream().allMatch(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 (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"); if (anyCompleted) throw conflict("Container plans are only partially completed; manual reconciliation is required");
LocalDateTime now = LocalDateTime.now(); 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); String sourceContainerBookingId = sourceContainerBookingId(contexts);
LocalDateTime photoUploadedAt = contexts.stream().map(context -> context.task().getUnloadPhotoUploadedAt()) LocalDateTime photoUploadedAt = contexts.stream().map(context -> context.task().getUnloadPhotoUploadedAt())
.max(LocalDateTime::compareTo).orElseThrow(); .max(LocalDateTime::compareTo).orElseThrow();
long aggregateVersion = contexts.stream().map(CompletionContext::task) long aggregateVersion = contexts.stream().map(CompletionContext::task)
.map(InboundPdaTask::getVersion).filter(Objects::nonNull).mapToLong(Integer::longValue).max().orElse(1L); .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); now.toInstant(ZoneOffset.UTC), photoUploadedAt.toInstant(ZoneOffset.UTC), aggregateVersion);
return contexts.stream().map(context -> task(context.task(), context.plan().getPlanNo())).toList(); return contexts.stream().map(context -> task(context.task(), context.plan().getPlanNo())).toList();
} }
@@ -47,7 +47,7 @@ public class InboundUnloadCompletedOutboxService {
if (task.getUnloadPhotoUploadedAt() == null) { if (task.getUnloadPhotoUploadedAt() == null) {
throw new IllegalStateException("Inbound task has no unload photo upload time"); 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), plan.getContainerNo(), task.getCompletedAt().toInstant(ZoneOffset.UTC),
task.getUnloadPhotoUploadedAt().toInstant(ZoneOffset.UTC), task.getUnloadPhotoUploadedAt().toInstant(ZoneOffset.UTC),
Math.max(1, task.getVersion() == null ? 1 : task.getVersion())); Math.max(1, task.getVersion() == null ? 1 : task.getVersion()));
@@ -56,10 +56,9 @@ public class InboundUnloadCompletedOutboxService {
/** /**
* 柜级唯一发布入口。调用方只能在同一物理柜的全部计划完成后调用一次;重复调用保留首次冻结事实。 * 柜级唯一发布入口。调用方只能在同一物理柜的全部计划完成后调用一次;重复调用保留首次冻结事实。
*/ */
public void publishContainerCompleted(String customerId, String sourceContainerBookingId, public void publishContainerCompleted(String sourceContainerBookingId,
String containerNo, Instant unloadingCompletedAt, String containerNo, Instant unloadingCompletedAt,
Instant unloadingPhotoUploadedAt, long aggregateVersion) { Instant unloadingPhotoUploadedAt, long aggregateVersion) {
requireText(customerId, "customerId");
requireText(sourceContainerBookingId, "sourceContainerBookingId"); requireText(sourceContainerBookingId, "sourceContainerBookingId");
requireText(containerNo, "containerNo"); requireText(containerNo, "containerNo");
Objects.requireNonNull(unloadingCompletedAt, "unloadingCompletedAt"); Objects.requireNonNull(unloadingCompletedAt, "unloadingCompletedAt");
@@ -71,7 +70,7 @@ public class InboundUnloadCompletedOutboxService {
String eventKey = EventTypes.OVERSEAS_CONTAINER_UNLOADED + ':' + sourceContainerBookingId; String eventKey = EventTypes.OVERSEAS_CONTAINER_UNLOADED + ':' + sourceContainerBookingId;
LastlegEventOutbox existing = mapper.findByEventKey(eventKey); LastlegEventOutbox existing = mapper.findByEventKey(eventKey);
if (existing != null) { if (existing != null) {
requireSameContainer(existing, customerId, sourceContainerBookingId, containerNo); requireSameContainer(existing, sourceContainerBookingId, containerNo);
return; return;
} }
var payload = new OverseasContainerUnloadedV1(sourceContainerBookingId, var payload = new OverseasContainerUnloadedV1(sourceContainerBookingId,
@@ -79,12 +78,12 @@ public class InboundUnloadCompletedOutboxService {
JsonNode payloadNode = objectMapper.valueToTree(payload); JsonNode payloadNode = objectMapper.valueToTree(payload);
String traceId = EventIdentifiers.newUuid(); String traceId = EventIdentifiers.newUuid();
EventEnvelope envelope = new EventEnvelope(EventIdentifiers.newUuid(), EventTypes.OVERSEAS_CONTAINER_UNLOADED, 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, sourceContainerBookingId, Math.max(1, aggregateVersion), 1L, traceId, eventKey, null, payloadNode,
EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payloadNode))).requireValid(); EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payloadNode))).requireValid();
LastlegEventOutbox event = new LastlegEventOutbox(); 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.setAggregateType(envelope.aggregateType()); event.setAggregateId(envelope.aggregateId());
event.setEventType(envelope.eventType()); event.setEventVersion(envelope.eventVersion()); event.setEventType(envelope.eventType()); event.setEventVersion(envelope.eventVersion());
event.setEventKey(eventKey); event.setPayloadJson(json(envelope)); event.setStatus("PENDING"); event.setEventKey(eventKey); event.setPayloadJson(json(envelope)); event.setStatus("PENDING");
@@ -94,17 +93,17 @@ public class InboundUnloadCompletedOutboxService {
} catch (DuplicateKeyException exception) { } catch (DuplicateKeyException exception) {
LastlegEventOutbox raced = mapper.findByEventKey(eventKey); LastlegEventOutbox raced = mapper.findByEventKey(eventKey);
if (raced == null) throw exception; 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) { String sourceContainerBookingId, String containerNo) {
try { try {
EventEnvelope envelope = objectMapper.readValue(existing.getPayloadJson(), EventEnvelope.class); EventEnvelope envelope = objectMapper.readValue(existing.getPayloadJson(), EventEnvelope.class);
OverseasContainerUnloadedV1 frozen = objectMapper.treeToValue( OverseasContainerUnloadedV1 frozen = objectMapper.treeToValue(
envelope.payload(), OverseasContainerUnloadedV1.class); envelope.payload(), OverseasContainerUnloadedV1.class);
if (!Objects.equals(existing.getCustomerId(), customerId) if (!"0".equals(existing.getCustomerId())
|| !Objects.equals(existing.getAggregateId(), sourceContainerBookingId) || !Objects.equals(existing.getAggregateId(), sourceContainerBookingId)
|| !Objects.equals(frozen.containerNo(), containerNo)) { || !Objects.equals(frozen.containerNo(), containerNo)) {
throw new IllegalStateException("Unload-completed event key conflicts with another container"); throw new IllegalStateException("Unload-completed event key conflicts with another container");
@@ -59,6 +59,7 @@ import java.util.stream.Collectors;
@Service @Service
public class ReceivingContainerService { public class ReceivingContainerService {
private static final String PLATFORM_SCOPE = "0";
private static final String PHOTO_DOWNLOAD_PATH = private static final String PHOTO_DOWNLOAD_PATH =
"/api/pda/v1/last-leg/receiving-containers/current/unload-photos/files/{fileId}/download"; "/api/pda/v1/last-leg/receiving-containers/current/unload-photos/files/{fileId}/download";
private static final String PALLET_MERGE_HINT = private static final String PALLET_MERGE_HINT =
@@ -125,10 +126,9 @@ public class ReceivingContainerService {
} }
public ReceivingContainerResponses.Session detail(String operatorKey, long overseasWarehouseId, String containerNo) { public ReceivingContainerResponses.Session detail(String operatorKey, long overseasWarehouseId, String containerNo) {
requireReadyContainer(containerNo, overseasWarehouseId, false);
List<InboundPlan> containerPlans = requirePlans(containerNo); List<InboundPlan> containerPlans = requirePlans(containerNo);
String customerId = requireSingleCustomer(containerPlans, containerNo);
if (!warehouseEligible(containerPlans, overseasWarehouseId)) throw conflict("当前柜已分配至其他海外接柜仓,无法在本仓操作"); if (!warehouseEligible(containerPlans, overseasWarehouseId)) throw conflict("当前柜已分配至其他海外接柜仓,无法在本仓操作");
requireReceivingAvailable(customerId, containerNo);
ReceivingContainerSession current = sessions.findActiveByOperator(activeOperatorKey(operatorKey)); ReceivingContainerSession current = sessions.findActiveByOperator(activeOperatorKey(operatorKey));
requireSessionWarehouse(current, overseasWarehouseId); requireSessionWarehouse(current, overseasWarehouseId);
if (current != null && !containerNo.equals(current.getContainerNo())) { if (current != null && !containerNo.equals(current.getContainerNo())) {
@@ -141,7 +141,7 @@ public class ReceivingContainerService {
WarehouseResourceGateway.WarehouseReference receivingWarehouse = WarehouseResourceGateway.WarehouseReference receivingWarehouse =
warehouse.requireActiveSharedWarehouse(overseasWarehouseId); warehouse.requireActiveSharedWarehouse(overseasWarehouseId);
return snapshot(current != null && containerNo.equals(current.getContainerNo()) ? current : waitingView( return snapshot(current != null && containerNo.equals(current.getContainerNo()) ? current : waitingView(
customerId, operatorKey, receivingWarehouse, containerNo), containerPlans); operatorKey, receivingWarehouse, containerNo), containerPlans);
} }
@Transactional @Transactional
@@ -158,9 +158,7 @@ public class ReceivingContainerService {
return result; return result;
} }
List<InboundPlan> containerPlans = requirePlans(containerNo); List<InboundPlan> containerPlans = requirePlans(containerNo);
String customerId = requireSingleCustomer(containerPlans, containerNo);
if (!warehouseEligible(containerPlans, overseasWarehouseId)) throw conflict("当前柜已分配至其他海外接柜仓,无法在本仓操作"); if (!warehouseEligible(containerPlans, overseasWarehouseId)) throw conflict("当前柜已分配至其他海外接柜仓,无法在本仓操作");
requireReceivingAvailable(customerId, containerNo);
String activeOperatorKey = activeOperatorKey(operatorKey); String activeOperatorKey = activeOperatorKey(operatorKey);
ReceivingContainerSession active = sessions.lockActiveByOperator(activeOperatorKey); ReceivingContainerSession active = sessions.lockActiveByOperator(activeOperatorKey);
requireSessionWarehouse(active, overseasWarehouseId); requireSessionWarehouse(active, overseasWarehouseId);
@@ -179,7 +177,7 @@ public class ReceivingContainerService {
} }
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
ReceivingContainerSession session = new ReceivingContainerSession(); 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.setSourceContainerBookingId(readiness.sourceContainerBookingId());
session.setContainerFactHash(readiness.factHash()); session.setContainerFactHash(readiness.factHash());
session.setOverseasWarehouseId(overseasWarehouseId); session.setOverseasWarehouseId(overseasWarehouseId);
@@ -197,9 +195,8 @@ public class ReceivingContainerService {
public ReceivingContainerResponses.Photo uploadPhoto(String operatorKey, long operatorId, MultipartFile file) { public ReceivingContainerResponses.Photo uploadPhoto(String operatorKey, long operatorId, MultipartFile file) {
ReceivingContainerSession session = requireCurrent(operatorKey, true); 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"); 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()); 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); ReceivingContainerOperation replay = replay(operatorKey, "SIGN", request.idempotencyKey(), request);
if (replay != null) return read(replay, ReceivingContainerResponses.Session.class); if (replay != null) return read(replay, ReceivingContainerResponses.Session.class);
ReceivingContainerSession session = requireCurrent(operatorKey, true); ReceivingContainerSession session = requireCurrent(operatorKey, true);
String customerId = session.getCustomerId();
if (!"WAIT_SIGN".equals(session.getStatus())) throw conflict("Container has already been signed"); if (!"WAIT_SIGN".equals(session.getStatus())) throw conflict("Container has already been signed");
List<ReceivingContainerResponses.SignedPhoto> photos = validateSignPhotos(request); List<ReceivingContainerResponses.SignedPhoto> photos = validateSignPhotos(request);
List<Long> photoFileIds = photos.stream().map(photo -> positiveFileId(photo.fileId())).toList(); List<Long> 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(); LocalDateTime now = LocalDateTime.now();
session.setSignPhotos(write(photos)); session.setStatus("SIGNED"); session.setSignedAt(now); session.setSignPhotos(write(photos)); session.setStatus("SIGNED"); session.setSignedAt(now);
session.setOperatorName(operatorName); session.setUpdatedAt(now); update(session); session.setOperatorName(operatorName); session.setUpdatedAt(now); update(session);
for (InboundPlan plan : requirePlans(customerId, session.getContainerNo())) { List<InboundPlan> containerPlans = requirePlans(session.getContainerNo());
workflow.ensureSignedTask(customerId, plan, operatorKey, operatorName, photoFileIds, now); 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; save(session, "SIGN", request.idempotencyKey(), request, result); return result;
} }
public ReceivingContainerResponses.Session current(String operatorKey) { public ReceivingContainerResponses.Session current(String operatorKey) {
ReceivingContainerSession session = requireCurrent(operatorKey, false); 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) { public UnloadPhotoFileGateway.DownloadedFile downloadCurrentPhoto(String operatorKey, String fileId) {
@@ -236,7 +233,7 @@ public class ReceivingContainerService {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, throw new ResponseStatusException(HttpStatus.FORBIDDEN,
"Unload photo is not part of the current receiving session"); "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) { public void requireCurrentWarehouse(String operatorKey, long overseasWarehouseId) {
@@ -246,9 +243,8 @@ public class ReceivingContainerService {
public ReceivingContainerResponses.CartonResolved resolveCarton(String operatorKey, public ReceivingContainerResponses.CartonResolved resolveCarton(String operatorKey,
ReceivingContainerRequests.CartonResolve request) { ReceivingContainerRequests.CartonResolve request) {
ReceivingContainerSession session = requireSigned(operatorKey); ReceivingContainerSession session = requireSigned(operatorKey);
String customerId = session.getCustomerId();
String inboundCode = cartonInboundCode(request.cartonMark()); String inboundCode = cartonInboundCode(request.cartonMark());
List<InboundRef> matches = refs(customerId, session.getContainerNo()).stream() List<InboundRef> matches = refs(session.getContainerNo()).stream()
.filter(value -> inboundCode.equals(value.line().getInboundCode())).toList(); .filter(value -> inboundCode.equals(value.line().getInboundCode())).toList();
CartonRef carton; CartonRef carton;
if (matches.size() > 1) { 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()))) { } else if (matches.size() == 1 && "FBA".equals(destinationType(matches.get(0).line(), matches.get(0).order()))) {
carton = new CartonRef(matches.get(0)); carton = new CartonRef(matches.get(0));
} else if (matches.size() == 1) { } else if (matches.size() == 1) {
carton = requireCartonRef(customerId, session.getContainerNo(), request.cartonMark()); carton = requireCartonRef(matches.get(0).order().getCustomerId(), session.getContainerNo(), request.cartonMark());
} else { } else {
carton = requireUnmatchedFbaCartonPrefix(customerId, inboundCode); carton = requireUnmatchedFbaCartonPrefix(inboundCode);
} }
InboundRef ref = carton.ref(); InboundRef ref = carton.ref();
ReceivingContainerResponses.Inbound inbound = inbound(ref); ReceivingContainerResponses.Inbound inbound = inbound(ref);
@@ -273,8 +269,7 @@ public class ReceivingContainerService {
public ReceivingContainerResponses.PalletResolved resolvePallet(String operatorKey, public ReceivingContainerResponses.PalletResolved resolvePallet(String operatorKey,
ReceivingContainerRequests.PalletResolve request) { ReceivingContainerRequests.PalletResolve request) {
ReceivingContainerSession session = requireSigned(operatorKey); ReceivingContainerSession session = requireSigned(operatorKey);
String customerId = session.getCustomerId(); InboundRef ref = requireInbound(session.getContainerNo(), request.inboundCode());
InboundRef ref = requireInbound(customerId, session.getContainerNo(), request.inboundCode());
PalletSelection selection = resolveReceivingPallet(ref, request.palletCode(), false); PalletSelection selection = resolveReceivingPallet(ref, request.palletCode(), false);
WarehouseResourceGateway.WarehousePalletReference pallet = selection.resource(); WarehouseResourceGateway.WarehousePalletReference pallet = selection.resource();
return new ReceivingContainerResponses.PalletResolved(pallet.palletNo(), pallet.warehouseId(), pallet.status(), 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); ReceivingContainerOperation replay = replay(operatorKey, "BULK_RECEIVE", request.idempotencyKey(), request);
if (replay != null) return read(replay, ReceivingContainerResponses.Receipt.class); if (replay != null) return read(replay, ReceivingContainerResponses.Receipt.class);
ReceivingContainerSession session = requireSigned(operatorKey); ReceivingContainerSession session = requireSigned(operatorKey);
String customerId = session.getCustomerId(); InboundRef ref = requireInbound(session.getContainerNo(), request.inboundCode());
InboundRef ref = requireInbound(customerId, session.getContainerNo(), request.inboundCode()); String customerId = ref.order().getCustomerId();
if (!"FBA".equals(destinationType(ref.line(), ref.order()))) { if (!"FBA".equals(destinationType(ref.line(), ref.order()))) {
throw conflict("OVERSEAS destination must use carton-by-carton scanning; bulk count is forbidden"); throw conflict("OVERSEAS destination must use carton-by-carton scanning; bulk count is forbidden");
} }
List<InboundRef> fbaPlanRefs = requireFbaPlanRefs(customerId, session.getContainerNo(), ref); List<InboundRef> fbaPlanRefs = requireFbaPlanRefs(session.getContainerNo(), ref);
List<FbaSkuAllocation> skuAllocations = allocateFbaSkuReceipts(customerId, fbaPlanRefs, request.packageCount()); List<FbaSkuAllocation> skuAllocations = allocateFbaSkuReceipts(customerId, fbaPlanRefs, request.packageCount());
skuAllocations.forEach(allocation -> workflow.validateBulkResolved(customerId, allocation.ref().plan(), skuAllocations.forEach(allocation -> workflow.validateBulkResolved(customerId, allocation.ref().plan(),
allocation.ref().line(), allocation.packages())); allocation.ref().line(), allocation.packages()));
@@ -328,12 +323,14 @@ public class ReceivingContainerService {
ReceivingContainerOperation replay = replay(operatorKey, "CARTON_RECEIVE", request.idempotencyKey(), request); ReceivingContainerOperation replay = replay(operatorKey, "CARTON_RECEIVE", request.idempotencyKey(), request);
if (replay != null) return read(replay, ReceivingContainerResponses.Receipt.class); if (replay != null) return read(replay, ReceivingContainerResponses.Receipt.class);
ReceivingContainerSession session = requireSigned(operatorKey); ReceivingContainerSession session = requireSigned(operatorKey);
String customerId = session.getCustomerId(); List<InboundRef> currentRefs = refs(session.getContainerNo());
List<InboundRef> currentRefs = refs(customerId, session.getContainerNo());
if (currentRefs.stream().noneMatch(value -> if (currentRefs.stream().noneMatch(value ->
"OVERSEAS".equals(destinationType(value.line(), value.order())))) { "OVERSEAS".equals(destinationType(value.line(), value.order())))) {
throw conflict("FBA destination must use bulk count"); 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()); CartonRef carton = requireCartonRef(customerId, session.getContainerNo(), request.cartonMark());
InboundRef ref = carton.ref(); InboundRef ref = carton.ref();
if (!"OVERSEAS".equals(destinationType(ref.line(), ref.order()))) { if (!"OVERSEAS".equals(destinationType(ref.line(), ref.order()))) {
@@ -367,12 +364,11 @@ public class ReceivingContainerService {
ReceivingContainerOperation replay = replay(operatorKey, "COMPLETE", request.idempotencyKey(), request); ReceivingContainerOperation replay = replay(operatorKey, "COMPLETE", request.idempotencyKey(), request);
if (replay != null) return read(replay, ReceivingContainerResponses.Session.class); if (replay != null) return read(replay, ReceivingContainerResponses.Session.class);
ReceivingContainerSession session = requireSigned(operatorKey); ReceivingContainerSession session = requireSigned(operatorKey);
String customerId = session.getCustomerId(); List<InboundPlan> containerPlans = requirePlans(session.getContainerNo());
List<InboundPlan> containerPlans = requirePlans(customerId, session.getContainerNo());
Totals totals = totals(containerPlans); Totals totals = totals(containerPlans);
if (totals.total() != totals.received()) throw conflict("Every inbound code must be fully received before completing the container"); if (totals.total() != totals.received()) throw conflict("Every inbound code must be fully received before completing the container");
validateFbaSkuBalance(customerId, session.getContainerNo()); validateFbaSkuBalance(session.getContainerNo());
workflow.completeContainer(customerId, containerPlans); workflow.completeContainer(containerPlans);
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
session.setStatus("COMPLETED"); session.setCompletedAt(now); session.setUpdatedAt(now); session.setStatus("COMPLETED"); session.setCompletedAt(now); session.setUpdatedAt(now);
session.setActiveOperatorKey(null); session.setActiveContainerKey(null); update(session); session.setActiveOperatorKey(null); session.setActiveContainerKey(null); update(session);
@@ -446,7 +442,6 @@ public class ReceivingContainerService {
Set<String> warehouseNames = orderFacts.stream().map(LastlegOrder::getOverseasWarehouseName) Set<String> warehouseNames = orderFacts.stream().map(LastlegOrder::getOverseasWarehouseName)
.map(ReceivingContainerService::trim).filter(Objects::nonNull).collect(Collectors.toSet()); .map(ReceivingContainerService::trim).filter(Objects::nonNull).collect(Collectors.toSet());
boolean warehouseSnapshotConsistent = warehouseCodes.size() == 1 && warehouseNames.size() == 1; boolean warehouseSnapshotConsistent = warehouseCodes.size() == 1 && warehouseNames.size() == 1;
Set<String> customers = orderFacts.stream().map(LastlegOrder::getCustomerId).collect(Collectors.toSet());
String containerNo = orderFacts.stream().map(LastlegOrder::getContainerNo).filter(Objects::nonNull).findFirst().orElse(null); String containerNo = orderFacts.stream().map(LastlegOrder::getContainerNo).filter(Objects::nonNull).findFirst().orElse(null);
int totalOrders = orderFacts.size(); int totalOrders = orderFacts.size();
int inboundCodes = (int) orderFacts.stream().map(LastlegOrder::getInboundCode).map(ReceivingContainerService::trim) 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()); return values.size() == 1 && plansById.containsKey(values.get(0).getPlanId());
}).count(); }).count();
int planCount = matchedPlans.size(); int planCount = matchedPlans.size();
boolean allPlansReady = totalOrders > 0 && customers.size() == 1 && inboundCodes == totalOrders boolean allPlansReady = totalOrders > 0 && inboundCodes == totalOrders
&& plannedOrders == totalOrders && planCount == totalOrders; && plannedOrders == totalOrders && planCount == totalOrders;
List<InboundPlan> containerPlans = planFacts.stream() List<InboundPlan> containerPlans = planFacts.stream()
.filter(plan -> containerNo.equals(plan.getContainerNo())).toList(); .filter(plan -> containerNo.equals(plan.getContainerNo())).toList();
boolean allPlansCompleted = !containerPlans.isEmpty() boolean allPlansCompleted = !containerPlans.isEmpty()
&& containerPlans.stream().allMatch(plan -> "COMPLETED".equals(plan.getStatus())); && containerPlans.stream().allMatch(plan -> "COMPLETED".equals(plan.getStatus()));
String customerId = customers.size() == 1 ? customers.iterator().next() : null; boolean shippingReady = shippingGates.receivingAvailable(sourceContainerBookingId, containerNo) == 1;
boolean shippingReady = customerId != null && shippingGates.receivingAvailable(customerId, containerNo) == 1;
boolean operable = allPlansReady && shippingReady && warehouseSnapshotConsistent; boolean operable = allPlansReady && shippingReady && warehouseSnapshotConsistent;
int pending = Math.max(0, totalOrders - plannedOrders); int pending = Math.max(0, totalOrders - plannedOrders);
String blockReason = operable ? null : (!warehouseSnapshotConsistent String blockReason = operable ? null : (!warehouseSnapshotConsistent
@@ -521,42 +515,28 @@ public class ReceivingContainerService {
List<InboundPlan> values = plans.selectList(new LambdaQueryWrapper<InboundPlan>() List<InboundPlan> values = plans.selectList(new LambdaQueryWrapper<InboundPlan>()
.eq(InboundPlan::getContainerNo, containerNo).orderByAsc(InboundPlan::getId)); .eq(InboundPlan::getContainerNo, containerNo).orderByAsc(InboundPlan::getId));
if (values.isEmpty()) throw notFound("Receiving container was not found"); 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()))) { if (values.stream().allMatch(value -> "COMPLETED".equals(value.getStatus()))) {
throw conflict("Receiving container is already completed"); throw conflict("Receiving container is already completed");
} }
return values; return values;
} }
private List<InboundPlan> requirePlans(String customerId, String containerNo) { private List<InboundPlan> plansForContainer(String containerNo) {
List<InboundPlan> values = plansForContainer(customerId, containerNo);
if (values.isEmpty()) throw notFound("Receiving container was not found");
if (values.stream().allMatch(value -> "COMPLETED".equals(value.getStatus()))) {
throw conflict("Receiving container is already completed");
}
return values;
}
private List<InboundPlan> plansForContainer(String customerId, String containerNo) {
List<InboundPlan> values = plans.selectList(new LambdaQueryWrapper<InboundPlan>() List<InboundPlan> values = plans.selectList(new LambdaQueryWrapper<InboundPlan>()
.eq(InboundPlan::getCustomerId, customerId).eq(InboundPlan::getContainerNo, containerNo) .eq(InboundPlan::getContainerNo, containerNo)
.orderByAsc(InboundPlan::getId)); .orderByAsc(InboundPlan::getId));
if (values.isEmpty()) throw notFound("Receiving container was not found"); if (values.isEmpty()) throw notFound("Receiving container was not found");
return values; return values;
} }
private String requireSingleCustomer(List<InboundPlan> values, String containerNo) { private List<InboundRef> refs(String containerNo) {
Set<String> customers = values.stream().map(InboundPlan::getCustomerId).map(ReceivingContainerService::trim)
.filter(Objects::nonNull).collect(Collectors.toCollection(java.util.LinkedHashSet::new));
if (customers.size() != 1 || values.stream().anyMatch(value -> trim(value.getCustomerId()) == null)) {
throw conflict("Receiving container must resolve to exactly one customer: " + containerNo);
}
return customers.iterator().next();
}
private List<InboundRef> refs(String customerId, String containerNo) {
List<InboundRef> result = new ArrayList<>(); List<InboundRef> 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<InboundPlanLine>() for (InboundPlanLine line : lines.selectList(new LambdaQueryWrapper<InboundPlanLine>()
.eq(InboundPlanLine::getCustomerId, customerId).eq(InboundPlanLine::getPlanId, plan.getId()) .eq(InboundPlanLine::getCustomerId, customerId).eq(InboundPlanLine::getPlanId, plan.getId())
.orderByAsc(InboundPlanLine::getId))) { .orderByAsc(InboundPlanLine::getId))) {
@@ -568,8 +548,8 @@ public class ReceivingContainerService {
return result; return result;
} }
private InboundRef requireInbound(String customerId, String containerNo, String inboundCode) { private InboundRef requireInbound(String containerNo, String inboundCode) {
List<InboundRef> matches = refs(customerId, containerNo).stream() List<InboundRef> matches = refs(containerNo).stream()
.filter(value -> inboundCode.equals(value.line().getInboundCode())).toList(); .filter(value -> inboundCode.equals(value.line().getInboundCode())).toList();
if (matches.isEmpty()) throw notFound("Inbound code is not part of the current container"); if (matches.isEmpty()) throw notFound("Inbound code is not part of the current container");
Set<Long> planIds = matches.stream().map(value -> value.plan().getId()).collect(Collectors.toSet()); Set<Long> planIds = matches.stream().map(value -> value.plan().getId()).collect(Collectors.toSet());
@@ -581,7 +561,8 @@ public class ReceivingContainerService {
InboundPlanCarton carton = cartons.selectOne(new LambdaQueryWrapper<InboundPlanCarton>() InboundPlanCarton carton = cartons.selectOne(new LambdaQueryWrapper<InboundPlanCarton>()
.eq(InboundPlanCarton::getCustomerId, customerId).eq(InboundPlanCarton::getCartonMark, cartonMark)); .eq(InboundPlanCarton::getCustomerId, customerId).eq(InboundPlanCarton::getCartonMark, cartonMark));
if (carton == null) throw notFound("Carton mark was not found"); if (carton == null) throw notFound("Carton mark was not found");
List<InboundRef> matches = refs(customerId, containerNo).stream() List<InboundRef> 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.getLineId() == null || Objects.equals(value.line().getId(), carton.getLineId()))
.filter(value -> carton.getPlanId() == null || Objects.equals(value.plan().getId(), carton.getPlanId())) .filter(value -> carton.getPlanId() == null || Objects.equals(value.plan().getId(), carton.getPlanId()))
.filter(value -> Objects.equals(value.line().getOrderId(), carton.getOrderId())).toList(); .filter(value -> Objects.equals(value.line().getOrderId(), carton.getOrderId())).toList();
@@ -597,16 +578,15 @@ public class ReceivingContainerService {
return inboundCode; return inboundCode;
} }
private CartonRef requireUnmatchedFbaCartonPrefix(String customerId, String inboundCode) { private CartonRef requireUnmatchedFbaCartonPrefix(String inboundCode) {
boolean exists = lines.selectList(new LambdaQueryWrapper<InboundPlanLine>() boolean exists = lines.selectList(new LambdaQueryWrapper<InboundPlanLine>()
.eq(InboundPlanLine::getCustomerId, customerId)
.eq(InboundPlanLine::getInboundCode, inboundCode)).stream().findAny().isPresent(); .eq(InboundPlanLine::getInboundCode, inboundCode)).stream().findAny().isPresent();
if (exists) throw conflict("Carton mark belongs to another container"); if (exists) throw conflict("Carton mark belongs to another container");
throw notFound("Carton mark was not found"); throw notFound("Carton mark was not found");
} }
private List<InboundRef> requireFbaPlanRefs(String customerId, String containerNo, InboundRef selected) { private List<InboundRef> requireFbaPlanRefs(String containerNo, InboundRef selected) {
List<InboundRef> values = refs(customerId, containerNo).stream() List<InboundRef> values = refs(containerNo).stream()
.filter(value -> Objects.equals(value.plan().getId(), selected.plan().getId())) .filter(value -> Objects.equals(value.plan().getId(), selected.plan().getId()))
.sorted(Comparator.comparing(value -> value.line().getId())).toList(); .sorted(Comparator.comparing(value -> value.line().getId())).toList();
if (values.isEmpty() || values.stream().anyMatch(value -> if (values.isEmpty() || values.stream().anyMatch(value ->
@@ -635,11 +615,12 @@ public class ReceivingContainerService {
} }
private PalletSelection resolveReceivingPallet(InboundRef ref, String palletCode, boolean requirePlanPallet) { private PalletSelection resolveReceivingPallet(InboundRef ref, String palletCode, boolean requirePlanPallet) {
String normalizedPalletCode = palletCode.trim();
List<InboundPlanPallet> reserved = pallets.selectList(new LambdaQueryWrapper<InboundPlanPallet>() List<InboundPlanPallet> reserved = pallets.selectList(new LambdaQueryWrapper<InboundPlanPallet>()
.eq(InboundPlanPallet::getCustomerId, ref.order().getCustomerId()) .eq(InboundPlanPallet::getCustomerId, ref.order().getCustomerId())
.eq(InboundPlanPallet::getPlanId, ref.plan().getId())).stream() .eq(InboundPlanPallet::getPlanId, ref.plan().getId())).stream()
.filter(value -> sameDestination(ref.line(), value)) .filter(value -> sameDestination(ref.line(), value))
.filter(value -> palletCode.trim().equals(value.getPalletNo())) .filter(value -> matchesPlannedPalletCode(value, normalizedPalletCode))
.filter(this::hasConfirmedReservation) .filter(this::hasConfirmedReservation)
.toList(); .toList();
if (reserved.size() > 1) { if (reserved.size() > 1) {
@@ -651,11 +632,11 @@ public class ReceivingContainerService {
pallet = reserved.get(0); pallet = reserved.get(0);
long warehouseId = ref.plan().getOverseasWarehouseId() == null long warehouseId = ref.plan().getOverseasWarehouseId() == null
? Long.parseLong(ref.order().getOverseasWarehouseId()) : ref.plan().getOverseasWarehouseId(); ? 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()); pallet.getWarehousePalletId(), pallet.getWarehouseLocationId(), pallet.getWarehouseResourceLockId());
} else { } else {
try { try {
resource = warehouse.resolveAvailablePallet(palletCode.trim()); resource = warehouse.resolveAvailablePallet(normalizedPalletCode);
} catch (IllegalArgumentException exception) { } catch (IllegalArgumentException exception) {
throw notFound("Pallet was not found"); throw notFound("Pallet was not found");
} }
@@ -666,6 +647,11 @@ public class ReceivingContainerService {
return new PalletSelection(pallet, resource); 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) { private boolean hasConfirmedReservation(InboundPlanPallet pallet) {
return pallet.getWarehousePalletId() != null && pallet.getWarehousePalletId() > 0 return pallet.getWarehousePalletId() != null && pallet.getWarehousePalletId() > 0
&& pallet.getWarehouseLocationId() != null && pallet.getWarehouseLocationId() > 0 && pallet.getWarehouseLocationId() != null && pallet.getWarehouseLocationId() > 0
@@ -706,7 +692,7 @@ public class ReceivingContainerService {
WarehouseResourceGateway.WarehousePalletReference resource) { } WarehouseResourceGateway.WarehousePalletReference resource) { }
private ReceivingContainerResponses.Session snapshot(ReceivingContainerSession session, List<InboundPlan> containerPlans) { private ReceivingContainerResponses.Session snapshot(ReceivingContainerSession session, List<InboundPlan> containerPlans) {
List<ReceivingContainerResponses.Inbound> inbounds = refs(session.getCustomerId(), session.getContainerNo()).stream() List<ReceivingContainerResponses.Inbound> inbounds = refs(session.getContainerNo()).stream()
.map(this::inbound).toList(); .map(this::inbound).toList();
int total = inbounds.stream().mapToInt(ReceivingContainerResponses.Inbound::totalPackages).sum(); int total = inbounds.stream().mapToInt(ReceivingContainerResponses.Inbound::totalPackages).sum();
int received = inbounds.stream().mapToInt(ReceivingContainerResponses.Inbound::receivedPackages).sum(); int received = inbounds.stream().mapToInt(ReceivingContainerResponses.Inbound::receivedPackages).sum();
@@ -749,8 +735,8 @@ public class ReceivingContainerService {
} }
private Totals totals(List<InboundPlan> containerPlans) { private Totals totals(List<InboundPlan> containerPlans) {
List<ReceivingContainerResponses.Inbound> values = refs(containerPlans.get(0).getCustomerId(), List<ReceivingContainerResponses.Inbound> values = refs(containerPlans.get(0).getContainerNo())
containerPlans.get(0).getContainerNo()).stream().map(this::inbound).toList(); .stream().map(this::inbound).toList();
List<String> destinationCodes = values.stream().map(this::destinationCode) List<String> destinationCodes = values.stream().map(this::destinationCode)
.filter(Objects::nonNull).distinct().sorted().toList(); .filter(Objects::nonNull).distinct().sorted().toList();
return new Totals(values.size(), destinationCodes, return new Totals(values.size(), destinationCodes,
@@ -856,14 +842,15 @@ public class ReceivingContainerService {
} }
private void recordSkuReceipt(ReceivingContainerSession session, FbaSkuAllocation sku, InboundPlanPallet pallet) { private void recordSkuReceipt(ReceivingContainerSession session, FbaSkuAllocation sku, InboundPlanPallet pallet) {
List<ReceivingSkuReceipt> existing = skuReceipts.lockLineSku(session.getCustomerId(), String customerId = sku.ref().order().getCustomerId();
List<ReceivingSkuReceipt> existing = skuReceipts.lockLineSku(customerId,
sku.ref().line().getId(), sku.skuCode()); sku.ref().line().getId(), sku.skuCode());
ReceivingSkuReceipt receipt = existing.stream() ReceivingSkuReceipt receipt = existing.stream()
.filter(value -> Objects.equals(value.getPalletId(), pallet.getId())).findFirst().orElse(null); .filter(value -> Objects.equals(value.getPalletId(), pallet.getId())).findFirst().orElse(null);
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
if (receipt == null) { if (receipt == null) {
receipt = new ReceivingSkuReceipt(); receipt.setId(IdWorker.getId()); 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.setPlanId(sku.ref().plan().getId()); receipt.setLineId(sku.ref().line().getId());
receipt.setPalletId(pallet.getId()); receipt.setOrderId(sku.ref().order().getId()); receipt.setPalletId(pallet.getId()); receipt.setOrderId(sku.ref().order().getId());
receipt.setInboundCode(sku.ref().line().getInboundCode()); receipt.setSkuCode(sku.skuCode()); receipt.setInboundCode(sku.ref().line().getInboundCode()); receipt.setSkuCode(sku.skuCode());
@@ -877,9 +864,10 @@ public class ReceivingContainerService {
} }
} }
private void validateFbaSkuBalance(String customerId, String containerNo) { private void validateFbaSkuBalance(String containerNo) {
for (InboundRef ref : refs(customerId, containerNo)) { for (InboundRef ref : refs(containerNo)) {
if (!"FBA".equals(destinationType(ref.line(), ref.order()))) continue; if (!"FBA".equals(destinationType(ref.line(), ref.order()))) continue;
String customerId = ref.order().getCustomerId();
List<SkuExpectation> skuCodes = skuExpectations(ref); List<SkuExpectation> skuCodes = skuExpectations(ref);
int received = 0; int received = 0;
for (SkuExpectation sku : skuCodes) { for (SkuExpectation sku : skuCodes) {
@@ -914,10 +902,10 @@ public class ReceivingContainerService {
if (sessions.updateById(session) != 1) throw conflict("Receiving container session changed concurrently"); 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, WarehouseResourceGateway.WarehouseReference warehouse,
String containerNo) { String containerNo) {
ReceivingContainerSession value = new ReceivingContainerSession(); value.setCustomerId(customerId); ReceivingContainerSession value = new ReceivingContainerSession();
value.setOverseasWarehouseId(warehouse.warehouseId()); value.setOverseasWarehouseId(warehouse.warehouseId());
value.setOverseasWarehouseCode(warehouse.warehouseCode()); value.setOverseasWarehouseCode(warehouse.warehouseCode());
value.setOverseasWarehouseName(warehouse.warehouseName()); value.setOverseasWarehouseName(warehouse.warehouseName());
@@ -925,12 +913,6 @@ public class ReceivingContainerService {
return value; return value;
} }
private boolean eligible(List<InboundPlan> containerPlans, long overseasWarehouseId) {
String customerId = requireSingleCustomer(containerPlans, containerPlans.get(0).getContainerNo());
return warehouseEligible(containerPlans, overseasWarehouseId)
&& shippingGates.receivingAvailable(customerId, containerPlans.get(0).getContainerNo()) == 1;
}
private boolean warehouseEligible(List<InboundPlan> containerPlans, long overseasWarehouseId) { private boolean warehouseEligible(List<InboundPlan> containerPlans, long overseasWarehouseId) {
Set<Long> assigned = containerPlans.stream().map(InboundPlan::getOverseasWarehouseId) Set<Long> assigned = containerPlans.stream().map(InboundPlan::getOverseasWarehouseId)
.filter(Objects::nonNull).collect(Collectors.toSet()); .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) { private void requireSessionWarehouse(ReceivingContainerSession session, long overseasWarehouseId) {
if (session != null && !Objects.equals(session.getOverseasWarehouseId(), overseasWarehouseId)) { if (session != null && !Objects.equals(session.getOverseasWarehouseId(), overseasWarehouseId)) {
throw conflict("Current container belongs to another overseas warehouse"); 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) { private void save(ReceivingContainerSession session, String action, String key, Object request, Object result) {
key = idempotencyKey(key); key = idempotencyKey(key);
ReceivingContainerOperation value = new ReceivingContainerOperation(); value.setId(IdWorker.getId()); 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.setOperatorKey(session.getOperatorKey()); value.setAction(action); value.setIdempotencyKey(key);
value.setRequestHash(hash(request)); value.setRequestJson(write(request)); value.setResultJson(write(result)); value.setRequestHash(hash(request)); value.setRequestJson(write(request)); value.setResultJson(write(result));
value.setCreatedAt(LocalDateTime.now()); operations.insert(value); value.setCreatedAt(LocalDateTime.now()); operations.insert(value);
@@ -116,7 +116,7 @@ public class LoadingCompletedConsumer {
private void freezeContainerIdentity(EventEnvelope envelope, String sourceContainerBookingId, private void freezeContainerIdentity(EventEnvelope envelope, String sourceContainerBookingId,
String containerNo) { String containerNo) {
shippingGates.initialize(sourceContainerBookingId, envelope.tenantId(), "", shippingGates.initialize(sourceContainerBookingId, "",
containerNo, 0, envelope.handoverVersion()); containerNo, 0, envelope.handoverVersion());
ContainerShippingGate frozen = shippingGates.lock(sourceContainerBookingId); ContainerShippingGate frozen = shippingGates.lock(sourceContainerBookingId);
if (frozen == null) { if (frozen == null) {
@@ -12,7 +12,6 @@ import java.time.LocalDateTime;
public class ContainerShippingGate { public class ContainerShippingGate {
@TableId(type = IdType.INPUT) @TableId(type = IdType.INPUT)
private String containerBookingId; private String containerBookingId;
private String customerId;
private String oceanBookingId; private String oceanBookingId;
private String containerNo; private String containerNo;
private String status; private String status;
@@ -12,18 +12,17 @@ public interface ContainerShippingGateMapper extends BaseMapper<ContainerShippin
@Select("SELECT * FROM ll_container_shipping_gate WHERE container_booking_id=#{containerBookingId} FOR UPDATE") @Select("SELECT * FROM ll_container_shipping_gate WHERE container_booking_id=#{containerBookingId} FOR UPDATE")
ContainerShippingGate lock(@Param("containerBookingId") String containerBookingId); ContainerShippingGate lock(@Param("containerBookingId") String containerBookingId);
@Insert("INSERT IGNORE INTO ll_container_shipping_gate(container_booking_id,customer_id,ocean_booking_id," + @Insert("INSERT IGNORE INTO ll_container_shipping_gate(container_booking_id,ocean_booking_id," +
"container_no,status,node_sequence,aggregate_version,handover_version,created_at,updated_at) VALUES(" + "container_no,status,node_sequence,aggregate_version,handover_version,created_at,updated_at) VALUES(" +
"#{containerBookingId},#{customerId},#{oceanBookingId},#{containerNo},'AWAITING_PICKUP_READY',0," + "#{containerBookingId},#{oceanBookingId},#{containerNo},'AWAITING_PICKUP_READY',0," +
"#{aggregateVersion},#{handoverVersion},NOW(3),NOW(3))") "#{aggregateVersion},#{handoverVersion},NOW(3),NOW(3))")
int initialize(@Param("containerBookingId") String containerBookingId, @Param("customerId") String customerId, int initialize(@Param("containerBookingId") String containerBookingId,
@Param("oceanBookingId") String oceanBookingId, @Param("containerNo") String containerNo, @Param("oceanBookingId") String oceanBookingId, @Param("containerNo") String containerNo,
@Param("aggregateVersion") long aggregateVersion, @Param("handoverVersion") long handoverVersion); @Param("aggregateVersion") long aggregateVersion, @Param("handoverVersion") long handoverVersion);
@Select("SELECT CASE WHEN COUNT(DISTINCT o.source_container_booking_id)=1 " + @Select("SELECT CASE WHEN COUNT(*)=1 AND MIN(CASE WHEN status='RECEIVING_AVAILABLE' THEN 1 ELSE 0 END)=1 " +
"AND MIN(CASE WHEN g.status='RECEIVING_AVAILABLE' THEN 1 ELSE 0 END)=1 THEN 1 ELSE 0 END " + "THEN 1 ELSE 0 END FROM ll_container_shipping_gate " +
"FROM ll_order o LEFT JOIN ll_container_shipping_gate g " + "WHERE container_booking_id=#{containerBookingId} AND container_no=#{containerNo}")
"ON g.container_booking_id=o.source_container_booking_id AND g.customer_id=o.customer_id " + int receivingAvailable(@Param("containerBookingId") String containerBookingId,
"WHERE o.customer_id=#{customerId} AND o.container_no=#{containerNo}") @Param("containerNo") String containerNo);
int receivingAvailable(@Param("customerId") String customerId, @Param("containerNo") String containerNo);
} }
@@ -49,11 +49,10 @@ public class ContainerShippingNodeConsumer {
return 1; return 1;
} }
gates.initialize(payload.containerBookingId(), payload.customerId(), payload.oceanBookingId(), gates.initialize(payload.containerBookingId(), payload.oceanBookingId(),
payload.containerNo(), 0, 1); payload.containerNo(), 0, 1);
ContainerShippingGate gate = gates.lock(payload.containerBookingId()); ContainerShippingGate gate = gates.lock(payload.containerBookingId());
if (gate == null || !payload.customerId().equals(gate.getCustomerId()) if (gate == null || !payload.containerNo().equals(gate.getContainerNo())) {
|| !payload.containerNo().equals(gate.getContainerNo())) {
throw new IllegalArgumentException("船节点柜号快照与装柜交接不一致"); throw new IllegalArgumentException("船节点柜号快照与装柜交接不一致");
} }
apply(gate, envelope, payload); apply(gate, envelope, payload);
@@ -108,9 +107,8 @@ public class ContainerShippingNodeConsumer {
} }
private void validateEnvelopePayload(EventEnvelope envelope, FirstLegContainerShippingNodeRecordedV1 payload) { private void validateEnvelopePayload(EventEnvelope envelope, FirstLegContainerShippingNodeRecordedV1 payload) {
if (!envelope.tenantId().equals(payload.customerId()) if (!"0".equals(envelope.tenantId()) || !envelope.aggregateId().equals(payload.containerBookingId())) {
|| !envelope.aggregateId().equals(payload.containerBookingId())) { throw new IllegalArgumentException("船节点交接平台作用域或聚合标识不一致");
throw new IllegalArgumentException("船节点交接客户或聚合标识不一致");
} }
} }
@@ -19,7 +19,7 @@ CREATE TABLE IF NOT EXISTS ll_loading_event_inbox (
) ENGINE=InnoDB COMMENT='头程装柜完成事件接收幂等箱'; ) ENGINE=InnoDB COMMENT='头程装柜完成事件接收幂等箱';
CREATE TABLE IF NOT EXISTS ll_container_shipping_gate ( CREATE TABLE IF NOT EXISTS ll_container_shipping_gate (
container_booking_id VARCHAR(128) NOT NULL COMMENT '来源头程柜预订ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户标识', container_booking_id VARCHAR(128) NOT NULL COMMENT '来源头程柜预订ID',
ocean_booking_id VARCHAR(128) NOT NULL COMMENT '来源海运订舱ID', container_no VARCHAR(64) NOT NULL COMMENT '实际集装箱号', ocean_booking_id VARCHAR(128) NOT NULL COMMENT '来源海运订舱ID', container_no VARCHAR(64) NOT NULL COMMENT '实际集装箱号',
status VARCHAR(32) NOT NULL DEFAULT 'AWAITING_PICKUP_READY' COMMENT '海外收货门禁状态:AWAITING_PICKUP_READY、RECEIVING_AVAILABLE', status VARCHAR(32) NOT NULL DEFAULT 'AWAITING_PICKUP_READY' COMMENT '海外收货门禁状态:AWAITING_PICKUP_READY、RECEIVING_AVAILABLE',
standard_node VARCHAR(64) NULL COMMENT '最新有效标准船节点', node_sequence INT NOT NULL DEFAULT 0 COMMENT '最新有效节点单调序号', standard_node VARCHAR(64) NULL COMMENT '最新有效标准船节点', node_sequence INT NOT NULL DEFAULT 0 COMMENT '最新有效节点单调序号',
@@ -31,7 +31,7 @@ CREATE TABLE IF NOT EXISTS ll_container_shipping_gate (
handover_version BIGINT NOT NULL DEFAULT 1 COMMENT '跨区交接版本', last_event_id VARCHAR(128) NULL COMMENT '最近采纳的跨区事件ID', handover_version BIGINT NOT NULL DEFAULT 1 COMMENT '跨区交接版本', last_event_id VARCHAR(128) NULL COMMENT '最近采纳的跨区事件ID',
payload_hash VARCHAR(71) NULL COMMENT '最近采纳事件规范化负载摘要', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', payload_hash VARCHAR(71) 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 '更新时间', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY(container_booking_id), KEY idx_ll_shipping_gate_candidate(customer_id,container_no,status), PRIMARY KEY(container_booking_id), KEY idx_ll_shipping_gate_candidate(container_no,status),
KEY idx_ll_shipping_gate_version(container_booking_id,aggregate_version,node_sequence) KEY idx_ll_shipping_gate_version(container_booking_id,aggregate_version,node_sequence)
) ENGINE=InnoDB COMMENT='海外PDA柜号船节点收货资格快照'; ) ENGINE=InnoDB COMMENT='海外PDA柜号船节点收货资格快照';
@@ -992,7 +992,7 @@ WHERE (l.fba_warehouse_code IS NULL AND o.fba_warehouse_code IS NOT NULL)
OR l.destination_type IS NULL OR l.destination_type='' OR l.destination_type='OVERSEAS'; OR l.destination_type IS NULL OR l.destination_type='' OR l.destination_type='OVERSEAS';
CREATE TABLE IF NOT EXISTS ll_receiving_container_session ( CREATE TABLE IF NOT EXISTS ll_receiving_container_session (
id BIGINT NOT NULL COMMENT '柜号签收会话ID', customer_id VARCHAR(128) NOT NULL COMMENT '所选柜解析出的客户标识', id BIGINT NOT NULL COMMENT '柜号签收会话ID',
source_container_booking_id VARCHAR(128) NOT NULL COMMENT '稳定来源柜预订ID', container_fact_hash VARCHAR(64) NOT NULL COMMENT '领取时整柜订单与计划事实摘要', source_container_booking_id VARCHAR(128) NOT NULL COMMENT '稳定来源柜预订ID', container_fact_hash VARCHAR(64) NOT NULL COMMENT '领取时整柜订单与计划事实摘要',
container_no VARCHAR(64) NOT NULL COMMENT '集装箱号', overseas_warehouse_id BIGINT NOT NULL COMMENT '平台预分配并由操作员Token匹配的海外接柜仓ID', overseas_warehouse_code VARCHAR(128) NOT NULL COMMENT '海外接柜仓业务编码快照', overseas_warehouse_name VARCHAR(255) NOT NULL COMMENT '仓库服务校验后的海外接柜仓名称快照', operator_key VARCHAR(128) NOT NULL COMMENT '操作账号唯一标识', container_no VARCHAR(64) NOT NULL COMMENT '集装箱号', overseas_warehouse_id BIGINT NOT NULL COMMENT '平台预分配并由操作员Token匹配的海外接柜仓ID', overseas_warehouse_code VARCHAR(128) NOT NULL COMMENT '海外接柜仓业务编码快照', overseas_warehouse_name VARCHAR(255) NOT NULL COMMENT '仓库服务校验后的海外接柜仓名称快照', operator_key VARCHAR(128) NOT NULL COMMENT '操作账号唯一标识',
operator_name VARCHAR(255) NULL COMMENT '操作账号名称', active_operator_key VARCHAR(255) NULL COMMENT '未完成会话平台操作账号全局唯一锁键', operator_name VARCHAR(255) NULL COMMENT '操作账号名称', active_operator_key VARCHAR(255) NULL COMMENT '未完成会话平台操作账号全局唯一锁键',
@@ -1002,7 +1002,7 @@ CREATE TABLE IF NOT EXISTS ll_receiving_container_session (
completed_at DATETIME(3) NULL COMMENT '完成时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', completed_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 '更新时间', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本',
PRIMARY KEY(id), UNIQUE KEY uk_ll_receiving_active_operator(active_operator_key), UNIQUE KEY uk_ll_receiving_active_container(active_container_key), PRIMARY KEY(id), UNIQUE KEY uk_ll_receiving_active_operator(active_operator_key), UNIQUE KEY uk_ll_receiving_active_container(active_container_key),
KEY idx_ll_receiving_container(customer_id,container_no,status), KEY idx_ll_receiving_operator(customer_id,operator_key,created_at) KEY idx_ll_receiving_container(container_no,status), KEY idx_ll_receiving_operator(operator_key,created_at)
) ENGINE=InnoDB COMMENT='海外PDA柜号签收收货会话'; ) ENGINE=InnoDB COMMENT='海外PDA柜号签收收货会话';
CREATE TABLE IF NOT EXISTS ll_receiving_sku_receipt ( CREATE TABLE IF NOT EXISTS ll_receiving_sku_receipt (
@@ -1020,7 +1020,7 @@ CREATE TABLE IF NOT EXISTS ll_receiving_sku_receipt (
) ENGINE=InnoDB COMMENT='海外PDA柜号FBA SKU实收进度'; ) ENGINE=InnoDB COMMENT='海外PDA柜号FBA SKU实收进度';
CREATE TABLE IF NOT EXISTS ll_receiving_container_operation ( CREATE TABLE IF NOT EXISTS ll_receiving_container_operation (
id BIGINT NOT NULL COMMENT '柜号收货操作ID', session_id BIGINT NOT NULL COMMENT '柜号签收会话ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识', id BIGINT NOT NULL COMMENT '柜号收货操作ID', session_id BIGINT NOT NULL COMMENT '柜号签收会话ID',
operator_key VARCHAR(128) NOT NULL COMMENT '操作账号唯一标识', action VARCHAR(32) NOT NULL COMMENT '操作动作', idempotency_key VARCHAR(255) NOT NULL COMMENT '客户端幂等键', operator_key VARCHAR(128) NOT NULL COMMENT '操作账号唯一标识', 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 '请求内容快照', result_json LONGTEXT NOT NULL COMMENT '响应内容快照', request_hash VARCHAR(128) NOT NULL COMMENT '请求内容哈希', request_json LONGTEXT NOT NULL COMMENT '请求内容快照', result_json LONGTEXT NOT NULL COMMENT '响应内容快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id),
@@ -29,8 +29,8 @@ paths:
'409': { description: 入仓码跨客户或快照关系不一致 } '409': { description: 入仓码跨客户或快照关系不一致 }
/api/pda/v1/last-leg/receiving-containers/candidates: /api/pda/v1/last-leg/receiving-containers/candidates:
get: get:
summary: 查询当前客户待签收柜号 summary: 查询当前仓待签收柜号
description: 权限 lastleg:pda:operate;仅 PLATFORM OVERSEAS_WAREHOUSE_PDA_OPERATOR 可用;仅返回已单调消费 PICKUP_READY(80) 或更高实际船节点、状态为 RECEIVING_AVAILABLE 的柜,不接收 customerId。 description: 权限 lastleg:pda:operate;仅 PLATFORM OVERSEAS_WAREHOUSE_PDA_OPERATOR 可用;按来源柜跨客户聚合,仅返回已单调消费 PICKUP_READY(80) 或更高实际船节点、状态为 RECEIVING_AVAILABLE 的柜,不接收 customerId。
security: [{ bearerAuth: [] }] security: [{ bearerAuth: [] }]
responses: { '200': { description: 柜号候选及箱数进度, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingCandidateListApiResponse' } } } }, '403': { description: 缺少 PDA 操作权限 } } responses: { '200': { description: 柜号候选及箱数进度, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingCandidateListApiResponse' } } } }, '403': { description: 缺少 PDA 操作权限 } }
/api/pda/v1/last-leg/receiving-containers/warehouse-profile: /api/pda/v1/last-leg/receiving-containers/warehouse-profile:
@@ -42,7 +42,7 @@ paths:
/api/pda/v1/last-leg/receiving-containers/{containerNo}: /api/pda/v1/last-leg/receiving-containers/{containerNo}:
get: get:
summary: 查询待签收柜号详情 summary: 查询待签收柜号详情
description: 权限 lastleg:pda:operate;按柜号唯一解析客户并返回柜内全部入仓码、目的类型、目的仓、托数和实收进度。 description: 权限 lastleg:pda:operate;按来源柜返回跨客户的全部入仓码、目的类型、目的仓、托数和实收进度;每个入仓码仍按自身客户隔离。
security: [{ bearerAuth: [] }] security: [{ bearerAuth: [] }]
parameters: [{ name: containerNo, in: path, required: true, schema: { type: string } }] parameters: [{ name: containerNo, in: path, required: true, schema: { type: string } }]
responses: { '200': { description: 柜号详情, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingSessionApiResponse' } } } }, '409': { description: 当前账号已锁定其他柜号 } } responses: { '200': { description: 柜号详情, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingSessionApiResponse' } } } }, '409': { description: 当前账号已锁定其他柜号 } }
@@ -95,6 +95,12 @@ class ReceivingContainerContractTest {
assertTrue(schema.contains("柜号全局唯一锁键")); assertTrue(schema.contains("柜号全局唯一锁键"));
assertTrue(schema.contains("create table if not exists ll_receiving_container_operation")); assertTrue(schema.contains("create table if not exists ll_receiving_container_operation"));
assertTrue(schema.contains("unique key uk_ll_receiving_operation(operator_key,idempotency_key)")); assertTrue(schema.contains("unique key uk_ll_receiving_operation(operator_key,idempotency_key)"));
assertFalse(tableBlock(schema, "ll_receiving_container_session").contains("customer_id"));
assertFalse(tableBlock(schema, "ll_receiving_container_operation").contains("customer_id"));
assertFalse(tableBlock(schema, "ll_container_shipping_gate").contains("customer_id"));
assertTrue(unified.contains("alter table ll_receiving_container_session drop column customer_id"));
assertTrue(unified.contains("alter table ll_receiving_container_operation drop column customer_id"));
assertTrue(unified.contains("alter table ll_container_shipping_gate drop column customer_id"));
assertTrue(schema.contains("create table if not exists ll_receiving_sku_receipt")); assertTrue(schema.contains("create table if not exists ll_receiving_sku_receipt"));
assertTrue(schema.contains("unique key uk_ll_receiving_sku_pallet (customer_id,line_id,pallet_id,sku_code)")); assertTrue(schema.contains("unique key uk_ll_receiving_sku_pallet (customer_id,line_id,pallet_id,sku_code)"));
String receivingSession = tableBlock(schema, "ll_receiving_container_session"); String receivingSession = tableBlock(schema, "ll_receiving_container_session");
@@ -46,13 +46,15 @@ class InboundPdaContainerCompletionTest {
} }
@Test @Test
void twoPlansOfOneContainerStockInTogetherAndPublishOneContainerEvent() { void twoCustomerPlansOfOneContainerStockInTogetherAndPublishOneContainerEvent() {
InboundPlan first = plan(1L, "PLAN-1"); InboundPlan first = plan(1L, "PLAN-1");
InboundPlan second = plan(2L, "PLAN-2"); InboundPlan second = plan(2L, "PLAN-2");
second.setCustomerId("200");
InboundPdaTask firstTask = task(11L, 1L); InboundPdaTask firstTask = task(11L, 1L);
InboundPdaTask secondTask = task(22L, 2L); InboundPdaTask secondTask = task(22L, 2L);
secondTask.setCustomerId("200");
when(repository.lockTask("100", 1L)).thenReturn(firstTask); when(repository.lockTask("100", 1L)).thenReturn(firstTask);
when(repository.lockTask("100", 2L)).thenReturn(secondTask); when(repository.lockTask("200", 2L)).thenReturn(secondTask);
when(repository.lines(1L)).thenReturn(List.of(line(101L))); when(repository.lines(1L)).thenReturn(List.of(line(101L)));
when(repository.lines(2L)).thenReturn(List.of(line(202L))); when(repository.lines(2L)).thenReturn(List.of(line(202L)));
when(repository.allocations(1L)).thenReturn(List.of(allocation(101L))); when(repository.allocations(1L)).thenReturn(List.of(allocation(101L)));
@@ -63,11 +65,12 @@ class InboundPdaContainerCompletionTest {
when(orders.selectById(101L)).thenReturn(source); when(orders.selectById(101L)).thenReturn(source);
when(orders.selectById(102L)).thenReturn(source); when(orders.selectById(102L)).thenReturn(source);
service.completeContainer("100", List.of(second, first)); service.completeContainer(List.of(second, first));
service.completeContainer("100", List.of(first, second)); service.completeContainer(List.of(first, second));
verify(stockReceipts, times(2)).stockIn(eq("100"), any(), any(), any(), any(), any(), any()); verify(stockReceipts).stockIn(eq("100"), any(), any(), any(), any(), any(), any());
verify(outbox, times(1)).publishContainerCompleted(eq("100"), eq("BOOKING-1"), eq("CONT-1"), verify(stockReceipts).stockIn(eq("200"), any(), any(), any(), any(), any(), any());
verify(outbox, times(1)).publishContainerCompleted(eq("BOOKING-1"), eq("CONT-1"),
any(Instant.class), any(Instant.class), anyLong()); any(Instant.class), any(Instant.class), anyLong());
verify(repository, times(2)).update(any(InboundPlan.class)); verify(repository, times(2)).update(any(InboundPlan.class));
verify(repository, times(2)).update(any(InboundPdaTask.class)); verify(repository, times(2)).update(any(InboundPdaTask.class));
@@ -592,7 +592,7 @@ class InboundPdaWorkflowServiceTest {
verify(repository).update(plan); verify(repository).update(plan);
verify(stockReceipts).stockIn(eq("100"), eq(plan), eq(task), eq(List.of(targetLine)), verify(stockReceipts).stockIn(eq("100"), eq(plan), eq(task), eq(List.of(targetLine)),
eq(List.of(target, moved)), any(), any()); eq(List.of(target, moved)), any(), any());
verify(unloadCompletedOutbox).publishContainerCompleted(eq("100"), eq("BOOKING-1"), eq("CONT-1"), verify(unloadCompletedOutbox).publishContainerCompleted(eq("BOOKING-1"), eq("CONT-1"),
any(), any(), anyLong()); any(), any(), anyLong());
} }
@@ -45,7 +45,7 @@ class InboundUnloadCompletedOutboxServiceTest {
Instant photoUploadedAt = Instant.parse("2026-08-24T08:00:00Z"); Instant photoUploadedAt = Instant.parse("2026-08-24T08:00:00Z");
Instant containerCompletedAt = Instant.parse("2026-08-24T09:30:00Z"); Instant containerCompletedAt = Instant.parse("2026-08-24T09:30:00Z");
service.publishContainerCompleted("100", "booking-1", "MSCU6639870", service.publishContainerCompleted("booking-1", "MSCU6639870",
containerCompletedAt, photoUploadedAt, 7); containerCompletedAt, photoUploadedAt, 7);
ArgumentCaptor<LastlegEventOutbox> captured = ArgumentCaptor.forClass(LastlegEventOutbox.class); ArgumentCaptor<LastlegEventOutbox> captured = ArgumentCaptor.forClass(LastlegEventOutbox.class);
@@ -57,6 +57,8 @@ class InboundUnloadCompletedOutboxServiceTest {
assertThat(payload.unloadingCompletedAt()).isEqualTo(containerCompletedAt); assertThat(payload.unloadingCompletedAt()).isEqualTo(containerCompletedAt);
assertThat(payload.unloadingPhotoUploadedAt()).isEqualTo(photoUploadedAt); assertThat(payload.unloadingPhotoUploadedAt()).isEqualTo(photoUploadedAt);
assertThat(row.getEventKey()).isEqualTo(EventTypes.OVERSEAS_CONTAINER_UNLOADED + ":booking-1"); 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"); assertThat(envelope.aggregateId()).isEqualTo("booking-1");
} }
@@ -87,11 +89,11 @@ class InboundUnloadCompletedOutboxServiceTest {
@Test @Test
void repeatedContainerCompletionDoesNotPublishAnotherEvent() { 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")) when(mapper.findByEventKey(EventTypes.OVERSEAS_CONTAINER_UNLOADED + ":booking-1"))
.thenReturn(existing); .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); Instant.parse("2026-08-24T10:00:00Z"), Instant.parse("2026-08-24T08:00:00Z"), 8);
verify(mapper, never()).insert(any(LastlegEventOutbox.class)); verify(mapper, never()).insert(any(LastlegEventOutbox.class));
@@ -99,11 +101,11 @@ class InboundUnloadCompletedOutboxServiceTest {
@Test @Test
void sameIdempotencyKeyCannotRepresentAnotherPhysicalContainer() { 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")) when(mapper.findByEventKey(EventTypes.OVERSEAS_CONTAINER_UNLOADED + ":booking-1"))
.thenReturn(existing); .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)) Instant.parse("2026-08-24T10:00:00Z"), Instant.parse("2026-08-24T08:00:00Z"), 8))
.isInstanceOf(IllegalStateException.class) .isInstanceOf(IllegalStateException.class)
.hasMessageContaining("conflicts with another container"); .hasMessageContaining("conflicts with another container");
@@ -93,7 +93,7 @@ class ReceivingContainerServiceBehaviorTest {
when(lines.selectList(any())).thenReturn(List.of(line("FBA"))); when(lines.selectList(any())).thenReturn(List.of(line("FBA")));
LastlegOrder order = containerOrder(1L, "100", "CONT-A", "BOOKING-A", "IN-1"); LastlegOrder order = containerOrder(1L, "100", "CONT-A", "BOOKING-A", "IN-1");
when(orders.selectList(any())).thenReturn(List.of(order)); 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(); assertThat(service.candidates("7", 100L)).isEmpty();
assertThatThrownBy(() -> service.prepare("7", "receiver", 100L, "CONT-A", assertThatThrownBy(() -> service.prepare("7", "receiver", 100L, "CONT-A",
@@ -118,7 +118,6 @@ class ReceivingContainerServiceBehaviorTest {
assertThat(response.overseasWarehouseName()).isEqualTo("Shared LAX"); assertThat(response.overseasWarehouseName()).isEqualTo("Shared LAX");
ArgumentCaptor<ReceivingContainerSession> selected = ArgumentCaptor.forClass(ReceivingContainerSession.class); ArgumentCaptor<ReceivingContainerSession> selected = ArgumentCaptor.forClass(ReceivingContainerSession.class);
verify(sessions).insert(selected.capture()); verify(sessions).insert(selected.capture());
assertThat(selected.getValue().getCustomerId()).isEqualTo("100");
assertThat(selected.getValue().getActiveOperatorKey()).isEqualTo("OPERATOR:7"); assertThat(selected.getValue().getActiveOperatorKey()).isEqualTo("OPERATOR:7");
assertThat(selected.getValue().getActiveContainerKey()).isEqualTo("CONTAINER:CONT-A"); assertThat(selected.getValue().getActiveContainerKey()).isEqualTo("CONTAINER:CONT-A");
ArgumentCaptor<ReceivingContainerOperation> saved = ArgumentCaptor.forClass(ReceivingContainerOperation.class); ArgumentCaptor<ReceivingContainerOperation> saved = ArgumentCaptor.forClass(ReceivingContainerOperation.class);
@@ -131,7 +130,7 @@ class ReceivingContainerServiceBehaviorTest {
} }
@Test @Test
void pickerCandidatesSpanCustomersButEachContainerMustResolveOneCustomer() { void pickerCandidatesAllowMultipleCustomersInTheSameContainer() {
InboundPlan first = plan("CONT-A"); InboundPlan first = plan("CONT-A");
InboundPlan second = plan("CONT-B"); second.setId(3L); second.setCustomerId("200"); 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)); 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( when(orders.selectList(any())).thenReturn(List.of(
containerOrder(1L, "100", "CONT-A", "BOOKING-A", "IN-1"), containerOrder(1L, "100", "CONT-A", "BOOKING-A", "IN-1"),
containerOrder(2L, "200", "CONT-A", "BOOKING-A", "IN-2"))); 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 @Test
@@ -311,6 +310,9 @@ class ReceivingContainerServiceBehaviorTest {
@Test @Test
void detailRejectsContainerReservedByAnotherOperator() { void detailRejectsContainerReservedByAnotherOperator() {
when(plans.selectList(any())).thenReturn(List.of(plan("CONT-B"))); 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"); ReceivingContainerSession other = session("CONT-B", "SIGNED");
other.setOperatorKey("8"); other.setActiveOperatorKey("OPERATOR:8"); other.setOperatorKey("8"); other.setActiveOperatorKey("OPERATOR:8");
other.setActiveContainerKey("CONTAINER:CONT-B"); other.setActiveContainerKey("CONTAINER:CONT-B");
@@ -324,6 +326,9 @@ class ReceivingContainerServiceBehaviorTest {
@Test @Test
void waitSignSelectionAlreadyLocksAccountFromViewingAnotherContainer() { void waitSignSelectionAlreadyLocksAccountFromViewingAnotherContainer() {
when(plans.selectList(any())).thenReturn(List.of(plan("CONT-B"))); 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")); when(sessions.findActiveByOperator("OPERATOR:7")).thenReturn(session("CONT-A", "WAIT_SIGN"));
assertThatThrownBy(() -> service.detail("7", 100L, "CONT-B")) assertThatThrownBy(() -> service.detail("7", 100L, "CONT-B"))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409"); .isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
@@ -397,8 +402,6 @@ class ReceivingContainerServiceBehaviorTest {
org.mockito.Mockito.reset(plans, lines, orders, sessions, operations, cartons, warehouse, pallets, workflow); org.mockito.Mockito.reset(plans, lines, orders, sessions, operations, cartons, warehouse, pallets, workflow);
stubCurrent("OVERSEAS", "CONT-A"); 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", assertThatThrownBy(() -> service.receiveCarton("7", "receiver",
new ReceivingContainerRequests.CartonReceive("BOX-1", "PAL-1", "duplicate-key"))) new ReceivingContainerRequests.CartonReceive("BOX-1", "PAL-1", "duplicate-key")))
.isInstanceOf(ResponseStatusException.class); .isInstanceOf(ResponseStatusException.class);
@@ -429,7 +432,7 @@ class ReceivingContainerServiceBehaviorTest {
when(allocations.selectList(any())).thenReturn(List.of(partial)); when(allocations.selectList(any())).thenReturn(List.of(partial));
assertThatThrownBy(() -> service.complete("7", new ReceivingContainerRequests.Action("complete-key"))) assertThatThrownBy(() -> service.complete("7", new ReceivingContainerRequests.Action("complete-key")))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409"); .isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
verify(workflow, never()).completeContainer(any(), any()); verify(workflow, never()).completeContainer(any());
} }
@Test @Test
@@ -492,6 +495,39 @@ class ReceivingContainerServiceBehaviorTest {
org.mockito.ArgumentMatchers.same(occupied)); 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 @Test
void signRequiresEachTypedPhotoExactlyOnceAndRestoresRoles() { void signRequiresEachTypedPhotoExactlyOnceAndRestoresRoles() {
when(sessions.lockActiveByOperator("OPERATOR:7")).thenReturn(session("CONT-A", "WAIT_SIGN")); 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/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/2/download",
"/api/pda/v1/last-leg/receiving-containers/current/unload-photos/files/3/download"); "/api/pda/v1/last-leg/receiving-containers/current/unload-photos/files/3/download");
verify(unloadPhotos).validate("100", 8L, 2092476334483931138L); verify(unloadPhotos).validate("0", 8L, 2092476334483931138L);
verify(unloadPhotos).validate("100", 8L, 2L); verify(unloadPhotos).validate("0", 8L, 2L);
verify(unloadPhotos).validate("100", 8L, 3L); verify(unloadPhotos).validate("0", 8L, 3L);
} }
@Test @Test
@@ -558,7 +594,7 @@ class ReceivingContainerServiceBehaviorTest {
} }
@Test @Test
void currentSignedPhotoCanBeDownloadedOnlyThroughItsOperatorCustomerAndSessionScope() { void currentSignedPhotoCanBeDownloadedOnlyThroughItsPlatformSessionScope() {
ReceivingContainerSession current = session("CONT-A", "SIGNED"); ReceivingContainerSession current = session("CONT-A", "SIGNED");
current.setSignPhotos("[{\"role\":\"LICENSE_PLATE\",\"fileId\":\"9\"}," current.setSignPhotos("[{\"role\":\"LICENSE_PLATE\",\"fileId\":\"9\"},"
+ "{\"role\":\"CONTAINER_SEAL\",\"fileId\":\"10\"}," + "{\"role\":\"CONTAINER_SEAL\",\"fileId\":\"10\"},"
@@ -566,10 +602,10 @@ class ReceivingContainerServiceBehaviorTest {
when(sessions.findActiveByOperator("OPERATOR:7")).thenReturn(current); when(sessions.findActiveByOperator("OPERATOR:7")).thenReturn(current);
UnloadPhotoFileGateway.DownloadedFile expected = UnloadPhotoFileGateway.DownloadedFile expected =
new UnloadPhotoFileGateway.DownloadedFile("image".getBytes(), "plate.jpg", "image/jpeg"); 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); assertThat(service.downloadCurrentPhoto("7", "9")).isSameAs(expected);
verify(unloadPhotos).download("100", 8L, 9L); verify(unloadPhotos).download("0", 8L, 9L);
} }
@Test @Test
@@ -781,7 +817,7 @@ class ReceivingContainerServiceBehaviorTest {
private static ReceivingContainerSession session(String containerNo, String status) { private static ReceivingContainerSession session(String containerNo, String status) {
ReceivingContainerSession value = new ReceivingContainerSession(); 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.setOverseasWarehouseId(100L); value.setOverseasWarehouseCode("SHARED-LAX");
value.setOverseasWarehouseName("Shared LAX"); value.setOverseasWarehouseName("Shared LAX");
value.setStatus(status); value.setVersion(0); value.setStatus(status); value.setVersion(0);
@@ -4,17 +4,17 @@ import com.baiyue.common.event.*; import com.baiyue.lastleg.entity.*; import com
class LoadingCompletedConsumerTest { class LoadingCompletedConsumerTest {
private final Map<String,String> frozenContainerNos=new HashMap<>(); private final Map<String,String> 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; @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<InventoryCommandRequests.InTransit> 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<LastlegOrder> 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<LastlegInboundOrder> 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 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<InventoryCommandRequests.InTransit> 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<LastlegOrder> 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<LastlegInboundOrder> 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<LastlegOrder> 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 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<LastlegOrder> 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 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<LastlegOrder> 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 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<LastlegOrder> 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 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 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 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 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<LastlegOrder> 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<LastlegOrder> 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 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 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)); } @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)); }
@@ -113,7 +113,7 @@ class ContainerShippingNodeConsumerTest {
private ContainerShippingGate gate(String status, int sequence, long version, Instant actualAt) { private ContainerShippingGate gate(String status, int sequence, long version, Instant actualAt) {
ContainerShippingGate gate = new ContainerShippingGate(); gate.setContainerBookingId("CB-1"); 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.setStatus(status); gate.setNodeSequence(sequence); gate.setAggregateVersion(version);
gate.setHandoverVersion(version + 1); gate.setActualAt(java.time.LocalDateTime.ofInstant(actualAt, gate.setHandoverVersion(version + 1); gate.setActualAt(java.time.LocalDateTime.ofInstant(actualAt,
java.time.ZoneOffset.UTC)); return gate; 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) { private EventEnvelope event(String eventId, String node, int sequence, long version, Instant actualAt) {
FirstLegContainerShippingNodeRecordedV1 payload = new FirstLegContainerShippingNodeRecordedV1( 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)); actualAt, "LAX", "Los Angeles", "CARRIER", "PE-1", node, "REF-1", "a".repeat(64));
JsonNode payloadNode = json.valueToTree(payload); JsonNode payloadNode = json.valueToTree(payload);
String hash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payloadNode)); String hash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payloadNode));
return new EventEnvelope(eventId, EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED, 1, 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, version, version + 1, "123e4567-e89b-12d3-a456-426614174199", "shipping:CB-1", null,
payloadNode, hash); payloadNode, hash);
} }