From f9649c44e9159ef07d2f2c2c0cfdee886ab9b0fb Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 15 Sep 2026 13:36:58 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- common-event-contract/AGENTS.md | 4 +- .../event/FirstLegLoadingPlanCompletedV3.java | 45 +++++++++++++++ .../openapi/cross-region-events-v1.yaml | 19 +++++++ .../FirstLegLoadingPlanCompletedV3Test.java | 46 ++++++++++++++++ config/nacos/overseas/lastleg-service.yml | 5 ++ deploy/sql/海外区域统一增量-MySQL8.sql | 14 ++++- firstleg/AGENTS.md | 2 +- .../LoadingCompletedOutboxService.java | 32 +++++++++-- .../LoadingCompletedOutboxServiceTest.java | 21 ++++++- .../TaillegRealProducerAcceptanceTest.java | 13 ++++- lastleg-service/AGENTS.md | 5 +- .../config/LastlegRestClientProperties.java | 3 + .../dto/ManualInboundPlanRequest.java | 2 - .../mapper/InboundPlanOrderMapper.java | 5 ++ .../ReceivingContainerController.java | 12 ++++ .../pda/dto/ReceivingContainerResponses.java | 9 ++- .../pda/entity/ContainerLoadingEvidence.java | 22 ++++++++ .../DomesticLoadingEvidenceGateway.java | 7 +++ .../HttpDomesticLoadingEvidenceGateway.java | 55 +++++++++++++++++++ .../ContainerLoadingEvidenceMapper.java | 18 ++++++ .../service/ReceivingContainerService.java | 51 ++++++++++++++++- .../service/InboundPlanService.java | 48 ++++++++++++---- .../service/LoadingCompletedConsumer.java | 50 ++++++++++++++++- .../src/main/resources/application.yml | 5 ++ .../src/main/resources/db/lastleg.sql | 12 ++++ .../openapi/overseas-business-v1.yaml | 35 ++++++++++-- .../TailPlanCommandOpenApiContractTest.java | 4 +- .../controller/InboundPlanControllerTest.java | 3 +- .../ReceivingContainerControllerTest.java | 16 ++++++ ...ReceivingContainerServiceBehaviorTest.java | 26 +++++++++ ...rInboundPlanConversionCoordinatorTest.java | 2 +- .../service/InboundPlanServiceTest.java | 40 +++++++++++--- .../service/LoadingCompletedConsumerTest.java | 24 +++++++- .../TaillegRealConsumerAcceptanceTest.java | 4 +- scripts/admin-test-env-acceptance.mjs | 3 +- scripts/admin-test-env-modules/README.md | 2 + .../dropship-browser-fixture.mjs | 2 + .../dropship-pallet-matrix-contract.test.mjs | 10 ++++ .../dropship-pallet-matrix.mjs | 17 ++++++ .../loading-list-remarks-contract.test.mjs | 9 ++- .../phase3-loading-finalize.mjs | 4 +- .../phase3-producer-chain-contract.test.mjs | 2 +- .../phase4-tail-order-controls.mjs | 1 - scripts/agents-index-contract.ps1 | 1 + 44 files changed, 647 insertions(+), 63 deletions(-) create mode 100644 common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV3.java create mode 100644 common-event-contract/src/test/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV3Test.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/ContainerLoadingEvidence.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/DomesticLoadingEvidenceGateway.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpDomesticLoadingEvidenceGateway.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ContainerLoadingEvidenceMapper.java create mode 100644 scripts/admin-test-env-modules/dropship-browser-fixture.mjs diff --git a/common-event-contract/AGENTS.md b/common-event-contract/AGENTS.md index 8e6c472e6..5f107b109 100644 --- a/common-event-contract/AGENTS.md +++ b/common-event-contract/AGENTS.md @@ -14,7 +14,7 @@ | 事件 ID 与业务键 | `EventIdentifiers.java` | | Payload 规范化与哈希 | `EventPayloadCanonicalizer.java`、`EventPayloadHash.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` | +| 头程装柜完成 V3(当前生产) | `FirstLegLoadingPlanCompletedV3.java` 在 V2 订单快照上增加物理柜空仓、半仓、满仓、锁仓、带锁码五类不可变照片引用;订单级仍冻结 `destinationType=FBA|OVERSEAS`,FBA 可按箱数接收,OVERSEAS 逐明细冻结与实装箱数一致的 SKU/箱号;国内生产者见 `../firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java`,海外消费者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java`;V1/V2 仅用于历史事件读取 | | 头程装柜完成 V1 解析模型 | `FirstLegLoadingPlanCompletedV1.java`;用于消费端冻结字段映射,新增生产不得回退为 V1 | | 海外卸柜完成 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 回传,国内按入仓单绑定、客户归属及聚合版本单调消费 | @@ -24,7 +24,7 @@ | 海外业务资金指令与结果 V1 | `WalletCommandRequestedV1.java`、`WalletCommandCompletedV1.java`;海外业务通过 Outbox 请求国内 Finance 冻结、结算或释放,Finance 执行后再经 Outbox 回传结果,双方以事件与业务幂等键防重,不跨区域同步调用钱包 | | IAM 安全事件 V1 | `IamSecurityEventsV1.java`;账号、授权快照等安全事件的冻结模型 | | 契约说明 | `package-info.java` | -| 契约测试 | `src/test/java/com/baiyue/common/event/EventEnvelopeTest.java`、`FirstLegLoadingPlanCompletedV1Test.java`、`FirstLegLoadingPlanCompletedV2Test.java`、`OverseasContainerUnloadedV1Test.java`、`FirstLegContainerShippingNodeRecordedV1Test.java` | +| 契约测试 | `src/test/java/com/baiyue/common/event/EventEnvelopeTest.java`、`FirstLegLoadingPlanCompletedV1Test.java`、`FirstLegLoadingPlanCompletedV2Test.java`、`FirstLegLoadingPlanCompletedV3Test.java`、`OverseasContainerUnloadedV1Test.java`、`FirstLegContainerShippingNodeRecordedV1Test.java` | | 真实生产/消费验收 | `../firstleg/src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java`、`../lastleg-service/src/test/java/com/baiyue/lastleg/service/TaillegRealConsumerAcceptanceTest.java` | ## 修改与验证 diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV3.java b/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV3.java new file mode 100644 index 000000000..188d15c68 --- /dev/null +++ b/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV3.java @@ -0,0 +1,45 @@ +package com.baiyue.common.event; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; + +/** 装柜完成 V3:在 V2 货物快照上增加整柜五类国内装柜照片引用。 */ +public record FirstLegLoadingPlanCompletedV3( + String loadingPlanId, String planNo, + @JsonProperty("sourceContainerBookingId") String containerBookingId, + String containerNo, String cabinetNo, String warehouseId, String warehouseName, + String estimatedPortArrivalAt, String actualPortArrivalAt, String estimatedWarehouseArrivalAt, + List orders, List loadingEvidence) { + public FirstLegLoadingPlanCompletedV3 { + if (loadingPlanId == null || loadingPlanId.isBlank() + || containerBookingId == null || containerBookingId.isBlank()) { + throw new IllegalArgumentException("loadingPlanId and sourceContainerBookingId are required"); + } + containerNo = ContainerNumbers.requireCanonical(containerNo); + orders = orders == null ? List.of() : List.copyOf(orders); + loadingEvidence = loadingEvidence == null ? List.of() : List.copyOf(loadingEvidence); + if (orders.isEmpty()) throw new IllegalArgumentException("orders are required"); + var owner = orders.get(0); + if (orders.stream().anyMatch(order -> !owner.customerId().equals(order.customerId()) + || !owner.customerName().equals(order.customerName()))) { + throw new IllegalArgumentException("orders must share one immutable customerId/customerName snapshot"); + } + if (loadingEvidence.size() != 5 + || loadingEvidence.stream().map(LoadingEvidence::evidenceType).distinct().count() != 5) { + throw new IllegalArgumentException("five unique loading evidence types are required"); + } + } + + public record LoadingEvidence(String evidenceType, long fileId, String fileName) { + public LoadingEvidence { + if (evidenceType == null || !evidenceType.matches("PHOTO_STEP_[1-4]|SEAL")) { + throw new IllegalArgumentException("unsupported loading evidence type"); + } + if (fileId <= 0) throw new IllegalArgumentException("loading evidence fileId must be positive"); + if (fileName == null || fileName.isBlank() || fileName.length() > 255) { + throw new IllegalArgumentException("loading evidence fileName is required"); + } + fileName = fileName.trim(); + } + } +} diff --git a/common-event-contract/src/main/resources/openapi/cross-region-events-v1.yaml b/common-event-contract/src/main/resources/openapi/cross-region-events-v1.yaml index 081b3e385..2b64f0259 100644 --- a/common-event-contract/src/main/resources/openapi/cross-region-events-v1.yaml +++ b/common-event-contract/src/main/resources/openapi/cross-region-events-v1.yaml @@ -5,6 +5,25 @@ info: paths: {} components: schemas: + FirstLegLoadingPlanCompletedV3: + allOf: + - $ref: '#/components/schemas/FirstLegLoadingPlanCompletedV2' + - type: object + required: [loadingEvidence] + properties: + loadingEvidence: + type: array + minItems: 5 + maxItems: 5 + description: Exactly one active domestic loading photo reference for each of PHOTO_STEP_1 through PHOTO_STEP_4 and SEAL. + items: {$ref: '#/components/schemas/FirstLegLoadingEvidenceV3'} + FirstLegLoadingEvidenceV3: + type: object + required: [evidenceType, fileId, fileName] + properties: + evidenceType: {type: string, enum: [PHOTO_STEP_1, PHOTO_STEP_2, PHOTO_STEP_3, PHOTO_STEP_4, SEAL]} + fileId: {type: integer, format: int64, minimum: 1, description: Domestic File service identity; never exposed to PDA clients} + fileName: {type: string, minLength: 1, maxLength: 255} FirstLegLoadingPlanCompletedV2: type: object required: [loadingPlanId, sourceContainerBookingId, containerNo, estimatedPortArrivalAt, actualPortArrivalAt, estimatedWarehouseArrivalAt, orders] diff --git a/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV3Test.java b/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV3Test.java new file mode 100644 index 000000000..dd83858d8 --- /dev/null +++ b/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV3Test.java @@ -0,0 +1,46 @@ +package com.baiyue.common.event; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.List; +import org.junit.jupiter.api.Test; + +class FirstLegLoadingPlanCompletedV3Test { + @Test + void carriesExactlyFiveDomesticLoadingPhotoReferences() { + var json = new ObjectMapper().valueToTree(payload(evidence())); + + assertThat(json.path("loadingEvidence")).hasSize(5); + assertThat(json.at("/loadingEvidence/4/evidenceType").asText()).isEqualTo("SEAL"); + } + + @Test + void rejectsMissingOrDuplicatePhotoTypes() { + assertThatThrownBy(() -> payload(evidence().subList(0, 4))) + .hasMessageContaining("five unique"); + assertThatThrownBy(() -> payload(List.of( + photo("PHOTO_STEP_1", 1), photo("PHOTO_STEP_1", 2), photo("PHOTO_STEP_2", 3), + photo("PHOTO_STEP_3", 4), photo("SEAL", 5)))) + .hasMessageContaining("five unique"); + } + + private FirstLegLoadingPlanCompletedV3 payload(List photos) { + var order = new FirstLegLoadingPlanCompletedV2.Order( + "2", "2", "3", "ORD", "3", "RK", null, "ONT8", "FBA ONT8", + 2, 20, 0, null, null, null, List.of(), "11", "客户甲", "FIRST_LEG", List.of(), "FBA"); + return new FirstLegLoadingPlanCompletedV3( + "4", "P", "5", "ZCSU7881970", "CAB", "6", "WH", + null, null, null, List.of(order), photos); + } + + private List evidence() { + return List.of(photo("PHOTO_STEP_1", 1), photo("PHOTO_STEP_2", 2), + photo("PHOTO_STEP_3", 3), photo("PHOTO_STEP_4", 4), photo("SEAL", 5)); + } + + private FirstLegLoadingPlanCompletedV3.LoadingEvidence photo(String type, long id) { + return new FirstLegLoadingPlanCompletedV3.LoadingEvidence(type, id, type + ".jpg"); + } +} diff --git a/config/nacos/overseas/lastleg-service.yml b/config/nacos/overseas/lastleg-service.yml index cf46380b1..c2844966f 100644 --- a/config/nacos/overseas/lastleg-service.yml +++ b/config/nacos/overseas/lastleg-service.yml @@ -34,12 +34,17 @@ baiyue: pallet-physical: connect-timeout: ${LASTLEG_PALLET_PHYSICAL_CONNECT_TIMEOUT:3s} read-timeout: ${LASTLEG_PALLET_PHYSICAL_READ_TIMEOUT:5s} + domestic-loading-evidence: + connect-timeout: ${LASTLEG_DOMESTIC_LOADING_EVIDENCE_CONNECT_TIMEOUT:3s} + read-timeout: ${LASTLEG_DOMESTIC_LOADING_EVIDENCE_READ_TIMEOUT:30s} clients: domestic-warehouse: url: ${DOMESTIC_WAREHOUSE_URL} cache-ttl: ${DOMESTIC_WAREHOUSE_CACHE_TTL:30m} file: url: ${FILE_SERVICE_URL} + domestic-file: + url: ${DOMESTIC_FILE_SERVICE_URL} event-bridge: url: ${CN_EVENT_BRIDGE_URL:${FIRSTLEG_EVENT_BRIDGE_URL}} warehouse: diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 285bdfb13..8a15f3e66 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -5058,4 +5058,16 @@ DELIMITER ; CALL assert_relabel_document_columns_ready(); DROP PROCEDURE IF EXISTS assert_relabel_document_columns_ready; -SELECT 'PASS' AS migration_status, '贴标申请箱唛总文件、SKU总文件及明细文件字段已就绪' AS message; +CREATE TABLE IF NOT EXISTS ll_container_loading_evidence ( + id BIGINT NOT NULL COMMENT '海外整柜装柜照片投影ID', + source_container_booking_id VARCHAR(128) NOT NULL COMMENT '国内稳定来源柜预订ID', + evidence_type VARCHAR(32) NOT NULL COMMENT 'PHOTO_STEP_1至PHOTO_STEP_4或SEAL', + source_file_id BIGINT NOT NULL COMMENT '国内File服务文件ID', + file_name VARCHAR(255) NOT NULL COMMENT '国内文件名快照', + created_at DATETIME(3) NOT NULL COMMENT '投影创建时间', + PRIMARY KEY(id), + UNIQUE KEY uk_ll_container_loading_evidence_type(source_container_booking_id,evidence_type), + UNIQUE KEY uk_ll_container_loading_evidence_file(source_container_booking_id,source_file_id) +) ENGINE=InnoDB COMMENT='海外拆柜只读国内装柜照片引用'; + +SELECT 'PASS' AS migration_status, '贴标申请文件字段与国内装柜照片投影已就绪' AS message; diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md index 7c48ac467..8343e0789 100644 --- a/firstleg/AGENTS.md +++ b/firstleg/AGENTS.md @@ -41,7 +41,7 @@ | 货柜与全局智能排柜 | `controller/PlatformContainerController.java`、`PlatformSmartLoadingController.java`、`service/SmartLoadingService.java`、`repository/SmartLoadingRepository.java`、`client/PendingReviewOrderClient.java`、`loading/`;全局候选合并本服务履约投影与 Order `/internal/order/v1/pending-review-orders` 权威待审核页,工作台柜次从 `/inbound-candidates`、`/containers` 进入;普通箱数、待审核占位、同柜组和全部未拣移除分别走 `/inbound-assignments`、`/pending-review-assignments`、`/group-assignments`、`/unpicked-items`,明细规格拆分留在服务端 | | 货柜资料、PDA 证据与业务总览 | `controller/PlatformContainerController.java` 的 `/{id}/overview`、`/{id}/documents/{documentType}`,聚合 `mapper/ContainerOverviewMapper.java`;契约 `src/main/resources/openapi/phase3-container-overview-v1.yaml`,四类资料固定为目的港清关、国内出口报关、DO、PU 的当前唯一版本 | | PDA 拣货与装柜、证据幂等上传 | `controller/PlatformLoadingPdaController.java`、`loading/controller/PlatformLoadingDeliveryController.java`、`loading/service/CabinetLoadingEvidenceService.java`;证据写入和 multipart 上传均先以任务内幂等键匹配请求哈希,上传哈希覆盖仓库、版本、证据类型、锁码、备注、文件名、媒体类型、大小与文件内容;完全重放返回当前视图且不得再次上传 File,相同键不同内容返回冲突 | -| 装柜完成跨区事件 | `service/LoadingCompletedOutboxService.java`、`LoadingCompletedOutboxRelay.java`、`repository/LoadingCompletedOutboxRepository.java`、`mapper/LoadingCartonManifestMapper.java`、`client/EventBridgeClient.java`;从 `fl_inbound_order.destination_type` 冻结 V2 `orders[].destinationType`(`FBA` 或 `OVERSEAS`),OVERSEAS 按最终实装与稳定箱码冻结,FBA 可仅冻结箱数;真实生产验收 `src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java`;事件模型见 `../common-event-contract/AGENTS.md` | +| 装柜完成跨区事件 | `service/LoadingCompletedOutboxService.java`、`LoadingCompletedOutboxRelay.java`、`repository/LoadingCompletedOutboxRepository.java`、`mapper/LoadingCartonManifestMapper.java`、`loading/mapper/CabinetLoadingEvidenceMapper.java`、`client/EventBridgeClient.java`;当前发布 V3,从 `fl_inbound_order.destination_type` 冻结 `orders[].destinationType`(`FBA` 或 `OVERSEAS`),OVERSEAS 按最终实装与稳定箱码冻结,FBA 可仅冻结箱数,并冻结物理柜五类有效装柜照片引用;真实生产验收 `src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java`;事件模型见 `../common-event-contract/AGENTS.md` | | 海外卸柜完成事件回写 | 内部入口 `controller/InternalOverseasContainerEventController.java`、消费者 `service/OverseasContainerEventConsumer.java`、Inbox `entity/ContainerEventInboxEntity.java` 与 `mapper/ContainerEventInboxMapper.java`;事件模型见 `../common-event-contract/AGENTS.md` | | 海外暂扣状态回写 | 同一内部事件入口、消费者 `service/OverseasInboundOrderDetainedConsumer.java`;按来源入仓单 ID 更新 `fl_inbound_order.hold_flag/current_hold_reason`,复用跨区 Inbox 并写 `fl_inbound_hold_record` | | 海外派送完成回写 | 同一内部事件入口、消费者 `service/OverseasDeliveryCompletedConsumer.java`;按来源入仓单、订单、入仓码和 FBA 仓校验后推进当前入仓单 `LOADED→DELIVERED`,同一 Order 全部有效入仓单完成后才经 `OrderFulfillmentCompletionClient` 推进 Order `COMPLETED` | diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java index 1dd920e0b..a0d076627 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java @@ -10,6 +10,7 @@ import com.baiyue.common.event.EventPayloadCanonicalizer; import com.baiyue.common.event.EventPayloadHash; import com.baiyue.common.event.EventTypes; import com.baiyue.common.event.FirstLegLoadingPlanCompletedV2; +import com.baiyue.common.event.FirstLegLoadingPlanCompletedV3; import com.baiyue.common.event.RegionCode; import com.baiyue.firstleg.entity.InboundOutboxEntity; import com.baiyue.firstleg.mapper.InboundOutboxMapper; @@ -19,6 +20,7 @@ import com.baiyue.firstleg.mapper.LoadingCompletedEventMapper.PalletRow; import com.baiyue.firstleg.mapper.LoadingCartonManifestMapper; import com.baiyue.firstleg.mapper.LoadingPalletLabelMapper; import com.baiyue.firstleg.mapper.LoadingCartonManifestMapper.LabelRow; +import com.baiyue.firstleg.loading.mapper.CabinetLoadingEvidenceMapper; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -42,11 +44,12 @@ import java.util.Set; /** 在装柜完成事务内按客户及可信客户名称快照写跨区业务 Outbox。 */ @Service public class LoadingCompletedOutboxService { - private static final long HANDOVER_VERSION = 2L; + private static final long HANDOVER_VERSION = 3L; @Resource private LoadingCompletedEventMapper snapshotMapper; @Resource private LoadingCartonManifestMapper cartonManifestMapper; @Resource private LoadingPalletLabelMapper palletLabelMapper; @Resource private LoadingPalletLabelService palletLabelService; + @Resource private CabinetLoadingEvidenceMapper cabinetEvidenceMapper; @Resource private InboundOutboxMapper outboxMapper; @Resource private ObjectMapper objectMapper; @@ -110,21 +113,42 @@ public class LoadingCompletedOutboxService { .filter(item -> Objects.equals(item.getSourceInboundId(), value.get(0).getSourceInboundId())).toList(), standardLabels.stream().filter(item -> Objects.equals(item.getInboundId(), value.get(0).getSourceInboundId())).toList(), cartonMarks, palletManifests)).toList(); - var payload = new FirstLegLoadingPlanCompletedV2(String.valueOf(first.getLoadingPlanId()), + var payload = new FirstLegLoadingPlanCompletedV3(String.valueOf(first.getLoadingPlanId()), first.getPlanNo(), string(first.getContainerBookingId()), ContainerNumbers.requireCanonical(first.getContainerNo()), first.getCabinetNo(), string(first.getWarehouseId()), first.getWarehouseName(), iso(first.getEstimatedPortArrivalAt()), - iso(first.getActualPortArrivalAt()), iso(first.getEstimatedWarehouseArrivalAt()), orders); + iso(first.getActualPortArrivalAt()), iso(first.getEstimatedWarehouseArrivalAt()), orders, + loadingEvidence(first.getContainerBookingId())); JsonNode payloadNode = objectMapper.valueToTree(payload); String hash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payloadNode)); String eventKey = "FIRSTLEG_LOADING_COMPLETED:" + first.getLoadingPlanId() + ':' + accountId + ':' + HANDOVER_VERSION; String traceId = EventIdentifiers.newUuid(); - return new EventEnvelope(EventIdentifiers.newUuid(), EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED, 2, + return new EventEnvelope(EventIdentifiers.newUuid(), EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED, 3, Instant.now(), RegionCode.CN, RegionCode.OVERSEAS, String.valueOf(accountId), "LOADING_PLAN", String.valueOf(first.getLoadingPlanId()), first.getAggregateVersion(), HANDOVER_VERSION, traceId, eventKey, null, payloadNode, hash).requireValid(); } + private List loadingEvidence(Long containerBookingId) { + if (containerBookingId == null || containerBookingId <= 0) { + throw new BizException(ResultCode.CONFLICT, "装柜计划缺少柜次,不能冻结装柜照片"); + } + List result = cabinetEvidenceMapper + .byContainer(containerBookingId).stream() + .filter(value -> Integer.valueOf(1).equals(value.getActiveFlag()) && "ACTIVE".equals(value.getStatus())) + .map(value -> new FirstLegLoadingPlanCompletedV3.LoadingEvidence( + value.getEvidenceType(), value.getFileId(), value.getFileName())) + .sorted(Comparator.comparing(FirstLegLoadingPlanCompletedV3.LoadingEvidence::evidenceType)) + .toList(); + Set types = result.stream().map(FirstLegLoadingPlanCompletedV3.LoadingEvidence::evidenceType) + .collect(java.util.stream.Collectors.toSet()); + if (result.size() != 5 || !types.equals(Set.of( + "PHOTO_STEP_1", "PHOTO_STEP_2", "PHOTO_STEP_3", "PHOTO_STEP_4", "SEAL"))) { + throw new BizException(ResultCode.CONFLICT, "国内装柜五类照片不完整,不能生成尾程交接事件"); + } + return result; + } + private FirstLegLoadingPlanCompletedV2.Order order(List values, List palletRows, List standardLabels, Map> cartonMarks, diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java index aee65a76c..86603b740 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java @@ -10,6 +10,8 @@ import com.baiyue.firstleg.mapper.LoadingCompletedEventMapper.PalletRow; import com.baiyue.firstleg.mapper.LoadingCartonManifestMapper; import com.baiyue.firstleg.mapper.LoadingCartonManifestMapper.LabelRow; import com.baiyue.firstleg.mapper.LoadingPalletLabelMapper; +import com.baiyue.firstleg.loading.mapper.CabinetLoadingEvidenceMapper; +import com.baiyue.firstleg.loading.entity.CabinetLoadingEvidenceEntity; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -33,6 +35,7 @@ class LoadingCompletedOutboxServiceTest { private final LoadingCartonManifestMapper manifests = mock(LoadingCartonManifestMapper.class); private final LoadingPalletLabelMapper palletLabels = mock(LoadingPalletLabelMapper.class); private final LoadingPalletLabelService palletLabelService = mock(LoadingPalletLabelService.class); + private final CabinetLoadingEvidenceMapper cabinetEvidence = mock(CabinetLoadingEvidenceMapper.class); private final ObjectMapper mapper = new ObjectMapper().findAndRegisterModules(); private final LoadingCompletedOutboxService service = new LoadingCompletedOutboxService(); @@ -41,14 +44,25 @@ class LoadingCompletedOutboxServiceTest { ReflectionTestUtils.setField(service,"cartonManifestMapper",manifests); ReflectionTestUtils.setField(service,"palletLabelMapper",palletLabels); ReflectionTestUtils.setField(service,"palletLabelService",palletLabelService); + ReflectionTestUtils.setField(service,"cabinetEvidenceMapper",cabinetEvidence); ReflectionTestUtils.setField(service,"outboxMapper",outbox); ReflectionTestUtils.setField(service,"objectMapper",mapper); when(palletLabels.labels(anyLong())).thenReturn(List.of()); + when(cabinetEvidence.byContainer(anyLong())).thenReturn(List.of( + evidence("PHOTO_STEP_1", 1), evidence("PHOTO_STEP_2", 2), evidence("PHOTO_STEP_3", 3), + evidence("PHOTO_STEP_4", 4), evidence("SEAL", 5))); when(manifests.labelPool(anyLong(), anyLong())).thenAnswer(invocation -> List.of( label(101, "BOX-101"), label(102, "BOX-102"), label(202, "BOX-202"), label(302, "BOX-302"))); } + private static CabinetLoadingEvidenceEntity evidence(String type, long fileId) { + CabinetLoadingEvidenceEntity value = new CabinetLoadingEvidenceEntity(); + value.setEvidenceType(type); value.setFileId(fileId); value.setFileName(type + ".jpg"); + value.setStatus("ACTIVE"); value.setActiveFlag(1); + return value; + } + @Test void groupsByAccountAndKeepsAllOrdersAndItemsStable() throws Exception { when(snapshots.rows(90L)).thenReturn(List.of(row(22,300,302),row(11,200,202),row(11,100,102),row(11,100,101))); when(snapshots.palletRows(90L)).thenReturn(List.of( @@ -79,9 +93,12 @@ class LoadingCompletedOutboxServiceTest { assertThat(first.payload().at("/actualPortArrivalAt").asText()).isEqualTo("2026-10-01T08:30"); assertThat(first.payload().at("/estimatedWarehouseArrivalAt").asText()).isEqualTo("2026-10-03T09:00"); assertThat(first.payload().at("/sourceContainerBookingId").asText()).isEqualTo("91"); + assertThat(first.payload().get("loadingEvidence")).hasSize(5); + assertThat(first.payload().at("/loadingEvidence/0/evidenceType").asText()).isEqualTo("PHOTO_STEP_1"); + assertThat(first.payload().at("/loadingEvidence/4/evidenceType").asText()).isEqualTo("SEAL"); assertThat(first.payload().has("containerBookingId")).isFalse(); - assertThat(rows.get(0).getEventKey()).isEqualTo("FIRSTLEG_LOADING_COMPLETED:90:11:2"); - assertThat(first.eventVersion()).isEqualTo(2); + assertThat(rows.get(0).getEventKey()).isEqualTo("FIRSTLEG_LOADING_COMPLETED:90:11:3"); + assertThat(first.eventVersion()).isEqualTo(3); } @Test void requiresFrozenPhysicalContainerNumberBeforePublishing() { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java index 67cb9964c..3a43c1dce 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java @@ -39,7 +39,7 @@ class TaillegRealProducerAcceptanceTest { @Test void completedLoadingPlanPublishesFirstLegAndDropshipOrders() { - String eventKey = "FIRSTLEG_LOADING_COMPLETED:" + PLAN + ":" + CUSTOMER + ":2"; + String eventKey = "FIRSTLEG_LOADING_COMPLETED:" + PLAN + ":" + CUSTOMER + ":3"; Integer existing = jdbc.queryForObject("SELECT COUNT(*) FROM fl_inbound_outbox WHERE event_key=?", Integer.class, eventKey); if (existing == 0) { @@ -51,6 +51,8 @@ class TaillegRealProducerAcceptanceTest { assertEquals(2, jdbc.queryForObject("SELECT JSON_LENGTH(JSON_EXTRACT(payload_json,'$.payload.orders')) " + "FROM fl_inbound_outbox WHERE event_key=?", Integer.class, eventKey)); + assertEquals(5, jdbc.queryForObject("SELECT JSON_LENGTH(JSON_EXTRACT(payload_json,'$.payload.loadingEvidence')) " + + "FROM fl_inbound_outbox WHERE event_key=?", Integer.class, eventKey)); assertEquals("真实验收客户", jdbc.queryForObject( "SELECT JSON_UNQUOTE(JSON_EXTRACT(payload_json,'$.payload.orders[0].customerName')) " + "FROM fl_inbound_outbox WHERE event_key=?", String.class, eventKey)); @@ -91,6 +93,15 @@ class TaillegRealProducerAcceptanceTest { 990022901L, PLAN, BOOKING, MARKER + "-CAB", MARKER + "-CNTR"); seedPickItem(990022911L, 990022321L, FIRST_INBOUND, 990022421L, 3); seedPickItem(990022912L, 990022322L, DROPSHIP_INBOUND, 990022422L, 2); + int evidenceIndex = 0; + for (String type : new String[] {"PHOTO_STEP_1", "PHOTO_STEP_2", "PHOTO_STEP_3", "PHOTO_STEP_4", "SEAL"}) { + long id = 990023000L + evidenceIndex; + jdbc.update("INSERT IGNORE INTO fl_loading_cabinet_evidence(id,container_booking_id,source_task_id," + + "evidence_type,file_id,file_name,status,active_flag,operation_key,request_hash,created_by,updated_by) " + + "VALUES(?,?,?,?,?,?,'ACTIVE',1,?,REPEAT('a',64),1,1)", + id, BOOKING, 990022901L, type, id + 1000, type + ".jpg", MARKER + "-EVIDENCE-" + type); + evidenceIndex++; + } jdbc.update("UPDATE fl_loading_plan SET plan_status='COMPLETED',confirmed_at=NOW(3),version=version+1 WHERE id=?", PLAN); } diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index d6a05edca..6330a5332 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -27,9 +27,9 @@ |---|---| | 内部模块化基线与依赖分析 | `docs/lastleg-internal-modularization-baseline.md`、`scripts/analyze-lastleg-dependencies.mjs`、`scripts/analyze-lastleg-dependencies.test.mjs`、`src/test/resources/architecture/lastleg-slice-dependencies.json`;只约束本服务内部包依赖,不改变部署单元或数据库边界 | | 事件接收、平台订单列表/详情/导出、柜级进度、目的仓调整 | `src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java`;`/api/platform/v1/last-leg/orders/container-summaries` 按 `sourceContainerBookingId` 服务端聚合全柜订单、入仓码、有效计划及托盘资源异常并分页返回全部稳定来源柜,已完成计划转换的柜仍保留用于接柜仓查询与修改;单票手工计划保存后,整柜最后一票齐全时自动生成并绑定全柜托盘码,一票柜立即生成,`resourcePendingPlanCount` 只作为失败重试事实;`/{sourceContainerBookingId}` 返回柜内全部订单/入仓码/计划状态且不受订单分页截断;`plannedArrivalAt` 只取装柜完成事件 `estimatedWarehouseArrivalAt` 冻结到 `ll_order.estimated_warehouse_at` 的正式事实,按其升序、null 最后、稳定柜 ID 排序;目的仓调整仅接受国内 Warehouse Service 平台公共 `ACTIVE + FBA` 主档,写入前由 `service/FbaWarehouseValidator.java` 经内部只读目录校验 ID/编码/名称一致 | -| 尾程订单完整计划、暂扣与人工节点 | `controller/LastlegOrderController.java`、`service/LastlegOrderCommandService.java`、`inboundplan/service/InboundPlanService.java`、`dto/LastlegOrderCommandDtos.java`;单票计划写入校验订单版本、客户范围、订单冻结入仓码、稳定接柜仓 ID、当前仓库主档与幂等键;一件代发人工计划托数必须严格等于不可变头程封托托盘数,禁止保存可增减托数但无法绑定真实资源的半成品计划;接柜仓编码/名称以该 ID 当前目录快照为准,不要求订单残留旧展示快照。暂扣/解除写不可变历史并发布 `InboundOrderDetainedOutboxService`,已拣货、已发运或已有出库单时禁止新增暂扣;暂扣期间不得进入排车或出库。人工节点必须一次提交固定八行完整快照且至少一个时间,首次保存后永久使用 `MANUAL` 来源,不得回退柜级节点或反向修改头程船节点 | +| 尾程订单完整计划、暂扣与人工节点 | `controller/LastlegOrderController.java`、`service/LastlegOrderCommandService.java`、`inboundplan/service/InboundPlanService.java`、`dto/LastlegOrderCommandDtos.java`;单票计划写入校验订单版本、客户范围、订单冻结入仓码、稳定接柜仓 ID、当前仓库主档与幂等键;预计到仓日期不接受请求输入,必须锁定来源柜全部订单的 `estimated_warehouse_at`,缺失或同柜不一致时阻断,人工与自动计划统一写其日期;一件代发人工计划托数必须严格等于不可变头程封托托盘数,禁止保存可增减托数但无法绑定真实资源的半成品计划;接柜仓编码/名称以该 ID 当前目录快照为准,不要求订单残留旧展示快照。暂扣/解除写不可变历史并发布 `InboundOrderDetainedOutboxService`,已拣货、已发运或已有出库单时禁止新增暂扣;暂扣期间不得进入排车或出库。人工节点必须一次提交固定八行完整快照且至少一个时间,首次保存后永久使用 `MANUAL` 来源,不得回退柜级节点或反向修改头程船节点 | | 客户尾程订单列表与详情 | `controller/MerchantLastlegOrderController.java`;客户范围只取认证身份,稳定类型/状态见 `model/LastlegOrderType.java`、`LastlegOrderStatus.java` | -| 装柜完成事件消费 | `service/LoadingCompletedConsumer.java`;当前生产信封为 `FIRST_LEG_LOADING_PLAN_COMPLETED` V2,消费端冻结 `destinationType`:FBA 可按箱数且不生成逐箱作业账,OVERSEAS 必须逐箱校验并生成箱级事实;历史缺字段事件仅按明确 FBA 单号或目的仓与接柜仓一致性安全推断,歧义直接拒绝 | +| 装柜完成事件消费 | `service/LoadingCompletedConsumer.java`;当前生产信封为 `FIRST_LEG_LOADING_PLAN_COMPLETED` V3,消费端除冻结 V2 货物事实外,按 `sourceContainerBookingId` 幂等投影物理柜五类国内装柜照片;FBA 可按箱数且不生成逐箱作业账,OVERSEAS 必须逐箱校验并生成箱级事实;V1/V2 仅用于历史事件读取 | | 尾程单号生成 | `service/LastlegOrderNoGenerator.java` | | 订单查询与更新 | `service/LastlegOrderService.java` | | 业务 Inbox | `entity/LastlegEventInbox.java`、`mapper/LastlegEventInboxMapper.java` | @@ -42,6 +42,7 @@ | FBA 预约创建、详情与编辑 | `InboundPlanController.java` 的 `/fba-appointments/**`、`inboundplan/service/InboundFbaAppointmentService.java`、`BatchFbaAppointmentRequest.java`、`UpdateFbaAppointmentRequest.java` | | PDA 入仓、实收、上架与完成 | `inboundplan/pda/controller/ReceivingContainerController.java`、`inboundplan/pda/service/ReceivingContainerService.java`、内部编排 `InboundPdaWorkflowService.java`;公网入口固定为 `/api/pda/v1/last-leg/receiving-containers/**`,`/warehouse-profile` 只按 Token 绑定仓解析共享仓库中文名称与编码,旧 planNo/allocationId Controller 已移除。多计划同柜返回计划级 `assigned/mergeAllowed/urgent/mergeHint`;普通 FBA/OVERSEAS 分别使用 bulk/carton 收货,一件代发只允许 `/current/receipts/pallet` 按国内冻结来源托盘整托收货。合托只作现场可选提示;全部计划完整绑定真实 pallet/location/lock 前不得查看、准备或签收该柜,首扫只确认已预留绑定,不得重新分配计划资源 | | PDA 卸柜照片上传、归属校验与预览 | `ReceivingContainerController.java` 的 `/current/unload-photos/files`、`/current/unload-photos/files/{fileId}/download`,网关 `inboundplan/pda/gateway/UnloadPhotoFileGateway.java`、`HttpUnloadPhotoFileGateway.java`;File 类型固定 `OVERSEAS_UNLOAD_PHOTO`,下载只允许当前会话已签收的 fileId 并返回 `no-store` | +| PDA 国内装柜照片对照 | `ReceivingContainerController.java` 的 `/current/domestic-loading-photos/{evidenceType}/content`、`ContainerLoadingEvidenceMapper.java`、`DomesticLoadingEvidenceGateway.java`;元数据按物理柜投影,不保存客户,下载必须经当前 PDA 会话鉴权并由 `DOMESTIC_FILE_SERVICE_URL` 只读访问国内 File,单图不可用不改变拆柜状态 | | 卸柜照片与海外卸柜完成 Outbox | `ReceivingContainerController.java`、`ReceivingContainerService.java`、`inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`、`entity/LastlegEventOutbox.java`、`mapper/LastlegEventOutboxMapper.java`;多计划同柜只能在全柜完成后调用 `publishContainerCompleted`,按来源柜预订 ID 幂等发布一次,完成时刻与拍照时刻分离;事件模型见 `../common-event-contract/AGENTS.md` | | 拆柜打托成本与客户应收分摊 | `inboundplan/service/DestuffingPalletCostJob.java`、`DestuffingPalletCostService.java`;仅全柜所有 PDA 入仓任务完成后执行,按实物托盘单价汇总并按客户实收 CBM 分摊,尾差落最后一位客户;每份分摊发布 `OVERSEAS_CONTAINER_FEE/DESTUFF_PALLET` 应收费用来源,不直接改国内钱包 | | 尾程暂扣状态回传 | `service/InboundOrderDetainedOutboxService.java`;暂扣与解除暂扣在订单更新事务内按 `sourceInboundId` 写入海外到国内 Outbox,国内按来源入仓单绑定更新 | diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/config/LastlegRestClientProperties.java b/lastleg-service/src/main/java/com/baiyue/lastleg/config/LastlegRestClientProperties.java index 481cfe5e3..b647f3a9b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/config/LastlegRestClientProperties.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/config/LastlegRestClientProperties.java @@ -14,6 +14,7 @@ public class LastlegRestClientProperties { private ClientTimeouts procurementFile = new ClientTimeouts(Duration.ofSeconds(3), Duration.ofSeconds(30)); private ClientTimeouts warehouseResource = new ClientTimeouts(Duration.ofSeconds(3), Duration.ofSeconds(5)); private ClientTimeouts palletPhysical = new ClientTimeouts(Duration.ofSeconds(3), Duration.ofSeconds(5)); + private ClientTimeouts domesticLoadingEvidence = new ClientTimeouts(Duration.ofSeconds(3), Duration.ofSeconds(30)); public ClientTimeouts getUnloadPhotoFile() { return unloadPhotoFile; } public void setUnloadPhotoFile(ClientTimeouts value) { unloadPhotoFile = required(value, "unload-photo-file"); } @@ -27,6 +28,8 @@ public class LastlegRestClientProperties { public void setWarehouseResource(ClientTimeouts value) { warehouseResource = required(value, "warehouse-resource"); } public ClientTimeouts getPalletPhysical() { return palletPhysical; } public void setPalletPhysical(ClientTimeouts value) { palletPhysical = required(value, "pallet-physical"); } + public ClientTimeouts getDomesticLoadingEvidence() { return domesticLoadingEvidence; } + public void setDomesticLoadingEvidence(ClientTimeouts value) { domesticLoadingEvidence = required(value, "domestic-loading-evidence"); } private static ClientTimeouts required(ClientTimeouts value, String client) { if (value == null) throw new IllegalArgumentException(client + " HTTP timeouts are required"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/ManualInboundPlanRequest.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/ManualInboundPlanRequest.java index ce7be8228..98e168f95 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/ManualInboundPlanRequest.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/ManualInboundPlanRequest.java @@ -5,13 +5,11 @@ import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; -import java.time.LocalDate; public record ManualInboundPlanRequest( @NotBlank @Size(max = 128) String customerId, @NotNull Long orderId, @NotNull @Min(0) Integer expectedOrderVersion, - @NotNull LocalDate plannedArrivalDate, @NotNull @Min(1) @Max(999) Integer palletCount, @NotBlank @Size(max = 32) String palletType, @NotNull Boolean assigned, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapper.java index 5b4536665..9ca38ae2d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapper.java @@ -75,6 +75,11 @@ public interface InboundPlanOrderMapper { String findSourceContainerBookingId(@Param("customerId") String customerId, @Param("orderId") Long orderId); + @Select("SELECT estimated_warehouse_at FROM ll_order " + + "WHERE source_container_booking_id=#{sourceContainerBookingId} ORDER BY customer_id,id") + List containerEstimatedWarehouseTimes( + @Param("sourceContainerBookingId") String sourceContainerBookingId); + @Select("SELECT order_type FROM ll_order WHERE customer_id=#{customerId} AND id=#{orderId} LIMIT 1") String findOrderType(@Param("customerId") String customerId, @Param("orderId") Long orderId); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java index b359e1e9e..783c55b38 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java @@ -91,6 +91,18 @@ public class ReceivingContainerController { .contentLength(download.content().length).body(download.content()); } + @GetMapping("/current/domestic-loading-photos/{evidenceType}/content") + public ResponseEntity downloadDomesticLoadingPhoto(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String evidenceType) { + PdaWarehouseIdentity identity = currentIdentity(user); + var download = service.downloadDomesticLoadingPhoto(identity.operatorKey(), evidenceType); + return ResponseEntity.ok().cacheControl(CacheControl.noStore()) + .header(HttpHeaders.CONTENT_DISPOSITION, + ContentDisposition.inline().filename(download.fileName(), StandardCharsets.UTF_8).build().toString()) + .contentType(MediaType.parseMediaType(download.contentType())) + .contentLength(download.content().length).body(download.content()); + } + @PostMapping("/current/sign") public ApiResponse sign(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody ReceivingContainerRequests.Sign request) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerResponses.java index 3319713ba..d1e1cb497 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerResponses.java @@ -32,7 +32,8 @@ public final class ReceivingContainerResponses { public record Session(String containerNo, Long overseasWarehouseId, String overseasWarehouseCode, String overseasWarehouseName, String status, String operatorName, int version, - List photos, LocalDateTime preparedAt, LocalDateTime signedAt, + List photos, List domesticLoadingPhotos, + LocalDateTime preparedAt, LocalDateTime signedAt, LocalDateTime receivingAt, LocalDateTime completedAt, int totalPackages, int receivedPackages, int pendingPackages, boolean hasDifference, List inbounds) { @@ -42,7 +43,7 @@ public final class ReceivingContainerResponses { LocalDateTime completedAt, int totalPackages, int receivedPackages, int pendingPackages, List inbounds) { this(containerNo, overseasWarehouseId, null, overseasWarehouseName, status, operatorName, - version, photos, preparedAt, signedAt, receivingAt, completedAt, totalPackages, + version, photos, List.of(), preparedAt, signedAt, receivingAt, completedAt, totalPackages, receivedPackages, pendingPackages, false, inbounds); } @@ -52,13 +53,15 @@ public final class ReceivingContainerResponses { LocalDateTime receivingAt, LocalDateTime completedAt, int totalPackages, int receivedPackages, int pendingPackages, List inbounds) { this(containerNo, overseasWarehouseId, overseasWarehouseCode, overseasWarehouseName, - status, operatorName, version, photos, preparedAt, signedAt, receivingAt, + status, operatorName, version, photos, List.of(), preparedAt, signedAt, receivingAt, completedAt, totalPackages, receivedPackages, pendingPackages, false, inbounds); } } public record SignedPhoto(ReceivingPhotoRole role, String fileId, String downloadPath) { } + public record DomesticLoadingPhoto(String evidenceType, String label, String downloadPath) { } + public record Inbound(String inboundCode, String destinationType, String overseasWarehouseId, String overseasWarehouseName, String destinationWarehouseId, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/ContainerLoadingEvidence.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/ContainerLoadingEvidence.java new file mode 100644 index 000000000..61b150f51 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/ContainerLoadingEvidence.java @@ -0,0 +1,22 @@ +package com.baiyue.lastleg.inboundplan.pda.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Getter +@Setter +@TableName("ll_container_loading_evidence") +public class ContainerLoadingEvidence { + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private String sourceContainerBookingId; + private String evidenceType; + private Long sourceFileId; + private String fileName; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/DomesticLoadingEvidenceGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/DomesticLoadingEvidenceGateway.java new file mode 100644 index 000000000..e079b48b6 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/DomesticLoadingEvidenceGateway.java @@ -0,0 +1,7 @@ +package com.baiyue.lastleg.inboundplan.pda.gateway; + +public interface DomesticLoadingEvidenceGateway { + DownloadedFile download(long containerBookingId, long fileId); + + record DownloadedFile(byte[] content, String fileName, String contentType) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpDomesticLoadingEvidenceGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpDomesticLoadingEvidenceGateway.java new file mode 100644 index 000000000..cf79e3ab6 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpDomesticLoadingEvidenceGateway.java @@ -0,0 +1,55 @@ +package com.baiyue.lastleg.inboundplan.pda.gateway; + +import com.baiyue.lastleg.config.LastlegRestClientFactory; +import com.baiyue.lastleg.config.LastlegRestClientProperties; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ContentDisposition; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; +import org.springframework.web.server.ResponseStatusException; + +@Component +public class HttpDomesticLoadingEvidenceGateway implements DomesticLoadingEvidenceGateway { + @Resource private RestClient.Builder restClientBuilder; + @Resource private LastlegRestClientFactory restClientFactory; + @Resource private LastlegRestClientProperties restClientProperties; + @Value("${baiyue.clients.domestic-file.url:http://127.0.0.1:9205}") private String baseUrl; + @Value("${baiyue.security.internal-service-token:}") private String internalToken; + private RestClient client; + + @PostConstruct + void initializeClient() { + client = restClientFactory.build(restClientBuilder, baseUrl, + restClientProperties.getDomesticLoadingEvidence()); + } + + @Override + public DownloadedFile download(long containerBookingId, long fileId) { + ResponseEntity response = client.get() + .uri("/internal/file/v1/loading-evidence/{containerBookingId}/{fileId}/content", + containerBookingId, fileId) + .header("X-Internal-Service-Token", internalToken).retrieve() + .onStatus(HttpStatusCode::is4xxClientError, (request, result) -> { + throw new ResponseStatusException(result.getStatusCode().value() == 404 + ? HttpStatus.NOT_FOUND : HttpStatus.BAD_REQUEST, "国内装柜照片不可用"); + }) + .onStatus(HttpStatusCode::is5xxServerError, (request, result) -> { + throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "国内装柜照片暂时无法加载"); + }).toEntity(byte[].class); + byte[] content = response.getBody() == null ? new byte[0] : response.getBody(); + String disposition = response.getHeaders().getFirst(HttpHeaders.CONTENT_DISPOSITION); + String fileName = null; + try { fileName = disposition == null ? null : ContentDisposition.parse(disposition).getFilename(); } + catch (IllegalArgumentException ignored) { } + String contentType = response.getHeaders().getContentType() == null + ? MediaType.APPLICATION_OCTET_STREAM_VALUE : response.getHeaders().getContentType().toString(); + return new DownloadedFile(content, fileName == null ? fileId + ".bin" : fileName, contentType); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ContainerLoadingEvidenceMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ContainerLoadingEvidenceMapper.java new file mode 100644 index 000000000..00bc5111d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ContainerLoadingEvidenceMapper.java @@ -0,0 +1,18 @@ +package com.baiyue.lastleg.inboundplan.pda.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.inboundplan.pda.entity.ContainerLoadingEvidence; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; + +@Mapper +public interface ContainerLoadingEvidenceMapper extends BaseMapper { + @Select("SELECT * FROM ll_container_loading_evidence WHERE source_container_booking_id=#{bookingId} ORDER BY CASE evidence_type WHEN 'PHOTO_STEP_1' THEN 1 WHEN 'PHOTO_STEP_2' THEN 2 WHEN 'PHOTO_STEP_3' THEN 3 WHEN 'PHOTO_STEP_4' THEN 4 ELSE 5 END") + List listByContainer(@Param("bookingId") String bookingId); + + @Select("SELECT * FROM ll_container_loading_evidence WHERE source_container_booking_id=#{bookingId} AND evidence_type=#{type} LIMIT 1") + ContainerLoadingEvidence find(@Param("bookingId") String bookingId, @Param("type") String type); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java index dbe1e4b04..51f35314b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java @@ -23,11 +23,13 @@ import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingContainerSession; import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingFbaPalletCount; import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingSkuReceipt; import com.baiyue.lastleg.inboundplan.pda.gateway.UnloadPhotoFileGateway; +import com.baiyue.lastleg.inboundplan.pda.gateway.DomesticLoadingEvidenceGateway; import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingContainerOperationMapper; import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingContainerSessionMapper; import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingFbaPalletCountMapper; import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingSkuReceiptMapper; +import com.baiyue.lastleg.inboundplan.pda.mapper.ContainerLoadingEvidenceMapper; import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper; import com.baiyue.lastleg.mapper.LastlegOrderMapper; import com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper; @@ -67,6 +69,8 @@ public class ReceivingContainerService { private static final String PLATFORM_SCOPE = "0"; private static final String PHOTO_DOWNLOAD_PATH = "/api/pda/v1/last-leg/receiving-containers/current/unload-photos/files/{fileId}/download"; + private static final String DOMESTIC_PHOTO_DOWNLOAD_PATH = + "/api/pda/v1/last-leg/receiving-containers/current/domestic-loading-photos/{type}/content"; private static final String PALLET_MERGE_HINT = "该入库计划可选择合托:合托时扫描同目的仓已使用的托盘码,独立收货时扫描新托盘码;不强制合托"; @@ -86,6 +90,8 @@ public class ReceivingContainerService { @Resource private DropshipOrderProfileMapper dropshipProfiles; @Resource private WarehouseResourceGateway warehouse; @Resource private UnloadPhotoFileGateway unloadPhotos; + @Resource private DomesticLoadingEvidenceGateway domesticEvidenceFiles; + @Resource private ContainerLoadingEvidenceMapper domesticEvidence; @Resource private InboundPdaWorkflowService workflow; @Resource private ObjectMapper sourceObjectMapper; @@ -247,6 +253,19 @@ public class ReceivingContainerService { return unloadPhotos.download(PLATFORM_SCOPE, session.getId(), parsedFileId); } + public DomesticLoadingEvidenceGateway.DownloadedFile downloadDomesticLoadingPhoto( + String operatorKey, String evidenceType) { + ReceivingContainerSession session = requireCurrent(operatorKey, false); + String type = requiredEvidenceType(evidenceType); + var evidence = domesticEvidence.find(session.getSourceContainerBookingId(), type); + if (evidence == null) throw notFound("当前柜没有该国内装柜照片"); + long bookingId; + try { bookingId = Long.parseLong(session.getSourceContainerBookingId()); } + catch (NumberFormatException exception) { throw new ResponseStatusException( + HttpStatus.SERVICE_UNAVAILABLE, "当前柜国内照片来源无效", exception); } + return domesticEvidenceFiles.download(bookingId, evidence.getSourceFileId()); + } + public void requireCurrentWarehouse(String operatorKey, long overseasWarehouseId) { requireSessionWarehouse(sessions.findActiveByOperator(activeOperatorKey(operatorKey)), overseasWarehouseId); } @@ -983,11 +1002,41 @@ public class ReceivingContainerService { int received = inbounds.stream().mapToInt(ReceivingContainerResponses.Inbound::receivedPackages).sum(); return new ReceivingContainerResponses.Session(session.getContainerNo(), session.getOverseasWarehouseId(), session.getOverseasWarehouseCode(), session.getOverseasWarehouseName(), session.getStatus(), session.getOperatorName(), - value(session.getVersion()), signPhotos(session), session.getPreparedAt(), session.getSignedAt(), + value(session.getVersion()), signPhotos(session), domesticLoadingPhotos(session), + session.getPreparedAt(), session.getSignedAt(), session.getReceivingAt(), session.getCompletedAt(), total, received, Math.max(0, total - received), hasFbaOverReceipt(session, containerPlans), inbounds); } + private List domesticLoadingPhotos( + ReceivingContainerSession session) { + String bookingId = trim(session.getSourceContainerBookingId()); + if (bookingId == null) return List.of(); + return domesticEvidence.listByContainer(bookingId).stream() + .map(value -> new ReceivingContainerResponses.DomesticLoadingPhoto(value.getEvidenceType(), + evidenceLabel(value.getEvidenceType()), DOMESTIC_PHOTO_DOWNLOAD_PATH.replace("{type}", value.getEvidenceType()))) + .toList(); + } + + private static String requiredEvidenceType(String value) { + String type = trim(value); + if (type == null || !Set.of("PHOTO_STEP_1", "PHOTO_STEP_2", "PHOTO_STEP_3", "PHOTO_STEP_4", "SEAL").contains(type)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "不支持的国内装柜照片类型"); + } + return type; + } + + private static String evidenceLabel(String type) { + return switch (type) { + case "PHOTO_STEP_1" -> "空仓"; + case "PHOTO_STEP_2" -> "半仓"; + case "PHOTO_STEP_3" -> "满仓"; + case "PHOTO_STEP_4" -> "锁仓"; + case "SEAL" -> "带锁码"; + default -> type; + }; + } + private void savePhysicalFbaCount(long sessionId, String groupKey, String palletCode, int actualPackages, ReceivingFbaPalletCount existing) { LocalDateTime now = LocalDateTime.now(); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java index c913ef7fa..7661fd96d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java @@ -38,6 +38,7 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.web.server.ResponseStatusException; import java.math.BigDecimal; +import java.time.LocalDate; import java.time.LocalDateTime; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; @@ -103,6 +104,8 @@ public class InboundPlanService { } String containerWarehouseId = requireSingleWarehouseId(containerFacts.stream() .map(InboundPlanOrderMapper.ContainerWarehouseRef::overseasWarehouseId).toList()); + LocalDate plannedArrivalDate = requiredContainerPlannedArrivalDate( + containerFacts.get(0).sourceContainerBookingId()); List snapshots = orderMapper.lockOrder(customerId, request.orderId()); if (snapshots == null || snapshots.size() != 1) { throw new ResponseStatusException(HttpStatus.NOT_FOUND, "尾程订单不存在"); @@ -152,7 +155,7 @@ public class InboundPlanService { "人工计划托数不能少于柜级预览建议托数 " + minimumPalletCount); } InboundPlanResponse response = createPlan(customerId, request.orderId(), snapshots, List.of(), null, - request, receivingWarehouse); + request, receivingWarehouse, plannedArrivalDate); LastlegOrderCommandRecord command = new LastlegOrderCommandRecord(); command.setId(IdWorker.getId()); command.setCustomerId(customerId); command.setOrderId(request.orderId()); command.setAction("MANUAL_PLAN"); command.setOperatorId(operatorId); command.setIdempotencyKey(key); @@ -426,21 +429,25 @@ public class InboundPlanService { return detail(customerId, existing); } validateOrders(orders); - return createPlan(customerId, selectedOrderId, orders, sourcePalletNos, wholePallets, null, null); - } - - private InboundPlanResponse createPlan(String customerId, Long selectedOrderId, - List orders, List sourcePalletNos, - List wholePallets, ManualInboundPlanRequest manual, - WarehouseResourceGateway.WarehouseReference receivingWarehouse) { - return createPlan(customerId, selectedOrderId, orders, sourcePalletNos, wholePallets, manual, - receivingWarehouse, null, Map.of()); + String sourceContainerBookingId = trimToNull(orderMapper.findSourceContainerBookingId(customerId, selectedOrderId)); + return createPlan(customerId, selectedOrderId, orders, sourcePalletNos, wholePallets, null, null, + requiredContainerPlannedArrivalDate(sourceContainerBookingId)); } private InboundPlanResponse createPlan(String customerId, Long selectedOrderId, List orders, List sourcePalletNos, List wholePallets, ManualInboundPlanRequest manual, WarehouseResourceGateway.WarehouseReference receivingWarehouse, + LocalDate plannedArrivalDate) { + return createPlan(customerId, selectedOrderId, orders, sourcePalletNos, wholePallets, manual, + receivingWarehouse, plannedArrivalDate, null, Map.of()); + } + + private InboundPlanResponse createPlan(String customerId, Long selectedOrderId, + List orders, List sourcePalletNos, + List wholePallets, ManualInboundPlanRequest manual, + WarehouseResourceGateway.WarehouseReference receivingWarehouse, + LocalDate plannedArrivalDate, PreparedPlan preparedPlan, Map reservedPallets) { String containerNo = requiredContainerNo(orders.get(0).containerNo()); @@ -463,7 +470,7 @@ public class InboundPlanService { throw new ResponseStatusException(HttpStatus.CONFLICT, "接柜仓ID格式无效", exception); } plan.setStatus("PENDING_ASSIGNMENT"); - plan.setPlannedArrivalDate(manual == null ? null : manual.plannedArrivalDate()); + plan.setPlannedArrivalDate(plannedArrivalDate); plan.setPalletType(manual == null ? null : manual.palletType().trim().toUpperCase()); plan.setAssignedFlag(manual != null && manual.assigned()); plan.setMergeRequired(manual != null && manual.mergeRequired()); @@ -550,6 +557,7 @@ public class InboundPlanService { if (bookingId == null) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "来源柜预订 ID 不能为空"); List refs = orderMapper.lockContainerOrders(bookingId); if (refs.isEmpty()) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "来源柜没有尾程订单"); + LocalDate plannedArrivalDate = requiredContainerPlannedArrivalDate(bookingId); if (snapshotVersion != null) { ContainerInboundPlanPreviewResponse current = previewContainer(bookingId); if (current.snapshotVersion() != snapshotVersion || !Objects.equals(current.snapshotHash(), snapshotHash) @@ -639,7 +647,7 @@ public class InboundPlanService { .map(value -> value.reservation().palletCode()).toList(); } created.put(refIndex, createPlan(ref.customerId(), ref.orderId(), snapshots.get(pendingIndex), effectivePalletNos, - wholePallets.get(pendingIndex), null, null, prepared, reserved)); + wholePallets.get(pendingIndex), null, null, plannedArrivalDate, prepared, reserved)); } for (int index = 0; index < refs.size(); index++) { InboundPlan existing = existingPlans.get(index); @@ -752,6 +760,22 @@ public class InboundPlanService { } } + private LocalDate requiredContainerPlannedArrivalDate(String sourceContainerBookingId) { + String bookingId = trimToNull(sourceContainerBookingId); + if (bookingId == null) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "尾程订单未绑定来源柜,无法取得预计到仓时间"); + } + List values = orderMapper.containerEstimatedWarehouseTimes(bookingId); + if (values == null || values.isEmpty() || values.stream().anyMatch(Objects::isNull)) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "来源柜预计到仓时间缺失,请先补齐柜级时间"); + } + LocalDateTime expected = values.get(0); + if (values.stream().anyMatch(value -> !Objects.equals(expected, value))) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "来源柜预计到仓时间不一致,请先修正柜级时间"); + } + return expected.toLocalDate(); + } + private void requirePlanContainer(InboundPlan plan, String containerNo) { String frozen = requiredContainerNo(plan.getContainerNo()); if (!Objects.equals(containerNo, frozen)) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java index ea2da7a9d..5a77ea048 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java @@ -7,6 +7,8 @@ import com.baiyue.lastleg.dropship.dto.DropshipRequests; import com.baiyue.lastleg.dropship.service.DropshipOrderService; import com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton; import com.baiyue.lastleg.inboundplan.mapper.InboundPlanCartonMapper; +import com.baiyue.lastleg.inboundplan.pda.entity.ContainerLoadingEvidence; +import com.baiyue.lastleg.inboundplan.pda.mapper.ContainerLoadingEvidenceMapper; import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests; import com.baiyue.lastleg.inventory.command.service.InventoryCommandService; import com.baiyue.lastleg.shipping.entity.ContainerShippingGate; @@ -46,12 +48,13 @@ public class LoadingCompletedConsumer { @Resource private InventoryCommandService inventory; @Resource private ContainerShippingGateMapper shippingGates; @Resource private LastlegSourcePalletMapper sourcePallets; + @Resource private ContainerLoadingEvidenceMapper loadingEvidence; @Transactional(isolation = Isolation.READ_COMMITTED) public int consume(EventEnvelope envelope) { envelope.requireValid(); if (!EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED.equals(envelope.eventType()) - || (envelope.eventVersion() != 1 && envelope.eventVersion() != 2)) + || (envelope.eventVersion() != 1 && envelope.eventVersion() != 2 && envelope.eventVersion() != 3)) throw new IllegalArgumentException("不支持的尾程交接事件"); if (envelope.sourceRegion() != RegionCode.CN || envelope.targetRegion() != RegionCode.OVERSEAS) throw new IllegalArgumentException("尾程交接事件区域无效"); @@ -64,7 +67,7 @@ public class LoadingCompletedConsumer { String sourceContainerBookingId = sourceContainerBookingId(envelope, payload); if(payload.orders()==null||payload.orders().isEmpty()) throw new IllegalArgumentException("事件没有入仓单"); OwnerSnapshot owner = requireOwnerSnapshot(payload.orders(), envelope.tenantId()); - if (envelope.eventVersion() == 2) validateCartonSkuMappings(payload, envelope.payload()); + if (envelope.eventVersion() >= 2) validateCartonSkuMappings(payload, envelope.payload()); freezeContainerIdentity(envelope, sourceContainerBookingId, containerNo); LastlegEventInbox existing=inboxMapper.selectById(envelope.eventId()); if(existing!=null) { @@ -85,6 +88,8 @@ public class LoadingCompletedConsumer { return countOrders(envelope,containerNo); } + projectLoadingEvidence(envelope, sourceContainerBookingId); + // The loading-plan and container-shipping events are independent aggregate streams. // Seeding the shipping version from the loading-plan version makes a valid first node // (aggregateVersion=0) look stale whenever the loading plan has a higher version. @@ -100,6 +105,45 @@ public class LoadingCompletedConsumer { return payload.orders().size(); } + private void projectLoadingEvidence(EventEnvelope envelope, String sourceContainerBookingId) { + if (envelope.eventVersion() < 3) return; + JsonNode values = envelope.payload().path("loadingEvidence"); + if (!values.isArray() || values.size() != 5) { + throw new IllegalArgumentException("V3 尾程交接事件必须包含五类国内装柜照片"); + } + Set expected = Set.of("PHOTO_STEP_1", "PHOTO_STEP_2", "PHOTO_STEP_3", "PHOTO_STEP_4", "SEAL"); + Set actual = new HashSet<>(); + for (JsonNode value : values) { + String type = value.path("evidenceType").asText("").trim(); + long fileId = value.path("fileId").asLong(0); + String fileName = value.path("fileName").asText("").trim(); + if (!expected.contains(type) || !actual.add(type) || fileId <= 0 + || fileName.isBlank() || fileName.length() > 255) { + throw new IllegalArgumentException("V3 尾程交接事件的国内装柜照片引用无效"); + } + ContainerLoadingEvidence existing = loadingEvidence.find(sourceContainerBookingId, type); + if (existing != null) { + if (!Objects.equals(existing.getSourceFileId(), fileId) + || !Objects.equals(existing.getFileName(), fileName)) { + throw new IllegalStateException("同一来源柜的国内装柜照片快照冲突"); + } + continue; + } + ContainerLoadingEvidence row = new ContainerLoadingEvidence(); + row.setId(IdWorker.getId()); + row.setSourceContainerBookingId(sourceContainerBookingId); + row.setEvidenceType(type); row.setSourceFileId(fileId); row.setFileName(fileName); + row.setCreatedAt(LocalDateTime.now()); + try { loadingEvidence.insert(row); } + catch (DuplicateKeyException duplicate) { + ContainerLoadingEvidence raced = loadingEvidence.find(sourceContainerBookingId, type); + if (raced == null || !Objects.equals(raced.getSourceFileId(), fileId) + || !Objects.equals(raced.getFileName(), fileName)) throw duplicate; + } + } + if (!actual.equals(expected)) throw new IllegalArgumentException("V3 尾程交接事件的国内装柜照片类型不完整"); + } + private FirstLegLoadingPlanCompletedV1 parsePayload(EventEnvelope envelope) { try { ObjectMapper compatibleMapper = objectMapper.copy() @@ -134,7 +178,7 @@ public class LoadingCompletedConsumer { } private String sourceContainerBookingId(EventEnvelope envelope, FirstLegLoadingPlanCompletedV1 payload) { - String value = envelope.eventVersion() == 2 + String value = envelope.eventVersion() >= 2 ? envelope.payload().path("sourceContainerBookingId").asText(null) : payload.containerBookingId(); if (value == null || value.isBlank() || value.trim().length() > 128) { diff --git a/lastleg-service/src/main/resources/application.yml b/lastleg-service/src/main/resources/application.yml index e4625e6c6..5b588d911 100644 --- a/lastleg-service/src/main/resources/application.yml +++ b/lastleg-service/src/main/resources/application.yml @@ -66,6 +66,9 @@ baiyue: pallet-physical: connect-timeout: ${LASTLEG_PALLET_PHYSICAL_CONNECT_TIMEOUT:3s} read-timeout: ${LASTLEG_PALLET_PHYSICAL_READ_TIMEOUT:5s} + domestic-loading-evidence: + connect-timeout: ${LASTLEG_DOMESTIC_LOADING_EVIDENCE_CONNECT_TIMEOUT:3s} + read-timeout: ${LASTLEG_DOMESTIC_LOADING_EVIDENCE_READ_TIMEOUT:30s} clients: print: url: ${PRINT_SERVICE_URL:} @@ -74,6 +77,8 @@ baiyue: cache-ttl: ${DOMESTIC_WAREHOUSE_CACHE_TTL:30m} file: url: ${FILE_SERVICE_URL:http://127.0.0.1:9205} + domestic-file: + url: ${DOMESTIC_FILE_SERVICE_URL:http://127.0.0.1:9205} event-bridge: url: ${CN_EVENT_BRIDGE_URL:${FIRSTLEG_EVENT_BRIDGE_URL:}} firstleg: diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index 2f62b71d0..0bc0c658f 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -2542,3 +2542,15 @@ CREATE TABLE IF NOT EXISTS ll_return_inventory_operation ( id BIGINT NOT NULL COMMENT '退货库存PDA操作ID', operator_id VARCHAR(128) NOT NULL COMMENT 'PDA操作人ID', idempotency_key VARCHAR(255) NOT NULL COMMENT '操作幂等键', request_hash CHAR(64) NOT NULL COMMENT '请求内容SHA-256', operation_type VARCHAR(32) NOT NULL COMMENT '操作类型,当前MERGE', result_carton_id BIGINT NULL COMMENT '合箱结果退货库存箱ID', created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间', PRIMARY KEY(id), UNIQUE KEY uk_ll_return_inventory_operation_operator_key(operator_id,idempotency_key), KEY idx_ll_return_inventory_operation_result(result_carton_id) ) ENGINE=InnoDB COMMENT='退货库存PDA合箱幂等操作审计'; + +CREATE TABLE IF NOT EXISTS ll_container_loading_evidence ( + id BIGINT NOT NULL COMMENT '海外整柜装柜照片投影ID', + source_container_booking_id VARCHAR(128) NOT NULL COMMENT '国内稳定来源柜预订ID', + evidence_type VARCHAR(32) NOT NULL COMMENT 'PHOTO_STEP_1至PHOTO_STEP_4或SEAL', + source_file_id BIGINT NOT NULL COMMENT '国内File服务文件ID', + file_name VARCHAR(255) NOT NULL COMMENT '国内文件名快照', + created_at DATETIME(3) NOT NULL COMMENT '投影创建时间', + PRIMARY KEY(id), + UNIQUE KEY uk_ll_container_loading_evidence_type(source_container_booking_id,evidence_type), + UNIQUE KEY uk_ll_container_loading_evidence_file(source_container_booking_id,source_file_id) +) ENGINE=InnoDB COMMENT='海外拆柜只读国内装柜照片引用'; diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index e1c2f2814..3c9c54383 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -80,6 +80,22 @@ paths: application/octet-stream: { schema: { type: string, format: binary } } '403': { description: fileId 未绑定到当前操作员的当前柜会话三类签收照片 } '404': { description: 当前柜会话或照片不存在 } + /api/pda/v1/last-leg/receiving-containers/current/domestic-loading-photos/{evidenceType}/content: + get: + summary: 查看当前柜国内装柜照片 + description: 权限 lastleg:pda:operate;只按当前操作员已锁定的物理柜读取国内装柜完成快照,不返回客户或内部文件ID,加载失败不改变拆柜状态。 + security: [{ bearerAuth: [] }] + parameters: + - { name: evidenceType, in: path, required: true, schema: { type: string, enum: [PHOTO_STEP_1, PHOTO_STEP_2, PHOTO_STEP_3, PHOTO_STEP_4, SEAL] } } + responses: + '200': + description: 国内装柜照片字节流;禁止缓存。 + content: + image/jpeg: { schema: { type: string, format: binary } } + image/png: { schema: { type: string, format: binary } } + application/octet-stream: { schema: { type: string, format: binary } } + '404': { description: 当前柜或指定类型照片不存在 } + '503': { description: 国内照片服务暂不可用 } /api/pda/v1/last-leg/receiving-containers/current/sign: post: summary: 签收当前柜 @@ -434,7 +450,7 @@ paths: /api/platform/v1/last-leg/inbound-plans/manual: post: summary: 平台按单票手工生成入库计划 - description: 权限 platform:overseas-warehouse:inbound-order:create;按 customerId 与 orderId 锁定单票,校验订单版本、状态、暂扣状态、真实入仓码、客户归属及有效公共接柜仓。一次只生成当前票的独立计划,不改变同柜其他订单;命令原子且幂等。 + description: 权限 platform:overseas-warehouse:inbound-order:create;按 customerId 与 orderId 锁定单票,校验订单版本、状态、暂扣状态、真实入仓码、客户归属及有效公共接柜仓。预计到仓日期由服务端锁定读取来源柜全部订单的 estimatedWarehouseAt,缺失或同柜不一致时拒绝生成,不接受调用方提交。一次只生成当前票的独立计划,不改变同柜其他订单;命令原子且幂等。 security: [{ bearerAuth: [] }] parameters: - { name: Idempotency-Key, in: header, required: true, schema: { type: string, minLength: 1, maxLength: 128 } } @@ -446,7 +462,7 @@ paths: responses: '200': { description: 统一 ApiResponse 包装的入库计划详情 } '400': { description: 客户、入仓码、仓库主数据或请求字段无效 } - '409': { description: 订单版本、状态、暂扣状态、重复计划或幂等键冲突 } + '409': { description: 订单版本、状态、暂扣状态、柜级预计到仓时间、重复计划或幂等键冲突 } /api/platform/v1/last-leg/inbound-plans/convert-container: post: summary: 按整柜批量生成剩余入库计划 @@ -1941,7 +1957,7 @@ components: currentAccount: { type: boolean } ReceivingSession: type: object - required: [containerNo, overseasWarehouseId, overseasWarehouseCode, overseasWarehouseName, status, version, photos, totalPackages, receivedPackages, pendingPackages, hasDifference, inbounds] + required: [containerNo, overseasWarehouseId, overseasWarehouseCode, overseasWarehouseName, status, version, photos, domesticLoadingPhotos, totalPackages, receivedPackages, pendingPackages, hasDifference, inbounds] properties: containerNo: { type: string } overseasWarehouseId: { type: string, pattern: '^\d+$', description: 平台预分配并与PDA Token匹配的海外接柜仓ID } @@ -1951,6 +1967,7 @@ components: operatorName: { type: string, nullable: true } version: { type: integer } photos: { type: array, maxItems: 3, items: { $ref: '#/components/schemas/ReceivingSignedSessionPhoto' } } + domesticLoadingPhotos: { type: array, maxItems: 5, items: { $ref: '#/components/schemas/DomesticLoadingPhoto' }, description: 当前物理柜国内装柜五类只读照片,不含客户信息与内部文件ID } preparedAt: { type: string, format: date-time, nullable: true } signedAt: { type: string, format: date-time, nullable: true } receivingAt: { type: string, format: date-time, nullable: true } @@ -1968,6 +1985,13 @@ components: fileName: { type: string } contentType: { type: string } sizeBytes: { type: integer, format: int64 } + DomesticLoadingPhoto: + type: object + required: [evidenceType, label, downloadPath] + properties: + evidenceType: { type: string, enum: [PHOTO_STEP_1, PHOTO_STEP_2, PHOTO_STEP_3, PHOTO_STEP_4, SEAL] } + label: { type: string, enum: [空仓, 半仓, 满仓, 锁仓, 带锁码] } + downloadPath: { type: string, description: 当前接柜会话鉴权下载路径 } ReceivingCartonResolved: allOf: - { $ref: '#/components/schemas/ReceivingInbound' } @@ -2606,13 +2630,12 @@ components: items: { $ref: '#/components/schemas/LastlegOrderManualNode' } ManualInboundPlanRequest: type: object - description: 单票人工计划只能使用所属柜已冻结的接柜仓;接柜仓变更必须通过柜级接口完成。 - required: [customerId, orderId, expectedOrderVersion, plannedArrivalDate, palletCount, palletType, assigned, mergeRequired, urgent] + description: 单票人工计划只能使用所属柜已冻结的接柜仓;接柜仓变更必须通过柜级接口完成。预计到仓日期由服务端读取来源柜冻结的 estimatedWarehouseArrivalAt,同柜所有计划保持一致,不接受人工提交。 + required: [customerId, orderId, expectedOrderVersion, palletCount, palletType, assigned, mergeRequired, urgent] properties: customerId: { type: string, pattern: '^[1-9][0-9]*$' } orderId: { type: integer, format: int64, minimum: 1 } expectedOrderVersion: { type: integer, minimum: 0 } - plannedArrivalDate: { type: string, format: date } palletCount: { type: integer, minimum: 1, maximum: 999 } palletType: { type: string, minLength: 1, maxLength: 64 } assigned: { type: boolean } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/TailPlanCommandOpenApiContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/TailPlanCommandOpenApiContractTest.java index 2256edd4c..798a5228a 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/TailPlanCommandOpenApiContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/TailPlanCommandOpenApiContractTest.java @@ -24,8 +24,10 @@ class TailPlanCommandOpenApiContractTest { int manualSchemaEnd = api.indexOf("\n PageMetadata:", manualSchemaStart); assertTrue(manualSchemaStart >= 0 && manualSchemaEnd > manualSchemaStart); assertFalse(api.substring(manualSchemaStart, manualSchemaEnd).contains("inboundCode")); + assertFalse(api.substring(manualSchemaStart, manualSchemaEnd).contains("plannedArrivalDate")); assertFalse(java.util.Arrays.stream(ManualInboundPlanRequest.class.getRecordComponents()) - .anyMatch(component -> "inboundCode".equals(component.getName()))); + .anyMatch(component -> java.util.Set.of("inboundCode", "plannedArrivalDate") + .contains(component.getName()))); assertTrue(api.contains("/api/platform/v1/last-leg/orders/{id}/hold:")); assertFalse(api.contains("/api/platform/v1/last-leg/orders/{id}/detained:")); assertTrue(api.contains("/api/platform/v1/last-leg/orders/{id}/manual-nodes:")); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanControllerTest.java index a95aef06f..4884b8743 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanControllerTest.java @@ -20,7 +20,6 @@ import org.springframework.web.server.ResponseStatusException; import java.util.List; import java.util.Set; import java.time.LocalDateTime; -import java.time.LocalDate; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.verify; @@ -106,6 +105,6 @@ class InboundPlanControllerTest { private static ManualInboundPlanRequest manualRequest() { return new ManualInboundPlanRequest("193000000000200003", 9223372036854775807L, 0, - LocalDate.of(2026, 9, 8), 2, "STANDARD", false, true, true, "加急入仓"); + 2, "STANDARD", false, true, true, "加急入仓"); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerControllerTest.java index 5b430f545..1d3474f23 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerControllerTest.java @@ -3,6 +3,7 @@ package com.baiyue.lastleg.inboundplan.pda.controller; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerRequests; import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerResponses; +import com.baiyue.lastleg.inboundplan.pda.gateway.DomesticLoadingEvidenceGateway; import com.baiyue.lastleg.inboundplan.pda.gateway.UnloadPhotoFileGateway; import com.baiyue.lastleg.inboundplan.pda.service.ReceivingContainerService; import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity; @@ -90,6 +91,21 @@ class ReceivingContainerControllerTest { verify(service).downloadCurrentPhoto("42", "9999"); } + @Test + void downloadDomesticLoadingPhotoDelegatesWithoutExposingTheSourceFileId() { + when(service.downloadDomesticLoadingPhoto("42", "PHOTO_STEP_1")).thenReturn( + new DomesticLoadingEvidenceGateway.DownloadedFile( + "domestic".getBytes(), "empty-container.jpg", MediaType.IMAGE_JPEG_VALUE)); + + ResponseEntity response = controller.downloadDomesticLoadingPhoto(picker(), "PHOTO_STEP_1"); + + assertThat(response.getStatusCodeValue()).isEqualTo(200); + assertThat(response.getHeaders().getContentType()).isEqualTo(MediaType.IMAGE_JPEG); + assertThat(response.getHeaders().getCacheControl()).isEqualTo("no-store"); + assertThat(response.getBody()).containsExactly("domestic".getBytes()); + verify(service).downloadDomesticLoadingPhoto("42", "PHOTO_STEP_1"); + } + @Test void pickerAuthorityWithoutWarehouseBindingIsForbidden() { AuthenticatedUser unbound = new AuthenticatedUser(42L, 0L, 0L, 8L, "receiver", "PLATFORM", "PDA", diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java index cf55e5a39..f73da7fe9 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java @@ -18,11 +18,13 @@ import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingContainerOperation; import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingContainerSession; import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingFbaPalletCount; import com.baiyue.lastleg.inboundplan.pda.gateway.UnloadPhotoFileGateway; +import com.baiyue.lastleg.inboundplan.pda.gateway.DomesticLoadingEvidenceGateway; import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingContainerOperationMapper; import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingContainerSessionMapper; import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingFbaPalletCountMapper; import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingSkuReceiptMapper; +import com.baiyue.lastleg.inboundplan.pda.mapper.ContainerLoadingEvidenceMapper; import com.baiyue.lastleg.dropship.mapper.DropshipCartonMapper; import com.baiyue.lastleg.dropship.mapper.DropshipOrderProfileMapper; import com.baiyue.lastleg.dropship.entity.DropshipCarton; @@ -71,6 +73,8 @@ class ReceivingContainerServiceBehaviorTest { @Mock DropshipOrderProfileMapper dropshipProfiles; @Mock WarehouseResourceGateway warehouse; @Mock UnloadPhotoFileGateway unloadPhotos; + @Mock DomesticLoadingEvidenceGateway domesticEvidenceFiles; + @Mock ContainerLoadingEvidenceMapper domesticEvidence; @Mock InboundPdaWorkflowService workflow; private ReceivingContainerService service; @@ -93,11 +97,14 @@ class ReceivingContainerServiceBehaviorTest { ReflectionTestUtils.setField(service, "dropshipProfiles", dropshipProfiles); ReflectionTestUtils.setField(service, "warehouse", warehouse); ReflectionTestUtils.setField(service, "unloadPhotos", unloadPhotos); + ReflectionTestUtils.setField(service, "domesticEvidenceFiles", domesticEvidenceFiles); + ReflectionTestUtils.setField(service, "domesticEvidence", domesticEvidence); ReflectionTestUtils.setField(service, "workflow", workflow); ReflectionTestUtils.setField(service, "sourceObjectMapper", new ObjectMapper().findAndRegisterModules()); service.initializeObjectMapper(); org.mockito.Mockito.lenient().when(shippingGates.receivingAvailable(any(), any())).thenReturn(1); org.mockito.Mockito.lenient().when(pallets.selectCount(any())).thenReturn(1L); + org.mockito.Mockito.lenient().when(domesticEvidence.listByContainer(any())).thenReturn(List.of()); } @Test @@ -904,6 +911,25 @@ class ReceivingContainerServiceBehaviorTest { verifyNoInteractions(warehouse); } + @Test + void domesticLoadingPhotoDownloadIsScopedToTheCurrentPhysicalContainer() { + ReceivingContainerSession current = session("CONT-A", "SIGNED"); + current.setSourceContainerBookingId("91"); + when(sessions.findActiveByOperator("OPERATOR:7")).thenReturn(current); + var evidence = new com.baiyue.lastleg.inboundplan.pda.entity.ContainerLoadingEvidence(); + evidence.setSourceContainerBookingId("91"); evidence.setEvidenceType("SEAL"); + evidence.setSourceFileId(501L); evidence.setFileName("seal.jpg"); + when(domesticEvidence.find("91", "SEAL")).thenReturn(evidence); + var downloaded = new DomesticLoadingEvidenceGateway.DownloadedFile( + new byte[]{1, 2}, "seal.jpg", "image/jpeg"); + when(domesticEvidenceFiles.download(91L, 501L)).thenReturn(downloaded); + + assertThat(service.downloadDomesticLoadingPhoto("7", "SEAL")).isSameAs(downloaded); + verify(domesticEvidenceFiles).download(91L, 501L); + assertThatThrownBy(() -> service.downloadDomesticLoadingPhoto("7", "UNKNOWN")) + .isInstanceOf(ResponseStatusException.class).hasMessageContaining("400"); + } + private void stubCurrent(String destinationType, String containerNo) { InboundPlan plan = plan(containerNo); InboundPlanLine line = line(destinationType); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinatorTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinatorTest.java index 122acef17..1838590df 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinatorTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinatorTest.java @@ -409,7 +409,7 @@ class ContainerInboundPlanConversionCoordinatorTest { } private ManualInboundPlanRequest manualRequest() { - return new ManualInboundPlanRequest("100", 11L, 0, LocalDate.of(2026, 9, 10), + return new ManualInboundPlanRequest("100", 11L, 0, 1, "WOOD", false, false, false, "自动生成"); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java index 89c063e8a..aa4696416 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java @@ -93,6 +93,10 @@ class InboundPlanServiceTest { org.mockito.Mockito.lenient().when(orderMapper.lockContainerWarehouseFacts(any(), any())).thenReturn(List.of( new InboundPlanOrderMapper.ContainerWarehouseRef( "100", 2L, "BOOKING-1", "193000000000200003"))); + org.mockito.Mockito.lenient().when(orderMapper.findSourceContainerBookingId(any(), anyLong())) + .thenReturn("BOOKING-1"); + org.mockito.Mockito.lenient().when(orderMapper.containerEstimatedWarehouseTimes(any())) + .thenReturn(List.of(LocalDateTime.of(2026, 9, 15, 8, 0))); } @Test @@ -108,6 +112,7 @@ class InboundPlanServiceTest { ArgumentCaptor plan = ArgumentCaptor.forClass(InboundPlan.class); verify(planMapper).insert(plan.capture()); assertEquals(2L, plan.getValue().getSourceOrderId()); + assertEquals(LocalDate.of(2026, 9, 15), plan.getValue().getPlannedArrivalDate()); assertEquals(1, plan.getValue().getLineCount()); assertEquals("PENDING_ASSIGNMENT", plan.getValue().getStatus()); assertEquals(1, plan.getValue().getPalletCount()); @@ -149,7 +154,7 @@ class InboundPlanServiceTest { when(orderMapper.markPlannedManual(eq("100"), eq(2L), any(), eq(2), eq(true), eq(0), any())) .thenReturn(1); ManualInboundPlanRequest request = new ManualInboundPlanRequest("100", 2L, 0, - LocalDate.of(2026, 9, 15), 2, "STANDARD", true, true, true, "优先收货"); + 2, "STANDARD", true, true, true, "优先收货"); var result = service.convertManual(request, "manual-2", "900"); @@ -167,6 +172,27 @@ class InboundPlanServiceTest { verify(orderMapper).lockContainerWarehouseFacts("100", 2L); } + @Test + void manualPlanRejectsMissingOrInconsistentContainerArrivalTime() { + ManualInboundPlanRequest request = new ManualInboundPlanRequest("100", 2L, 0, + 2, "STANDARD", true, true, true, "优先收货"); + when(orderMapper.containerEstimatedWarehouseTimes("BOOKING-1")).thenReturn(List.of()); + + ResponseStatusException missing = assertThrows(ResponseStatusException.class, + () -> service.convertManual(request, "manual-missing-arrival", "900")); + assertEquals(409, missing.getStatusCode().value()); + assertEquals("来源柜预计到仓时间缺失,请先补齐柜级时间", missing.getReason()); + + when(orderMapper.containerEstimatedWarehouseTimes("BOOKING-1")).thenReturn(List.of( + LocalDateTime.of(2026, 9, 15, 8, 0), LocalDateTime.of(2026, 9, 16, 8, 0))); + ResponseStatusException inconsistent = assertThrows(ResponseStatusException.class, + () -> service.convertManual(request, "manual-inconsistent-arrival", "900")); + assertEquals(409, inconsistent.getStatusCode().value()); + assertEquals("来源柜预计到仓时间不一致,请先修正柜级时间", inconsistent.getReason()); + verify(orderMapper, never()).lockOrder(any(), any()); + verify(planMapper, never()).insert(any(InboundPlan.class)); + } + @Test void manualPlanMayExceedButCannotUndercutPreviewPalletCount() { SourceOrderSnapshot source = new SourceOrderSnapshot(2L, "100", "MSCU6639870", "RK-2", @@ -175,7 +201,7 @@ class InboundPlanServiceTest { null, null, "Amazon ACY2", 0, 0, false, 0); when(orderMapper.lockOrder("100", 2L)).thenReturn(List.of(source)); ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> service.convertManual( - new ManualInboundPlanRequest("100", 2L, 0, LocalDate.of(2026, 9, 15), + new ManualInboundPlanRequest("100", 2L, 0, 2, "WOOD", false, false, false, null), "manual-below-preview", "900")); assertEquals(400, error.getStatusCode().value()); @@ -192,7 +218,7 @@ class InboundPlanServiceTest { new DropshipPalletSnapshot("DP-260914-0001", 10L, List.of()))); ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> service.convertManual( - new ManualInboundPlanRequest("100", 2L, 0, LocalDate.of(2026, 9, 15), + new ManualInboundPlanRequest("100", 2L, 0, 2, "WOOD", false, false, false, null), "dropship-pallet-mismatch", "900")); assertEquals(400, error.getStatusCode().value()); @@ -212,7 +238,7 @@ class InboundPlanServiceTest { .thenReturn(1); var result = service.convertManual(new ManualInboundPlanRequest("100", 2L, 0, - LocalDate.of(2026, 9, 15), 1, "WOOD", false, false, false, null), + 1, "WOOD", false, false, false, null), "dropship-exact-pallet-count", "900"); assertEquals(1, result.palletCount()); @@ -266,7 +292,7 @@ class InboundPlanServiceTest { .thenReturn(1); var result = service.convertManual(new ManualInboundPlanRequest("100", 2L, 0, - LocalDate.of(2026, 9, 15), 3, "STANDARD", true, false, false, null), + 3, "STANDARD", true, false, false, null), "manual-id-only", "900"); assertEquals("US-LAX-01", result.overseasWarehouseCode()); @@ -282,7 +308,7 @@ class InboundPlanServiceTest { new InboundPlanOrderMapper.ContainerWarehouseRef("200", 3L, "BOOKING-1", "193000000000200004"))); ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> service.convertManual( - new ManualInboundPlanRequest("100", 2L, 0, LocalDate.of(2026, 9, 15), + new ManualInboundPlanRequest("100", 2L, 0, 1, "STANDARD", false, false, false, null), "mixed-warehouse", "900")); assertEquals(409, error.getStatusCode().value()); @@ -300,7 +326,7 @@ class InboundPlanServiceTest { when(orderMapper.lockOrder("100", 2L)).thenReturn(List.of(held)); ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> service.convertManual( - new ManualInboundPlanRequest("100", 2L, 5, LocalDate.of(2026, 9, 15), + new ManualInboundPlanRequest("100", 2L, 5, 1, "STANDARD", false, false, false, null), "manual-held", "900")); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java index 44483efcb..e4efad347 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java @@ -3,8 +3,8 @@ import com.baiyue.common.event.*; import com.baiyue.lastleg.entity.*; import com @ExtendWith(org.mockito.junit.jupiter.MockitoExtension.class) class LoadingCompletedConsumerTest { private final Map frozenContainerNos=new HashMap<>(); - @Mock LastlegEventInboxMapper inbox; @Mock LastlegOrderMapper orders; @Mock LastlegInboundOrderMapper inboundOrders; @Mock LastlegOrderDetailMapper details; @Mock LastlegOrderStatusHistoryMapper histories; @Mock InboundPlanCartonMapper cartons; @Mock DropshipOrderService dropshipOrders; @Mock InventoryCommandService inventory; @Mock com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper shippingGates; @Mock LastlegOrderNoDailySequenceMapper sequences; ObjectMapper json=new ObjectMapper().findAndRegisterModules(); LoadingCompletedConsumer service; - @BeforeEach void setup(){ service=new LoadingCompletedConsumer(); ReflectionTestUtils.setField(service,"inboxMapper",inbox); ReflectionTestUtils.setField(service,"orderMapper",orders); ReflectionTestUtils.setField(service,"inboundOrderMapper",inboundOrders); ReflectionTestUtils.setField(service,"detailMapper",details); ReflectionTestUtils.setField(service,"historyMapper",histories); ReflectionTestUtils.setField(service,"cartonMapper",cartons); ReflectionTestUtils.setField(service,"dropshipOrders",dropshipOrders); ReflectionTestUtils.setField(service,"inventory",inventory); ReflectionTestUtils.setField(service,"shippingGates",shippingGates); ReflectionTestUtils.setField(service,"objectMapper",json); LastlegOrderNoGenerator generator=new LastlegOrderNoGenerator(); ReflectionTestUtils.setField(generator,"sequenceMapper",sequences); ReflectionTestUtils.setField(service,"orderNoGenerator",generator); lenient().when(sequences.currentValue()).thenReturn(1L); lenient().doAnswer(i->{ LastlegOrder o=i.getArgument(0); o.setId(Math.abs(o.getSourceInboundId().hashCode())+1L); return 1; }).when(orders).insert(any(LastlegOrder.class)); lenient().when(orders.lockContainerNumbers(anyString())).thenReturn(List.of()); lenient().when(shippingGates.initialize(anyString(),anyString(),anyString(),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; }); } + @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 com.baiyue.lastleg.inboundplan.pda.mapper.ContainerLoadingEvidenceMapper loadingEvidence; @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,"loadingEvidence",loadingEvidence); ReflectionTestUtils.setField(service,"objectMapper",json); LastlegOrderNoGenerator generator=new LastlegOrderNoGenerator(); ReflectionTestUtils.setField(generator,"sequenceMapper",sequences); ReflectionTestUtils.setField(service,"orderNoGenerator",generator); lenient().when(sequences.currentValue()).thenReturn(1L); lenient().doAnswer(i->{ LastlegOrder o=i.getArgument(0); o.setId(Math.abs(o.getSourceInboundId().hashCode())+1L); return 1; }).when(orders).insert(any(LastlegOrder.class)); lenient().when(orders.lockContainerNumbers(anyString())).thenReturn(List.of()); lenient().when(shippingGates.initialize(anyString(),anyString(),anyString(),anyLong(),anyLong())).thenAnswer(i->{ String key=i.getArgument(0); String value=i.getArgument(2); return frozenContainerNos.putIfAbsent(key,value)==null?1:0; }); lenient().when(shippingGates.lock(anyString())).thenAnswer(i->{ String key=i.getArgument(0); String value=frozenContainerNos.get(key); if(value==null)return null; var gate=new com.baiyue.lastleg.shipping.entity.ContainerShippingGate(); gate.setContainerBookingId(key); gate.setContainerNo(value); return gate; }); } @Test void createsPendingOrdersAndInTransitSnapshotsWithoutInboundPlans(){ EventEnvelope event=event(); assertEquals(2,service.consume(event)); assertThrows(NoSuchFieldException.class,()->LoadingCompletedConsumer.class.getDeclaredField("inboundPlans")); verify(orders,times(2)).insert(any(LastlegOrder.class)); ArgumentCaptor inTransit=ArgumentCaptor.forClass(InventoryCommandRequests.InTransit.class); verify(inventory,times(2)).registerInTransit(eq("100"),eq("SYSTEM"),inTransit.capture()); assertEquals(Set.of("RK1","RK2"),inTransit.getAllValues().stream().map(InventoryCommandRequests.InTransit::inboundOrderNo).collect(java.util.stream.Collectors.toSet())); verify(details,times(2)).insert(any(LastlegOrderDetail.class)); verify(histories,times(2)).insert(any(LastlegOrderStatusHistory.class)); ArgumentCaptor c=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(c.capture()); assertEquals(Set.of("in-1","in-2"),c.getAllValues().stream().map(LastlegOrder::getSourceInboundId).collect(java.util.stream.Collectors.toSet())); assertTrue(c.getAllValues().stream().allMatch(o->"PENDING_ASSIGNMENT".equals(o.getCurrentStatus()))); assertTrue(c.getAllValues().stream().allMatch(o->o.getInboundOrderNo()==null)); assertTrue(c.getAllValues().stream().allMatch(o->o.getId()!=null)); assertTrue(c.getAllValues().stream().allMatch(o->"MSCU6639870".equals(o.getContainerNo()))); assertTrue(c.getAllValues().stream().allMatch(o->"FBA".equals(o.getDestinationType()))); assertTrue(c.getAllValues().stream().allMatch(o->"Customer 100".equals(o.getCustomerName()))); assertTrue(c.getAllValues().stream().allMatch(o->"ow-1".equals(o.getSourceWarehouseId())&&"193000000000200003".equals(o.getOverseasWarehouseId())&&"US-LAX-01".equals(o.getOverseasWarehouseCode())&&"洛杉矶仓".equals(o.getOverseasWarehouseName())&&Integer.valueOf(0).equals(o.getReceivingWarehouseVersion()))); ArgumentCaptor snapshots=ArgumentCaptor.forClass(LastlegInboundOrder.class); verify(inboundOrders,times(2)).insert(snapshots.capture()); assertTrue(snapshots.getAllValues().stream().allMatch(o->"100".equals(o.getCustomerId())&&"FBA".equals(o.getDestinationType()))); assertEquals(1,c.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertTrue(c.getAllValues().get(0).getOrderNo().matches("FL-WH-[0-9]{8}-[0-9]{6}")); } @Test void oneContainerWith15InboundRowsSharesOneStableContainerOrderNumber(){ EventEnvelope source=event(); com.fasterxml.jackson.databind.node.ObjectNode node=source.payload().deepCopy(); com.fasterxml.jackson.databind.node.ArrayNode rows=json.createArrayNode(); for(int index=1;index<=15;index++){ com.fasterxml.jackson.databind.node.ObjectNode row=node.path("orders").get(0).deepCopy(); row.put("sourceInboundId","in-"+index).put("rootInboundId","root-"+index).put("sourceOrderId","order-"+index).put("sourceOrderNo","FL-"+index).put("rootOrderId","root-order-"+index).put("inboundCode","RK-"+index); rows.add(row); } node.set("orders",rows); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope fifteen=new EventEnvelope("123e4567-e89b-12d3-a456-426614174015",source.eventType(),source.eventVersion(),source.occurredAt(),source.sourceRegion(),source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); assertEquals(15,service.consume(fifteen)); ArgumentCaptor captured=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(15)).insert(captured.capture()); assertEquals(1,captured.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertTrue(captured.getAllValues().get(0).getOrderNo().matches("^FL-WH-\\d{8}-\\d{6}$")); verify(sequences,never()).nextValue(any(java.time.LocalDate.class)); } @Test void rejectsMissingOwnerSnapshotBeforeAnyLastlegWrite(){ EventEnvelope source=event(); com.fasterxml.jackson.databind.node.ObjectNode node=source.payload().deepCopy(); ((com.fasterxml.jackson.databind.node.ObjectNode)node.path("orders").get(0)).put("customerName"," "); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope missing=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),source.sourceRegion(),source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); IllegalArgumentException error=assertThrows(IllegalArgumentException.class,()->service.consume(missing)); assertTrue(error.getMessage().contains("尾程事件负载无效")||error.getMessage().contains("货主快照")); verify(inbox,never()).insert(any(LastlegEventInbox.class)); verify(orders,never()).insert(any(LastlegOrder.class)); } @@ -241,6 +241,26 @@ class LoadingCompletedConsumerTest { return new EventEnvelope(original.eventId(),original.eventType(),2,original.occurredAt(),original.sourceRegion(),original.targetRegion(),original.tenantId(),original.aggregateType(),original.aggregateId(),original.aggregateVersion(),2L,original.traceId(),original.correlationId(),original.causationId(),node,hash); } private EventEnvelope event(){ var item1=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-1","产品","Product",5,50,1,new BigDecimal("10"),new BigDecimal("9"),new BigDecimal("12"),new BigDecimal("1.2"),"wh-1","ONT8","FBA ONT8","FBA-1","SHIP-1","MARK-1"); var item2=new FirstLegLoadingPlanCompletedV1.Item("d-2","SKU-2","产品2","Product2",3,30,2,new BigDecimal("8"),new BigDecimal("7"),new BigDecimal("9"),new BigDecimal("0.8"),"wh-2","LAX9","FBA LAX9","FBA-2","SHIP-2","MARK-2"); var p=new FirstLegLoadingPlanCompletedV1("plan-1","LP1","book-1","MSCU6639870","CAB1","ow-1","洛杉矶海外仓",List.of(new FirstLegLoadingPlanCompletedV1.Order("in-1","root-1","ord-1","FL1","root-o1","RK1","wh-1","ONT8","FBA ONT8",5,50,1,new BigDecimal("10"),new BigDecimal("9"),new BigDecimal("1.2"),List.of(item1),"100","Customer 100","FIRST_LEG",List.of()),new FirstLegLoadingPlanCompletedV1.Order("in-2","root-2","ord-2","FL2","root-o2","RK2","wh-2","LAX9","FBA LAX9",3,30,2,new BigDecimal("8"),new BigDecimal("7"),new BigDecimal("0.8"),List.of(item2),"100","Customer 100","FIRST_LEG",List.of()))); JsonNode node=json.valueToTree(p); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); return new EventEnvelope("123e4567-e89b-12d3-a456-426614174000",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN","plan-1",2,1L,"123e4567-e89b-12d3-a456-426614174001","FIRSTLEG_LOADING_COMPLETED:plan-1:100:1",null,node,hash); } + @Test void projectsFiveDomesticLoadingPhotosOncePerPhysicalContainer(){ + EventEnvelope source=event(); com.fasterxml.jackson.databind.node.ObjectNode node=(com.fasterxml.jackson.databind.node.ObjectNode)v2Payload(source); + var values=node.putArray("loadingEvidence"); int fileId=10; + for(String type:List.of("PHOTO_STEP_1","PHOTO_STEP_2","PHOTO_STEP_3","PHOTO_STEP_4","SEAL")) values.addObject().put("evidenceType",type).put("fileId",fileId++).put("fileName",type+".jpg"); + String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); + EventEnvelope event=new EventEnvelope(source.eventId(),source.eventType(),3,source.occurredAt(),source.sourceRegion(),source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),3L,source.traceId(),source.correlationId(),source.causationId(),node,hash); + assertEquals(2,service.consume(event)); + ArgumentCaptor captured=ArgumentCaptor.forClass(com.baiyue.lastleg.inboundplan.pda.entity.ContainerLoadingEvidence.class); + verify(loadingEvidence,times(5)).insert(captured.capture()); + assertEquals(Set.of("PHOTO_STEP_1","PHOTO_STEP_2","PHOTO_STEP_3","PHOTO_STEP_4","SEAL"),captured.getAllValues().stream().map(com.baiyue.lastleg.inboundplan.pda.entity.ContainerLoadingEvidence::getEvidenceType).collect(java.util.stream.Collectors.toSet())); + assertTrue(captured.getAllValues().stream().allMatch(value->"book-1".equals(value.getSourceContainerBookingId()))); + } + @Test void rejectsV3EventWithoutTheFiveDomesticLoadingPhotos(){ + EventEnvelope source=event(); JsonNode node=v2Payload(source); + String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); + EventEnvelope event=new EventEnvelope(source.eventId(),source.eventType(),3,source.occurredAt(),source.sourceRegion(),source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),3L,source.traceId(),source.correlationId(),source.causationId(),node,hash); + IllegalArgumentException error=assertThrows(IllegalArgumentException.class,()->service.consume(event)); + assertTrue(error.getMessage().contains("五类国内装柜照片")); + verify(loadingEvidence,never()).insert(any(com.baiyue.lastleg.inboundplan.pda.entity.ContainerLoadingEvidence.class)); + } private JsonNode v2Payload(EventEnvelope source){ com.fasterxml.jackson.databind.node.ObjectNode node=source.payload().deepCopy(); node.set("sourceContainerBookingId",node.remove("containerBookingId")); return node; } private EventEnvelope eventForPayload(FirstLegLoadingPlanCompletedV1 payload, long version){ JsonNode node=json.valueToTree(payload); for(JsonNode order:node.path("orders")) for(JsonNode pallet:order.path("pallets")) addManifest(pallet,order.path("packageCount").asInt()); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); return new EventEnvelope("123e4567-e89b-12d3-a456-426614174099",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN",payload.loadingPlanId(),2,version,"123e4567-e89b-12d3-a456-426614174098","FIRSTLEG_LOADING_COMPLETED:"+payload.loadingPlanId()+":100:1",null,node,hash); } private void addManifest(JsonNode pallet,int packageCount){ diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/TaillegRealConsumerAcceptanceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/TaillegRealConsumerAcceptanceTest.java index c0cd97198..179433b94 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/TaillegRealConsumerAcceptanceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/TaillegRealConsumerAcceptanceTest.java @@ -43,7 +43,7 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; class TaillegRealConsumerAcceptanceTest { static final String CUSTOMER = "990022"; static final String MARKER = "REAL-TAIL-20260822-R3"; - static final String EVENT_KEY = "FIRSTLEG_LOADING_COMPLETED:990022022:990022:2"; + static final String EVENT_KEY = "FIRSTLEG_LOADING_COMPLETED:990022022:990022:3"; static final LocalDateTime RELABEL_DEADLINE = LocalDateTime.of(2026, 8, 29, 11, 1, 21, 628_000_000); static final LocalDateTime TRANSFER_ETA = LocalDateTime.of(2026, 9, 1, 11, 1, 21, 674_000_000); static final LocalDateTime RETURN_RELABEL_ETA = LocalDateTime.of(2026, 9, 1, 11, 1, 21, 756_000_000); @@ -62,7 +62,7 @@ class TaillegRealConsumerAcceptanceTest { InboundPlanService.class, InventoryCommandService.class, RelabelApplicationService.class, WarehouseTransferService.class, LocalLooseboardService.class}) @MapperScan({"com.baiyue.lastleg.mapper", "com.baiyue.lastleg.dropship.mapper", - "com.baiyue.lastleg.inboundplan.mapper", "com.baiyue.lastleg.inventory.mapper", + "com.baiyue.lastleg.inboundplan.mapper", "com.baiyue.lastleg.inboundplan.pda.mapper", "com.baiyue.lastleg.inventory.mapper", "com.baiyue.lastleg.inventory.command.mapper", "com.baiyue.lastleg.relabel.mapper", "com.baiyue.lastleg.transfer.mapper", "com.baiyue.lastleg.looseboard.mapper"}) static class AcceptanceApplication { diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index 7ceab527f..2536eb322 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -11,7 +11,7 @@ const OPTIONAL_MODULES = ['manual-order-splitting-fixture', 'manual-order-splitt 'manual-pda-ready-fixture', 'manual-pda-ready-verify', 'manual-pda-batch-fixture', 'manual-pda-batch-verify', 'manual-loading-complete-fixture', 'manual-loading-complete-verify', - 'coload-groups', 'dropship-pallet-matrix'] + 'coload-groups', 'dropship-pallet-matrix', 'dropship-browser-fixture'] const AVAILABLE_MODULES = new Set([...CONFIGURED_MODULES, ...OPTIONAL_MODULES]) const A0_LOCATION_PAGE_SIZE = 200 const A0_MAX_LOCATION_MUTATIONS = 8 @@ -43,6 +43,7 @@ export function planA0LocationIsolation(locations, requiredSlots = 1, const MODULE_DEPENDENCIES = new Map([ ['dropship-pallet-matrix', ['merchant-qualification-fixture']], + ['dropship-browser-fixture', ['merchant-qualification-fixture']], ['suppliers', ['merchant-qualification-fixture']], ['supplemental', ['merchant-qualification-fixture']], ['orders', ['merchant-qualification-fixture']], diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index 86bbe7c79..029302c00 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -37,6 +37,8 @@ export async function run(context) { ## 可用上下文 +`dropship-browser-fixture.mjs` 通过总入口 `--modules dropship-browser-fixture` 执行,自动依赖客户资质准备;复用矩阵生产者创建一票四箱两托,完成国内装柜、船节点和按柜生成海外入库计划,明确停在 `READY_FOR_BROWSER_RECEIVING`。只准备新测试数据,不调用海外 PDA 收货或出库、不创建海外操作员;后续由浏览器页面验收。报告仍保存于 `createdResources.dropshipPalletMatrix`,准备通过不代表全流程通过,不自动删除数据。 + `dropship-pallet-matrix.mjs` 验证下述四场景整托手工加入、整托移除、智能推荐、独立订舱与文件放舱、真实柜号关联、预装确认、PDA 整托拣货和五类证据上传;异步装柜完成必须等到任务 `COMPLETED` 并逐票回查 `LOADED`。智能装柜的托盘汇总与装柜详情逐票组成分别核对,不能把共托汇总行的首票编号当作全托归属。随后经正式报关、国内离仓及人工船节点接口推进至 `PICKUP_READY`,再按柜生成逐票海外入库计划,检查来源托盘完整绑定、共享托盘身份一致和同键重放。海外收货使用本轮创建并绑定接柜仓的 PDA 操作员,上传三类照片、签收、逐实物托盘收货、逐票箱数核平和完成入库,同键重放及同托新键扫码均不得重复入账。随后从本轮逐票库存候选创建一件代发专用出库,携带原始箱唛由 PDA 拣货、完成,核对终态、幂等和可用库存扣减;finally 停用本轮操作员。出库重放按业务身份和数据库毫秒时间精度核对。离线合同为 `dropship-pallet-matrix-contract.test.mjs`;脚本覆盖不等于真实 HTTP 已通过,须检查当轮报告和库存审计。 - `dropship-pallet-matrix` 通过总入口 `--modules dropship-pallet-matrix` 执行,自动依赖客户资质准备。通过正式 HTTP 创建五票订单,覆盖单票多托、单票单托、单票移箱合托、多票共享开放托盘;会创建并保留测试数据,不自动清理。报告 `createdResources.dropshipPalletMatrix` 保存逐票箱唛、托盘及各阶段证据,失败即停止并保留阻断;重跑创建新批次,不借用历史资源。`fullFlowVerified=false`,不能代替海外收货及一件代发专用出库的完整验收。 diff --git a/scripts/admin-test-env-modules/dropship-browser-fixture.mjs b/scripts/admin-test-env-modules/dropship-browser-fixture.mjs new file mode 100644 index 000000000..77e466d3b --- /dev/null +++ b/scripts/admin-test-env-modules/dropship-browser-fixture.mjs @@ -0,0 +1,2 @@ +// Explicit UI handoff: creates one fresh multi-pallet order and stops before overseas PDA writes. +export { prepareBrowserFixture as run } from './dropship-pallet-matrix.mjs' diff --git a/scripts/admin-test-env-modules/dropship-pallet-matrix-contract.test.mjs b/scripts/admin-test-env-modules/dropship-pallet-matrix-contract.test.mjs index f2a5d76b8..db8a42ad7 100644 --- a/scripts/admin-test-env-modules/dropship-pallet-matrix-contract.test.mjs +++ b/scripts/admin-test-env-modules/dropship-pallet-matrix-contract.test.mjs @@ -5,6 +5,16 @@ import { normalizeModuleOrder } from '../admin-test-env-acceptance.mjs' import { sameOutboundTask } from './dropship-pallet-matrix.mjs' import { probePartialPick } from './dropship-outbound-edge-cases.mjs' +test('browser fixture is explicit and stops before overseas receipt or outbound', () => { + assert.deepEqual(normalizeModuleOrder(['dropship-browser-fixture']), ['merchant-qualification-fixture', 'dropship-browser-fixture']) + const source = readFileSync(new URL('dropship-pallet-matrix.mjs', import.meta.url), 'utf8') + const branch = source.slice(source.indexOf('if (stopBeforeOverseasPda)'), source.indexOf("await verify(await prepare('SINGLE-MULTI'")) + assert.match(branch, /prepare\('BROWSER-MULTI', 2\)/) + assert.match(branch, /convertOverseas\(\[record\]\)/) + assert.match(branch, /READY_FOR_BROWSER_RECEIVING'[\s\S]*return/) + assert.doesNotMatch(branch, /receiveOverseas|outboundOverseas|createPicker/) +}) + test('partial-pick rejection stays failed; a changed snapshot aborts further picking', async () => { const failures = [] const snapshot = { pallets: [{ pickedPackageCount: 0 }] } diff --git a/scripts/admin-test-env-modules/dropship-pallet-matrix.mjs b/scripts/admin-test-env-modules/dropship-pallet-matrix.mjs index 4e765e8fc..1498936bb 100644 --- a/scripts/admin-test-env-modules/dropship-pallet-matrix.mjs +++ b/scripts/admin-test-env-modules/dropship-pallet-matrix.mjs @@ -30,6 +30,14 @@ export function sameOutboundTask(left, right) { // Domestic producer stage: these retained loaded fixtures must subsequently be verified // through overseas receipt and dedicated dropship outbound, not generic dispatch. export async function run(context) { + return runWorkflow(context, false) +} + +export async function prepareBrowserFixture(context) { + return runWorkflow(context, true) +} + +async function runWorkflow(context, stopBeforeOverseasPda) { const marker = context.marker const savedOptions = context.options.prepareDropshipManualCount const savedContext = { ...context.context } @@ -472,6 +480,15 @@ export async function run(context) { for (const record of group) record.outbound = { task: completed, selected, detail: outbound, completed: true } } try { + if (stopBeforeOverseasPda) { + const record = await prepare('BROWSER-MULTI', 2) + await verify(record, 2) + await allocate([record]) + await progressShipping([record]) + await convertOverseas([record]) + context.createdResources.dropshipPalletMatrix.stage = 'READY_FOR_BROWSER_RECEIVING' + return + } await verify(await prepare('SINGLE-MULTI', 2), 2) await verify(await prepare('SINGLE-SINGLE', 1), 1) await verify(await prepare('SINGLE-MERGE', 2, { mergeToFirstPallet: true }), 1) diff --git a/scripts/admin-test-env-modules/loading-list-remarks-contract.test.mjs b/scripts/admin-test-env-modules/loading-list-remarks-contract.test.mjs index d54259e0f..6bb25640f 100644 --- a/scripts/admin-test-env-modules/loading-list-remarks-contract.test.mjs +++ b/scripts/admin-test-env-modules/loading-list-remarks-contract.test.mjs @@ -7,6 +7,7 @@ const openapi = read('../../firstleg/src/main/resources/openapi/firstleg-v1.yaml const service = read('../../firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java') const outbox = read('../../firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java') const event = read('../../common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2.java') +const eventV3 = read('../../common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV3.java') const admin = read('../../../apps/admin-web/src/modules/loading-lists/components/LoadingListDetailDrawer.vue') test('fixture locks customer and latest internal note sources without cargoRemark leakage', () => { @@ -34,16 +35,18 @@ test('warehouse remark PATCH covers save clear stale version and source preserva assert.match(loading, /preservedRemarkItem\?\.warehouseRemark === warehouseRemark/) }) -test('completed event V2 carries all three remarks with stable event payload', () => { +test('completed event V3 retains V2 order remarks with stable event payload', () => { assert.match(outbox, /FirstLegLoadingPlanCompletedV2/) + assert.match(outbox, /FirstLegLoadingPlanCompletedV3/) + assert.match(eventV3, /List orders/) for (const field of ['customerRemark', 'internalRemark', 'warehouseRemark']) assert.match(event, new RegExp(field)) assert.match(outbox, /row\.getCustomerRemark\(\)/) assert.match(outbox, /row\.getInternalRemark\(\)/) assert.match(outbox, /row\.getWarehouseRemark\(\)/) - assert.match(outbox, /HANDOVER_VERSION = 2L/) + assert.match(outbox, /HANDOVER_VERSION = 3L/) assert.match(outbox, /EventPayloadCanonicalizer\.canonicalize\(payloadNode\)/) assert.match(outbox, /FIRSTLEG_LOADING_COMPLETED:[^\n]*HANDOVER_VERSION/) - assert.match(outbox, /EventTypes\.FIRST_LEG_LOADING_PLAN_COMPLETED, 2/) + assert.match(outbox, /EventTypes\.FIRST_LEG_LOADING_PLAN_COMPLETED, 3/) }) test('Admin exposes only warehouse remark editing and keeps completed rows read-only', () => { diff --git a/scripts/admin-test-env-modules/phase3-loading-finalize.mjs b/scripts/admin-test-env-modules/phase3-loading-finalize.mjs index 4c494d314..750fbf15f 100644 --- a/scripts/admin-test-env-modules/phase3-loading-finalize.mjs +++ b/scripts/admin-test-env-modules/phase3-loading-finalize.mjs @@ -558,8 +558,8 @@ export async function run(context) { const producer = { marker: context.context?.phase4ProducerMatrixMarker || context.marker, eventType: 'FIRST_LEG_LOADING_PLAN_COMPLETED', - eventVersion: 2, - eventKey: `FIRSTLEG_LOADING_COMPLETED:${loadingPlanId}:${tenantId}:2`, + eventVersion: 3, + eventKey: `FIRSTLEG_LOADING_COMPLETED:${loadingPlanId}:${tenantId}:3`, resourceKey: `${context.marker}:${fixture.destinationType || 'UNKNOWN'}:${fixture.containerNo || containerBookingId}`, tenantId, aggregateType: 'LOADING_PLAN', diff --git a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs index 965e482c7..6f9b19c34 100644 --- a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs @@ -813,7 +813,7 @@ test('finalize publishes a current-marker producer correlation for cross-region assert.match(finalize, /createdResources\.phase3LoadingCompletedProducer = producer/) assert.match(finalize, /marker: context\.context\?\.phase4ProducerMatrixMarker \|\| context\.marker/) assert.match(finalize, /eventType: 'FIRST_LEG_LOADING_PLAN_COMPLETED'/) - assert.match(finalize, /eventKey: `FIRSTLEG_LOADING_COMPLETED:\$\{loadingPlanId\}:\$\{tenantId\}:2`/) + assert.match(finalize, /eventKey: `FIRSTLEG_LOADING_COMPLETED:\$\{loadingPlanId\}:\$\{tenantId\}:3`/) assert.match(finalize, /sourceStatus: 'LOADED'/) assert.match(finalize, /PRODUCED_IN_COMPLETE_TRANSACTION_PENDING_BRIDGE_CONSUMER_HTTP/) }) diff --git a/scripts/admin-test-env-modules/phase4-tail-order-controls.mjs b/scripts/admin-test-env-modules/phase4-tail-order-controls.mjs index 51586c6f8..e19e10159 100644 --- a/scripts/admin-test-env-modules/phase4-tail-order-controls.mjs +++ b/scripts/admin-test-env-modules/phase4-tail-order-controls.mjs @@ -223,7 +223,6 @@ async function verifyDestinationMergeFeeAndExport(context, details, producer) { function manualPlanBody(order, version, marker) { return { customerId: id(order.customerId), orderId: id(order.id), expectedOrderVersion: Number(version), - plannedArrivalDate: new Date(Date.now() + 7 * 86_400_000).toISOString().slice(0, 10), inboundCode: order.inboundCode, palletCount: Math.min(999, Math.max(1, Number(order.palletCount) || 1)), palletType: 'STANDARD', assigned: false, mergeRequired: false, urgent: true, diff --git a/scripts/agents-index-contract.ps1 b/scripts/agents-index-contract.ps1 index cf2c5affc..2a9b71af6 100644 --- a/scripts/agents-index-contract.ps1 +++ b/scripts/agents-index-contract.ps1 @@ -1102,6 +1102,7 @@ $criticalPaths = @( 'baiyue-firstleg/gateway-overseas/src/test/java/com/baiyue/gateway/config/OverseasLastlegRouteContractTest.java', 'baiyue-firstleg/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/FirstlegEventClient.java', 'baiyue-firstleg/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2.java', + 'baiyue-firstleg/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV3.java', 'baiyue-firstleg/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegContainerShippingNodeRecordedV1.java', 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxService.java', 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java',