fix: support multi-customer overseas receiving containers
This commit is contained in:
@@ -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`;账号、授权快照等安全事件的冻结模型 |
|
||||||
|
|||||||
+1
-3
@@ -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("船节点交接业务标识不能为空");
|
||||||
|
|||||||
+4
-4
@@ -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
@@ -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` |
|
||||||
|
|||||||
+3
-3
@@ -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);
|
||||||
|
|||||||
+3
@@ -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");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 实收、托盘和库存始终使用入仓码所属票的真实客户。未完成会话按平台操作账号和柜号分别全局唯一;平台管理员和客户账号不得进入该作业入口。
|
||||||
|
|
||||||
## 查询与租户约束
|
## 查询与租户约束
|
||||||
|
|
||||||
|
|||||||
-1
@@ -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;
|
||||||
|
|||||||
-1
@@ -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;
|
||||||
|
|||||||
+8
-7
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-11
@@ -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");
|
||||||
|
|||||||
+65
-89
@@ -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);
|
||||||
|
|||||||
+1
-1
@@ -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) {
|
||||||
|
|||||||
-1
@@ -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;
|
||||||
|
|||||||
+8
-9
@@ -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);
|
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-6
@@ -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: 当前账号已锁定其他柜号 } }
|
||||||
|
|||||||
+6
@@ -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");
|
||||||
|
|||||||
+9
-6
@@ -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));
|
||||||
|
|||||||
+1
-1
@@ -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());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+7
-5
@@ -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");
|
||||||
|
|||||||
+50
-14
@@ -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);
|
||||||
|
|||||||
+3
-3
@@ -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)); }
|
||||||
|
|||||||
+3
-3
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user