diff --git a/common-core/src/main/java/com/baiyue/common/core/container/ContainerSpecificationCatalog.java b/common-core/src/main/java/com/baiyue/common/core/container/ContainerSpecificationCatalog.java new file mode 100644 index 000000000..7a955bdc8 --- /dev/null +++ b/common-core/src/main/java/com/baiyue/common/core/container/ContainerSpecificationCatalog.java @@ -0,0 +1,56 @@ +package com.baiyue.common.core.container; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** Platform-wide container types and practical loading limits. */ +public final class ContainerSpecificationCatalog { + public static final String DEFAULT_CONTAINER_TYPE = "40HQ"; + + private static final List ORDERED_VALUES = List.of( + new Specification("40GP", new BigDecimal("56"), new BigDecimal("58"), new BigDecimal("19500"), + "美线电商较少使用,通常优先选择40HQ"), + new Specification("40HQ", new BigDecimal("65"), new BigDecimal("68"), new BigDecimal("19500"), + "轻泡货首选,适合家居、玩具、日用品"), + new Specification("45HQ", new BigDecimal("82"), new BigDecimal("86"), new BigDecimal("19500"), + "抛货量大时考虑,底盘和海外仓预约资源较少") + ); + private static final Map VALUES = ORDERED_VALUES.stream() + .collect(Collectors.toUnmodifiableMap(Specification::containerType, Function.identity())); + + private ContainerSpecificationCatalog() {} + + public static List values() { + return ORDERED_VALUES; + } + + public static Specification require(String containerType) { + String normalized = containerType == null ? "" : containerType.trim().toUpperCase(Locale.ROOT); + Specification specification = VALUES.get(normalized); + if (specification == null) { + throw new BizException(ResultCode.VALIDATION_FAILED, "柜型仅支持40GP、40HQ、45HQ"); + } + return specification; + } + + public static Specification requireMatch(String containerType, BigDecimal capacityCbm, BigDecimal maxWeightKg) { + Specification specification = require(containerType); + if (capacityCbm == null || maxWeightKg == null + || specification.capacityCbm().compareTo(capacityCbm) != 0 + || specification.maxWeightKg().compareTo(maxWeightKg) != 0) { + throw new BizException(ResultCode.VALIDATION_FAILED, + "柜型容量和货重上限必须使用平台统一定义"); + } + return specification; + } + + public record Specification(String containerType, BigDecimal recommendedMinCbm, BigDecimal capacityCbm, + BigDecimal maxWeightKg, String description) {} +} diff --git a/common-core/src/test/java/com/baiyue/common/core/container/ContainerSpecificationCatalogTest.java b/common-core/src/test/java/com/baiyue/common/core/container/ContainerSpecificationCatalogTest.java new file mode 100644 index 000000000..058c744c9 --- /dev/null +++ b/common-core/src/test/java/com/baiyue/common/core/container/ContainerSpecificationCatalogTest.java @@ -0,0 +1,33 @@ +package com.baiyue.common.core.container; + +import com.baiyue.common.core.exception.BizException; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ContainerSpecificationCatalogTest { + @Test + void exposesTheThreeCanonicalContainerTypesInBusinessOrder() { + assertThat(ContainerSpecificationCatalog.values()) + .extracting(ContainerSpecificationCatalog.Specification::containerType) + .containsExactly("40GP", "40HQ", "45HQ"); + assertThat(ContainerSpecificationCatalog.DEFAULT_CONTAINER_TYPE).isEqualTo("40HQ"); + } + + @Test + void exposesPracticalLoadingRangesAndRejectsRemovedTypes() { + var specification = ContainerSpecificationCatalog.requireMatch( + "40hq", new BigDecimal("68.000"), new BigDecimal("19500.000")); + + assertThat(specification.containerType()).isEqualTo("40HQ"); + assertThat(specification.recommendedMinCbm()).isEqualByComparingTo("65"); + assertThatThrownBy(() -> ContainerSpecificationCatalog.require("40HC")) + .isInstanceOf(BizException.class).hasMessageContaining("40GP、40HQ、45HQ"); + assertThatThrownBy(() -> ContainerSpecificationCatalog.requireMatch( + "40HQ", new BigDecimal("76"), new BigDecimal("19500"))) + .isInstanceOf(BizException.class).hasMessageContaining("统一定义"); + } +} diff --git a/common-event-contract/AGENTS.md b/common-event-contract/AGENTS.md index d3d7e8542..5f107b109 100644 --- a/common-event-contract/AGENTS.md +++ b/common-event-contract/AGENTS.md @@ -14,16 +14,17 @@ | 事件 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 回传,国内按入仓单绑定、客户归属及聚合版本单调消费 | +| 海外派送完成 V1 | `OverseasDeliveryCompletedV1.java`、`EventTypes.OVERSEAS_DELIVERY_COMPLETED`;每个按 FBA 仓拆分的派送单完成后,按关联来源入仓单分别发布,携带来源订单、入仓码、FBA 仓和签收时间,国内幂等推进对应 Firstleg 入仓单与 Order 完成 | | 国内船节点记录 V1 | `FirstLegContainerShippingNodeRecordedV1.java`;按 `containerBookingId` 柜级聚合,不携带客户,信封固定使用 `tenantId=0` 平台技术范围;只接受十节点中带实际时间和完整供应商审计字段的事实,国内生产者 `../firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxService.java`,海外消费者 `../lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java` | | 海外业务费用来源变更 V1 | `BusinessFeeSourceChangedV1.java`、`EventTypes.BUSINESS_FEE_SOURCE_CHANGED`;携带可独立校验入池的完整不可变客户、业务、方向、供应商、付款、来源版本与哈希快照,长整型标识使用字符串;海外生产者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/financefee/`,Event Bridge 定向投递国内 `finance/`,Finance 不跨区同步回查 | | 海外业务资金指令与结果 V1 | `WalletCommandRequestedV1.java`、`WalletCommandCompletedV1.java`;海外业务通过 Outbox 请求国内 Finance 冻结、结算或释放,Finance 执行后再经 Outbox 回传结果,双方以事件与业务幂等键防重,不跨区域同步调用钱包 | | IAM 安全事件 V1 | `IamSecurityEventsV1.java`;账号、授权快照等安全事件的冻结模型 | | 契约说明 | `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/EventTypes.java b/common-event-contract/src/main/java/com/baiyue/common/event/EventTypes.java index a8886358c..17172f544 100644 --- a/common-event-contract/src/main/java/com/baiyue/common/event/EventTypes.java +++ b/common-event-contract/src/main/java/com/baiyue/common/event/EventTypes.java @@ -14,6 +14,7 @@ public final class EventTypes { "FirstLegContainerShippingNodeRecorded"; public static final String OVERSEAS_CONTAINER_UNLOADED = "OverseasContainerUnloaded"; public static final String OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED = "OverseasInboundOrderDetainedChanged"; + public static final String OVERSEAS_DELIVERY_COMPLETED = "OverseasDeliveryCompleted"; public static final String BUSINESS_FEE_SOURCE_CHANGED = "BusinessFeeSourceChanged"; public static final String WALLET_COMMAND_REQUESTED = "WalletCommandRequested"; public static final String WALLET_COMMAND_COMPLETED = "WalletCommandCompleted"; 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/java/com/baiyue/common/event/OverseasDeliveryCompletedV1.java b/common-event-contract/src/main/java/com/baiyue/common/event/OverseasDeliveryCompletedV1.java new file mode 100644 index 000000000..90d88f414 --- /dev/null +++ b/common-event-contract/src/main/java/com/baiyue/common/event/OverseasDeliveryCompletedV1.java @@ -0,0 +1,19 @@ +package com.baiyue.common.event; + +import java.time.Instant; + +/** 海外派送单按目的 FBA 仓签收后,回传对应国内入仓单和订单的完成事实。 */ +public record OverseasDeliveryCompletedV1( + String deliveryOrderId, + String deliveryNo, + String lastlegOrderId, + String lastlegOrderNo, + String sourceInboundId, + String inboundCode, + String sourceOrderId, + String sourceOrderNo, + String rootOrderId, + String fbaWarehouseCode, + Instant completedAt +) { +} 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/common-event-contract/src/test/java/com/baiyue/common/event/OverseasDeliveryCompletedV1Test.java b/common-event-contract/src/test/java/com/baiyue/common/event/OverseasDeliveryCompletedV1Test.java new file mode 100644 index 000000000..7d96a07b2 --- /dev/null +++ b/common-event-contract/src/test/java/com/baiyue/common/event/OverseasDeliveryCompletedV1Test.java @@ -0,0 +1,26 @@ +package com.baiyue.common.event; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.junit.jupiter.api.Test; + +import java.time.Instant; + +import static org.assertj.core.api.Assertions.assertThat; + +class OverseasDeliveryCompletedV1Test { + private final ObjectMapper mapper = new ObjectMapper().registerModule(new JavaTimeModule()); + + @Test + void roundTripsStableSourceIdentifiers() throws Exception { + var value = new OverseasDeliveryCompletedV1("31", "DL-31", "41", "LM-41", "51", + "NYEXX2609080022", "61", "FL-61", "61", "ACY2", + Instant.parse("2026-09-15T08:00:00Z")); + + var restored = mapper.readValue(mapper.writeValueAsBytes(value), OverseasDeliveryCompletedV1.class); + + assertThat(restored).isEqualTo(value); + assertThat(restored.sourceInboundId()).isEqualTo("51"); + assertThat(restored.sourceOrderId()).isEqualTo("61"); + } +} 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/docker-compose.overseas.yml b/deploy/docker-compose.overseas.yml index 33709eb4a..fbde2d580 100644 --- a/deploy/docker-compose.overseas.yml +++ b/deploy/docker-compose.overseas.yml @@ -81,6 +81,8 @@ services: WAREHOUSE_DB_USERNAME: ${WAREHOUSE_OVERSEAS_DB_USERNAME} WAREHOUSE_DB_PASSWORD: ${WAREHOUSE_OVERSEAS_DB_PASSWORD} DOMESTIC_WAREHOUSE_URL: ${DOMESTIC_WAREHOUSE_URL} + # 与海外 BOL 共用国内 Print;不能在海外 Nacos namespace 中发现该服务。 + PRINT_SERVICE_URL: ${PRINT_SERVICE_URL:-http://print:9207} event-bridge-service: <<: *overseas-common diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index dc92b6d01..488d2d08c 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -9919,8 +9919,6 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking ( destination_port_code VARCHAR(32) NULL COMMENT '目的港编码快照', destination_port_name VARCHAR(128) NULL COMMENT '目的港名称快照', place_of_delivery VARCHAR(128) NULL COMMENT '最终交货地快照', - service_term VARCHAR(32) NULL COMMENT '运输服务条款快照;发布前必填', - freight_term VARCHAR(32) NULL COMMENT '海运费支付条款快照;发布前必填', estimated_sailing_at DATETIME(3) NULL COMMENT '当前确认预计开船时间', estimated_arrival_at DATETIME(3) NULL COMMENT '当前确认预计到港时间', remark VARCHAR(1000) NULL COMMENT '平台内部备注', @@ -9997,8 +9995,6 @@ PREPARE ocean_drop_status_stmt FROM @ocean_drop_status_check; EXECUTE ocean_drop_status_stmt; DEALLOCATE PREPARE ocean_drop_status_stmt; ALTER TABLE fl_ocean_booking - MODIFY COLUMN service_term VARCHAR(32) NULL COMMENT '运输服务条款快照;发布前必填', - MODIFY COLUMN freight_term VARCHAR(32) NULL COMMENT '海运费支付条款快照;发布前必填', ADD CONSTRAINT chk_ocean_booking_status CHECK (status IN ('DRAFT','RELEASED')); @@ -14879,11 +14875,9 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'destination_po CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'destination_port_name', '目的港名称快照'); CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'estimated_arrival_at', '当前确认预计到港时间'); CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'estimated_sailing_at', '当前确认预计开船时间'); -CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'freight_term', '海运费支付条款快照'); CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'id', '海运订舱ID'); CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'place_of_delivery', '最终交货地快照'); CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'remark', '平台内部备注'); -CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'service_term', '运输服务条款快照'); CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'shipping_company_id', '船公司逻辑ID快照'); CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'shipping_company_name', '船公司名称快照'); CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'status', '订舱状态:DRAFT草稿、RELEASED已发布且永久只读'); @@ -16603,10 +16597,10 @@ BEGIN @phase3_channel_message = MESSAGE_TEXT; END; IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='baiyue_firstleg' AND table_name='fl_ocean_booking' AND column_name='channel_code') THEN - ALTER TABLE baiyue_firstleg.fl_ocean_booking ADD COLUMN channel_code VARCHAR(64) NULL COMMENT '订舱渠道编码快照' AFTER customer_name; + ALTER TABLE baiyue_firstleg.fl_ocean_booking ADD COLUMN channel_code VARCHAR(64) NULL COMMENT '订舱计划物流渠道组编码快照' AFTER customer_name; END IF; IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='baiyue_firstleg' AND table_name='fl_ocean_booking' AND column_name='channel_name') THEN - ALTER TABLE baiyue_firstleg.fl_ocean_booking ADD COLUMN channel_name VARCHAR(128) NULL COMMENT '订舱渠道名称快照' AFTER channel_code; + ALTER TABLE baiyue_firstleg.fl_ocean_booking ADD COLUMN channel_name VARCHAR(128) NULL COMMENT '订舱计划物流渠道组名称快照' AFTER channel_code; END IF; IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='baiyue_firstleg' AND table_name='fl_ocean_booking' AND column_name='warehouse_id') THEN ALTER TABLE baiyue_firstleg.fl_ocean_booking ADD COLUMN warehouse_id BIGINT UNSIGNED NULL COMMENT '国内中转仓ID快照' AFTER channel_name; @@ -19224,7 +19218,7 @@ USE baiyue_firstleg; CREATE TABLE IF NOT EXISTS fl_terminal_drayage ( id BIGINT UNSIGNED NOT NULL COMMENT '目的港码头拖车聚合ID', container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '唯一归属物理货柜ID', fleet_supplier_id BIGINT UNSIGNED NULL COMMENT 'Warehouse ACTIVE/TRUCKING供应商ID快照', fleet_supplier_name VARCHAR(128) NULL COMMENT 'Warehouse供应商名称快照', - pickup_appointment_at DATETIME(3) NULL COMMENT '预约提柜时间', actual_pickup_at DATETIME(3) NULL COMMENT '实际提柜时间', + pickup_appointment_at DATETIME(3) NULL COMMENT '预约提柜时间', estimated_empty_return_at DATETIME(3) NULL COMMENT '服务端按卸柜完成时间加72小时计算的预计还柜时间', actual_empty_return_at DATETIME(3) NULL COMMENT '实际还空柜时间', version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '乐观锁版本号', created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID', created_by_name VARCHAR(64) NULL COMMENT '创建人名称快照', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', @@ -19232,9 +19226,7 @@ CREATE TABLE IF NOT EXISTS fl_terminal_drayage ( updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', PRIMARY KEY(id), UNIQUE KEY uk_terminal_drayage_container(container_booking_id), KEY idx_terminal_drayage_warning(actual_empty_return_at,estimated_empty_return_at), - CONSTRAINT fk_terminal_drayage_container FOREIGN KEY(container_booking_id) REFERENCES fl_container_booking(id), - CONSTRAINT chk_terminal_drayage_times CHECK((actual_pickup_at IS NULL OR pickup_appointment_at IS NOT NULL) - AND (actual_empty_return_at IS NULL OR actual_pickup_at IS NULL OR actual_empty_return_at >= actual_pickup_at)) + CONSTRAINT fk_terminal_drayage_container FOREIGN KEY(container_booking_id) REFERENCES fl_container_booking(id) ) ENGINE=InnoDB COMMENT='目的港货柜级码头拖车与还空事实'; CREATE TABLE IF NOT EXISTS fl_terminal_drayage_mail ( id BIGINT UNSIGNED NOT NULL COMMENT '邮件任务ID', @@ -19270,7 +19262,7 @@ CREATE TABLE IF NOT EXISTS fl_terminal_drayage_history ( KEY idx_terminal_drayage_history(container_booking_id,created_at,id), CONSTRAINT fk_terminal_drayage_history FOREIGN KEY(drayage_id) REFERENCES fl_terminal_drayage(id), CONSTRAINT chk_terminal_drayage_history_action CHECK(action IN - ('SELECT_FLEET','SEND_PICKUP_EMAIL','SCHEDULE_PICKUP','RECORD_PICKUP','RECORD_EMPTY_RETURN')) + ('SELECT_FLEET','SEND_PICKUP_EMAIL','SCHEDULE_PICKUP','RECORD_EMPTY_RETURN')) ) ENGINE=InnoDB COMMENT='目的港码头拖车不可变操作历史'; CREATE TABLE IF NOT EXISTS fl_terminal_drayage_operation ( id BIGINT UNSIGNED NOT NULL COMMENT '幂等操作ID', container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '货柜ID', @@ -19283,17 +19275,19 @@ CREATE TABLE IF NOT EXISTS fl_terminal_drayage_operation ( ) ENGINE=InnoDB COMMENT='目的港码头拖车成功幂等操作'; -- MySQL DDL 会隐式提交;如果前次执行在末段校验失败,重跑时需把已创建表的列注释收敛到当前基线。 +SET @ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='fl_terminal_drayage' AND column_name='actual_pickup_at'), + 'ALTER TABLE fl_terminal_drayage DROP COLUMN actual_pickup_at', 'SELECT 1')); +PREPARE terminal_drayage_drop_manual_pickup_stmt FROM @ddl; +EXECUTE terminal_drayage_drop_manual_pickup_stmt; +DEALLOCATE PREPARE terminal_drayage_drop_manual_pickup_stmt; + ALTER TABLE fl_terminal_drayage - DROP CHECK chk_terminal_drayage_times, - ADD CONSTRAINT chk_terminal_drayage_times CHECK( - (actual_pickup_at IS NULL OR pickup_appointment_at IS NOT NULL) AND - (actual_empty_return_at IS NULL OR actual_pickup_at IS NULL OR actual_empty_return_at >= actual_pickup_at)), MODIFY COLUMN id BIGINT UNSIGNED NOT NULL COMMENT '目的港码头拖车聚合ID', MODIFY COLUMN container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '唯一归属物理货柜ID', MODIFY COLUMN fleet_supplier_id BIGINT UNSIGNED NULL COMMENT 'Warehouse ACTIVE/TRUCKING供应商ID快照', MODIFY COLUMN fleet_supplier_name VARCHAR(128) NULL COMMENT 'Warehouse供应商名称快照', MODIFY COLUMN pickup_appointment_at DATETIME(3) NULL COMMENT '预约提柜时间', - MODIFY COLUMN actual_pickup_at DATETIME(3) NULL COMMENT '实际提柜时间', MODIFY COLUMN estimated_empty_return_at DATETIME(3) NULL COMMENT '服务端按卸柜完成时间加72小时计算的预计还柜时间', MODIFY COLUMN actual_empty_return_at DATETIME(3) NULL COMMENT '实际还空柜时间', MODIFY COLUMN version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '乐观锁版本号', @@ -19307,7 +19301,7 @@ ALTER TABLE fl_terminal_drayage ALTER TABLE fl_terminal_drayage_history DROP CHECK chk_terminal_drayage_history_action, ADD CONSTRAINT chk_terminal_drayage_history_action CHECK(action IN - ('SELECT_FLEET','SEND_PICKUP_EMAIL','SCHEDULE_PICKUP','RECORD_PICKUP','RECORD_EMPTY_RETURN')), + ('SELECT_FLEET','SEND_PICKUP_EMAIL','SCHEDULE_PICKUP','RECORD_EMPTY_RETURN')), MODIFY COLUMN id BIGINT UNSIGNED NOT NULL COMMENT '历史ID', MODIFY COLUMN drayage_id BIGINT UNSIGNED NOT NULL COMMENT '码头拖车聚合ID', MODIFY COLUMN container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '货柜ID快照', @@ -19371,7 +19365,6 @@ BEGIN OR (id = 5102201 AND permission_code <> 'platform:firstleg:terminal-drayage:document:edit') OR (id = 5102202 AND permission_code <> 'platform:firstleg:terminal-drayage:fleet:edit') OR (id = 5102203 AND permission_code <> 'platform:firstleg:terminal-drayage:pickup-appointment:edit') - OR (id = 5102204 AND permission_code <> 'platform:firstleg:terminal-drayage:pickup:confirm') OR (id = 5102205 AND permission_code <> 'platform:firstleg:terminal-drayage:empty-return:confirm') ) OR EXISTS ( SELECT 1 FROM iam_permission @@ -19382,7 +19375,6 @@ BEGIN OR (permission_code = 'platform:firstleg:terminal-drayage:document:edit' AND id <> 5102201) OR (permission_code = 'platform:firstleg:terminal-drayage:fleet:edit' AND id <> 5102202) OR (permission_code = 'platform:firstleg:terminal-drayage:pickup-appointment:edit' AND id <> 5102203) - OR (permission_code = 'platform:firstleg:terminal-drayage:pickup:confirm' AND id <> 5102204) OR (permission_code = 'platform:firstleg:terminal-drayage:empty-return:confirm' AND id <> 5102205) ) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='货柜双工作台权限初始化失败:权限ID或编码冲突'; @@ -19406,8 +19398,6 @@ BEGIN '维护目的港码头车队',NULL,'POST',20,'ACTIVE',1,0), (5102203,0,'PLATFORM','BUTTON',5102200,'platform:firstleg:terminal-drayage:pickup-appointment:edit', '预约目的港提柜',NULL,'POST',30,'ACTIVE',1,0), - (5102204,0,'PLATFORM','BUTTON',5102200,'platform:firstleg:terminal-drayage:pickup:confirm', - '确认目的港实际提柜',NULL,'POST',40,'ACTIVE',1,0), (5102205,0,'PLATFORM','BUTTON',5102200,'platform:firstleg:terminal-drayage:empty-return:confirm', '确认目的港实际还柜',NULL,'POST',50,'ACTIVE',1,0) ON DUPLICATE KEY UPDATE @@ -19440,7 +19430,6 @@ BEGIN 'platform:firstleg:terminal-drayage:document:edit', 'platform:firstleg:terminal-drayage:fleet:edit', 'platform:firstleg:terminal-drayage:pickup-appointment:edit', - 'platform:firstleg:terminal-drayage:pickup:confirm', 'platform:firstleg:terminal-drayage:empty-return:confirm') WHERE role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR') @@ -19457,8 +19446,7 @@ BEGIN 'platform:firstleg:terminal-drayage:document:edit', 'platform:firstleg:terminal-drayage:fleet:edit', 'platform:firstleg:terminal-drayage:pickup-appointment:edit', - 'platform:firstleg:terminal-drayage:pickup:confirm', - 'platform:firstleg:terminal-drayage:empty-return:confirm')) <> 9 THEN + 'platform:firstleg:terminal-drayage:empty-return:confirm')) <> 8 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='货柜双工作台权限初始化失败:权限目录不完整'; END IF; @@ -19478,8 +19466,7 @@ BEGIN 'platform:firstleg:terminal-drayage:document:edit', 'platform:firstleg:terminal-drayage:fleet:edit', 'platform:firstleg:terminal-drayage:pickup-appointment:edit', - 'platform:firstleg:terminal-drayage:pickup:confirm', - 'platform:firstleg:terminal-drayage:empty-return:confirm')) <> 18 THEN + 'platform:firstleg:terminal-drayage:empty-return:confirm')) <> 16 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='货柜双工作台权限初始化失败:平台默认角色授权不完整'; END IF; diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 97a4ce48d..9744fe5ca 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -350,6 +350,10 @@ CREATE TABLE IF NOT EXISTS ll_delivery_order ( overseas_warehouse_id VARCHAR(128) NULL COMMENT '海外仓ID', overseas_warehouse_name VARCHAR(255) NULL COMMENT '海外仓名称快照', customer_name VARCHAR(255) NULL COMMENT '客户名称快照', + destination_warehouse_id VARCHAR(128) NULL COMMENT '本派送单目的FBA仓ID快照', + destination_warehouse_code VARCHAR(128) NOT NULL COMMENT '本派送单目的FBA仓编码快照', + destination_warehouse_name VARCHAR(255) NULL COMMENT '本派送单目的FBA仓名称快照', + delivery_group_key VARCHAR(255) NOT NULL COMMENT '同一出库单内按目的FBA仓拆单的稳定分组键', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_DELIVERY' COMMENT '派送状态:WAIT_DELIVERY待派送及后续正式状态', waybill_no VARCHAR(128) NULL COMMENT '承运运单号', bol_no VARCHAR(128) NULL COMMENT '派送资料BOL号,不作为文件归属标识', @@ -382,7 +386,7 @@ CREATE TABLE IF NOT EXISTS ll_delivery_order ( PRIMARY KEY (id), UNIQUE KEY uk_ll_delivery_no (customer_id,delivery_no), UNIQUE KEY uk_ll_delivery_idempotency (customer_id,idempotency_key), - UNIQUE KEY uk_ll_delivery_outbound (customer_id,outbound_order_id), + UNIQUE KEY uk_ll_delivery_outbound_destination (customer_id,outbound_order_id,delivery_group_key), KEY idx_ll_delivery_list (customer_id,status,created_at) ) ENGINE=InnoDB COMMENT='尾程派送单'; @@ -390,6 +394,12 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab PREPARE delivery_detail_stmt FROM @ddl; EXECUTE delivery_detail_stmt; DEALLOCATE PREPARE delivery_detail_stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='customer_name'), 'SELECT 1', 'ALTER TABLE ll_delivery_order ADD COLUMN customer_name VARCHAR(255) NULL COMMENT ''客户名称快照'' AFTER overseas_warehouse_name')); PREPARE delivery_detail_stmt FROM @ddl; EXECUTE delivery_detail_stmt; DEALLOCATE PREPARE delivery_detail_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='destination_warehouse_code'), 'SELECT 1', 'ALTER TABLE ll_delivery_order ADD COLUMN destination_warehouse_id VARCHAR(128) NULL COMMENT ''本派送单目的FBA仓ID快照'' AFTER customer_name, ADD COLUMN destination_warehouse_code VARCHAR(128) NULL COMMENT ''本派送单目的FBA仓编码快照'' AFTER destination_warehouse_id, ADD COLUMN destination_warehouse_name VARCHAR(255) NULL COMMENT ''本派送单目的FBA仓名称快照'' AFTER destination_warehouse_code, ADD COLUMN delivery_group_key VARCHAR(255) NULL COMMENT ''同一出库单内按目的FBA仓拆单的稳定分组键'' AFTER destination_warehouse_name')); +PREPARE delivery_destination_stmt FROM @ddl; EXECUTE delivery_destination_stmt; DEALLOCATE PREPARE delivery_destination_stmt; +SET @old_delivery_outbound_index = (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND index_name='uk_ll_delivery_outbound'); +SET @new_delivery_destination_index = (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND index_name='uk_ll_delivery_outbound_destination'); +SET @ddl = IF(@new_delivery_destination_index > 0, 'SELECT 1', IF(@old_delivery_outbound_index > 0, 'ALTER TABLE ll_delivery_order DROP INDEX uk_ll_delivery_outbound, ADD UNIQUE KEY uk_ll_delivery_outbound_destination (customer_id,outbound_order_id,delivery_group_key)', 'ALTER TABLE ll_delivery_order ADD UNIQUE KEY uk_ll_delivery_outbound_destination (customer_id,outbound_order_id,delivery_group_key)')); +PREPARE delivery_destination_index_stmt FROM @ddl; EXECUTE delivery_destination_index_stmt; DEALLOCATE PREPARE delivery_destination_index_stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='waybill_no'), 'SELECT 1', 'ALTER TABLE ll_delivery_order ADD COLUMN waybill_no VARCHAR(128) NULL COMMENT ''承运运单号'', ADD COLUMN delivery_method VARCHAR(32) NULL COMMENT ''派送方式'', ADD COLUMN carrier_name VARCHAR(255) NULL COMMENT ''派送承运商'', ADD COLUMN pickup_method VARCHAR(64) NULL COMMENT ''提货方式'', ADD COLUMN loading_type VARCHAR(64) NULL COMMENT ''装车类型'', ADD COLUMN vehicle_type VARCHAR(64) NULL COMMENT ''车辆类型'', ADD COLUMN vehicle_size VARCHAR(64) NULL COMMENT ''车辆大小'', ADD COLUMN vehicle_plate VARCHAR(64) NULL COMMENT ''车牌号'', ADD COLUMN driver_name VARCHAR(128) NULL COMMENT ''司机或车队名称'', ADD COLUMN driver_phone VARCHAR(64) NULL COMMENT ''车队联系电话'', ADD COLUMN appointment_account VARCHAR(128) NULL COMMENT ''预约账号'', ADD COLUMN seller_name VARCHAR(128) NULL COMMENT ''卖家姓名'' AFTER status')); PREPARE delivery_detail_stmt FROM @ddl; EXECUTE delivery_detail_stmt; DEALLOCATE PREPARE delivery_detail_stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='total_weight_kg'), 'SELECT 1', 'ALTER TABLE ll_delivery_order ADD COLUMN total_weight_kg DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT ''总重量(千克)'', ADD COLUMN total_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT ''总体积(立方米)'', ADD COLUMN planned_delivery_at DATETIME(3) NULL COMMENT ''预计派送时间'', ADD COLUMN dispatched_at DATETIME(3) NULL COMMENT ''实际派送时间'', ADD COLUMN expected_arrival_at DATETIME(3) NULL COMMENT ''预计到达时间'', ADD COLUMN signed_at DATETIME(3) NULL COMMENT ''签收时间'', ADD COLUMN remark VARCHAR(1000) NULL COMMENT ''派送备注'' AFTER pallet_count')); @@ -1517,6 +1527,7 @@ CREATE TABLE IF NOT EXISTS ll_outbound_order ( created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', outbound_type VARCHAR(32) NOT NULL DEFAULT 'DISPATCH_DELIVERY' COMMENT '出库类型:派送转出库、一件代发', lx_codes VARCHAR(1000) NULL COMMENT '关联一件代发领星入库单代码汇总', recipient_info VARCHAR(1000) NULL COMMENT '一件代发收件信息', + request_hash CHAR(64) NULL COMMENT '一件代发创建请求摘要,其他出库类型为空', PRIMARY KEY (id), UNIQUE KEY uk_ll_outbound_no (outbound_no), UNIQUE KEY uk_ll_outbound_idempotency (customer_id,idempotency_key), UNIQUE KEY uk_ll_outbound_dispatch (customer_id,dispatch_order_id), KEY idx_ll_outbound_list (customer_id,status,created_at) @@ -1623,6 +1634,8 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab PREPARE transfer_estimated_ship_stmt FROM @ddl; EXECUTE transfer_estimated_ship_stmt; DEALLOCATE PREPARE transfer_estimated_ship_stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='label_deadline'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN label_deadline DATETIME(3) NULL COMMENT ''换标截止时间'', ADD COLUMN relabel_contact_name VARCHAR(128) NULL COMMENT ''换标联系人'', ADD COLUMN relabel_contact_phone VARCHAR(64) NULL COMMENT ''换标联系电话'', ADD COLUMN relabel_charge_amount DECIMAL(20,2) NULL COMMENT ''换标费用CNY'', ADD COLUMN relabel_delivery_method VARCHAR(32) NULL COMMENT ''换标后派送方式''')); PREPARE transfer_relabel_config_stmt FROM @ddl; EXECUTE transfer_relabel_config_stmt; DEALLOCATE PREPARE transfer_relabel_config_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='relabel_completed_at'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN relabel_completed_at DATETIME(3) NULL COMMENT ''关联贴标完成时间'' AFTER relabel_no')); +PREPARE transfer_relabel_completed_stmt FROM @ddl; EXECUTE transfer_relabel_completed_stmt; DEALLOCATE PREPARE transfer_relabel_completed_stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='palletize_idempotency_key'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN palletize_idempotency_key VARCHAR(255) NULL COMMENT ''PDA打托幂等键'', ADD COLUMN palletize_request_hash CHAR(64) NULL COMMENT ''PDA打托请求哈希'', ADD COLUMN confirmation_idempotency_key VARCHAR(255) NULL COMMENT ''客户测量确认幂等键'', ADD COLUMN confirmation_request_hash CHAR(64) NULL COMMENT ''客户测量确认请求哈希'', ADD COLUMN customer_confirmed_at DATETIME(3) NULL COMMENT ''客户确认测量时间''')); PREPARE transfer_fulfillment_stmt FROM @ddl; EXECUTE transfer_fulfillment_stmt; DEALLOCATE PREPARE transfer_fulfillment_stmt; @@ -2014,7 +2027,9 @@ CREATE TABLE IF NOT EXISTS ll_cargo_discard_item ( id BIGINT NOT NULL COMMENT '主键ID', application_id BIGINT NOT NULL COMMENT '申请单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', order_id BIGINT NOT NULL COMMENT '订单ID', inbound_code VARCHAR(128) NOT NULL COMMENT '入仓单号快照', profile_id BIGINT NOT NULL COMMENT '托盘档案ID', sku_inventory_id BIGINT NOT NULL COMMENT 'SKU库存记录ID', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', product_name VARCHAR(255) NULL COMMENT '产品名称', - available_packages_snapshot INT NOT NULL COMMENT '可用箱数快照', discard_packages INT NOT NULL COMMENT '废弃箱数', + available_packages_snapshot INT NOT NULL COMMENT '可用箱数快照', discard_packages INT NOT NULL COMMENT '申请废弃箱数', + actual_discard_packages INT NULL COMMENT 'PDA确认实际废弃箱数', pda_status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT 'PDA处理状态:待处理PENDING、已完成COMPLETED', + pda_operator_id VARCHAR(128) NULL COMMENT 'PDA操作人', pda_completed_at DATETIME(3) NULL COMMENT 'PDA完成时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), UNIQUE KEY uk_ll_cargo_discard_item(application_id,profile_id,sku_code), KEY idx_ll_cargo_discard_item_list(customer_id,application_id), @@ -2080,7 +2095,7 @@ CREATE TABLE IF NOT EXISTS ll_us_truck_order ( origin_address VARCHAR(500) NULL COMMENT '起运地址', destination VARCHAR(255) NOT NULL COMMENT '目的地', destination_address VARCHAR(500) NULL COMMENT '目的地址', - container_type VARCHAR(32) NULL COMMENT '历史兼容设备类型,零担托盘业务不采集', + container_type VARCHAR(32) NOT NULL DEFAULT '40HQ' COMMENT '平台统一柜型:40GP、40HQ、45HQ', pallet_count INT NOT NULL DEFAULT 1 COMMENT '客户申报托盘数', package_count INT NULL COMMENT '可选箱数,零或空值均有效', received_pallet_count INT NOT NULL DEFAULT 0 COMMENT 'PDA实收托盘数', @@ -2158,7 +2173,7 @@ CREATE TABLE IF NOT EXISTS ll_local_looseboard_order ( pallet_required VARCHAR(8) NOT NULL COMMENT '是否需要打盘:YES或NO', transport_type VARCHAR(32) NOT NULL COMMENT '运输类型', origin VARCHAR(255) NOT NULL COMMENT '起运地', origin_address VARCHAR(500) NULL COMMENT '起运详细地址', destination VARCHAR(255) NOT NULL COMMENT '目的地', destination_address VARCHAR(500) NULL COMMENT '目的详细地址、码头或仓号', - container_type VARCHAR(32) NULL COMMENT '柜型', package_count INT NOT NULL DEFAULT 0 COMMENT '货物件数', + container_type VARCHAR(32) NOT NULL DEFAULT '40HQ' COMMENT '平台统一柜型:40GP、40HQ、45HQ', package_count INT NOT NULL DEFAULT 0 COMMENT '货物件数', total_weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT '总重量(千克)', total_volume_cbm DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '总体积(立方米)', budget_amount DECIMAL(18,2) NULL COMMENT '预算总价', currency CHAR(3) NOT NULL DEFAULT 'USD' COMMENT '预算币种', @@ -2607,6 +2622,18 @@ CREATE TABLE IF NOT EXISTS ll_return_inventory_operation ( 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='海外拆柜只读国内装柜照片引用'; + -- END INLINE EMPTY-DATABASE BASELINE: LASTLEG -- BEGIN INLINE EMPTY-DATABASE BASELINE: OVERSEAS WAREHOUSE @@ -5047,4 +5074,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/event-bridge-service/src/main/java/com/baiyue/eventbridge/metrics/EventBridgeMetrics.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/metrics/EventBridgeMetrics.java index d652670aa..1b97d2bdf 100644 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/metrics/EventBridgeMetrics.java +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/metrics/EventBridgeMetrics.java @@ -31,6 +31,7 @@ public class EventBridgeMetrics { EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED, EventTypes.OVERSEAS_CONTAINER_UNLOADED, EventTypes.OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED, + EventTypes.OVERSEAS_DELIVERY_COMPLETED, EventTypes.BUSINESS_FEE_SOURCE_CHANGED, EventTypes.WALLET_COMMAND_REQUESTED, EventTypes.WALLET_COMMAND_COMPLETED, diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md index 6b9efc776..8343e0789 100644 --- a/firstleg/AGENTS.md +++ b/firstleg/AGENTS.md @@ -41,9 +41,10 @@ | 货柜与全局智能排柜 | `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` | | 国内货柜履约海运订舱、资料/打印、关联候选与出仓前船期资料 | `oceanbooking/controller/PlatformOceanBookingController.java`、`oceanbooking/service/OceanBookingService.java`、`OceanBookingDocumentService.java`、`OceanBookingPrintService.java`、`oceanbooking/mapper/OceanBookingMapper.java`、`portschedule/controller/PlatformPortScheduleController.java`;文件经 `client/OceanBookingFileClient.java` 进入 File,关联候选 `/api/platform/v1/ocean-bookings/link-candidates`,契约 `src/main/resources/openapi/ocean-bookings-v1.yaml`,表结构见 `src/main/resources/db/phase3-ocean-booking-schema.sql` | | 海外仓业务国内出仓后船节点定时查询 | `outbound/dispatch/service/OutboundDispatchService.java` 在确认出仓事务内幂等创建现有 `CarrierScheduleQuery`;编排与租约 `portschedule/service/CarrierScheduleQueryScheduler.java`,任务/十节点事实/异常事实/不可变 attempt `CarrierScheduleQueryService.java`,平台查询与人工补录入口 `PlatformCarrierScheduleQueryController.java` 的 `/{queryId}/manual-nodes`;停止只由实际 `EMPTY_RETURNED` 节点触发,不提供无节点停止入口;外部船司/轨迹调用经 `integration-service/`;标准见 `../../docs/海外仓业务/船节点标准化契约.md` | | 目的港码头拖车与还空预警 | `terminaldrayage/controller/PlatformTerminalDrayageController.java`、`service/TerminalDrayageService.java`、`mapper/TerminalDrayageMapper.java`;货柜实际到港后引用 Warehouse `ACTIVE/TRUCKING` 供应商,依次选择车队、预约提柜、确认实际提柜,海外卸柜收货完成后确认还柜;服务端按卸柜完成时间加 72 小时返回报空预警,实际提柜和还柜通过现有 `CarrierScheduleQueryService` 推进 `PICKED_UP/EMPTY_RETURNED`,不复制船节点或车队主档。契约 `src/main/resources/openapi/terminal-drayage-v1.yaml`,基线 `src/main/resources/db/phase3-terminal-drayage-schema.sql` 邮件由 `terminaldrayage/service/TerminalDrayageMailService.java` 入队,`TerminalDrayageMailDispatcher.java` 顺序投递,模板与 SMTP 配置见 `docs/terminal-drayage-mail.md`;首次选队不发信,换队事务内生成取消与请求两封邮件。 | diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/OrderFulfillmentCompletionClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/OrderFulfillmentCompletionClient.java new file mode 100644 index 000000000..8833d0115 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/OrderFulfillmentCompletionClient.java @@ -0,0 +1,21 @@ +package com.baiyue.firstleg.client; + +import com.baiyue.common.core.api.ApiResponse; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; + +import java.time.Instant; + +@FeignClient(name = "order-service", contextId = "firstlegOrderFulfillmentCompletionClient", + url = "${baiyue.clients.order.url:}") +public interface OrderFulfillmentCompletionClient { + @PostMapping("/internal/order/v1/fulfillment-completions") + ApiResponse complete(@RequestHeader("X-Internal-Service-Token") String token, + @RequestBody Request request); + + record Request(String tenantId, String orderId, String orderNo, String inboundCode, + String deliveryNo, Instant completedAt, String eventId, String payloadHash) { } + record Result(Long orderId, String status, boolean replayed) { } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalOverseasContainerEventController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalOverseasContainerEventController.java index 095e376c8..039cce166 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalOverseasContainerEventController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalOverseasContainerEventController.java @@ -7,6 +7,7 @@ import com.baiyue.common.event.EventEnvelope; import com.baiyue.common.event.EventTypes; import com.baiyue.firstleg.service.OverseasContainerEventConsumer; import com.baiyue.firstleg.service.OverseasInboundOrderDetainedConsumer; +import com.baiyue.firstleg.service.OverseasDeliveryCompletedConsumer; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.web.bind.annotation.PostMapping; @@ -22,6 +23,7 @@ import java.security.MessageDigest; public class InternalOverseasContainerEventController { private final OverseasContainerEventConsumer consumer; private final OverseasInboundOrderDetainedConsumer detainedConsumer; + private final OverseasDeliveryCompletedConsumer deliveryCompletedConsumer; @Value("${baiyue.security.internal-service-token}") private String expectedToken; @PostMapping("/internal/firstleg/v1/container-events") @@ -30,7 +32,9 @@ public class InternalOverseasContainerEventController { @RequestBody EventEnvelope event) { authenticate(token); Object result = EventTypes.OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED.equals(event.eventType()) - ? detainedConsumer.consume(event) : consumer.consume(event); + ? detainedConsumer.consume(event) + : EventTypes.OVERSEAS_DELIVERY_COMPLETED.equals(event.eventType()) + ? deliveryCompletedConsumer.consume(event) : consumer.consume(event); return ApiResponse.success(result); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/domain/ContainerSpecificationCatalog.java b/firstleg/src/main/java/com/baiyue/firstleg/domain/ContainerSpecificationCatalog.java deleted file mode 100644 index c5d101c7d..000000000 --- a/firstleg/src/main/java/com/baiyue/firstleg/domain/ContainerSpecificationCatalog.java +++ /dev/null @@ -1,43 +0,0 @@ -package com.baiyue.firstleg.domain; - -import com.baiyue.common.core.api.ResultCode; -import com.baiyue.common.core.exception.BizException; - -import java.math.BigDecimal; -import java.util.Locale; -import java.util.Map; - -/** Platform-controlled container specifications shared by booking and simulation flows. */ -public final class ContainerSpecificationCatalog { - private static final Map VALUES = Map.of( - "20GP", new Specification("20GP", new BigDecimal("33"), new BigDecimal("28000")), - "40GP", new Specification("40GP", new BigDecimal("67"), new BigDecimal("28000")), - "40HC", new Specification("40HC", new BigDecimal("76"), new BigDecimal("28000")), - "40NR", new Specification("40NR", new BigDecimal("67"), new BigDecimal("28000")), - "45HC", new Specification("45HC", new BigDecimal("86"), new BigDecimal("28000")) - ); - - private ContainerSpecificationCatalog() {} - - public static Specification require(String containerType) { - String normalized = containerType == null ? "" : containerType.trim().toUpperCase(Locale.ROOT); - Specification specification = VALUES.get(normalized); - if (specification == null) { - throw new BizException(ResultCode.VALIDATION_FAILED, "柜型必须使用平台固定规格"); - } - return specification; - } - - public static Specification requireMatch(String containerType, BigDecimal capacityCbm, BigDecimal maxWeightKg) { - Specification specification = require(containerType); - if (capacityCbm == null || maxWeightKg == null - || specification.capacityCbm().compareTo(capacityCbm) != 0 - || specification.maxWeightKg().compareTo(maxWeightKg) != 0) { - throw new BizException(ResultCode.VALIDATION_FAILED, - "柜型容量和限重必须使用平台固定规格"); - } - return specification; - } - - public record Specification(String containerType, BigDecimal capacityCbm, BigDecimal maxWeightKg) {} -} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java index 16335a476..f503a3a7f 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java @@ -11,8 +11,8 @@ import org.apache.ibatis.annotations.Update; @Mapper public interface ContainerBookingMapper extends BaseMapper { - @Update("UPDATE fl_container_booking SET container_no=#{containerNo},ocean_booking_id=#{oceanBookingId},ocean_booking_requirement_id=#{requirementId},record_status='ACTIVE',bill_of_lading_no=#{billOfLadingNo},vessel_name=#{vesselName},voyage_no=#{voyageNo},shipping_company_id=#{shippingCompanyId},shipping_company_name=#{shippingCompanyName},departure_port_code=#{departurePortCode},departure_port_name=#{departurePortName},destination_port_code=#{destinationPortCode},destination_port_name=#{destinationPortName},channel_code=#{channelCode},channel_name=#{channelName},customer_id=#{customerId},customer_name=#{customerName},estimated_sailing_at=#{estimatedSailingAt},estimated_port_arrival_at=#{estimatedPortArrivalAt},updated_by=#{operatorId},updated_by_name=#{operatorName},updated_at=NOW(),version=version+1 WHERE id=#{id} AND version=#{version} AND source_type='SIMULATION' AND record_status='ACTIVE' AND ocean_booking_id IS NULL") - int linkReleasedBooking(@Param("id") long id, @Param("oceanBookingId") long oceanBookingId, @Param("requirementId") long requirementId, @Param("containerNo") String containerNo, @Param("billOfLadingNo") String billOfLadingNo, @Param("vesselName") String vesselName, @Param("voyageNo") String voyageNo, @Param("shippingCompanyId") Long shippingCompanyId, @Param("shippingCompanyName") String shippingCompanyName, @Param("departurePortCode") String departurePortCode, @Param("departurePortName") String departurePortName, @Param("destinationPortCode") String destinationPortCode, @Param("destinationPortName") String destinationPortName, @Param("channelCode") String channelCode, @Param("channelName") String channelName, @Param("customerId") Long customerId, @Param("customerName") String customerName, @Param("estimatedSailingAt") java.time.LocalDateTime estimatedSailingAt, @Param("estimatedPortArrivalAt") java.time.LocalDateTime estimatedPortArrivalAt, @Param("version") int version, @Param("operatorId") long operatorId, @Param("operatorName") String operatorName); + @Update("UPDATE fl_container_booking SET container_no=#{containerNo},ocean_booking_id=#{oceanBookingId},ocean_booking_requirement_id=#{requirementId},record_status='ACTIVE',bill_of_lading_no=#{billOfLadingNo},vessel_name=#{vesselName},voyage_no=#{voyageNo},shipping_company_id=#{shippingCompanyId},shipping_company_name=#{shippingCompanyName},departure_port_code=#{departurePortCode},departure_port_name=#{departurePortName},destination_port_code=#{destinationPortCode},destination_port_name=#{destinationPortName},customer_id=#{customerId},customer_name=#{customerName},estimated_sailing_at=#{estimatedSailingAt},estimated_port_arrival_at=#{estimatedPortArrivalAt},updated_by=#{operatorId},updated_by_name=#{operatorName},updated_at=NOW(),version=version+1 WHERE id=#{id} AND version=#{version} AND source_type='SIMULATION' AND record_status='ACTIVE' AND ocean_booking_id IS NULL") + int linkReleasedBooking(@Param("id") long id, @Param("oceanBookingId") long oceanBookingId, @Param("requirementId") long requirementId, @Param("containerNo") String containerNo, @Param("billOfLadingNo") String billOfLadingNo, @Param("vesselName") String vesselName, @Param("voyageNo") String voyageNo, @Param("shippingCompanyId") Long shippingCompanyId, @Param("shippingCompanyName") String shippingCompanyName, @Param("departurePortCode") String departurePortCode, @Param("departurePortName") String departurePortName, @Param("destinationPortCode") String destinationPortCode, @Param("destinationPortName") String destinationPortName, @Param("customerId") Long customerId, @Param("customerName") String customerName, @Param("estimatedSailingAt") java.time.LocalDateTime estimatedSailingAt, @Param("estimatedPortArrivalAt") java.time.LocalDateTime estimatedPortArrivalAt, @Param("version") int version, @Param("operatorId") long operatorId, @Param("operatorName") String operatorName); @Update(""" UPDATE fl_container_booking SET container_no=#{containerNo},updated_by=#{operatorId}, updated_by_name=#{operatorName},updated_at=CURRENT_TIMESTAMP(3),version=version+1 diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundOrderMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundOrderMapper.java index ffb4f0d87..bd8784895 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundOrderMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundOrderMapper.java @@ -24,6 +24,29 @@ public interface InboundOrderMapper extends BaseMapper { @Param("detained") boolean detained, @Param("reason") String reason, @Param("sourceVersion") long sourceVersion, @Param("operatorId") long operatorId); + @Update(""" + UPDATE fl_inbound_order + SET process_status='DELIVERED', updated_by=0, updated_at=#{completedAt}, version=version+1 + WHERE tenant_id=#{tenantId} AND id=#{inboundId} AND deleted=0 AND process_status='LOADED' + """) + int completeOverseasDelivery(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId, + @Param("completedAt") java.time.LocalDateTime completedAt); + + @Select(""" + SELECT COUNT(*) FROM fl_inbound_order + WHERE tenant_id=#{tenantId} AND order_id=#{orderId} AND deleted=0 AND process_status!='DELIVERED' + """) + int countNotDeliveredByOrder(@Param("tenantId") long tenantId, @Param("orderId") long orderId); + + @Update(""" + UPDATE fl_inbound_order + SET order_status_snapshot='COMPLETED', updated_by=0, updated_at=#{completedAt}, version=version+1 + WHERE tenant_id=#{tenantId} AND order_id=#{orderId} AND deleted=0 + AND (order_status_snapshot IS NULL OR order_status_snapshot!='COMPLETED') + """) + int markOrderSnapshotCompleted(@Param("tenantId") long tenantId, @Param("orderId") long orderId, + @Param("completedAt") java.time.LocalDateTime completedAt); + @Update(""" UPDATE fl_inbound_order SET hold_flag=#{holdFlag}, current_hold_reason=#{reason}, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/dto/OceanBookingDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/dto/OceanBookingDtos.java index ab58dda00..616b98671 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/dto/OceanBookingDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/dto/OceanBookingDtos.java @@ -72,8 +72,6 @@ public final class OceanBookingDtos { @Size(max = 32) String destinationPortCode, @Size(max = 128) String destinationPortName, @Size(max = 128) String placeOfDelivery, - @Size(max = 32) String serviceTerm, - @Size(max = 32) String freightTerm, LocalDateTime estimatedSailingAt, LocalDateTime estimatedArrivalAt, @Size(max = 1000) String remark, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/entity/OceanBookingEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/entity/OceanBookingEntity.java index 4e79948fd..f4d3caef3 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/entity/OceanBookingEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/entity/OceanBookingEntity.java @@ -37,8 +37,6 @@ public class OceanBookingEntity { private String destinationPortCode; private String destinationPortName; private String placeOfDelivery; - private String serviceTerm; - private String freightTerm; private LocalDateTime estimatedSailingAt; private LocalDateTime estimatedArrivalAt; private String remark; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingMapper.java index bd29c36c3..479ee3d79 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingMapper.java @@ -25,7 +25,7 @@ public interface OceanBookingMapper extends BaseMapper { """) int touchDraft(@Param("id") long id, @Param("expectedVersion") int expectedVersion, @Param("operatorId") long operatorId, @Param("operatorName") String operatorName); - @Update("UPDATE fl_ocean_booking SET customer_id=#{value.customerId},customer_name=#{value.customerName},channel_code=#{value.channelCode},channel_name=#{value.channelName},warehouse_id=#{value.warehouseId},warehouse_name=#{value.warehouseName},bill_of_lading_no=#{value.billOfLadingNo},shipping_company_id=#{value.shippingCompanyId},shipping_company_name=#{value.shippingCompanyName},carrier_code=#{value.carrierCode},booking_agent_id=#{value.bookingAgentId},booking_agent_name=#{value.bookingAgentName},vessel_name=#{value.vesselName},voyage_no=#{value.voyageNo},departure_port_code=#{value.departurePortCode},departure_port_name=#{value.departurePortName},destination_port_code=#{value.destinationPortCode},destination_port_name=#{value.destinationPortName},place_of_delivery=#{value.placeOfDelivery},service_term=#{value.serviceTerm},freight_term=#{value.freightTerm},estimated_sailing_at=#{value.estimatedSailingAt},estimated_arrival_at=#{value.estimatedArrivalAt},remark=#{value.remark},updated_by=#{operatorId},updated_by_name=#{operatorName},version=version+1,updated_at=NOW() WHERE id=#{id} AND status='DRAFT' AND version=#{version}") + @Update("UPDATE fl_ocean_booking SET customer_id=#{value.customerId},customer_name=#{value.customerName},channel_code=#{value.channelCode},channel_name=#{value.channelName},warehouse_id=#{value.warehouseId},warehouse_name=#{value.warehouseName},bill_of_lading_no=#{value.billOfLadingNo},shipping_company_id=#{value.shippingCompanyId},shipping_company_name=#{value.shippingCompanyName},carrier_code=#{value.carrierCode},booking_agent_id=#{value.bookingAgentId},booking_agent_name=#{value.bookingAgentName},vessel_name=#{value.vesselName},voyage_no=#{value.voyageNo},departure_port_code=#{value.departurePortCode},departure_port_name=#{value.departurePortName},destination_port_code=#{value.destinationPortCode},destination_port_name=#{value.destinationPortName},place_of_delivery=#{value.placeOfDelivery},estimated_sailing_at=#{value.estimatedSailingAt},estimated_arrival_at=#{value.estimatedArrivalAt},remark=#{value.remark},updated_by=#{operatorId},updated_by_name=#{operatorName},version=version+1,updated_at=NOW() WHERE id=#{id} AND status='DRAFT' AND version=#{version}") int updateDraft(@Param("id") long id, @Param("value") OceanBookingEntity value, @Param("version") int version, @Param("operatorId") long operatorId, @Param("operatorName") String operatorName); @Delete("DELETE FROM fl_ocean_booking WHERE id=#{id} AND status='DRAFT' AND version=#{version}") int deleteDraft(@Param("id") long id, @Param("version") int version); @@ -76,16 +76,14 @@ public interface OceanBookingMapper extends BaseMapper { AND allocated.requirement_id=requirement.id WHERE booking.status='RELEASED' AND booking.warehouse_id=#{query.warehouseId} - AND booking.channel_code IN - #{channelCode} + AND booking.channel_code=#{query.channelGroupCode} AND requirement.container_type=#{query.containerType} AND requirement.quantity>COALESCE(allocated.allocated_quantity,0) ORDER BY booking.updated_at DESC,booking.id DESC,requirement.sort_no ASC,requirement.id ASC """) Page selectLinkCandidates(Page page, - @Param("query") LinkCandidateQuery query, - @Param("channelCodes") java.util.Set channelCodes); + @Param("query") LinkCandidateQuery query); @Update(""" UPDATE fl_ocean_booking SET status=#{toStatus}, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingRepository.java index b5f5bb663..e6c8f1013 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingRepository.java @@ -33,8 +33,8 @@ public class OceanBookingRepository { public PageResult page(Query query) { return PageResult.from(bookings.selectPage(new Page<>(query.page(), query.size()), query)); } - public PageResult linkCandidates(LinkCandidateQuery query, java.util.Set channelCodes) { - return PageResult.from(bookings.selectLinkCandidates(new Page<>(query.page(), query.size()), query, channelCodes)); + public PageResult linkCandidates(LinkCandidateQuery query) { + return PageResult.from(bookings.selectLinkCandidates(new Page<>(query.page(), query.size()), query)); } public OceanBookingEntity find(long id) { return bookings.selectById(id); } public OceanBookingEntity findForUpdate(long id) { return bookings.selectForUpdate(id); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingPrintService.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingPrintService.java index ca7a1b1dd..2f4dcf7cf 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingPrintService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingPrintService.java @@ -75,8 +75,8 @@ public class OceanBookingPrintService { fields.put("portOfLoading", first(booking.getDeparturePortName(), booking.getDeparturePortCode())); fields.put("portOfDischarge", first(booking.getDestinationPortName(), booking.getDestinationPortCode())); fields.put("placeOfDelivery", text(booking.getPlaceOfDelivery())); - fields.put("serviceTerm", text(booking.getServiceTerm())); - fields.put("freightTerm", text(booking.getFreightTerm())); + fields.put("serviceTerm", ""); + fields.put("freightTerm", ""); fields.put("totalPackagesInWords", ""); if (SHIPPING_INSTRUCTION.equals(template)) { fields.put("toCompany", text(booking.getBookingAgentName())); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java index 14163fa4d..1c2225276 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java @@ -9,7 +9,7 @@ import com.baiyue.firstleg.client.WarehouseMasterDataClient; import com.baiyue.firstleg.client.WarehousePartnerClient; import com.baiyue.firstleg.client.WarehouseSupplierClient; import com.baiyue.firstleg.oceanbooking.domain.OceanBookingStatus; -import com.baiyue.firstleg.domain.ContainerSpecificationCatalog; +import com.baiyue.common.core.container.ContainerSpecificationCatalog; import com.baiyue.firstleg.domain.SmartLoadingChannelGroupCatalog; import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.*; import com.baiyue.firstleg.oceanbooking.entity.*; @@ -75,8 +75,9 @@ public class OceanBookingService { SmartLoadingChannelGroupCatalog.Definition group; try { group = SmartLoadingChannelGroupCatalog.require(query.channelGroupCode()); } catch (IllegalArgumentException exception) { throw badRequest(exception.getMessage()); } + String containerType = ContainerSpecificationCatalog.require(query.containerType()).containerType(); return repository.linkCandidates(new LinkCandidateQuery(query.warehouseId(), - group.code(), query.containerType().trim(), query.page(), query.size()), group.channelCodes()); + group.code(), containerType, query.page(), query.size())); } public List channels(long originWarehouseId) { @@ -228,8 +229,7 @@ public class OceanBookingService { List requirements) { if (!StringUtils.hasText(booking.getChannelCode()) || booking.getWarehouseId() == null || !StringUtils.hasText(booking.getBillOfLadingNo()) || !StringUtils.hasText(booking.getVesselName()) - || !StringUtils.hasText(booking.getVoyageNo()) || !StringUtils.hasText(booking.getServiceTerm()) - || !StringUtils.hasText(booking.getFreightTerm()) || booking.getEstimatedSailingAt() == null + || !StringUtils.hasText(booking.getVoyageNo()) || booking.getEstimatedSailingAt() == null || booking.getEstimatedArrivalAt() == null || booking.getShippingCompanyId() == null || (!StringUtils.hasText(booking.getDeparturePortName()) && !StringUtils.hasText(booking.getDeparturePortCode())) || (!StringUtils.hasText(booking.getDestinationPortName()) && !StringUtils.hasText(booking.getDestinationPortCode())) @@ -239,16 +239,18 @@ public class OceanBookingService { || requirements == null || requirements.isEmpty()) throw badRequest("订舱资料尚未填写完整,无法发布"); validateRange(booking.getEstimatedSailingAt(), booking.getEstimatedArrivalAt()); } - private ChannelOption requireActiveChannel(Long originWarehouseId, String channelCode) { + private SmartLoadingChannelGroupCatalog.Definition requireActiveChannelGroup(Long originWarehouseId, String channelGroupCode) { if (originWarehouseId == null || originWarehouseId <= 0) throw badRequest("请选择有效起运仓"); - String normalized = normalizeChannel(channelCode); + SmartLoadingChannelGroupCatalog.Definition group; + try { group = SmartLoadingChannelGroupCatalog.require(normalizeChannel(channelGroupCode)); } + catch (IllegalArgumentException exception) { throw badRequest(exception.getMessage()); } Long versionId = priceBooks.activeVersionId(); if (versionId == null) throw conflict("当前没有 ACTIVE 价格版本"); - ChannelRow row = priceBooks.channels(versionId, originWarehouseId).stream() - .filter(value -> normalized.equals(normalizeChannel(value.getChannelCode()))) - .findFirst().orElseThrow(() -> conflict("所选物流渠道已失效,请重新选择")); - if (!StringUtils.hasText(row.getChannelName())) throw conflict("所选物流渠道名称配置无效"); - return channelOption(row, java.util.Map.of()); + boolean active = priceBooks.channels(versionId, originWarehouseId).stream() + .map(ChannelRow::getChannelCode).filter(StringUtils::hasText).map(String::trim) + .anyMatch(group.channelCodes()::contains); + if (!active) throw conflict("所选物流渠道分组在当前仓库已失效,请重新选择"); + return group; } private static ChannelOption channelOption(ChannelRow row, java.util.Map> taxModes) { @@ -269,7 +271,7 @@ public class OceanBookingService { if (d.warehouseId() != null && StringUtils.hasText(d.channelCode())) { WarehouseMasterDataClient.Warehouse warehouse = requireDomesticTransitWarehouse(d.warehouseId()); warehouseName = warehouse.warehouseName(); - channelName = requireActiveChannel(warehouse.id(), d.channelCode()).channelName(); + channelName = requireActiveChannelGroup(warehouse.id(), d.channelCode()).name(); } var carrier = resolveCarrier(d.shippingCompanyId(), d.shippingCompanyName(), d.carrierCode()); var agent = resolveBookingAgent(d.bookingAgentId(), d.bookingAgentName(), carrier == null ? null : carrier.carrierCode()); @@ -287,7 +289,7 @@ public class OceanBookingService { v.setVesselName(trim(d.vesselName())); v.setVoyageNo(trim(d.voyageNo())); v.setDeparturePortCode(trim(d.departurePortCode())); v.setDeparturePortName(trim(d.departurePortName())); v.setDestinationPortCode(trim(d.destinationPortCode())); v.setDestinationPortName(trim(d.destinationPortName())); - v.setPlaceOfDelivery(trim(d.placeOfDelivery())); v.setServiceTerm(trim(d.serviceTerm())); v.setFreightTerm(trim(d.freightTerm())); + v.setPlaceOfDelivery(trim(d.placeOfDelivery())); v.setEstimatedSailingAt(d.estimatedSailingAt()); v.setEstimatedArrivalAt(d.estimatedArrivalAt()); v.setRemark(trim(d.remark())); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/ContainerRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/ContainerRepository.java index cfc60b9d2..0112dcba9 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/ContainerRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/ContainerRepository.java @@ -37,7 +37,7 @@ public class ContainerRepository { public int linkReleasedBooking(long id, long oceanBookingId, long requirementId, String containerNo, com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity booking, int version, long operatorId, String operatorName) { - return mapper.linkReleasedBooking(id, oceanBookingId, requirementId, containerNo, booking.getBillOfLadingNo(), booking.getVesselName(), booking.getVoyageNo(), booking.getShippingCompanyId(), booking.getShippingCompanyName(), booking.getDeparturePortCode(), booking.getDeparturePortName(), booking.getDestinationPortCode(), booking.getDestinationPortName(), booking.getChannelCode(), booking.getChannelName(), booking.getCustomerId(), booking.getCustomerName(), booking.getEstimatedSailingAt(), booking.getEstimatedArrivalAt(), version, operatorId, operatorName); + return mapper.linkReleasedBooking(id, oceanBookingId, requirementId, containerNo, booking.getBillOfLadingNo(), booking.getVesselName(), booking.getVoyageNo(), booking.getShippingCompanyId(), booking.getShippingCompanyName(), booking.getDeparturePortCode(), booking.getDeparturePortName(), booking.getDestinationPortCode(), booking.getDestinationPortName(), booking.getCustomerId(), booking.getCustomerName(), booking.getEstimatedSailingAt(), booking.getEstimatedArrivalAt(), version, operatorId, operatorName); } public int assignRealContainerNo(long id, String containerNo, int version, long operatorId, String operatorName) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java index c2565a9ff..4936a9cf3 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java @@ -6,7 +6,7 @@ import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.client.WarehouseMasterDataClient; import com.baiyue.firstleg.domain.SmartLoadingStatus; -import com.baiyue.firstleg.domain.ContainerSpecificationCatalog; +import com.baiyue.common.core.container.ContainerSpecificationCatalog; import com.baiyue.firstleg.domain.SmartLoadingChannelGroupCatalog; import com.baiyue.firstleg.dto.ContainerDtos.FullUpdateRequest; import com.baiyue.firstleg.dto.ContainerDtos.SaveRequest; @@ -89,7 +89,7 @@ public class ContainerService { String containerNo = requestedContainerNo == null ? null : Iso6346ContainerNumber.require(requestedContainerNo); if (containerNo != null && repository.containerNoExists(containerNo, id)) throw conflict("集装箱号已存在"); if (!java.util.Objects.equals(container.getWarehouseId(), booking.getWarehouseId()) - || !SmartLoadingChannelGroupCatalog.contains(container.getChannelGroupCode(), booking.getChannelCode()) + || !java.util.Objects.equals(container.getChannelGroupCode(), booking.getChannelCode()) || !java.util.Objects.equals(container.getContainerType(), requirement.getContainerType())) { throw conflict("模拟柜与真实订舱柜仓库、渠道分组或柜型不一致"); } 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/main/java/com/baiyue/firstleg/service/OverseasDeliveryCompletedConsumer.java b/firstleg/src/main/java/com/baiyue/firstleg/service/OverseasDeliveryCompletedConsumer.java new file mode 100644 index 000000000..6fa43e279 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/OverseasDeliveryCompletedConsumer.java @@ -0,0 +1,148 @@ +package com.baiyue.firstleg.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.EventPayloadCanonicalizer; +import com.baiyue.common.event.EventPayloadHash; +import com.baiyue.common.event.EventTypes; +import com.baiyue.common.event.OverseasDeliveryCompletedV1; +import com.baiyue.common.event.RegionCode; +import com.baiyue.firstleg.client.OrderFulfillmentCompletionClient; +import com.baiyue.firstleg.entity.ContainerEventInboxEntity; +import com.baiyue.firstleg.entity.InboundOrderEntity; +import com.baiyue.firstleg.entity.InboundStatusHistoryEntity; +import com.baiyue.firstleg.mapper.ContainerEventInboxMapper; +import com.baiyue.firstleg.mapper.InboundOrderMapper; +import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.Locale; +import java.util.Objects; + +@Service +@RequiredArgsConstructor +public class OverseasDeliveryCompletedConsumer { + private final InboundOrderMapper inboundOrders; + private final InboundStatusHistoryMapper histories; + private final ContainerEventInboxMapper inbox; + private final OrderFulfillmentCompletionClient orderClient; + private final ObjectMapper objectMapper; + @Value("${baiyue.security.internal-service-token}") private String internalToken; + + @Transactional + public InboundOrderEntity consume(EventEnvelope event) { + validate(event); + OverseasDeliveryCompletedV1 payload = payload(event); + long inboundId = positive(payload.sourceInboundId(), "来源入仓单 ID 无效"); + long tenantId = positive(event.tenantId(), "客户 ID 无效"); + if (!Objects.equals(event.aggregateId(), payload.sourceInboundId())) { + throw new IllegalArgumentException("事件 aggregateId 与来源入仓单 ID 不一致"); + } + ContainerEventInboxEntity existing = inbox.selectById(event.eventId()); + if (existing != null) { + if (!existing.getPayloadHash().equalsIgnoreCase(event.payloadHash())) { + throw new IllegalArgumentException("相同 eventId 对应的事件摘要冲突"); + } + return required(inboundId, tenantId, payload); + } + ContainerEventInboxEntity inboxRow = inbox(event); + try { inbox.insert(inboxRow); } + catch (DuplicateKeyException duplicate) { + ContainerEventInboxEntity raced = inbox.selectById(event.eventId()); + if (raced == null || !raced.getPayloadHash().equalsIgnoreCase(event.payloadHash())) throw duplicate; + return required(inboundId, tenantId, payload); + } + InboundOrderEntity inbound = required(inboundId, tenantId, payload); + if (!"LOADED".equals(inbound.getProcessStatus()) && !"DELIVERED".equals(inbound.getProcessStatus())) { + throw new IllegalArgumentException("来源头程入仓单尚未装柜完成"); + } + if ("LOADED".equals(inbound.getProcessStatus())) { + LocalDateTime completedAt = LocalDateTime.ofInstant(payload.completedAt(), ZoneOffset.UTC); + if (inboundOrders.completeOverseasDelivery(tenantId, inboundId, completedAt) != 1) { + throw new IllegalStateException("来源头程入仓单状态并发变化"); + } + InboundStatusHistoryEntity history = new InboundStatusHistoryEntity(); + history.setId(IdWorker.getId()); history.setTenantId(tenantId); history.setInboundId(inboundId); + history.setFromStatus("LOADED"); history.setToStatus("DELIVERED"); + history.setAction("COMPLETE_DELIVERY"); + history.setReason("海外派送单 " + payload.deliveryNo() + " 已签收"); + history.setOperatorId(0L); history.setOperatorName("OVERSEAS_EVENT_BRIDGE"); + history.setOccurredAt(completedAt); histories.insert(history); + } + long sourceOrderId = positive(payload.sourceOrderId(), "来源订单 ID 无效"); + if (inboundOrders.countNotDeliveredByOrder(tenantId, sourceOrderId) == 0) { + var response = orderClient.complete(internalToken, new OrderFulfillmentCompletionClient.Request( + event.tenantId(), payload.sourceOrderId(), payload.sourceOrderNo(), payload.inboundCode(), + payload.deliveryNo(), payload.completedAt(), event.eventId(), event.payloadHash())); + if (response == null || !response.successful() || response.data() == null + || !"COMPLETED".equals(response.data().status())) { + throw new IllegalStateException("Order 服务未确认来源订单完成"); + } + inboundOrders.markOrderSnapshotCompleted(tenantId, sourceOrderId, + LocalDateTime.ofInstant(payload.completedAt(), ZoneOffset.UTC)); + } + inboxRow.setStatus("PROCESSED"); inboxRow.setProcessedAt(LocalDateTime.now(ZoneOffset.UTC)); + inbox.updateById(inboxRow); + return required(inboundId, tenantId, payload); + } + + private void validate(EventEnvelope event) { + event.requireValid(); + if (!EventTypes.OVERSEAS_DELIVERY_COMPLETED.equals(event.eventType()) || event.eventVersion() != 1 + || event.sourceRegion() != RegionCode.OVERSEAS || event.targetRegion() != RegionCode.CN + || !"INBOUND_ORDER".equals(event.aggregateType())) { + throw new IllegalArgumentException("不支持的海外派送完成事件"); + } + if (!EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(event.payload()), event.payloadHash())) { + throw new IllegalArgumentException("payloadHash 与事件负载不一致"); + } + } + + private OverseasDeliveryCompletedV1 payload(EventEnvelope event) { + try { + OverseasDeliveryCompletedV1 value = objectMapper.treeToValue(event.payload(), OverseasDeliveryCompletedV1.class); + if (value.completedAt() == null || !StringUtils.hasText(value.deliveryNo()) + || !StringUtils.hasText(value.sourceOrderId()) || !StringUtils.hasText(value.sourceOrderNo()) + || !StringUtils.hasText(value.inboundCode())) throw new IllegalArgumentException("字段不完整"); + return value; + } catch (Exception exception) { + throw new IllegalArgumentException("海外派送完成事件负载无效", exception); + } + } + + private InboundOrderEntity required(long inboundId, long tenantId, OverseasDeliveryCompletedV1 payload) { + InboundOrderEntity inbound = inboundOrders.selectActiveByIdForUpdate(inboundId); + if (inbound == null || !Objects.equals(inbound.getTenantId(), tenantId) + || !Objects.equals(inbound.getInboundCode(), payload.inboundCode()) + || !Objects.equals(String.valueOf(inbound.getOrderId()), payload.sourceOrderId()) + || !Objects.equals(inbound.getOrderNo(), payload.sourceOrderNo()) + || (StringUtils.hasText(payload.fbaWarehouseCode()) && StringUtils.hasText(inbound.getDestinationCode()) + && !Objects.equals(inbound.getDestinationCode(), payload.fbaWarehouseCode()))) { + throw new IllegalArgumentException("事件与来源头程入仓单归属不一致"); + } + return inbound; + } + + private ContainerEventInboxEntity inbox(EventEnvelope event) { + ContainerEventInboxEntity row = new ContainerEventInboxEntity(); + row.setEventId(event.eventId()); row.setEventType(event.eventType()); row.setEventVersion(event.eventVersion()); + row.setAggregateId(event.aggregateId()); row.setAggregateVersion(event.aggregateVersion()); + row.setPayloadHash(event.payloadHash().toLowerCase(Locale.ROOT)); row.setPayloadJson(event.payload().toString()); + row.setStatus("RECEIVED"); row.setReceivedAt(LocalDateTime.now(ZoneOffset.UTC)); + return row; + } + + private static long positive(String value, String message) { + try { long parsed = Long.parseLong(value); if (parsed > 0) return parsed; } + catch (RuntimeException ignored) { } + throw new IllegalArgumentException(message); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/controller/PlatformTerminalDrayageController.java b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/controller/PlatformTerminalDrayageController.java index 494789da7..263235431 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/controller/PlatformTerminalDrayageController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/controller/PlatformTerminalDrayageController.java @@ -122,20 +122,6 @@ public class PlatformTerminalDrayageController { containerBookingId, user.userId(), user.username(), key, request), user.permissions())); } - @PostMapping("/{containerBookingId}/pickup") - @PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:pickup:confirm')") - @Operation(summary = "确认目的港实际提柜") - @BusinessAudit(event = "TERMINAL_DRAYAGE_RECORD_PICKUP", resourceType = "CONTAINER", - resourceId = "#containerBookingId", description = "确认目的港实际提柜") - public ApiResponse recordPickup(@AuthenticationPrincipal AuthenticatedUser user, - @PathVariable @Positive long containerBookingId, - @RequestHeader("Idempotency-Key") String key, - @Valid @RequestBody ActualTimeRequest request) { - FirstlegScopeGuard.platform(user); - return ApiResponse.success(service.authorize(service.recordPickup( - containerBookingId, user.userId(), user.username(), key, request), user.permissions())); - } - @PostMapping("/{containerBookingId}/empty-return") @PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:empty-return:confirm')") @Operation(summary = "确认实际还空柜") diff --git a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/entity/TerminalDrayageEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/entity/TerminalDrayageEntity.java index ddd847c04..80035dae0 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/entity/TerminalDrayageEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/entity/TerminalDrayageEntity.java @@ -17,7 +17,6 @@ public class TerminalDrayageEntity { private Long fleetSupplierId; private String fleetSupplierName; private LocalDateTime pickupAppointmentAt; - private LocalDateTime actualPickupAt; private LocalDateTime estimatedEmptyReturnAt; private LocalDateTime actualEmptyReturnAt; private Integer version; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageMapper.java index dfc14f0df..1e713a7f1 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageMapper.java @@ -69,7 +69,7 @@ public interface TerminalDrayageMapper extends BaseMapper @Update(""" UPDATE fl_terminal_drayage SET fleet_supplier_id=#{value.fleetSupplierId}, fleet_supplier_name=#{value.fleetSupplierName},pickup_appointment_at=#{value.pickupAppointmentAt}, - actual_pickup_at=#{value.actualPickupAt},estimated_empty_return_at=#{value.estimatedEmptyReturnAt}, + estimated_empty_return_at=#{value.estimatedEmptyReturnAt}, actual_empty_return_at=#{value.actualEmptyReturnAt},updated_by=#{operatorId}, updated_by_name=#{operatorName},updated_at=CURRENT_TIMESTAMP(3),version=version+1 WHERE container_booking_id=#{value.containerBookingId} AND version=#{expectedVersion} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/ContainerWorkbenchService.java b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/ContainerWorkbenchService.java index 333213814..7594856e1 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/ContainerWorkbenchService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/ContainerWorkbenchService.java @@ -3,6 +3,7 @@ package com.baiyue.firstleg.terminaldrayage.service; import com.baiyue.common.core.api.PageResult; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.core.container.ContainerSpecificationCatalog; import com.baiyue.firstleg.dto.ContainerDtos.Query; import com.baiyue.firstleg.entity.ContainerBookingEntity; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerActualFacts; @@ -85,8 +86,12 @@ public class ContainerWorkbenchService { } private static Query normalize(Query query) { + String containerType = trim(query.containerType()); + if (containerType != null) { + containerType = ContainerSpecificationCatalog.require(containerType).containerType(); + } return new Query(trim(query.containerNo()), trim(query.cabinetNo()), trim(query.billOfLadingNo()), - trim(query.keyword()), query.customerId(), query.warehouseId(), trim(query.containerType()), + trim(query.keyword()), query.customerId(), query.warehouseId(), containerType, query.shippingCompanyId(), trim(query.departurePortCode()), trim(query.destinationPortCode()), trim(query.destinationWarehouseCode()), query.status(), query.estimatedSailingFrom(), query.estimatedSailingTo(), query.scope(), query.page(), query.size()); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageService.java b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageService.java index 033ac2009..96d6bd269 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageService.java @@ -135,35 +135,6 @@ public class TerminalDrayageService { request.reason(), operatorId, operatorName); } - @Transactional - public View recordPickup(long containerBookingId, long operatorId, String operatorName, String key, - ActualTimeRequest request) { - String action = "RECORD_PICKUP"; - WorkContext context = requiredLockedContext(containerBookingId); - View replay = replay(context, action, key, request); - if (replay != null) return replay; - requireAtLeast(context, CarrierScheduleNode.PICKUP_READY, "货柜尚未清关放行,不允许确认实际提柜"); - TerminalDrayageEntity value = requiredAggregate(containerBookingId); - requireVersion(value, request.expectedVersion()); - if (value.getFleetSupplierId() == null || value.getPickupAppointmentAt() == null) { - throw conflict("选择码头车队并预约提柜后才能确认实际提柜"); - } - if (actualPickupAt(context, value) != null) throw conflict("实际提柜时间已确认"); - if (context.facts() != null && context.facts().latestActualAt() != null - && request.actualAt().isBefore(context.facts().latestActualAt())) { - throw badRequest("实际提柜时间不能早于当前实际船节点时间"); - } - advanceNode(context, CarrierScheduleNode.PICKED_UP, request.actualAt(), action, key, - request.remark(), operatorId, operatorName); - int fromVersion = version(value); - String fromStatus = status(context, value); - value.setActualPickupAt(request.actualAt()); - applyAuthoritativeDeadline(context, value); - persist(value, fromVersion, operatorId, operatorName); - return finish(requiredContext(containerBookingId), value, action, key, request, fromStatus, - fromVersion, request.remark(), operatorId, operatorName); - } - @Transactional public View recordEmptyReturn(long containerBookingId, long operatorId, String operatorName, String key, ActualTimeRequest request) { @@ -297,9 +268,6 @@ public class TerminalDrayageService { result.add("SELECT_FLEET"); if (value != null && value.getFleetSupplierId() != null) result.add("SCHEDULE_PICKUP"); } - if (current != null && current.sequence() >= CarrierScheduleNode.PICKUP_READY.sequence() - && value != null && value.getFleetSupplierId() != null && value.getPickupAppointmentAt() != null - && actualPickupAt(context, value) == null) result.add("RECORD_PICKUP"); if (context.base().unloadingCompletedAt() != null && actualEmptyReturnAt(context, value) == null) result.add("RECORD_EMPTY_RETURN"); return List.copyOf(result); @@ -333,8 +301,8 @@ public class TerminalDrayageService { } private LocalDateTime actualPickupAt(WorkContext context, TerminalDrayageEntity value) { - return value != null && value.getActualPickupAt() != null ? value.getActualPickupAt() - : context.facts() == null ? null : context.facts().pickedUpAt(); + if (context.facts() != null && context.facts().pickedUpAt() != null) return context.facts().pickedUpAt(); + return context.base().unloadingCompletedAt(); } private LocalDateTime actualEmptyReturnAt(WorkContext context, TerminalDrayageEntity value) { @@ -400,7 +368,6 @@ public class TerminalDrayageService { case "SELECT_FLEET" -> "platform:firstleg:terminal-drayage:fleet:edit"; case "SEND_PICKUP_EMAIL" -> "platform:firstleg:terminal-drayage:fleet:edit"; case "SCHEDULE_PICKUP" -> "platform:firstleg:terminal-drayage:pickup-appointment:edit"; - case "RECORD_PICKUP" -> "platform:firstleg:terminal-drayage:pickup:confirm"; case "RECORD_EMPTY_RETURN" -> "platform:firstleg:terminal-drayage:empty-return:confirm"; default -> ""; }; diff --git a/firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql b/firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql index 06742b374..337b7aace 100644 --- a/firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql +++ b/firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql @@ -7,8 +7,8 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking ( status VARCHAR(24) NOT NULL DEFAULT 'DRAFT' COMMENT '订舱状态:DRAFT草稿、RELEASED已发布且永久只读', customer_id BIGINT UNSIGNED NULL COMMENT '可选客户ID快照', customer_name VARCHAR(128) NULL COMMENT '可选客户名称快照', - channel_code VARCHAR(64) NULL COMMENT '订舱渠道编码快照', - channel_name VARCHAR(128) NULL COMMENT '订舱渠道名称快照', + channel_code VARCHAR(64) NULL COMMENT '订舱计划物流渠道组编码快照', + channel_name VARCHAR(128) NULL COMMENT '订舱计划物流渠道组名称快照', warehouse_id BIGINT UNSIGNED NULL COMMENT '国内中转仓ID快照', warehouse_name VARCHAR(128) NULL COMMENT '国内中转仓名称快照', bill_of_lading_no VARCHAR(128) NULL COMMENT '提单号', @@ -24,8 +24,6 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking ( destination_port_code VARCHAR(32) NULL COMMENT '目的港编码快照', destination_port_name VARCHAR(128) NULL COMMENT '目的港名称快照', place_of_delivery VARCHAR(128) NULL COMMENT '最终交货地快照', - service_term VARCHAR(32) NULL COMMENT '运输服务条款快照;发布前必填', - freight_term VARCHAR(32) NULL COMMENT '海运费支付条款快照;发布前必填', estimated_sailing_at DATETIME(3) NULL COMMENT '当前确认预计开船时间', estimated_arrival_at DATETIME(3) NULL COMMENT '当前确认预计到港时间', remark VARCHAR(1000) NULL COMMENT '平台内部备注', @@ -76,8 +74,6 @@ PREPARE ocean_drop_status_stmt FROM @ocean_drop_status_check; EXECUTE ocean_drop_status_stmt; DEALLOCATE PREPARE ocean_drop_status_stmt; ALTER TABLE fl_ocean_booking - MODIFY COLUMN service_term VARCHAR(32) NULL COMMENT '运输服务条款快照;发布前必填', - MODIFY COLUMN freight_term VARCHAR(32) NULL COMMENT '海运费支付条款快照;发布前必填', ADD CONSTRAINT chk_ocean_booking_status CHECK (status IN ('DRAFT','RELEASED')); @@ -110,10 +106,10 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking_party ( CREATE TABLE IF NOT EXISTS fl_ocean_booking_container_requirement ( id BIGINT UNSIGNED NOT NULL COMMENT '订舱箱型需求ID', ocean_booking_id BIGINT UNSIGNED NOT NULL COMMENT '所属海运订舱ID', - container_type VARCHAR(32) NOT NULL COMMENT '所需集装箱尺寸类型编码', + container_type VARCHAR(32) NOT NULL COMMENT '平台统一柜型:40GP、40HQ、45HQ;默认40HQ', container_kind VARCHAR(32) NOT NULL COMMENT '所需集装箱箱种编码', - capacity_cbm DECIMAL(18,4) NOT NULL COMMENT '该需求柜型可用容量快照m3', - max_weight_kg DECIMAL(18,3) NOT NULL COMMENT '该需求柜型最大承重快照kg', + capacity_cbm DECIMAL(18,4) NOT NULL COMMENT '统一实装上限快照m3:40GP=58、40HQ=68、45HQ=86', + max_weight_kg DECIMAL(18,3) NOT NULL COMMENT '统一货重上限快照kg:19500', quantity INT UNSIGNED NOT NULL COMMENT '该箱型箱种所需柜数', sort_no INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '订舱需求展示顺序', PRIMARY KEY (id), diff --git a/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql index dba77f523..b4a5b92c6 100644 --- a/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql +++ b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql @@ -53,7 +53,7 @@ CREATE TABLE IF NOT EXISTS fl_container_booking ( bill_of_lading_no VARCHAR(128) NULL COMMENT '提单号', vessel_name VARCHAR(128) NULL COMMENT '船名快照', voyage_no VARCHAR(64) NULL COMMENT '航次;确认前允许修订', - container_type VARCHAR(32) NOT NULL COMMENT '平台固定柜型:20GP、40GP、40HC、40NR、45HC', + container_type VARCHAR(32) NOT NULL COMMENT '平台统一柜型:40GP、40HQ、45HQ;默认40HQ', shipping_company_id BIGINT UNSIGNED NULL COMMENT '船司ID快照', shipping_company_name VARCHAR(128) NULL COMMENT '船司名称快照', departure_port_code VARCHAR(32) NULL COMMENT '起运港编码快照', diff --git a/firstleg/src/main/resources/db/phase3-terminal-drayage-schema.sql b/firstleg/src/main/resources/db/phase3-terminal-drayage-schema.sql index eaf6d8cdc..3747d45b4 100644 --- a/firstleg/src/main/resources/db/phase3-terminal-drayage-schema.sql +++ b/firstleg/src/main/resources/db/phase3-terminal-drayage-schema.sql @@ -7,7 +7,6 @@ CREATE TABLE IF NOT EXISTS fl_terminal_drayage ( fleet_supplier_id BIGINT UNSIGNED NULL COMMENT 'Warehouse ACTIVE/TRUCKING供应商ID快照', fleet_supplier_name VARCHAR(128) NULL COMMENT 'Warehouse供应商名称快照', pickup_appointment_at DATETIME(3) NULL COMMENT '预约提柜时间', - actual_pickup_at DATETIME(3) NULL COMMENT '实际提柜时间', estimated_empty_return_at DATETIME(3) NULL COMMENT '服务端按卸柜完成时间加72小时计算的预计还柜时间', actual_empty_return_at DATETIME(3) NULL COMMENT '实际还空柜时间', version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '乐观锁版本号', @@ -20,10 +19,7 @@ CREATE TABLE IF NOT EXISTS fl_terminal_drayage ( PRIMARY KEY(id), UNIQUE KEY uk_terminal_drayage_container(container_booking_id), KEY idx_terminal_drayage_warning(actual_empty_return_at,estimated_empty_return_at), - CONSTRAINT fk_terminal_drayage_container FOREIGN KEY(container_booking_id) REFERENCES fl_container_booking(id), - CONSTRAINT chk_terminal_drayage_times CHECK( - (actual_pickup_at IS NULL OR pickup_appointment_at IS NOT NULL) AND - (actual_empty_return_at IS NULL OR actual_pickup_at IS NULL OR actual_empty_return_at >= actual_pickup_at)) + CONSTRAINT fk_terminal_drayage_container FOREIGN KEY(container_booking_id) REFERENCES fl_container_booking(id) ) ENGINE=InnoDB COMMENT='目的港货柜级码头拖车与还空事实'; CREATE TABLE IF NOT EXISTS fl_terminal_drayage_mail ( @@ -54,7 +50,7 @@ CREATE TABLE IF NOT EXISTS fl_terminal_drayage_history ( id BIGINT UNSIGNED NOT NULL COMMENT '历史ID', drayage_id BIGINT UNSIGNED NOT NULL COMMENT '码头拖车聚合ID', container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '货柜ID快照', - action VARCHAR(32) NOT NULL COMMENT 'SELECT_FLEET/SCHEDULE_PICKUP/RECORD_PICKUP/RECORD_EMPTY_RETURN', + action VARCHAR(32) NOT NULL COMMENT 'SELECT_FLEET/SCHEDULE_PICKUP/RECORD_EMPTY_RETURN', from_status VARCHAR(32) NOT NULL COMMENT '操作前状态', to_status VARCHAR(32) NOT NULL COMMENT '操作后状态', from_version INT UNSIGNED NOT NULL COMMENT '操作前版本', @@ -67,7 +63,7 @@ CREATE TABLE IF NOT EXISTS fl_terminal_drayage_history ( KEY idx_terminal_drayage_history(container_booking_id,created_at,id), CONSTRAINT fk_terminal_drayage_history FOREIGN KEY(drayage_id) REFERENCES fl_terminal_drayage(id), CONSTRAINT chk_terminal_drayage_history_action CHECK(action IN - ('SELECT_FLEET','SEND_PICKUP_EMAIL','SCHEDULE_PICKUP','RECORD_PICKUP','RECORD_EMPTY_RETURN')) + ('SELECT_FLEET','SEND_PICKUP_EMAIL','SCHEDULE_PICKUP','RECORD_EMPTY_RETURN')) ) ENGINE=InnoDB COMMENT='目的港码头拖车不可变操作历史'; CREATE TABLE IF NOT EXISTS fl_terminal_drayage_operation ( diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index 9477cf80a..a47fca32c 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -577,7 +577,7 @@ paths: LOADING 全部未完成状态,已完成装柜永不返回。状态映射为:Order SUBMITTED=PENDING_REVIEW; Firstleg WAIT_MEASURE/MEASURING/MEASURE_PENDING/MEASURE_REJECTED/WAIT_PRICING/FEE_CONFIRMING/INBOUNDING =WAIT_MEASURE;WAIT_SPLIT=WAIT_SPLIT;LOADING=LOADING。状态只用于搜索和展示,canAssign 由有效柜次占用、 - 剩余箱数、同柜组和容量等事实决定;容积和重量均允许达到柜型额定值的150%,超过即拒绝。 + 剩余箱数、同柜组和容量等事实决定;容积和重量均允许达到柜型基准值的150%,超过即拒绝。 待审核及待测量可以先加入清单;确认预装/PDA 前 仍必须完成审核与测量并进入 WAIT_SPLIT。同柜不可拆组仅在全部成员命中当前状态集合时作为一个分页单位返回。 parameters: @@ -719,7 +719,7 @@ paths: 服务端在单一事务内按剩余规格箱数比例生成内部规格快照,用于体积估算及兼容 PDA/拆单; 人工分配不限制订单国内仓及渠道与柜次一致;智能推荐允许跨国内仓,但仍只选择柜次渠道; 占用、同柜组和容量门禁不变。 - 柜内总容积和总重量分别允许达到柜型额定值的150%,超过即拒绝。 + 柜内总容积和总重量分别允许达到柜型基准值的150%,超过即拒绝。 只记录数量计划,具体实装箱号归属为 UNVERIFIED,不得将内部比例分摊宣称为现场箱号事实。 parameters: - {name: planId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} @@ -2143,7 +2143,7 @@ components: allowedActions: type: array description: 已同时按真实业务状态与当前用户权限裁剪 - items: {type: string, enum: [EDIT_CONTAINER, MANAGE_CUSTOMS, CONFIRM_OUTBOUND, MANAGE_DESTINATION_DOCUMENTS, SELECT_FLEET, SCHEDULE_PICKUP, RECORD_PICKUP, RECORD_EMPTY_RETURN, SEND_PICKUP_EMAIL]} + items: {type: string, enum: [EDIT_CONTAINER, MANAGE_CUSTOMS, CONFIRM_OUTBOUND, MANAGE_DESTINATION_DOCUMENTS, SELECT_FLEET, SCHEDULE_PICKUP, RECORD_EMPTY_RETURN, SEND_PICKUP_EMAIL]} actualFacts: {$ref: '#/components/schemas/ContainerActualFacts'} terminalDrayage: {$ref: '#/components/schemas/ContainerTerminalDrayage'} ContainerWorkbenchStatusSummary: @@ -2185,12 +2185,12 @@ components: fleetSupplierId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true} fleetSupplierName: {type: string, nullable: true} pickupAppointmentAt: {type: string, format: date-time, nullable: true} - actualPickupAt: {type: string, format: date-time, nullable: true} + actualPickupAt: {type: string, format: date-time, nullable: true, description: 优先使用实际船节点提柜时间;缺失时在海外PDA确认整柜拆柜完成后使用卸柜完成时间} estimatedEmptyReturnAt: {type: string, format: date-time, nullable: true, description: unloadingCompletedAt加72小时} actualEmptyReturnAt: {type: string, format: date-time, nullable: true} unloadingCompletedAt: {type: string, format: date-time, nullable: true} emptyReturnWarning: {type: object, nullable: true} - allowedActions: {type: array, items: {type: string, enum: [SELECT_FLEET, SCHEDULE_PICKUP, RECORD_PICKUP, RECORD_EMPTY_RETURN, SEND_PICKUP_EMAIL]}} + allowedActions: {type: array, items: {type: string, enum: [SELECT_FLEET, SCHEDULE_PICKUP, RECORD_EMPTY_RETURN, SEND_PICKUP_EMAIL]}} ContainerBooking: type: object required: [id, bookingNo, cabinetNo, sourceType, recordStatus, warehouseId, warehouseName, channelGroupCode, containerType, capacityCbm, maxWeightKg, planStatus, exceptionActive, destinationClearanceDocumentStatus, customsDeclarationDocumentStatus, deliveryOrderDocumentStatus, version] @@ -2214,7 +2214,7 @@ components: billOfLadingNo: {type: string, maxLength: 128, nullable: true} vesselName: {type: string, maxLength: 128, nullable: true} voyageNo: {type: string, maxLength: 64, nullable: true} - containerType: {type: string, enum: [20GP, 40GP, 40HC, 40NR, 45HC], description: 平台固定柜型} + containerType: {type: string, enum: [40GP, 40HQ, 45HQ], description: 平台统一柜型;默认40HQ} shippingCompanyId: {type: string, nullable: true} shippingCompanyName: {type: string, maxLength: 128, nullable: true} departurePortCode: {type: string, maxLength: 32, nullable: true} @@ -2223,8 +2223,8 @@ components: destinationPortName: {type: string, maxLength: 128, nullable: true} destinationWarehouseCode: {type: string, maxLength: 64, nullable: true} destinationWarehouseName: {type: string, maxLength: 128, nullable: true} - capacityCbm: {type: number, exclusiveMinimum: true, minimum: 0, description: 必须等于柜型固定容量} - maxWeightKg: {type: number, exclusiveMinimum: true, minimum: 0, description: 必须等于柜型固定限重} + capacityCbm: {type: number, enum: [58, 68, 86], description: 统一实装上限;40GP=58、40HQ=68、45HQ=86} + maxWeightKg: {type: number, enum: [19500], description: 统一货重上限} terminalTruckingCompanyId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: ACTIVE Warehouse Supplier ID validated as TRUCKING} terminalTruckingCompany: {type: string, maxLength: 128, nullable: true, description: Server-frozen Warehouse supplier name snapshot} loadingAt: {type: string, format: date-time, nullable: true} @@ -2265,7 +2265,7 @@ components: billOfLadingNo: {type: string, maxLength: 128, nullable: true} vesselName: {type: string, maxLength: 128, nullable: true} voyageNo: {type: string, maxLength: 64, nullable: true} - containerType: {type: string, enum: [20GP, 40GP, 40HC, 40NR, 45HC], description: 平台固定柜型} + containerType: {type: string, enum: [40GP, 40HQ, 45HQ], description: 平台统一柜型;默认40HQ} shippingCompanyId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true} shippingCompanyName: {type: string, maxLength: 128, nullable: true} departurePortCode: {type: string, maxLength: 32, nullable: true} @@ -2274,8 +2274,8 @@ components: destinationPortName: {type: string, maxLength: 128, nullable: true} destinationWarehouseCode: {type: string, maxLength: 64, nullable: true} destinationWarehouseName: {type: string, maxLength: 128, nullable: true} - capacityCbm: {type: number, exclusiveMinimum: true, minimum: 0, description: 必须等于柜型固定容量} - maxWeightKg: {type: number, exclusiveMinimum: true, minimum: 0, description: 必须等于柜型固定限重} + capacityCbm: {type: number, enum: [58, 68, 86], description: 统一实装上限;40GP=58、40HQ=68、45HQ=86} + maxWeightKg: {type: number, enum: [19500], description: 统一货重上限} terminalTruckingCompanyId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: ACTIVE Warehouse Supplier ID validated as TRUCKING} terminalTruckingCompany: {type: string, maxLength: 128, nullable: true, description: Optional consistency hint; persisted name always comes from Warehouse and cannot replace the ID} loadingAt: {type: string, format: date-time, nullable: true} @@ -2742,9 +2742,9 @@ components: properties: channelGroupCode: {type: string, maxLength: 64, enum: [MATSON_CLX, MATSON_MAX, EXX, HEDE, OA_LA, OA_CHICAGO, OA_HOUSTON, OA_SAVANNAH, OA_NEW_YORK_TRUCK], description: 智能装柜使用的九类计划渠道分组} warehouseId: {type: integer, format: int64, minimum: 1} - containerType: {type: string, enum: [20GP, 40GP, 40HC, 40NR, 45HC], description: 与订舱共用的平台固定柜型} - capacityCbm: {type: number, minimum: 0, exclusiveMinimum: true, description: 必须等于柜型固定容量} - maxWeightKg: {type: number, minimum: 0, exclusiveMinimum: true, description: 必须等于柜型固定限重} + containerType: {type: string, enum: [40GP, 40HQ, 45HQ], default: 40HQ, description: 与订舱共用的平台统一柜型} + capacityCbm: {type: number, enum: [58, 68, 86], description: 统一实装上限;40GP=58、40HQ=68、45HQ=86} + maxWeightKg: {type: number, enum: [19500], description: 统一货重上限} ContainerOceanBookingLinkRequest: type: object required: [oceanBookingId, requirementId, expectedVersion] diff --git a/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml b/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml index 35fbed98f..45b4db532 100644 --- a/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml +++ b/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml @@ -117,7 +117,7 @@ paths: parameters: - {name: warehouseId, in: query, required: true, schema: {type: integer, format: int64, minimum: 1}} - {name: channelGroupCode, in: query, required: true, schema: {type: string, maxLength: 64}} - - {name: containerType, in: query, required: true, schema: {type: string, maxLength: 32}} + - {name: containerType, in: query, required: true, schema: {type: string, enum: [40GP, 40HQ, 45HQ]}} - {name: page, in: query, required: false, schema: {type: integer, format: int64, minimum: 1, default: 1}} - {name: size, in: query, required: false, schema: {type: integer, format: int64, minimum: 1, maximum: 200, default: 20}} responses: @@ -165,7 +165,7 @@ components: properties: customerId: {type: integer, format: int64, minimum: 1, nullable: true, description: 可选启用客户ID;多客户订舱留空,空值不是公开访问授权} customerName: {type: string, nullable: true, description: 可选名称提示;有客户ID时由服务端校验并保存客户主数据名称,清空客户时同时置null} - channelCode: {type: string, nullable: true} + channelCode: {type: string, nullable: true, description: 固定计划物流渠道组编码} warehouseId: {type: integer, format: int64, nullable: true} billOfLadingNo: {type: string, nullable: true} shippingCompanyId: {type: integer, format: int64, nullable: true} @@ -191,15 +191,13 @@ components: destinationPortCode: {type: string, nullable: true} destinationPortName: {type: string, nullable: true} placeOfDelivery: {type: string, nullable: true} - serviceTerm: {type: string, nullable: true} - freightTerm: {type: string, nullable: true} estimatedSailingAt: {type: string, format: date-time, nullable: true} estimatedArrivalAt: {type: string, format: date-time, nullable: true} remark: {type: string, nullable: true} parties: {type: array, nullable: true, items: {$ref: '#/components/schemas/PartyRequest'}} containerRequirements: {type: array, nullable: true, items: {$ref: '#/components/schemas/ContainerRequirementRequest'}} PartyRequest: {type: object, required: [partyType, partyName], properties: {partyType: {type: string, enum: [SHIPPER, CONSIGNEE, NOTIFY_PARTY]}, partyName: {type: string}, address: {type: string, nullable: true}, contactName: {type: string, nullable: true}, contactPhone: {type: string, nullable: true}}} - ContainerRequirementRequest: {type: object, required: [containerType, containerKind, quantity, capacityCbm, maxWeightKg], properties: {containerType: {type: string}, containerKind: {type: string}, quantity: {type: integer, minimum: 1}, capacityCbm: {type: number, exclusiveMinimum: true, minimum: 0}, maxWeightKg: {type: number, exclusiveMinimum: true, minimum: 0}}} + ContainerRequirementRequest: {type: object, required: [containerType, containerKind, quantity, capacityCbm, maxWeightKg], properties: {containerType: {type: string, enum: [40GP, 40HQ, 45HQ], default: 40HQ}, containerKind: {type: string}, quantity: {type: integer, minimum: 1}, capacityCbm: {type: number, exclusiveMinimum: true, minimum: 0, description: 统一实装上限;40GP=58、40HQ=68、45HQ=86}, maxWeightKg: {type: number, enum: [19500], description: 统一货重上限}}} ReleaseRequest: {type: object, required: [expectedVersion], properties: {expectedVersion: {type: integer, minimum: 0}, reason: {type: string, nullable: true}}} ActionRequest: {type: object, required: [expectedVersion], properties: {expectedVersion: {type: integer, minimum: 0}, reason: {type: string, nullable: true}}} DocumentType: {type: string, enum: [BOOKING_CONFIRMATION, BILL_OF_LADING]} diff --git a/firstleg/src/main/resources/openapi/terminal-drayage-v1.yaml b/firstleg/src/main/resources/openapi/terminal-drayage-v1.yaml index 9c50f781a..3361eb0d6 100644 --- a/firstleg/src/main/resources/openapi/terminal-drayage-v1.yaml +++ b/firstleg/src/main/resources/openapi/terminal-drayage-v1.yaml @@ -38,13 +38,6 @@ paths: parameters: [{$ref: '#/components/parameters/ContainerBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}] requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/PickupAppointmentRequest'}}}} responses: {'200': {$ref: '#/components/responses/ViewResponse'}} - /api/platform/v1/terminal-drayages/{containerBookingId}/pickup: - post: - summary: 清关可提柜后确认实际提柜并推进PICKED_UP节点 - x-permission: platform:firstleg:terminal-drayage:pickup:confirm - parameters: [{$ref: '#/components/parameters/ContainerBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}] - requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ActualTimeRequest'}}}} - responses: {'200': {$ref: '#/components/responses/ViewResponse'}} /api/platform/v1/terminal-drayages/{containerBookingId}/empty-return: post: summary: 海外卸柜收货完成后确认实际还柜并推进EMPTY_RETURNED节点 @@ -153,12 +146,12 @@ components: fleetSupplierId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true} fleetSupplierName: {type: string, nullable: true} pickupAppointmentAt: {type: string, format: date-time, nullable: true} - actualPickupAt: {type: string, format: date-time, nullable: true} + actualPickupAt: {type: string, format: date-time, nullable: true, description: 优先使用实际船节点提柜时间;缺失时在海外PDA确认整柜拆柜完成后使用卸柜完成时间} estimatedEmptyReturnAt: {type: string, format: date-time, nullable: true, description: 服务端权威值,卸柜完成时间加72小时} actualEmptyReturnAt: {type: string, format: date-time, nullable: true} unloadingCompletedAt: {type: string, format: date-time, nullable: true} emptyReturnWarning: {$ref: '#/components/schemas/Warning'} - allowedActions: {type: array, items: {type: string, enum: [SELECT_FLEET, SCHEDULE_PICKUP, RECORD_PICKUP, RECORD_EMPTY_RETURN, SEND_PICKUP_EMAIL]}} + allowedActions: {type: array, items: {type: string, enum: [SELECT_FLEET, SCHEDULE_PICKUP, RECORD_EMPTY_RETURN, SEND_PICKUP_EMAIL]}} actualFacts: {$ref: '#/components/schemas/ContainerActualFacts'} updatedAt: {type: string, format: date-time, nullable: true} ContainerActualFacts: @@ -181,7 +174,7 @@ components: type: object required: [action, fromStatus, toStatus, fromVersion, toVersion, operatorId, createdAt] properties: - action: {type: string, enum: [SELECT_FLEET, SCHEDULE_PICKUP, RECORD_PICKUP, RECORD_EMPTY_RETURN, SEND_PICKUP_EMAIL]} + action: {type: string, enum: [SELECT_FLEET, SCHEDULE_PICKUP, RECORD_EMPTY_RETURN, SEND_PICKUP_EMAIL]} fromStatus: {type: string} toStatus: {type: string} fromVersion: {type: integer, minimum: 0} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformSmartLoadingControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformSmartLoadingControllerContractTest.java index 988875e3e..0a904edec 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformSmartLoadingControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformSmartLoadingControllerContractTest.java @@ -84,7 +84,7 @@ class PlatformSmartLoadingControllerContractTest { .contains("成功响应中的 version 是递增后的 planVersion") .contains("items[].version 是递增后的 itemVersion") .contains("数量调整或动态增量不会覆盖已有协作备注") - .contains("柜内总容积和总重量分别允许达到柜型额定值的150%") + .contains("柜内总容积和总重量分别允许达到柜型基准值的150%") .contains("人工分配不限制订单国内仓及渠道与柜次一致") .contains("智能推荐允许跨国内仓,但仍只选择柜次渠道") .contains("canAssign: {type: boolean}") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/domain/ContainerSpecificationCatalogTest.java b/firstleg/src/test/java/com/baiyue/firstleg/domain/ContainerSpecificationCatalogTest.java deleted file mode 100644 index a4848da93..000000000 --- a/firstleg/src/test/java/com/baiyue/firstleg/domain/ContainerSpecificationCatalogTest.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.baiyue.firstleg.domain; - -import com.baiyue.common.core.exception.BizException; -import org.junit.jupiter.api.Test; - -import java.math.BigDecimal; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -class ContainerSpecificationCatalogTest { - @Test - void exposesCanonicalSpecificationsAndRejectsAliases() { - var specification = ContainerSpecificationCatalog.requireMatch( - "40HC", new BigDecimal("76.000"), new BigDecimal("28000.000")); - - assertThat(specification.containerType()).isEqualTo("40HC"); - assertThatThrownBy(() -> ContainerSpecificationCatalog.require("40HQ")) - .isInstanceOf(BizException.class).hasMessageContaining("固定规格"); - assertThatThrownBy(() -> ContainerSpecificationCatalog.requireMatch( - "40HC", new BigDecimal("68"), new BigDecimal("28000"))) - .isInstanceOf(BizException.class).hasMessageContaining("容量和限重"); - } -} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingMapperContractTest.java index 156feea9c..1a73ab477 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingMapperContractTest.java @@ -49,12 +49,12 @@ class OceanBookingMapperContractTest { @Test void linkCandidatesUsesOneBoundedSetQueryAndExactRemainingSemantics() throws Exception { String sql = String.join("\n", OceanBookingMapper.class.getDeclaredMethod("selectLinkCandidates", - com.baomidou.mybatisplus.extension.plugins.pagination.Page.class, LinkCandidateQuery.class, java.util.Set.class) + com.baomidou.mybatisplus.extension.plugins.pagination.Page.class, LinkCandidateQuery.class) .getAnnotation(Select.class).value()); assertThat(sql) .contains("booking.status='RELEASED'") .contains("booking.warehouse_id=#{query.warehouseId}") - .contains("> captor = ArgumentCaptor.forClass(List.class); verify(repository).replaceRequirements(eq(inserted.get().getId()), captor.capture()); assertThat(captor.getValue()).singleElement().satisfies(value -> { - assertThat(value.getContainerType()).isEqualTo("40HC"); + assertThat(value.getContainerType()).isEqualTo("40HQ"); assertThat(value.getQuantity()).isEqualTo(2); }); verify(repository, never()).insertContainer(any()); @@ -326,24 +326,22 @@ class OceanBookingServiceTest { @Test void linkCandidatesDefaultsPaginationAndDelegatesOneNormalizedSetQuery() { - LinkCandidate candidate = new LinkCandidate(101L, "OB-260818-0001", 201L, "40HC", - new BigDecimal("76"), new BigDecimal("28000"), 3, 1, 2, - "客户A", "深圳中转仓", "美西海运"); - when(repository.linkCandidates(any(), anySet())).thenReturn(new PageResult<>(List.of(candidate), 1, 20, 1, 1)); + LinkCandidate candidate = new LinkCandidate(101L, "OB-260818-0001", 201L, "40HQ", + new BigDecimal("68"), new BigDecimal("19500"), 3, 1, 2, + "客户A", "深圳中转仓", "美森正班"); + when(repository.linkCandidates(any())).thenReturn(new PageResult<>(List.of(candidate), 1, 20, 1, 1)); PageResult result = service.linkCandidates(new LinkCandidateQuery(701L, - " MATSON_CLX ", " 40HC ", null, null)); + " MATSON_CLX ", " 40HQ ", null, null)); assertThat(result.records()).containsExactly(candidate); assertThat(result.records().get(0).remainingQuantity()).isEqualTo(2); ArgumentCaptor query = ArgumentCaptor.forClass(LinkCandidateQuery.class); - @SuppressWarnings("unchecked") ArgumentCaptor> channels = ArgumentCaptor.forClass(java.util.Set.class); - verify(repository).linkCandidates(query.capture(), channels.capture()); + verify(repository).linkCandidates(query.capture()); assertThat(query.getValue().channelGroupCode()).isEqualTo("MATSON_CLX"); - assertThat(query.getValue().containerType()).isEqualTo("40HC"); + assertThat(query.getValue().containerType()).isEqualTo("40HQ"); assertThat(query.getValue().page()).isEqualTo(1L); assertThat(query.getValue().size()).isEqualTo(20L); - assertThat(channels.getValue()).contains("MATSON_CLX_TRUCK", "MATSON_CLX_PARCEL", "ZBYHK"); verify(repository, never()).find(anyLong()); verify(repository, never()).requirements(anyLong()); verify(repository, never()).containers(anyLong()); @@ -351,15 +349,15 @@ class OceanBookingServiceTest { @Test void linkCandidatesReturnsNormalEmptyPageWithOneSetQueryAndNoDiagnosticFields() { - when(repository.linkCandidates(any(), anySet())).thenReturn(new PageResult<>(List.of(), 1, 20, 0, 0)); + when(repository.linkCandidates(any())).thenReturn(new PageResult<>(List.of(), 1, 20, 0, 0)); PageResult result = service.linkCandidates( - new LinkCandidateQuery(701L, "MATSON_CLX", "45HC", null, null)); + new LinkCandidateQuery(701L, "MATSON_CLX", "45HQ", null, null)); assertThat(result.records()).isEmpty(); assertThat(result.total()).isZero(); assertThat(result.pages()).isZero(); - verify(repository, times(1)).linkCandidates(any(), anySet()); + verify(repository, times(1)).linkCandidates(any()); assertThat(LinkCandidate.class.getRecordComponents()).extracting(java.lang.reflect.RecordComponent::getName) .doesNotContain("mismatchReasons", "disabledReason", "warehouseId", "channelCode"); } @@ -386,7 +384,7 @@ class OceanBookingServiceTest { private void stubMasterData() { ChannelRow row = new ChannelRow(); - row.setPriceBookVersionId(51L); row.setChannelCode("SEA-US"); row.setChannelName("美西海运"); + row.setPriceBookVersionId(51L); row.setChannelCode("MATSON_CLX_TRUCK"); row.setChannelName("美森正班海卡"); row.setPriceModel("FBA_WAREHOUSE_RATE"); when(priceBooks.activeVersionId()).thenReturn(51L); when(priceBooks.channels(51L, 701L)).thenReturn(List.of(row)); @@ -402,31 +400,30 @@ class OceanBookingServiceTest { private static DraftData partialDraft(String remark) { return new DraftData(null, null, null, null, null, null, null, null, null, null, - null, null, null, null, null, null, null, null, null, null, null, remark, null, null); + null, null, null, null, null, null, null, null, null, remark, null, null); } private static DraftData completeDraft() { - return new DraftData(100L, "客户", "SEA-US", 701L, "BL-001", 71L, "船公司", "MATSON", + return new DraftData(100L, "客户", "MATSON_CLX", 701L, "BL-001", 71L, "船公司", "MATSON", 81L, null, "VESSEL", "V001", "CNSZX", "深圳", "USLAX", "洛杉矶", - "LAX", "CY-CY", "PREPAID", LocalDateTime.of(2026, 9, 1, 0, 0), + "LAX", LocalDateTime.of(2026, 9, 1, 0, 0), LocalDateTime.of(2026, 9, 20, 0, 0), null, List.of(new PartyRequest("SHIPPER", "发货人", null, null, null), new PartyRequest("CONSIGNEE", "收货人", null, null, null), new PartyRequest("NOTIFY_PARTY", "通知人", null, null, null)), - List.of(new ContainerRequirementRequest("40HC", "DRY", 2, - new BigDecimal("76"), new BigDecimal("28000")))); + List.of(new ContainerRequirementRequest("40HQ", "DRY", 2, + new BigDecimal("68"), new BigDecimal("19500")))); } private static OceanBookingEntity completeBooking(String status, int version) { OceanBookingEntity value = booking(status, version); - value.setCustomerId(100L); value.setCustomerName("客户"); value.setChannelCode("SEA-US"); + value.setCustomerId(100L); value.setCustomerName("客户"); value.setChannelCode("MATSON_CLX"); value.setWarehouseId(701L); value.setBillOfLadingNo("BL-001"); value.setShippingCompanyId(71L); value.setCarrierCode("MATSON"); value.setBookingAgentId(81L); value.setVesselName("VESSEL"); value.setVoyageNo("V001"); value.setDeparturePortCode("CNSZX"); value.setDeparturePortName("深圳"); value.setDestinationPortCode("USLAX"); value.setDestinationPortName("洛杉矶"); value.setPlaceOfDelivery("LAX"); - value.setServiceTerm("CY-CY"); value.setFreightTerm("PREPAID"); value.setEstimatedSailingAt(LocalDateTime.of(2026, 9, 1, 0, 0)); value.setEstimatedArrivalAt(LocalDateTime.of(2026, 9, 20, 0, 0)); return value; @@ -447,9 +444,9 @@ class OceanBookingServiceTest { private static OceanBookingContainerRequirementEntity requirement() { var value = new OceanBookingContainerRequirementEntity(); - value.setId(11L); value.setContainerType("40HC"); value.setContainerKind("DRY"); - value.setQuantity(1); value.setCapacityCbm(new BigDecimal("76")); - value.setMaxWeightKg(new BigDecimal("28000")); return value; + value.setId(11L); value.setContainerType("40HQ"); value.setContainerKind("DRY"); + value.setQuantity(1); value.setCapacityCbm(new BigDecimal("68")); + value.setMaxWeightKg(new BigDecimal("19500")); return value; } private String hash(String action, Long id, Object request) throws Exception { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerBookingMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerBookingMapperContractTest.java index e339e34ab..18f379ad1 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerBookingMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerBookingMapperContractTest.java @@ -64,6 +64,7 @@ class ContainerBookingMapperContractTest { .filter(candidate -> candidate.getName().equals("linkReleasedBooking")) .findFirst().orElseThrow(); String sql = String.join("\n", method.getAnnotation(Update.class).value()); + assertThat(sql).doesNotContain("channel_code=#{channelCode}", "channel_name=#{channelName}"); var parameterNames = Arrays.stream(method.getParameters()) .map(parameter -> parameter.getAnnotation(Param.class).value()) .toList(); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java index b173ab652..65b078ae6 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java @@ -85,7 +85,7 @@ class ContainerServiceTest { assertThat(saved.getChannelName()).isNull(); assertThat(saved.getWarehouseId()).isEqualTo(101L); assertThat(saved.getWarehouseName()).isEqualTo("上海中转仓"); - assertThat(saved.getCapacityCbm()).isEqualByComparingTo("76.0000"); + assertThat(saved.getCapacityCbm()).isEqualByComparingTo("68.0000"); assertThat(saved.getPlanId()).isEqualTo(501L); assertThat(saved.getPlanNo()).isEqualTo("LP-260818-0001"); assertThat(saved.getPlanStatus()).isEqualTo("EDITING"); @@ -96,11 +96,11 @@ class ContainerServiceTest { @DisplayName("新增模拟柜拒绝柜型与固定规格不匹配") void rejectsSimulationWithNonCanonicalSpecification() { SimulationCreateRequest invalid = new SimulationCreateRequest( - "MATSON_CLX", 101L, "40HC", new BigDecimal("100"), new BigDecimal("28000")); + "MATSON_CLX", 101L, "40HQ", new BigDecimal("100"), new BigDecimal("19500")); assertThatThrownBy(() -> service.createSimulation(9L, "admin", invalid)) .isInstanceOf(BizException.class) - .hasMessageContaining("容量和限重"); + .hasMessageContaining("容量和货重上限"); verify(repository, never()).insert(any()); } @@ -204,12 +204,12 @@ class ContainerServiceTest { container.setPlanId(501L); container.setPlanNo("LP-260818-0001"); container.setContainerNo(null); - container.setWarehouseId(101L); container.setChannelGroupCode("EXX"); container.setChannelCode(null); container.setContainerType("40HC"); + container.setWarehouseId(101L); container.setChannelGroupCode("EXX"); container.setChannelCode(null); container.setContainerType("40HQ"); container.setCapacityCbm(new BigDecimal("85.5000")); container.setMaxWeightKg(new BigDecimal("27500.000")); OceanBookingEntity booking = new OceanBookingEntity(); - booking.setId(21L); booking.setStatus("RELEASED"); booking.setWarehouseId(101L); booking.setChannelCode("EXX_RAIL_NY"); + booking.setId(21L); booking.setStatus("RELEASED"); booking.setWarehouseId(101L); booking.setChannelCode("EXX"); OceanBookingContainerRequirementEntity requirement = new OceanBookingContainerRequirementEntity(); - requirement.setId(31L); requirement.setQuantity(1); requirement.setContainerType("40HC"); requirement.setCapacityCbm(new BigDecimal("76.0000")); requirement.setMaxWeightKg(new BigDecimal("28000.000")); + requirement.setId(31L); requirement.setQuantity(1); requirement.setContainerType("40HQ"); requirement.setCapacityCbm(new BigDecimal("68.0000")); requirement.setMaxWeightKg(new BigDecimal("19500.000")); when(repository.find(11L)).thenReturn(container, container); when(oceanBookings.find(21L)).thenReturn(booking); when(oceanBookings.requirement(21L, 31L)).thenReturn(requirement); @@ -313,15 +313,15 @@ class ContainerServiceTest { void linksMultipleRealContainersToTheSameReleasedBookingRequirement() { ContainerBookingEntity first = booking("WAIT_LOADING", 2); first.setId(11L); first.setContainerNo(null); first.setWarehouseId(101L); - first.setChannelGroupCode("MATSON_CLX"); first.setChannelCode(null); first.setContainerType("40HC"); + first.setChannelGroupCode("MATSON_CLX"); first.setChannelCode(null); first.setContainerType("40HQ"); ContainerBookingEntity second = booking("WAIT_LOADING", 4); second.setId(12L); second.setContainerNo(null); second.setWarehouseId(101L); - second.setChannelGroupCode("MATSON_CLX"); second.setChannelCode(null); second.setContainerType("40HC"); + second.setChannelGroupCode("MATSON_CLX"); second.setChannelCode(null); second.setContainerType("40HQ"); OceanBookingEntity oceanBooking = new OceanBookingEntity(); oceanBooking.setId(21L); oceanBooking.setStatus("RELEASED"); - oceanBooking.setWarehouseId(101L); oceanBooking.setChannelCode("MATSON_CLX_TRUCK"); + oceanBooking.setWarehouseId(101L); oceanBooking.setChannelCode("MATSON_CLX"); OceanBookingContainerRequirementEntity requirement = new OceanBookingContainerRequirementEntity(); - requirement.setId(31L); requirement.setQuantity(2); requirement.setContainerType("40HC"); + requirement.setId(31L); requirement.setQuantity(2); requirement.setContainerType("40HQ"); when(repository.find(11L)).thenReturn(first); when(repository.find(12L)).thenReturn(second); when(oceanBookings.find(21L)).thenReturn(oceanBooking); @@ -362,19 +362,19 @@ class ContainerServiceTest { @Test @DisplayName("首次关联拒绝装柜仓库不一致") void rejectsLinkWhenWarehouseDiffers() { - assertThreeFieldMismatch(102L, "MATSON_CLX_TRUCK", "40HC"); + assertThreeFieldMismatch(102L, "MATSON_CLX", "40HQ"); } @Test @DisplayName("首次关联拒绝物流渠道不一致") void rejectsLinkWhenChannelDiffers() { - assertThreeFieldMismatch(101L, "HEDE_TRUCK", "40HC"); + assertThreeFieldMismatch(101L, "HEDE", "40HQ"); } @Test @DisplayName("首次关联拒绝柜型不一致") void rejectsLinkWhenContainerTypeDiffers() { - assertThreeFieldMismatch(101L, "MATSON_CLX_TRUCK", "45HC"); + assertThreeFieldMismatch(101L, "MATSON_CLX", "45HQ"); } @Test @@ -418,7 +418,7 @@ class ContainerServiceTest { ContainerBookingEntity container = booking("WAIT_LOADING", 2); container.setContainerNo(null); container.setWarehouseId(101L); container.setChannelGroupCode("MATSON_CLX"); container.setChannelCode(null); - container.setContainerType("40HC"); + container.setContainerType("40HQ"); OceanBookingEntity oceanBooking = new OceanBookingEntity(); oceanBooking.setId(21L); oceanBooking.setStatus("RELEASED"); oceanBooking.setWarehouseId(bookingWarehouseId); oceanBooking.setChannelCode(bookingChannel); @@ -440,12 +440,12 @@ class ContainerServiceTest { ContainerBookingEntity container = booking("WAIT_LOADING", 2); container.setContainerNo(null); container.setWarehouseId(101L); container.setChannelGroupCode("MATSON_CLX"); container.setChannelCode(null); - container.setContainerType("40HC"); + container.setContainerType("40HQ"); OceanBookingEntity oceanBooking = new OceanBookingEntity(); oceanBooking.setId(21L); oceanBooking.setStatus("RELEASED"); - oceanBooking.setWarehouseId(101L); oceanBooking.setChannelCode("MATSON_CLX_TRUCK"); + oceanBooking.setWarehouseId(101L); oceanBooking.setChannelCode("MATSON_CLX"); OceanBookingContainerRequirementEntity requirement = new OceanBookingContainerRequirementEntity(); - requirement.setId(31L); requirement.setQuantity(2); requirement.setContainerType("40HC"); + requirement.setId(31L); requirement.setQuantity(2); requirement.setContainerType("40HQ"); when(repository.find(11L)).thenReturn(container); when(oceanBookings.find(21L)).thenReturn(oceanBooking); when(oceanBookings.requirement(21L, 31L)).thenReturn(requirement); @@ -485,15 +485,15 @@ class ContainerServiceTest { private static SaveRequest request() { return new SaveRequest(101L, "上海仓", "BOL-001", "柏悦轮", - "V001", "40HC", 201L, "船司A", "CNSHA", "上海港", "USLAX", "洛杉矶港", - "ONT8", "ONT8", new BigDecimal("76.0000"), new BigDecimal("28000.000"), 202L, "拖车公司A", + "V001", "40HQ", 201L, "船司A", "CNSHA", "上海港", "USLAX", "洛杉矶港", + "ONT8", "ONT8", new BigDecimal("68.0000"), new BigDecimal("19500.000"), 202L, "拖车公司A", LocalDateTime.of(2026, 8, 10, 8, 0), LocalDateTime.of(2026, 8, 12, 8, 0), null, LocalDateTime.of(2026, 8, 25, 8, 0), null, LocalDateTime.of(2026, 8, 27, 8, 0), null); } private static SimulationCreateRequest simulation() { - return new SimulationCreateRequest("MATSON_CLX", 101L, "40HC", new BigDecimal("76.0000"), new BigDecimal("28000.000")); + return new SimulationCreateRequest("MATSON_CLX", 101L, "40HQ", new BigDecimal("68.0000"), new BigDecimal("19500.000")); } private static ChannelRow channel() { 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/OverseasDeliveryCompletedConsumerTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/OverseasDeliveryCompletedConsumerTest.java new file mode 100644 index 000000000..8fa0a26ba --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/OverseasDeliveryCompletedConsumerTest.java @@ -0,0 +1,86 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.event.*; +import com.baiyue.firstleg.client.OrderFulfillmentCompletionClient; +import com.baiyue.firstleg.entity.InboundOrderEntity; +import com.baiyue.firstleg.mapper.ContainerEventInboxMapper; +import com.baiyue.firstleg.mapper.InboundOrderMapper; +import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.Instant; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class OverseasDeliveryCompletedConsumerTest { + @Test + void completesOnlyEventSourceInboundAndOrder() { + InboundOrderMapper inbounds = mock(InboundOrderMapper.class); + InboundStatusHistoryMapper histories = mock(InboundStatusHistoryMapper.class); + ContainerEventInboxMapper inbox = mock(ContainerEventInboxMapper.class); + OrderFulfillmentCompletionClient orders = mock(OrderFulfillmentCompletionClient.class); + ObjectMapper json = new ObjectMapper().registerModule(new JavaTimeModule()); + OverseasDeliveryCompletedConsumer consumer = new OverseasDeliveryCompletedConsumer( + inbounds, histories, inbox, orders, json); + ReflectionTestUtils.setField(consumer, "internalToken", "token"); + InboundOrderEntity inbound = new InboundOrderEntity(); + inbound.setId(101L); inbound.setTenantId(88L); inbound.setOrderId(201L); + inbound.setOrderNo("FL-201"); inbound.setInboundCode("IN-1"); inbound.setDestinationCode("ONT8"); + inbound.setProcessStatus("LOADED"); + when(inbounds.selectActiveByIdForUpdate(101L)).thenReturn(inbound); + when(inbounds.completeOverseasDelivery(eq(88L), eq(101L), any())).thenReturn(1); + when(inbounds.countNotDeliveredByOrder(88L, 201L)).thenReturn(0); + when(orders.complete(eq("token"), any())).thenReturn(ApiResponse.success( + new OrderFulfillmentCompletionClient.Result(201L, "COMPLETED", false))); + + consumer.consume(event(json)); + + verify(inbounds).completeOverseasDelivery(eq(88L), eq(101L), any()); + verify(orders).complete(eq("token"), argThat(request -> + "201".equals(request.orderId()) && "IN-1".equals(request.inboundCode()) + && "DL-ONT8".equals(request.deliveryNo()))); + verify(inbounds).markOrderSnapshotCompleted(eq(88L), eq(201L), any()); + verify(inbounds, never()).completeOverseasDelivery(eq(88L), eq(102L), any()); + } + + @Test + void leavesOrderOpenWhileAnotherSourceInboundIsNotDelivered() { + InboundOrderMapper inbounds = mock(InboundOrderMapper.class); + InboundStatusHistoryMapper histories = mock(InboundStatusHistoryMapper.class); + ContainerEventInboxMapper inbox = mock(ContainerEventInboxMapper.class); + OrderFulfillmentCompletionClient orders = mock(OrderFulfillmentCompletionClient.class); + ObjectMapper json = new ObjectMapper().registerModule(new JavaTimeModule()); + OverseasDeliveryCompletedConsumer consumer = new OverseasDeliveryCompletedConsumer( + inbounds, histories, inbox, orders, json); + ReflectionTestUtils.setField(consumer, "internalToken", "token"); + InboundOrderEntity inbound = new InboundOrderEntity(); + inbound.setId(101L); inbound.setTenantId(88L); inbound.setOrderId(201L); + inbound.setOrderNo("FL-201"); inbound.setInboundCode("IN-1"); inbound.setDestinationCode("ONT8"); + inbound.setProcessStatus("LOADED"); + when(inbounds.selectActiveByIdForUpdate(101L)).thenReturn(inbound); + when(inbounds.completeOverseasDelivery(eq(88L), eq(101L), any())).thenReturn(1); + when(inbounds.countNotDeliveredByOrder(88L, 201L)).thenReturn(1); + + consumer.consume(event(json)); + + verifyNoInteractions(orders); + verify(inbounds, never()).markOrderSnapshotCompleted(anyLong(), anyLong(), any()); + } + + private EventEnvelope event(ObjectMapper json) { + Instant time = Instant.parse("2026-09-15T08:00:00Z"); + var payload = new OverseasDeliveryCompletedV1("1", "DL-ONT8", "11", "LM-11", + "101", "IN-1", "201", "FL-201", "201", "ONT8", time); + var node = json.valueToTree(payload); + return new EventEnvelope("00000000-0000-4000-8000-000000000001", + EventTypes.OVERSEAS_DELIVERY_COMPLETED, 1, time, RegionCode.OVERSEAS, RegionCode.CN, + "88", "INBOUND_ORDER", "101", 2L, 1L, + "00000000-0000-4000-8000-000000000099", "delivery-101", null, node, + EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node))); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java index 53181f21c..7ed1f0e21 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java @@ -552,8 +552,8 @@ class SmartLoadingServiceTest { @Test void pagesWorkbenchContainersWithTheSameGlobalFilters() { WorkbenchContainer container = new WorkbenchContainer(99L, "CB-1", "CAB-1", null, - 100L, "义乌仓", "MATSON_CLX", null, null, "40HC", - new BigDecimal("68"), new BigDecimal("28000"), + 100L, "义乌仓", "MATSON_CLX", null, null, "40HQ", + new BigDecimal("68"), new BigDecimal("19500"), 1L, "LP-1", "EDITING", 2); WorkbenchContainerQuery query = new WorkbenchContainerQuery(List.of("MATSON_CLX"), 100L, 9L, "IN-10", LocalDate.parse("2026-09-01"), LocalDate.parse("2026-09-30"), 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/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/controller/TerminalDrayageOpenApiContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/controller/TerminalDrayageOpenApiContractTest.java index dbae50224..fdc975777 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/controller/TerminalDrayageOpenApiContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/controller/TerminalDrayageOpenApiContractTest.java @@ -33,7 +33,6 @@ class TerminalDrayageOpenApiContractTest { assertPermission("retryEmail", "platform:firstleg:terminal-drayage:fleet:edit"); assertPermission("emails", "platform:firstleg:terminal-drayage:list"); assertPermission("schedulePickup", "platform:firstleg:terminal-drayage:pickup-appointment:edit"); - assertPermission("recordPickup", "platform:firstleg:terminal-drayage:pickup:confirm"); assertPermission("recordEmptyReturn", "platform:firstleg:terminal-drayage:empty-return:confirm"); } @@ -43,18 +42,18 @@ class TerminalDrayageOpenApiContractTest { assertThat(yaml).contains( "/api/platform/v1/terminal-drayages/{containerBookingId}/fleet:", "/api/platform/v1/terminal-drayages/{containerBookingId}/pickup-appointment:", - "/api/platform/v1/terminal-drayages/{containerBookingId}/pickup:", "/api/platform/v1/terminal-drayages/{containerBookingId}/empty-return:", "platform:firstleg:terminal-drayage:list", "platform:firstleg:terminal-drayage:fleet:edit", "platform:firstleg:terminal-drayage:pickup-appointment:edit", - "platform:firstleg:terminal-drayage:pickup:confirm", "platform:firstleg:terminal-drayage:empty-return:confirm", "expectedVersion", "Idempotency-Key", "estimatedEmptyReturnAt", "emptyReturnWarning", "actualFacts", "deadlineAt", "overdueHours", "overdueDays", "船节点跟踪任务尚未建立也返回WAITING_FOR_ARRIVAL空详情", "允许读取空邮件列表,不以船节点跟踪任务是否已经建立作为货柜存在性条件"); assertThat(yaml).contains("description: 卸柜完成时间加72小时"); + assertThat(yaml).doesNotContain("/api/platform/v1/terminal-drayages/{containerBookingId}/pickup:", + "platform:firstleg:terminal-drayage:pickup:confirm", "RECORD_PICKUP"); } @Test diff --git a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageSqlContractTest.java index a8e42a201..52bc4bd58 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageSqlContractTest.java @@ -26,24 +26,19 @@ class TerminalDrayageSqlContractTest { assertThat(Files.readString(path)).contains( "CREATE TABLE IF NOT EXISTS fl_terminal_drayage_mail (", "CHECK(status IN ('PENDING','SENDING','SENT','FAILED'))", - "('SELECT_FLEET','SEND_PICKUP_EMAIL','SCHEDULE_PICKUP','RECORD_PICKUP','RECORD_EMPTY_RETURN')"); + "('SELECT_FLEET','SEND_PICKUP_EMAIL','SCHEDULE_PICKUP','RECORD_EMPTY_RETURN')"); } } @Test - void bothSqlEntrypointsAllowReturnWithoutPickupAndRetainKnownTimeOrdering() throws Exception { - for (Path path : java.util.List.of( - Path.of("src/main/resources/db/phase3-terminal-drayage-schema.sql"), - Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"))) { - String sql = Files.readString(path); - assertThat(sql).contains("actual_empty_return_at IS NULL OR actual_pickup_at IS NULL OR actual_empty_return_at >= actual_pickup_at") - .doesNotContain("actual_empty_return_at IS NULL OR actual_pickup_at IS NOT NULL"); - } + void bothSqlEntrypointsRemoveTheManualPickupTimestamp() throws Exception { + String baseline = Files.readString(Path.of("src/main/resources/db/phase3-terminal-drayage-schema.sql")); + assertThat(baseline).doesNotContain("actual_pickup_at", "chk_terminal_drayage_times"); String release = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql")); - assertThat(release).contains("DROP CHECK chk_terminal_drayage_times", - "ADD CONSTRAINT chk_terminal_drayage_times CHECK(", + assertThat(release).contains("ALTER TABLE fl_terminal_drayage DROP COLUMN actual_pickup_at", "DROP CHECK chk_terminal_drayage_history_action", "ADD CONSTRAINT chk_terminal_drayage_history_action CHECK(action IN"); + assertThat(release).doesNotContain("chk_terminal_drayage_times"); } @Test diff --git a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/ContainerWorkbenchServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/ContainerWorkbenchServiceTest.java index a9a814cfa..74175e9c0 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/ContainerWorkbenchServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/ContainerWorkbenchServiceTest.java @@ -142,7 +142,6 @@ class ContainerWorkbenchServiceTest { row.setId(51L); row.setContainerBookingId(11L); row.setVersion(3); - row.setActualPickupAt(LocalDateTime.of(2026, 9, 4, 9, 0)); when(repository.page(eq("OVERSEAS"), any())).thenReturn(new PageResult<>(List.of(container), 1, 20, 1, 1)); when(repository.find(11L)).thenReturn(container); when(repository.departed(11L)).thenReturn(true); @@ -205,9 +204,9 @@ class ContainerWorkbenchServiceTest { assertThat(result.workbenchStage()).isEqualTo("UNLOADED"); assertThat(result.terminalDrayage().status()).isEqualTo("UNLOADED"); assertThat(result.terminalDrayage().version()).isZero(); - assertThat(result.terminalDrayage().actualPickupAt()).isNull(); - assertThat(result.allowedActions()).containsExactly("SELECT_FLEET", "RECORD_EMPTY_RETURN"); - assertThat(result.terminalDrayage().allowedActions()).containsExactly("SELECT_FLEET", "RECORD_EMPTY_RETURN"); + assertThat(result.terminalDrayage().actualPickupAt()).isEqualTo(unloadedAt); + assertThat(result.allowedActions()).containsExactly("RECORD_EMPTY_RETURN"); + assertThat(result.terminalDrayage().allowedActions()).containsExactly("RECORD_EMPTY_RETURN"); } verify(mapper, never()).insert(any(com.baiyue.firstleg.terminaldrayage.entity.TerminalDrayageEntity.class)); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageServiceTest.java index 6990ee7f0..3d002d287 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageServiceTest.java @@ -104,40 +104,12 @@ class TerminalDrayageServiceTest { verify(mapper).insertOperation(anyLong(), anyLong(), anyString(), anyString(), anyString(), anyInt(), anyLong()); } - @Test - void recordsPickupOnceAndAdvancesCanonicalNode() { - Context before = context("PICKUP_READY", null); - Context after = context("PICKED_UP", null); - when(mapper.lockContext(11L)).thenReturn(before); - when(mapper.context(11L)).thenReturn(after); - when(mapper.operation(11L, "pickup-1")).thenReturn(null); - TerminalDrayageEntity row = row(2); - row.setPickupAppointmentAt(LocalDateTime.of(2026, 9, 2, 9, 0)); - when(mapper.lock(11L)).thenReturn(row); - when(mapper.updateAggregate(any(), anyInt(), anyLong(), anyString())).thenAnswer(invocation -> { - row.setVersion(3); - return 1; - }); - when(mapper.find(11L)).thenReturn(row); - - var result = service.recordPickup(11L, 7L, "admin", "pickup-1", - new ActualTimeRequest(2, LocalDateTime.of(2026, 9, 2, 10, 0), "码头提柜")); - - assertThat(result.status()).isEqualTo("PICKED_UP"); - assertThat(result.actualPickupAt()).isEqualTo(LocalDateTime.of(2026, 9, 2, 10, 0)); - ArgumentCaptor node = ArgumentCaptor.forClass(ContainerManualNodeRequest.class); - verify(carrierSchedules).addContainerManualNode(anyLong(), anyLong(), anyString(), anyString(), node.capture()); - assertThat(node.getValue().standardNode()).isEqualTo("PICKED_UP"); - verify(mapper, times(1)).updateAggregate(any(), anyInt(), anyLong(), anyString()); - } - @Test void requiresOverseasUnloadBeforeEmptyReturn() { Context context = context("PICKED_UP", null); when(mapper.lockContext(11L)).thenReturn(context); when(mapper.operation(11L, "return-1")).thenReturn(null); TerminalDrayageEntity row = row(3); - row.setActualPickupAt(LocalDateTime.of(2026, 9, 2, 10, 0)); when(mapper.lock(11L)).thenReturn(row); assertThatThrownBy(() -> service.recordEmptyReturn(11L, 7L, "admin", "return-1", @@ -152,7 +124,6 @@ class TerminalDrayageServiceTest { LocalDateTime unloadedAt = LocalDateTime.of(2026, 9, 1, 0, 0); Context context = context("PICKED_UP", unloadedAt); TerminalDrayageEntity row = row(3); - row.setActualPickupAt(LocalDateTime.of(2026, 8, 31, 18, 0)); when(mapper.context(11L)).thenReturn(context); when(mapper.find(11L)).thenReturn(row); @@ -178,7 +149,7 @@ class TerminalDrayageServiceTest { assertThat(result.status()).isEqualTo("UNLOADED"); assertThat(result.allowedActions()).contains("RECORD_EMPTY_RETURN"); - assertThat(result.actualPickupAt()).isNull(); + assertThat(result.actualPickupAt()).isEqualTo(LocalDateTime.of(2026, 9, 3, 0, 0)); assertThat(result.version()).isZero(); } @@ -206,7 +177,7 @@ class TerminalDrayageServiceTest { var result = service.recordEmptyReturn(11L, 7L, "admin", "return-new", request); assertThat(result.status()).isEqualTo("EMPTY_RETURNED"); assertThat(result.emptyReturnWarning().status()).isEqualTo("RESOLVED"); - assertThat(result.actualPickupAt()).isNull(); + assertThat(result.actualPickupAt()).isEqualTo(LocalDateTime.of(2026, 9, 3, 0, 0)); assertThat(result.fleetSupplierId()).isNull(); assertThat(result.version()).isEqualTo(1); assertThat(result.allowedActions()).doesNotContain("RECORD_EMPTY_RETURN"); @@ -229,12 +200,12 @@ class TerminalDrayageServiceTest { void unloadedContainerOffersEmptyReturnOnlyWithDedicatedPermission() { Context context = context("PICKED_UP", LocalDateTime.of(2026, 9, 3, 0, 0)); TerminalDrayageEntity row = row(3); - row.setActualPickupAt(LocalDateTime.of(2026, 9, 2, 10, 0)); when(mapper.context(11L)).thenReturn(context); when(mapper.find(11L)).thenReturn(row); var raw = service.detail(11L); assertThat(raw.status()).isEqualTo("UNLOADED"); + assertThat(raw.actualPickupAt()).isEqualTo(LocalDateTime.of(2026, 9, 2, 8, 0)); assertThat(service.authorize(raw, Set.of("platform:firstleg:terminal-drayage:empty-return:confirm")).allowedActions()) .containsExactly("RECORD_EMPTY_RETURN"); @@ -265,22 +236,6 @@ class TerminalDrayageServiceTest { verify(carrierSchedules, times(2)).containerActualFacts(List.of(11L)); } - @Test - void allowedActionsAreReducedByFrozenPermissionMatrix() { - Context context = context("PICKUP_READY", null); - TerminalDrayageEntity row = row(3); - row.setPickupAppointmentAt(LocalDateTime.of(2026, 9, 2, 9, 0)); - when(mapper.context(11L)).thenReturn(context); - when(mapper.find(11L)).thenReturn(row); - - var raw = service.detail(11L); - var authorized = service.authorize(raw, - Set.of("platform:firstleg:terminal-drayage:pickup:confirm")); - - assertThat(raw.allowedActions()).contains("SELECT_FLEET", "SCHEDULE_PICKUP", "RECORD_PICKUP"); - assertThat(authorized.allowedActions()).containsExactly("RECORD_PICKUP"); - } - @Test void sameIdempotencyKeyWithDifferentPayloadConflictsWithoutWriting() { Context context = context("ARRIVED_DESTINATION_PORT", null); @@ -337,7 +292,6 @@ class TerminalDrayageServiceTest { when(mapper.operation(11L, "return-2")).thenReturn(null); TerminalDrayageEntity row = row(3); row.setPickupAppointmentAt(LocalDateTime.of(2026, 9, 2, 9, 0)); - row.setActualPickupAt(LocalDateTime.of(2026, 9, 2, 10, 0)); when(mapper.lock(11L)).thenReturn(row); when(mapper.updateAggregate(any(), anyInt(), anyLong(), anyString())).thenAnswer(invocation -> { row.setVersion(4); diff --git a/iam/src/main/resources/db/container-workspace-permissions.sql b/iam/src/main/resources/db/container-workspace-permissions.sql index 57b15c1fd..b55c70ebb 100644 --- a/iam/src/main/resources/db/container-workspace-permissions.sql +++ b/iam/src/main/resources/db/container-workspace-permissions.sql @@ -15,7 +15,6 @@ BEGIN OR (id = 5102201 AND permission_code <> 'platform:firstleg:terminal-drayage:document:edit') OR (id = 5102202 AND permission_code <> 'platform:firstleg:terminal-drayage:fleet:edit') OR (id = 5102203 AND permission_code <> 'platform:firstleg:terminal-drayage:pickup-appointment:edit') - OR (id = 5102204 AND permission_code <> 'platform:firstleg:terminal-drayage:pickup:confirm') OR (id = 5102205 AND permission_code <> 'platform:firstleg:terminal-drayage:empty-return:confirm') ) OR EXISTS ( SELECT 1 FROM iam_permission @@ -26,7 +25,6 @@ BEGIN OR (permission_code = 'platform:firstleg:terminal-drayage:document:edit' AND id <> 5102201) OR (permission_code = 'platform:firstleg:terminal-drayage:fleet:edit' AND id <> 5102202) OR (permission_code = 'platform:firstleg:terminal-drayage:pickup-appointment:edit' AND id <> 5102203) - OR (permission_code = 'platform:firstleg:terminal-drayage:pickup:confirm' AND id <> 5102204) OR (permission_code = 'platform:firstleg:terminal-drayage:empty-return:confirm' AND id <> 5102205) ) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='货柜双工作台权限初始化失败:权限ID或编码冲突'; @@ -50,8 +48,6 @@ BEGIN '维护目的港码头车队',NULL,'POST',20,'ACTIVE',1,0), (5102203,0,'PLATFORM','BUTTON',5102200,'platform:firstleg:terminal-drayage:pickup-appointment:edit', '预约目的港提柜',NULL,'POST',30,'ACTIVE',1,0), - (5102204,0,'PLATFORM','BUTTON',5102200,'platform:firstleg:terminal-drayage:pickup:confirm', - '确认目的港实际提柜',NULL,'POST',40,'ACTIVE',1,0), (5102205,0,'PLATFORM','BUTTON',5102200,'platform:firstleg:terminal-drayage:empty-return:confirm', '确认目的港实际还柜',NULL,'POST',50,'ACTIVE',1,0) ON DUPLICATE KEY UPDATE @@ -84,7 +80,6 @@ BEGIN 'platform:firstleg:terminal-drayage:document:edit', 'platform:firstleg:terminal-drayage:fleet:edit', 'platform:firstleg:terminal-drayage:pickup-appointment:edit', - 'platform:firstleg:terminal-drayage:pickup:confirm', 'platform:firstleg:terminal-drayage:empty-return:confirm') WHERE role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR') @@ -101,8 +96,7 @@ BEGIN 'platform:firstleg:terminal-drayage:document:edit', 'platform:firstleg:terminal-drayage:fleet:edit', 'platform:firstleg:terminal-drayage:pickup-appointment:edit', - 'platform:firstleg:terminal-drayage:pickup:confirm', - 'platform:firstleg:terminal-drayage:empty-return:confirm')) <> 9 THEN + 'platform:firstleg:terminal-drayage:empty-return:confirm')) <> 8 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='货柜双工作台权限初始化失败:权限目录不完整'; END IF; @@ -122,8 +116,7 @@ BEGIN 'platform:firstleg:terminal-drayage:document:edit', 'platform:firstleg:terminal-drayage:fleet:edit', 'platform:firstleg:terminal-drayage:pickup-appointment:edit', - 'platform:firstleg:terminal-drayage:pickup:confirm', - 'platform:firstleg:terminal-drayage:empty-return:confirm')) <> 18 THEN + 'platform:firstleg:terminal-drayage:empty-return:confirm')) <> 16 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='货柜双工作台权限初始化失败:平台默认角色授权不完整'; END IF; diff --git a/iam/src/main/resources/openapi/iam-v1.yaml b/iam/src/main/resources/openapi/iam-v1.yaml index dd8fce05a..33640c020 100644 --- a/iam/src/main/resources/openapi/iam-v1.yaml +++ b/iam/src/main/resources/openapi/iam-v1.yaml @@ -124,7 +124,7 @@ paths: get: tags: [IAM Resources / IAM 资源] summary: 查询当前用户有效权限 - description: 返回当前有效平台成员权限编码;只要求有效 PLATFORM JWT,不依赖 `platform:role:list`。入库单扣货与解除扣货返回独立写权限 `platform:firstleg:inbound:hold`,不会由 `platform:firstleg:inbound:list` 查看权限自动获得。资金账户充值审核与手工调账分别返回 `platform:finance:recharge:review`、`platform:finance:customer-account:adjust`,并与 `platform:finance:customer-account:list` 只读权限分离。海外仓返回 `platform:overseas-warehouse:dispatch:list`、`platform:overseas-warehouse:dispatch:edit`、`platform:overseas-warehouse:dispatch:cancel`、`platform:overseas-warehouse:dispatch:transfer-delivery`、`platform:overseas-warehouse:outbound:list`、`platform:overseas-warehouse:outbound:create`、`platform:overseas-warehouse:outbound:cancel`、`platform:overseas-warehouse:delivery:list`、`platform:overseas-warehouse:delivery:return`、`platform:overseas-warehouse:delivery:status`、`platform:overseas-warehouse:delivery:documents`、`platform:overseas-warehouse:exception-order:list`、`platform:overseas-warehouse:exception-order:create`、`platform:overseas-warehouse:exception-order:process`、`platform:overseas-warehouse:customer-visit:list`、`platform:overseas-warehouse:customer-visit:edit`;海外 PDA 盘点、移库、换标和现场查询统一返回 `lastleg:pda:operate`,且仅限 `OVERSEAS_WAREHOUSE_PDA_OPERATOR`。货柜双工作台以 `platform:firstleg:container:list/create/edit` 维护国内送柜报关,国外清关提柜独立返回 `platform:firstleg:terminal-drayage:list`、`platform:firstleg:terminal-drayage:document:edit`、`platform:firstleg:terminal-drayage:fleet:edit`、`platform:firstleg:terminal-drayage:pickup-appointment:edit`、`platform:firstleg:terminal-drayage:pickup:confirm`、`platform:firstleg:terminal-drayage:empty-return:confirm`,不以 `container:list/edit` 兼容放行国外操作。派送三类资料管理使用独立写权限 `platform:overseas-warehouse:delivery:documents`;读取和下载仍使用 `platform:overseas-warehouse:delivery:list`。列表权限包含详情和只读辅助接口,写操作独立授权。 + description: 返回当前有效平台成员权限编码;只要求有效 PLATFORM JWT,不依赖 `platform:role:list`。入库单扣货与解除扣货返回独立写权限 `platform:firstleg:inbound:hold`,不会由 `platform:firstleg:inbound:list` 查看权限自动获得。资金账户充值审核与手工调账分别返回 `platform:finance:recharge:review`、`platform:finance:customer-account:adjust`,并与 `platform:finance:customer-account:list` 只读权限分离。海外仓返回 `platform:overseas-warehouse:dispatch:list`、`platform:overseas-warehouse:dispatch:edit`、`platform:overseas-warehouse:dispatch:cancel`、`platform:overseas-warehouse:dispatch:transfer-delivery`、`platform:overseas-warehouse:outbound:list`、`platform:overseas-warehouse:outbound:create`、`platform:overseas-warehouse:outbound:cancel`、`platform:overseas-warehouse:delivery:list`、`platform:overseas-warehouse:delivery:return`、`platform:overseas-warehouse:delivery:status`、`platform:overseas-warehouse:delivery:documents`、`platform:overseas-warehouse:exception-order:list`、`platform:overseas-warehouse:exception-order:create`、`platform:overseas-warehouse:exception-order:process`、`platform:overseas-warehouse:customer-visit:list`、`platform:overseas-warehouse:customer-visit:edit`;海外 PDA 盘点、移库、换标和现场查询统一返回 `lastleg:pda:operate`,且仅限 `OVERSEAS_WAREHOUSE_PDA_OPERATOR`。货柜双工作台以 `platform:firstleg:container:list/create/edit` 维护国内送柜报关,国外清关提柜独立返回 `platform:firstleg:terminal-drayage:list`、`platform:firstleg:terminal-drayage:document:edit`、`platform:firstleg:terminal-drayage:fleet:edit`、`platform:firstleg:terminal-drayage:pickup-appointment:edit`、`platform:firstleg:terminal-drayage:empty-return:confirm`,不以 `container:list/edit` 兼容放行国外操作。派送三类资料管理使用独立写权限 `platform:overseas-warehouse:delivery:documents`;读取和下载仍使用 `platform:overseas-warehouse:delivery:list`。列表权限包含详情和只读辅助接口,写操作独立授权。 responses: '200': description: 当前平台成员权限查询成功 diff --git a/iam/src/test/java/com/baiyue/iam/controller/IamOpenApiContractTest.java b/iam/src/test/java/com/baiyue/iam/controller/IamOpenApiContractTest.java index 9223ad914..99e75072f 100644 --- a/iam/src/test/java/com/baiyue/iam/controller/IamOpenApiContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/controller/IamOpenApiContractTest.java @@ -42,7 +42,6 @@ class IamOpenApiContractTest { .contains("platform:firstleg:terminal-drayage:document:edit") .contains("platform:firstleg:terminal-drayage:fleet:edit") .contains("platform:firstleg:terminal-drayage:pickup-appointment:edit") - .contains("platform:firstleg:terminal-drayage:pickup:confirm") .contains("platform:firstleg:terminal-drayage:empty-return:confirm") .contains("不以 `container:list/edit` 兼容放行国外操作") .contains("merchant:warehouse:delivery:list") diff --git a/iam/src/test/java/com/baiyue/iam/repository/ContainerWorkspacePermissionSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/ContainerWorkspacePermissionSqlContractTest.java index 64b215294..70a0f887e 100644 --- a/iam/src/test/java/com/baiyue/iam/repository/ContainerWorkspacePermissionSqlContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/repository/ContainerWorkspacePermissionSqlContractTest.java @@ -22,7 +22,6 @@ class ContainerWorkspacePermissionSqlContractTest { "platform:firstleg:terminal-drayage:document:edit", "platform:firstleg:terminal-drayage:fleet:edit", "platform:firstleg:terminal-drayage:pickup-appointment:edit", - "platform:firstleg:terminal-drayage:pickup:confirm", "platform:firstleg:terminal-drayage:empty-return:confirm"); @Test @@ -50,7 +49,6 @@ class ContainerWorkspacePermissionSqlContractTest { "'platform:firstleg:terminal-drayage:document:edit',\n '维护目的港清关与DO/PU',NULL,'POST'", "'platform:firstleg:terminal-drayage:fleet:edit',\n '维护目的港码头车队',NULL,'POST'", "'platform:firstleg:terminal-drayage:pickup-appointment:edit',\n '预约目的港提柜',NULL,'POST'", - "'platform:firstleg:terminal-drayage:pickup:confirm',\n '确认目的港实际提柜',NULL,'POST'", "'platform:firstleg:terminal-drayage:empty-return:confirm',\n '确认目的港实际还柜',NULL,'POST'"); } @@ -75,8 +73,8 @@ class ContainerWorkspacePermissionSqlContractTest { "MERCHANT_OWNER"); assertThat(sql).contains( "permission_code IN (", - ")) <> 9 THEN", - ")) <> 18 THEN"); + ")) <> 8 THEN", + ")) <> 16 THEN"); } @Test diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index 3ddb7e31a..d86b7bf4b 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,22 +42,24 @@ | 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,国内按来源入仓单绑定更新 | +| 派送完成回传 | `delivery/service/DeliveryCompletedOutboxService.java`;签收资料保存并自动完成时在业务事务内按关联 `sourceInboundId` 分别写 `OVERSEAS_DELIVERY_COMPLETED` Outbox,同车不同 FBA 派送单互不代完成;不保留人工完成入口 | | 入仓后 SKU 库存余额、库位和流水 | `inventory/`;平台只读查询与非出库库存命令分别从 `inventory/query/controller/InventoryQueryController.java`、`inventory/command/controller/InventoryCommandController.java` 进入 | | 海外PDA盘点、同仓整托移库、托盘组拆换绑与换标现场作业 | `pda/stocktake/`、`pda/move/`、`palletops/`、`relabel/pda/`;版本化契约 `src/main/resources/openapi/overseas-pda-operations-v1.yaml`;全部范围取自PDA Token,排车和出库使用独立 PDA Controller | | 贴标申请、箱唛/SKU/组合换标与 PDA 完工 | `relabel/`、`relabel/pda/`;一条 `ll_relabel_item` 对应一个客户填写的我方箱唛任务,`CARTON_LABEL`、`SKU_LABEL`、`CARTON_AND_SKU` 分别保存箱唛、SKU 或组合需求,SKU 子项 JSON 数量按件;普通尾程入库没有箱唛到实物托盘的精确快照,历史入仓库存仓位只能作为冻结记账锚点和现场大概位置,禁止把该仓位解释为箱唛已绑定具体托盘。库存只按箱任务冻结一次。涉及箱唛换标时申请必须选择 `TRUCK/EXPRESS/TRUCK_PICKUP/EXPRESS_PICKUP`,两种快递方式可要求打印并上传面单;PDA 扫我方箱唛完成逐箱事实和照片,仓库复核后等待客户查看照片确认。对外换标状态只投影为 `PENDING_PROCESSING/COMPLETED`(待处理/换标完成),`PROCESSING`、`CUSTOMER_CONFIRM_PENDING`、`READY_FOR_OUTBOUND` 只属于内部作业节点且不得直接出现在业务列表;客户确认后仅卡车派送进入 `WAIT_PRE_DISPATCH`,快递派送和两种自提直接创建换标出库单。客户确认前不得进入出库 | | PDA 仓内通用扫码与操作历史 | `pda/warehouse/controller/PdaWarehouseController.java`、`pda/warehouse/service/PdaWarehouseService.java`;解析库存、库位和托盘码并查询操作历史,固定要求 `lastleg:pda:operate` 与 `ROLE_WAREHOUSE_PICKER`,仓库范围只能取自 PDA Token | | 船节点接收与海外 PDA 可收货门禁 | `shipping/service/ContainerShippingNodeConsumer.java`、`shipping/entity/ContainerShippingGate.java`、`shipping/mapper/ContainerShippingGateMapper.java`;按 `containerBookingId` 柜级接收,信封使用 `tenantId=0` 且门禁不存客户;只单调接受实际标准节点,`PICKUP_READY` 及以后才允许对应柜进入 PDA 收货候选 | -| 平台排车列表/详情与智能预排 | `dispatch/controller/DispatchQueryController.java`、`SmartDispatchController.java`、`dispatch/mapper/DispatchCandidateMapper.java`、`dispatch/service/DispatchService.java`;草稿柜 `entity/DispatchDraftContainer.java`、`mapper/DispatchDraftContainerMapper.java`,托盘锁 `DispatchPalletLock.java`;入口 `/api/platform/v1/last-leg/dispatches`,查询、预排与执行边界见“海外仓全流程开发路由” | +| 平台排车列表/详情与智能预排 | `dispatch/controller/DispatchQueryController.java`、`SmartDispatchController.java`、`dispatch/mapper/DispatchCandidateMapper.java`、`dispatch/service/DispatchService.java`;草稿柜 `entity/DispatchDraftContainer.java`、`mapper/DispatchDraftContainerMapper.java`,托盘锁 `DispatchPalletLock.java`;入口 `/api/platform/v1/last-leg/dispatches`,路线由当前已排托盘的 FBA 仓编码去重后按稳定顺序以英文逗号连接,预排与普通编辑请求均不接收路线;查询、预排与执行边界见“海外仓全流程开发路由” | | 排车编辑、PDA 出库、派送与交接 | `dispatch/controller/DispatchController.java`、`dispatch/pda/controller/DispatchPdaController.java`、`delivery/controller/DeliveryTransferController.java`、`MerchantDeliveryController.java`、`OutboundPdaController.java` | | 仓储资源调用 | `inboundplan/pda/gateway/WarehouseResourceGateway.java`、`HttpWarehouseResourceGateway.java` | | 仓储转运、历史订单/入仓绑定、可选换标联动 | `transfer/controller/WarehouseTransferController.java`、`transfer/service/WarehouseTransferService.java` | | 一件代发接收、SKU/箱唛库存 | 内部接收入口 `dropship/controller/DropshipOrderController.java` 的 `/internal/v1/lastleg/dropship/receive`,必须经 `InternalServiceTokenGuard`;版本化契约 `src/main/resources/openapi/lastleg-internal-dropship-v1.yaml`;处理 `dropship/service/DropshipOrderService.java`、`dropship/entity/`、`dropship/mapper/` | | 货物废弃、待审锁箱、审批扣减 | `discard/controller/CargoDiscardController.java`、`discard/service/CargoDiscardService.java` | -| 客户拜访、派送状态与签收附件 | `customervisit/controller/CustomerVisitController.java`、`customervisit/service/CustomerVisitService.java`、`delivery/model/DeliveryStatusPolicy.java`;派送单返回服务端 `allowedActions/version`,状态只允许单调迁移,同状态幂等,完成后生成回访记录 | -| 派送三类资料、摘要与历史证据 | `delivery/controller/DeliveryDocumentController.java`、`delivery/service/DeliveryDocumentService.java`、`delivery/gateway/DeliveryDocumentFileGateway.java`、`src/main/resources/openapi/delivery-documents-v1.yaml`;固定装车照片 `LOADING_PHOTO`、BOL文件(含签名)`BOL`、签收文件(POD签收/快递签收)`RECEIPT`,按已保存类别计数 `x/3`。每类0至20文件,文件精确绑定客户、派送单和类别;全量保存共用派送版本并记录不可变历史,替换删除仅解绑,未保存上传不计数、不授权下载,不增加派送完成门禁或签名识别 | +| 客户拜访、派送状态与签收附件 | `customervisit/controller/CustomerVisitController.java`、`customervisit/service/CustomerVisitService.java`、`delivery/model/DeliveryStatusPolicy.java`;派送单返回服务端 `allowedActions/version`,状态只允许单调迁移,同状态幂等;人工动作不再提供完成派送,签收资料保存后自动完成当前目的FBA仓派送单 | +| 派送三类资料、摘要与历史证据 | `delivery/controller/DeliveryDocumentController.java`、`delivery/service/DeliveryDocumentService.java`、`delivery/gateway/DeliveryDocumentFileGateway.java`、`src/main/resources/openapi/delivery-documents-v1.yaml`;固定装车照片 `LOADING_PHOTO`、BOL文件(含签名)`BOL`、签收文件(POD签收/快递签收)`RECEIPT`,按已保存类别计数 `x/3`。每类0至20文件,文件精确绑定客户、按目的FBA仓拆分的派送单和类别;全量保存共用派送版本并记录不可变历史,替换删除仅解绑,未保存上传不计数、不授权下载。`RECEIPT` 成功保存进权威快照时事务内自动完成当前派送单,单纯上传不触发完成 | | 异常订单与派送退回入仓 | `exceptionorder/controller/ExceptionOrderController.java`、`exceptionorder/service/ExceptionOrderService.java`、`exceptionorder/gateway/ExceptionEvidenceFileGateway.java`、`inventory/command/service/InventoryCommandService.java`;证据固定按客户、异常单 ID 和 `OVERSEAS_EXCEPTION_EVIDENCE` 校验。退回候选按原出库拣货事实冻结,不接受自由 SKU 或数量;异常按 `PENDING→PROCESSING→COMPLETED`(待处理可取消)、版本与幂等键流转。退回海外仓须把实收按原出库来源恢复到服务端校验的真实托盘/库位并写 `RETURN_STOCK_IN` 流水,库存恢复、异常完成与派送 `RETURNED` 在同一事务完成 | | 海外采购、客户下单、采购审核、客户/采购仓候选与操作流水 | `procurement/controller/`、`procurement/service/OverseasProcurementService.java`;客户入口要求 `merchant:cross-border:procurement` 且只取 Token 客户范围;OpenAPI `src/main/resources/openapi/overseas-business-v1.yaml` | | 海外业务资金指令与结果 | `wallet/WalletCommandOutboxService.java`、`WalletCommandCompletedConsumer.java`、`WalletResultHandler.java`;采购和换标通过 `WALLET_COMMAND_REQUESTED` 请求国内 Finance 冻结、结算或释放,并以 `WALLET_COMMAND_COMPLETED` 幂等回写业务资金状态;不得跨区同步调用 Finance 钱包或在 Lastleg 自建余额账本 | @@ -75,7 +77,7 @@ - 海外仓全流程连续覆盖尾程订单、入仓计划/PDA 入仓、换标、产品与箱唛快照、库存、转运、废弃、采购、本土卡车、本土散板、排车查询/智能预排、排车编辑/取消/发车、PDA 拣货与出库、一件代发、转派送、客户派送单、异常退回和客户回访。 - 服务端正式入口包括 `dispatch/controller/DispatchController.java`、`dispatch/pda/controller/DispatchPdaController.java`、`delivery/controller/DeliveryTransferController.java`、`delivery/controller/OutboundPdaController.java`、`delivery/controller/MerchantDeliveryController.java`、`exceptionorder/` 与 `customervisit/`;设备页面是否可用以海外 PDA 正式路由和能力开关为准,未由 PDA 承载的操作由 Admin/Client 完成。 - 平台一件代发出库候选由 `/outbound-orders/one-click-dropship/candidates` 统一返回已完成入库且仍有可用箱唛、SKU 汇总和库存仓位绑定的托盘明细;查询不强制先传 `customerId`,未传时跨客户展示供页面分页筛选,创建时必须从所选候选解析为唯一客户和唯一海外仓,禁止一张出库单跨客户或跨仓。 -- 排车转派送允许在 `PENDING_DEPARTURE`、`PICKING`、`READY_TO_DEPART` 阶段幂等生成 `WAIT_OUTBOUND` 出库单与派送单,但必须早于发车;PDA 排车详情由 `DispatchPdaWorkflowService` 返回 `canDepart`、`departBlockReason`,只有车辆信息完整、拣货任务完成且箱数核平、排车为 `READY_TO_DEPART`、关联出库单仍为 `WAIT_OUTBOUND` 时才允许发车,PDA 不得自行推导该门禁。 +- 排车转派送允许在 `PENDING_DEPARTURE`、`PICKING`、`READY_TO_DEPART` 阶段幂等生成一张 `WAIT_OUTBOUND` 出库单,并按目的FBA仓生成多张派送单,但必须早于发车;BOL和签收资料均绑定拆分后的单仓派送单,派送费用仍只发布一次。PDA 排车详情由 `DispatchPdaWorkflowService` 返回 `canDepart`、`departBlockReason`,只有车辆信息完整、拣货任务完成且箱数核平、排车为 `READY_TO_DEPART`、关联出库单仍为 `WAIT_OUTBOUND` 时才允许发车,PDA 不得自行推导该门禁。 - 排车执行与 `delivery/` Controller 和其他尾程业务接口一同注册,不受功能开关控制。平台排车列表与详情仍由 `lastleg.dispatch-query-api.enabled=true` 独立启用;入库单智能排车候选、草稿锁定/解锁/释放和预排创建仍由 `lastleg.smart-dispatch-api.enabled=true` 独立启用。这些排车平台入口均不接收 `customerId`:排车列表跨客户分页,详情按全局排车单号定位后使用记录自身客户范围加载明细;智能排车候选未传计划 ID 时跨客户查询,传入时根据计划 ID 解析范围;托盘移入草稿时立即以操作人和随机草稿标识锁定,移除或关闭时释放,草稿锁超时失效;预排创建根据所选托盘 ID 在服务端解析并校验唯一客户范围,并将当前操作人的草稿锁原子转换为正式排车锁。 - 仓库 ID、类型、状态、地址和能力唯一权威归国内 `warehouse-service`;海外库位、实物托盘和资源锁归 `warehouse-overseas-service/` 且只引用同一国内 `warehouseId`。Lastleg PDA 通过 Warehouse Overseas 内部资源入口校验该国内共享收货仓,不得要求海外数据库重复建仓;浏览器 API 统一经 `gateway-overseas/`。 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/customervisit/controller/CustomerVisitController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/controller/CustomerVisitController.java index 1707f2fef..ba27ae938 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/controller/CustomerVisitController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/controller/CustomerVisitController.java @@ -64,14 +64,6 @@ public class CustomerVisitController { return ApiResponse.success(service.upload(requiredScope(user, customerId), user.userId(), visitId, file)); } - @PostMapping("/delivery-orders/{deliveryNo}/complete") - @PreAuthorize("hasAuthority('" + OverseasPermissions.DELIVERY_STATUS + "')") - public ApiResponse completeDelivery( - @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, - @PathVariable String deliveryNo, @Valid @RequestBody CustomerVisitRequests.DeliveryStatus request) { - return ApiResponse.success(service.completeDelivery(requiredScope(user, customerId), operator(user), deliveryNo, request)); - } - @PutMapping("/delivery-orders/{deliveryNo}/status") @PreAuthorize("hasAuthority('" + OverseasPermissions.DELIVERY_STATUS + "')") public ApiResponse updateDeliveryStatus( diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/service/CustomerVisitService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/service/CustomerVisitService.java index bc1d68090..801207e6c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/service/CustomerVisitService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/service/CustomerVisitService.java @@ -76,39 +76,6 @@ public class CustomerVisitService { return detail(visit); } - @Transactional - public CustomerVisitResponses.Completed completeDelivery(String customerId, String operatorId, String deliveryNo, - CustomerVisitRequests.DeliveryStatus request) { - if (!"DELIVERED".equals(trim(request.status()).toUpperCase(Locale.ROOT))) { - throw badRequest("派送完成接口只接受 DELIVERED 状态"); - } - DeliveryOrder delivery = deliveryOrders.lockByNo(customerId, deliveryNo); - if (delivery == null) throw notFound("派送单不存在"); - boolean replayed = "DELIVERED".equals(delivery.getStatus()); - if (!replayed) { - requireVersion(delivery, request.expectedVersion()); - requireDeliveryTransition(delivery.getStatus(), "DELIVERED"); - } - LocalDateTime now = replayed && delivery.getSignedAt() != null ? delivery.getSignedAt() : LocalDateTime.now(); - if (!replayed) { - delivery.setStatus("DELIVERED"); - delivery.setSignedAt(now); - delivery.setUpdatedAt(now); - delivery.setVersion(version(delivery) + 1); - deliveryOrders.updateById(delivery); - for (LastlegOrder order : lastMileOrders.selectList(new LambdaQueryWrapper() - .eq(LastlegOrder::getCustomerId, customerId) - .eq(LastlegOrder::getDeliveryOrderNo, deliveryNo))) { - order.setSubOrderStatus("DELIVERY:DELIVERED"); - order.setUpdatedAt(now); - lastMileOrders.updateById(order); - } - recordOperation(delivery, operatorId, "COMPLETE_DELIVERY", "派送完成并生成客户回访记录", now); - } - CustomerVisit visit = ensureVisit(delivery, now); - return new CustomerVisitResponses.Completed(visit.getId(), deliveryNo, delivery.getStatus(), visit.getStatus(), replayed); - } - @Transactional public CustomerVisitResponses.Completed updateDeliveryStatus(String customerId, String operatorId, String deliveryNo, @@ -117,7 +84,6 @@ public class CustomerVisitService { if (!DeliveryStatusPolicy.isEditableTarget(target)) { throw badRequest("不支持的派送状态"); } - if ("DELIVERED".equals(target)) return completeDelivery(customerId, operatorId, deliveryNo, request); DeliveryOrder delivery = deliveryOrders.lockByNo(customerId, deliveryNo); if (delivery == null) throw notFound("派送单不存在"); if (target.equals(delivery.getStatus())) { @@ -192,6 +158,10 @@ public class CustomerVisitService { uploaded.contentType(), uploaded.sizeBytes()); } + public void ensureForCompletedDelivery(DeliveryOrder delivery, LocalDateTime completedAt) { + ensureVisit(delivery, completedAt); + } + private CustomerVisit ensureVisit(DeliveryOrder delivery, LocalDateTime completedAt) { CustomerVisit existing = visits.findByDelivery(delivery.getCustomerId(), delivery.getId()); if (existing != null) return existing; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java index 3934c7380..e8ab8e2ff 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java @@ -25,7 +25,7 @@ public class DeliveryTransferController { @PostMapping("/dispatches/{dispatchNo}/transfer-delivery") @PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_TRANSFER_DELIVERY + "')") - @Operation(summary = "Create one pending outbound order and one pending delivery order") + @Operation(summary = "Create one pending outbound order and one delivery order per destination FBA warehouse") public ApiResponse transfer( @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String dispatchNo, @Valid @RequestBody DeliveryRequests.Transfer request) { @@ -46,7 +46,7 @@ public class DeliveryTransferController { @PreAuthorize("hasAuthority('" + OverseasPermissions.OUTBOUND_CREATE + "')") @Operation(summary = "List server-authoritative one-click dropship outbound candidates") public ApiResponse> oneClickCandidates( - @AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId, + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @RequestParam(required = false) String warehouseId) { requirePlatform(user); return ApiResponse.success(service.oneClickCandidates(customerId, warehouseId)); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryRequests.java index 6aac098af..c61e717d4 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryRequests.java @@ -7,7 +7,6 @@ import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; import jakarta.validation.Valid; import java.util.List; -import java.util.Map; import java.util.Collections; import java.math.BigDecimal; import java.time.LocalDateTime; @@ -24,26 +23,14 @@ public final class DeliveryRequests { @NotBlank @Size(max = 64) String pickupMethod, @NotBlank @Size(max = 64) String loadingType, @Size(max = 64) String vehicleType, - @Size(max = 64) String vehicleSize, + @jakarta.validation.constraints.Pattern(regexp = "15托|30托") String vehicleSize, @Size(max = 128) String driverName, @Size(max = 64) String driverPhone, @Size(max = 64) String vehiclePlate, @Size(max = 128) String appointmentAccount, @Size(max = 128) String sellerName, @Size(max = 1000) String remark, - @NotNull @Valid @Size(min = 1, max = 20) List fees, - Map> palletCartonMarks) { - public Transfer(String idempotencyKey, LocalDateTime plannedDeliveryAt, String deliveryMethod, String waybillNo, - String carrierName, String pickupMethod, String loadingType, String vehicleType, String vehicleSize, - String driverName, String driverPhone, String vehiclePlate, String appointmentAccount, - String sellerName, String remark, List fees) { - this(idempotencyKey, plannedDeliveryAt, deliveryMethod, waybillNo, carrierName, pickupMethod, loadingType, - vehicleType, vehicleSize, driverName, driverPhone, vehiclePlate, appointmentAccount, sellerName, remark, fees, Map.of()); - } - } - - public record PalletCartonSelection(@NotBlank @Size(max = 128) String palletNo, - @NotNull @Size(min = 1, max = 500) List<@NotBlank @Size(max = 128) String> cartonMarks) {} + @NotNull @Valid @Size(min = 1, max = 20) List fees) {} public record FeeItem(@NotBlank @Size(max = 64) String feeType, @NotBlank @Size(max = 128) String feeName, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java index 654827545..df4ae6169 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java @@ -7,9 +7,13 @@ import java.util.List; public final class DeliveryResponses { private DeliveryResponses() {} + public record TransferDelivery(String deliveryNo, String deliveryStatus, + String destinationWarehouseId, String destinationWarehouseCode, + String destinationWarehouseName, int palletCount, int packageCount) {} + public record TransferResult(String dispatchNo, String outboundNo, String outboundStatus, - String deliveryNo, String deliveryStatus, int palletCount, - int packageCount, boolean replayed) {} + int palletCount, int packageCount, + List deliveries, boolean replayed) {} public record OutboundRow(Long id, String outboundNo, String dispatchNo, String overseasWarehouseId, String lxCodes, String status, int palletCount, int packageCount, LocalDateTime createdAt, String outboundType) {} @@ -25,7 +29,9 @@ public final class DeliveryResponses { LocalDateTime plannedDeliveryAt, LocalDateTime dispatchedAt, LocalDateTime expectedArrivalAt, LocalDateTime signedAt, String remark, LocalDateTime createdAt, long version, List allowedActions, - String bolNo, DeliveryDocumentDtos.Summary documentSummary) {} + String bolNo, DeliveryDocumentDtos.Summary documentSummary, + String destinationWarehouseId, String destinationWarehouseCode, + String destinationWarehouseName) {} public record DeliverySku(String inboundCode, String customerName, String destinationWarehouseCode, String receivingAddress, String shipmentNo, String fbaTrackingNo, @@ -45,7 +51,7 @@ public final class DeliveryResponses { public record OutboundPalletRow(Long id, String palletNo, String cartonMarks, String inboundCode, String destinationWarehouseCode, String destinationWarehouseName, String skuCode, int packageCount, int pickedPackageCount, String status) {} - public record OutboundDetail(OutboundRow outbound, DeliveryRow delivery, List pallets) {} + public record OutboundDetail(OutboundRow outbound, List deliveries, List pallets) {} public record OneClickCandidate(Long inventoryPositionId, String orderNo, String customerId, String customerName, String overseasWarehouseId, String overseasWarehouseName, String palletNo, String inboundCode, String skuCode, String productName, long availablePackages) {} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/DeliveryOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/DeliveryOrder.java index 5bf10042a..54ff1a699 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/DeliveryOrder.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/DeliveryOrder.java @@ -23,6 +23,10 @@ public class DeliveryOrder { private String overseasWarehouseId; private String overseasWarehouseName; private String customerName; + private String destinationWarehouseId; + private String destinationWarehouseCode; + private String destinationWarehouseName; + private String deliveryGroupKey; private String status; private String waybillNo; private String bolNo; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/OutboundOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/OutboundOrder.java index aa3b792a6..0c088baaf 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/OutboundOrder.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/OutboundOrder.java @@ -25,6 +25,7 @@ public class OutboundOrder { private Integer packageCount; private Integer palletCount; private String idempotencyKey; + private String requestHash; private String createdBy; private LocalDateTime createdAt; private LocalDateTime updatedAt; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliveryOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliveryOrderMapper.java index de1372e55..e3606c3af 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliveryOrderMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliveryOrderMapper.java @@ -5,6 +5,7 @@ import com.baiyue.lastleg.delivery.entity.DeliveryOrder; 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 DeliveryOrderMapper extends BaseMapper { @@ -14,6 +15,6 @@ public interface DeliveryOrderMapper extends BaseMapper { DeliveryOrder lockByNo(@Param("customerId") String customerId, @Param("deliveryNo") String deliveryNo); @Select("SELECT * FROM ll_delivery_order WHERE customer_id=#{customerId} AND idempotency_key=#{key} LIMIT 1") DeliveryOrder findByIdempotency(@Param("customerId") String customerId, @Param("key") String key); - @Select("SELECT * FROM ll_delivery_order WHERE customer_id=#{customerId} AND outbound_order_id=#{outboundOrderId} LIMIT 1 FOR UPDATE") - DeliveryOrder lockByOutbound(@Param("customerId") String customerId, @Param("outboundOrderId") Long outboundOrderId); + @Select("SELECT * FROM ll_delivery_order WHERE customer_id=#{customerId} AND outbound_order_id=#{outboundOrderId} ORDER BY id FOR UPDATE") + List lockByOutbound(@Param("customerId") String customerId, @Param("outboundOrderId") Long outboundOrderId); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/OutboundOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/OutboundOrderMapper.java index 3509fe1c2..a71e718ce 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/OutboundOrderMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/OutboundOrderMapper.java @@ -15,6 +15,8 @@ public interface OutboundOrderMapper extends BaseMapper { OutboundOrder findGlobalByNo(@Param("outboundNo") String outboundNo); @Select("SELECT * FROM ll_outbound_order WHERE customer_id=#{customerId} AND idempotency_key=#{key} LIMIT 1") OutboundOrder findByIdempotency(@Param("customerId") String customerId, @Param("key") String key); + @Select("SELECT * FROM ll_outbound_order WHERE customer_id=#{customerId} AND idempotency_key=#{key} LIMIT 1 FOR UPDATE") + OutboundOrder lockByIdempotency(@Param("customerId") String customerId, @Param("key") String key); @Select("SELECT * FROM ll_outbound_order WHERE customer_id=#{customerId} AND dispatch_order_id=#{dispatchOrderId} LIMIT 1") OutboundOrder findByDispatch(@Param("customerId") String customerId, @Param("dispatchOrderId") Long dispatchOrderId); @Select("SELECT * FROM ll_outbound_order WHERE customer_id=#{customerId} AND dispatch_order_id=#{dispatchOrderId} LIMIT 1 FOR UPDATE") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/model/DeliveryStatusPolicy.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/model/DeliveryStatusPolicy.java index 2ae76d3bb..1b6b01368 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/model/DeliveryStatusPolicy.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/model/DeliveryStatusPolicy.java @@ -6,7 +6,7 @@ import java.util.Set; public final class DeliveryStatusPolicy { private static final Set EDITABLE_TARGETS = Set.of( - "WAIT_DELIVERY", "DELIVERING", "DELIVERED", "REJECTED", "CANCELLED"); + "WAIT_DELIVERY", "DELIVERING", "REJECTED", "CANCELLED"); private static final Map> TRANSITIONS = Map.of( "WAIT_DELIVERY", Set.of("DELIVERING", "DELIVERED", "REJECTED", "CANCELLED"), "DELIVERING", Set.of("DELIVERED", "REJECTED"), @@ -29,8 +29,8 @@ public final class DeliveryStatusPolicy { public static List allowedActions(String status) { return switch (status == null ? "" : status) { case "WAIT_DELIVERY" -> List.of( - "START_DELIVERY", "COMPLETE_DELIVERY", "REJECT_DELIVERY", "CANCEL_DELIVERY"); - case "DELIVERING" -> List.of("COMPLETE_DELIVERY", "REJECT_DELIVERY", "MARK_RETURN"); + "START_DELIVERY", "REJECT_DELIVERY", "CANCEL_DELIVERY"); + case "DELIVERING" -> List.of("REJECT_DELIVERY", "MARK_RETURN"); case "DELIVERED", "REJECTED" -> List.of("MARK_RETURN"); default -> List.of(); }; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryCompletedOutboxService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryCompletedOutboxService.java new file mode 100644 index 000000000..26a7db8fd --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryCompletedOutboxService.java @@ -0,0 +1,95 @@ +package com.baiyue.lastleg.delivery.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.EventIdentifiers; +import com.baiyue.common.event.EventPayloadCanonicalizer; +import com.baiyue.common.event.EventPayloadHash; +import com.baiyue.common.event.EventTypes; +import com.baiyue.common.event.OverseasDeliveryCompletedV1; +import com.baiyue.common.event.RegionCode; +import com.baiyue.lastleg.delivery.entity.DeliveryOrder; +import com.baiyue.lastleg.entity.LastlegEventOutbox; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** 在派送完成事务内按来源入仓单冻结海外到国内的完成事件。 */ +@Service +public class DeliveryCompletedOutboxService { + @Resource private LastlegEventOutboxMapper outbox; + @Resource private ObjectMapper objectMapper; + + public void publish(DeliveryOrder delivery, List orders, Instant completedAt) { + Objects.requireNonNull(delivery, "delivery"); + Objects.requireNonNull(completedAt, "completedAt"); + Map bySourceInbound = new LinkedHashMap<>(); + for (LastlegOrder order : orders == null ? List.of() : orders) { + if (!StringUtils.hasText(order.getSourceInboundId())) continue; + requireText(order.getSourceOrderId(), "sourceOrderId"); + requireText(order.getInboundCode(), "inboundCode"); + requireText(order.getCustomerId(), "customerId"); + LastlegOrder existing = bySourceInbound.putIfAbsent(order.getSourceInboundId(), order); + if (existing != null && (!Objects.equals(existing.getSourceOrderId(), order.getSourceOrderId()) + || !Objects.equals(existing.getInboundCode(), order.getInboundCode()))) { + throw new IllegalStateException("同一来源入仓单对应的派送完成快照冲突"); + } + } + for (LastlegOrder order : bySourceInbound.values()) publishOne(delivery, order, completedAt); + } + + private void publishOne(DeliveryOrder delivery, LastlegOrder order, Instant completedAt) { + long aggregateVersion = Math.max(1L, delivery.getVersion() == null ? 1L : delivery.getVersion()); + String eventKey = EventTypes.OVERSEAS_DELIVERY_COMPLETED + ':' + delivery.getDeliveryNo() + + ':' + order.getSourceInboundId(); + var payload = new OverseasDeliveryCompletedV1(String.valueOf(delivery.getId()), delivery.getDeliveryNo(), + String.valueOf(order.getId()), order.getOrderNo(), order.getSourceInboundId(), order.getInboundCode(), + order.getSourceOrderId(), order.getSourceOrderNo(), order.getRootOrderId(), + order.getFbaWarehouseCode(), completedAt); + JsonNode node = objectMapper.valueToTree(payload); + EventEnvelope envelope = new EventEnvelope(EventIdentifiers.newUuid(), + EventTypes.OVERSEAS_DELIVERY_COMPLETED, 1, completedAt, RegionCode.OVERSEAS, RegionCode.CN, + order.getCustomerId(), "INBOUND_ORDER", order.getSourceInboundId(), aggregateVersion, 1L, + EventIdentifiers.newUuid(), eventKey, + EventIdentifiers.isSupported(order.getSourceEventId()) ? order.getSourceEventId() : null, + node, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node))).requireValid(); + LastlegEventOutbox row = new LastlegEventOutbox(); + row.setId(IdWorker.getId()); row.setCustomerId(order.getCustomerId()); + row.setAggregateType(envelope.aggregateType()); row.setAggregateId(envelope.aggregateId()); + row.setEventType(envelope.eventType()); row.setEventVersion(envelope.eventVersion()); + row.setEventKey(eventKey); row.setPayloadJson(json(envelope)); row.setStatus("PENDING"); + row.setRetryCount(0); row.setCreatedAt(LocalDateTime.ofInstant(completedAt, ZoneOffset.UTC)); + try { + outbox.insert(row); + } catch (DuplicateKeyException duplicate) { + LastlegEventOutbox existing = outbox.findByEventKey(eventKey); + if (existing == null || !Objects.equals(existing.getAggregateId(), order.getSourceInboundId()) + || !Objects.equals(existing.getCustomerId(), order.getCustomerId())) throw duplicate; + } + } + + private static void requireText(String value, String field) { + if (!StringUtils.hasText(value)) throw new IllegalStateException(field + " is required"); + } + + private String json(Object value) { + try { return objectMapper.writeValueAsString(value); } + catch (JsonProcessingException exception) { + throw new IllegalStateException("Cannot serialize delivery-completed event", exception); + } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentService.java index 5a5982605..9c31d7c67 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentService.java @@ -6,6 +6,10 @@ import com.baiyue.lastleg.delivery.entity.*; import com.baiyue.lastleg.delivery.gateway.DeliveryDocumentFileGateway; import com.baiyue.lastleg.delivery.mapper.*; import com.baiyue.lastleg.delivery.model.*; +import com.baiyue.lastleg.customervisit.service.CustomerVisitService; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.annotation.Resource; @@ -17,6 +21,7 @@ import org.springframework.web.server.ResponseStatusException; import java.nio.charset.StandardCharsets; import java.security.*; import java.time.LocalDateTime; +import java.time.ZoneOffset; import java.util.*; @Service @@ -26,6 +31,9 @@ public class DeliveryDocumentService { @Resource private DeliveryOperationMapper operations; @Resource private DeliveryDocumentFileGateway files; @Resource private ObjectMapper objectMapper; + @Resource private LastlegOrderMapper lastlegOrders; + @Resource private DeliveryCompletedOutboxService deliveryCompletedOutbox; + @Resource private CustomerVisitService customerVisits; public Detail detail(String customerId, String deliveryNo) { return detail(require(customerId, deliveryNo), false); } @@ -69,7 +77,21 @@ public class DeliveryDocumentService { order.setDocumentsJson(write(categories)); order.setVersion(version(order) + 1); order.setUpdatedAt(now); + boolean receiptSaved = !bindings.getOrDefault(DeliveryDocumentCategory.RECEIPT, List.of()).isEmpty(); + boolean autoCompleted = receiptSaved && !"DELIVERED".equals(order.getStatus()); + if (autoCompleted) { + if (!DeliveryStatusPolicy.canTransition(order.getStatus(), "DELIVERED")) { + throw conflict("当前派送状态不允许通过签收文件完成"); + } + order.setStatus("DELIVERED"); + order.setSignedAt(now); + } orders.updateById(order); + if (autoCompleted) { + List completedOrders = completeRelatedOrders(order, now); + deliveryCompletedOutbox.publish(order, completedOrders, now.toInstant(ZoneOffset.UTC)); + customerVisits.ensureForCompletedDelivery(order, now); + } Detail result = detail(order, false); DeliveryDocumentRevision revision = new DeliveryDocumentRevision(); revision.setCustomerId(customerId); revision.setDeliveryOrderId(order.getId()); revision.setVersion(order.getVersion()); @@ -79,6 +101,7 @@ public class DeliveryDocumentService { operation.setCustomerId(customerId); operation.setDeliveryOrderId(order.getId()); operation.setOperatorId(operatorId); operation.setOperationNode("SAVE_DOCUMENTS"); operation.setResult("保存派送资料:" + result.uploadedCategoryCount() + "/3类"); operation.setCreatedAt(now); operations.insert(operation); + if (autoCompleted) recordAutomaticCompletion(order, operatorId, now); return result; } public List history(String customerId, String deliveryNo) { @@ -110,6 +133,25 @@ public class DeliveryDocumentService { (int) categories.stream().filter(c -> !c.files().isEmpty()).count(), 3, categories, List.of("MANAGE_DOCUMENTS"), replayed); } private static long version(DeliveryOrder order) { return Objects.requireNonNullElse(order.getVersion(), 0L); } + private List completeRelatedOrders(DeliveryOrder delivery, LocalDateTime now) { + List completedOrders = lastlegOrders.selectList(new LambdaQueryWrapper() + .eq(LastlegOrder::getCustomerId, delivery.getCustomerId()) + .eq(LastlegOrder::getDeliveryOrderNo, delivery.getDeliveryNo())); + for (LastlegOrder order : completedOrders) { + order.setSubOrderStatus("DELIVERY:DELIVERED"); + order.setUpdatedAt(now); + lastlegOrders.updateById(order); + } + return completedOrders; + } + private void recordAutomaticCompletion(DeliveryOrder order, String operatorId, LocalDateTime now) { + DeliveryOperation operation = new DeliveryOperation(); + operation.setCustomerId(order.getCustomerId()); operation.setDeliveryOrderId(order.getId()); + operation.setOperatorId(operatorId); operation.setOperatorName(operatorId); + operation.setOperationNode("RECEIPT_AUTO_COMPLETE"); + operation.setResult("签收文件保存成功,派送单自动完成"); + operation.setCreatedAt(now); operations.insert(operation); + } private String hash(Object value) { try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(write(value).getBytes(StandardCharsets.UTF_8))); } catch (NoSuchAlgorithmException e) { throw new IllegalStateException(e); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java index 94c7f9ff1..f6bba2b50 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java @@ -73,6 +73,11 @@ import java.time.format.DateTimeFormatter; import com.baiyue.lastleg.service.LastlegOrderNoGenerator; import java.math.BigDecimal; import java.util.*; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.core.JsonProcessingException; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; @Service public class DeliveryTransferService { @@ -103,6 +108,7 @@ public class DeliveryTransferService { @Resource private RelabelItemMapper relabelItems; @Resource private RelabelApplicationService relabelService; @Resource private LastlegOrderNoGenerator orderNoGenerator; + @Resource private ObjectMapper objectMapper; @Transactional public DeliveryResponses.TransferResult platformTransfer(String dispatchNo, String operatorId, @@ -128,10 +134,10 @@ public class DeliveryTransferService { OutboundOrder replay = outboundOrders.findByIdempotency(customerId, request.idempotencyKey().trim()); if (replay != null) { if (!dispatch.getId().equals(replay.getDispatchOrderId())) throw conflict("Idempotency key belongs to another dispatch"); - return result(replay, requireDelivery(customerId, replay.getId()), true); + return result(replay, requireDeliveries(customerId, replay.getId()), true); } OutboundOrder existing = outboundOrders.lockByDispatch(customerId, dispatch.getId()); - if (existing != null) return result(existing, requireDelivery(customerId, existing.getId()), true); + if (existing != null) return result(existing, requireDeliveries(customerId, existing.getId()), true); if (Set.of("CANCELLED", "DEPARTED", "PARTIAL_DEPARTED", "COMPLETED").contains(dispatch.getStatus())) { throw conflict("Dispatch cannot be transferred to delivery in its current status"); } @@ -161,11 +167,11 @@ public class DeliveryTransferService { // the document created by the winning transaction rather than receive a false failure. OutboundOrder concurrent = outboundOrders.findByIdempotency(customerId, request.idempotencyKey().trim()); if (concurrent != null && dispatch.getId().equals(concurrent.getDispatchOrderId())) { - return result(concurrent, requireDelivery(customerId, concurrent.getId()), true); + return result(concurrent, requireDeliveries(customerId, concurrent.getId()), true); } concurrent = outboundOrders.lockByDispatch(customerId, dispatch.getId()); if (concurrent != null) { - return result(concurrent, requireDelivery(customerId, concurrent.getId()), true); + return result(concurrent, requireDeliveries(customerId, concurrent.getId()), true); } throw conflict("Dispatch was concurrently transferred to delivery"); } @@ -189,43 +195,61 @@ public class DeliveryTransferService { outboundPallets.insert(item); } - DeliveryOrder delivery = new DeliveryOrder(); - delivery.setId(IdWorker.getId()); delivery.setCustomerId(customerId); - delivery.setDeliveryNo(orderNoGenerator.next("DELIVERY", dispatch.getOverseasWarehouseId(), delivery.getId())); - delivery.setOutboundOrderId(outbound.getId()); delivery.setOutboundNo(outbound.getOutboundNo()); - delivery.setDispatchOrderId(dispatch.getId()); delivery.setDispatchNo(dispatch.getDispatchNo()); - delivery.setOverseasWarehouseId(dispatch.getOverseasWarehouseId()); delivery.setOverseasWarehouseName(dispatch.getOverseasWarehouseName()); + Map> destinationGroups = items.stream().collect(java.util.stream.Collectors.groupingBy( + this::deliveryGroupKey, LinkedHashMap::new, java.util.stream.Collectors.toList())); + List deliveries = new ArrayList<>(); + int groupIndex = 0; + for (Map.Entry> entry : destinationGroups.entrySet()) { + List groupItems = entry.getValue(); + DispatchItem destination = groupItems.get(0); + DeliveryOrder delivery = new DeliveryOrder(); + delivery.setId(IdWorker.getId()); delivery.setCustomerId(customerId); + delivery.setDeliveryNo(orderNoGenerator.next("DELIVERY", dispatch.getOverseasWarehouseId(), delivery.getId())); + delivery.setOutboundOrderId(outbound.getId()); delivery.setOutboundNo(outbound.getOutboundNo()); + delivery.setDispatchOrderId(dispatch.getId()); delivery.setDispatchNo(dispatch.getDispatchNo()); + delivery.setOverseasWarehouseId(dispatch.getOverseasWarehouseId()); delivery.setOverseasWarehouseName(dispatch.getOverseasWarehouseName()); + delivery.setDestinationWarehouseId(trim(destination.getDestinationWarehouseId())); + delivery.setDestinationWarehouseCode(firstText( + firstText(destination.getDestinationWarehouseCode(), destination.getFbaWarehouseCode()), + destination.getDestinationWarehouseId())); + delivery.setDestinationWarehouseName(trim(destination.getDestinationWarehouseName())); + delivery.setDeliveryGroupKey(entry.getKey()); + LastlegOrder firstOrder = firstTailOrder(customerId, groupItems); + delivery.setCustomerName(firstOrder == null ? null : firstOrder.getCustomerName()); delivery.setStatus("WAIT_DELIVERY"); delivery.setVersion(0L); + delivery.setWaybillNo(trim(request.waybillNo())); delivery.setDeliveryMethod(request.deliveryMethod().trim()); + delivery.setCarrierName(firstText(request.carrierName(), dispatch.getCarrierName())); delivery.setPickupMethod(trim(request.pickupMethod())); + delivery.setLoadingType(trim(request.loadingType())); delivery.setVehicleType(trim(request.vehicleType())); delivery.setVehicleSize(trim(request.vehicleSize())); + delivery.setVehiclePlate(firstText(request.vehiclePlate(), dispatch.getVehiclePlate())); delivery.setDriverName(firstText(request.driverName(), dispatch.getDriverName())); + delivery.setDriverPhone(firstText(request.driverPhone(), dispatch.getDriverPhone())); delivery.setAppointmentAccount(trim(request.appointmentAccount())); + delivery.setSellerName(trim(request.sellerName())); delivery.setPlannedDeliveryAt(request.plannedDeliveryAt()); + delivery.setPackageCount(groupItems.stream().map(DispatchItem::getPackageCount).filter(Objects::nonNull).mapToInt(Integer::intValue).sum()); + delivery.setPalletCount(groupItems.size()); + delivery.setTotalVolumeCbm(groupItems.stream().map(DispatchItem::getVolumeCbm).filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add)); + delivery.setTotalWeightKg(totalWeight(customerId, groupItems)); delivery.setExpectedArrivalAt(dispatch.getEstimatedArrivalAt()); delivery.setRemark(trim(request.remark())); + delivery.setIdempotencyKey(childKey(request.idempotencyKey(), "DEST-" + groupIndex++)); delivery.setCreatedBy(operatorId); + delivery.setCreatedAt(now); delivery.setUpdatedAt(now); deliveryOrders.insert(delivery); + recordOperation(customerId, delivery.getId(), operatorId, "CREATE_DELIVERY", "按目的FBA仓生成派送单", now); + bindTailOrders(customerId, groupItems, outbound.getOutboundNo(), delivery.getDeliveryNo(), request.remark(), now); + deliveries.add(delivery); + } + DeliveryOrder feeDelivery = deliveries.get(0); LastlegOrder firstOrder = firstTailOrder(customerId, items); - delivery.setCustomerName(firstOrder == null ? null : firstOrder.getCustomerName()); delivery.setStatus("WAIT_DELIVERY"); delivery.setVersion(0L); - delivery.setWaybillNo(trim(request.waybillNo())); delivery.setDeliveryMethod(request.deliveryMethod().trim()); - delivery.setCarrierName(firstText(request.carrierName(), dispatch.getCarrierName())); delivery.setPickupMethod(trim(request.pickupMethod())); - delivery.setLoadingType(trim(request.loadingType())); delivery.setVehicleType(trim(request.vehicleType())); delivery.setVehicleSize(trim(request.vehicleSize())); - delivery.setVehiclePlate(firstText(request.vehiclePlate(), dispatch.getVehiclePlate())); delivery.setDriverName(firstText(request.driverName(), dispatch.getDriverName())); - delivery.setDriverPhone(firstText(request.driverPhone(), dispatch.getDriverPhone())); delivery.setAppointmentAccount(trim(request.appointmentAccount())); - delivery.setSellerName(trim(request.sellerName())); delivery.setPlannedDeliveryAt(request.plannedDeliveryAt()); - delivery.setPackageCount(outbound.getPackageCount()); delivery.setPalletCount(outbound.getPalletCount()); - delivery.setTotalVolumeCbm(items.stream().map(DispatchItem::getVolumeCbm).filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add)); - delivery.setTotalWeightKg(totalWeight(customerId, items)); delivery.setExpectedArrivalAt(dispatch.getEstimatedArrivalAt()); delivery.setRemark(trim(request.remark())); - delivery.setIdempotencyKey(request.idempotencyKey().trim()); delivery.setCreatedBy(operatorId); - delivery.setCreatedAt(now); delivery.setUpdatedAt(now); deliveryOrders.insert(delivery); List feeInputs = request.fees() == null ? List.of() : request.fees(); for (DeliveryRequests.FeeItem input : feeInputs) { feeSources.publishApproved(operatorId, childKey(request.idempotencyKey(), input.feeType()), - new OverseasFeeSourceDtos.SaveRequest(customerId, delivery.getCustomerName(), - "OVERSEAS_DELIVERY_FEE", "DELIVERY_ORDER", delivery.getId(), delivery.getDeliveryNo(), + new OverseasFeeSourceDtos.SaveRequest(customerId, feeDelivery.getCustomerName(), + "OVERSEAS_DELIVERY_FEE", "DELIVERY_ORDER", feeDelivery.getId(), feeDelivery.getDeliveryNo(), firstOrder == null ? null : firstOrder.getId(), null, firstOrder == null ? null : firstOrder.getInboundCode(), null, firstOrder == null ? null : parseLong(firstOrder.getSourceContainerBookingId()), - firstOrder == null ? null : firstOrder.getContainerNo(), parseLong(delivery.getOverseasWarehouseId()), + firstOrder == null ? null : firstOrder.getContainerNo(), parseLong(feeDelivery.getOverseasWarehouseId()), input.feeType().trim(), input.feeName().trim(), input.direction(), input.supplierId(), input.supplierName(), input.supplierCategory(), input.settlementMode(), input.paidAt(), input.paymentMethod(), input.paymentReferenceNo(), input.paymentVoucherFileIds(), LocalDate.now(), input.currency(), input.amount(), input.chargeableQuantity(), input.quantityUnit(), input.unitPrice(), input.periodStart(), input.periodEnd())); } - recordOperation(customerId, delivery.getId(), operatorId, "CREATE_DELIVERY", "CREATED", now); - bindTailOrders(customerId, items, outbound.getOutboundNo(), delivery.getDeliveryNo(), request.remark(), now); - return result(outbound, delivery, false); + return result(outbound, deliveries, false); } private void validateTransferFees(DeliveryRequests.Transfer request) { @@ -254,17 +278,33 @@ public class DeliveryTransferService { @Transactional public DeliveryResponses.PdaTask createOneClick(String customerId, String operatorId, DeliveryRequests.OneClickOutbound request) { + String requestHash = oneClickRequestHash(request); OutboundOrder existing = outboundOrders.findByIdempotency(customerId, request.idempotencyKey().trim()); - if (existing != null) return taskResult(existing, pdaTasks.lockByOutbound(customerId, existing.getId())); + if (existing != null) return replayOneClick(existing, requestHash); LocalDateTime now = LocalDateTime.now(); - String selectedWarehouseId = validateOneClickItems(customerId, request.items()); + InventoryPosition firstPosition = inventoryPositions.selectById(request.items().get(0).inventoryPositionId()); + if (firstPosition == null || !customerId.equals(firstPosition.getCustomerId())) { + throw conflict("所选库存不是可用的一件代发库存,或已被其他作业占用"); + } + String selectedWarehouseId = required(firstPosition.getOverseasWarehouseId(), "库存缺少所属海外仓"); OutboundOrder outbound = new OutboundOrder(); outbound.setId(IdWorker.getId()); outbound.setCustomerId(customerId); outbound.setOutboundNo(orderNoGenerator.next("ONE_CLICK_DROPSHIP", selectedWarehouseId, outbound.getId())); outbound.setOutboundType("ONE_CLICK_DROPSHIP"); outbound.setStatus("WAIT_PICK"); outbound.setRecipientInfo(request.recipient().trim()); outbound.setPackageCount(request.items().stream().mapToInt(i -> i.packages().intValue()).sum()); outbound.setPalletCount(request.items().size()); outbound.setIdempotencyKey(request.idempotencyKey().trim()); outbound.setCreatedBy(operatorId); outbound.setCreatedAt(now); outbound.setUpdatedAt(now); - outboundOrders.insert(outbound); + outbound.setRequestHash(requestHash); + // Claim the unique command key before taking inventory locks. A racing insert + // waits for the first transaction; use a current read after it commits, not + // the REPEATABLE READ snapshot established by the initial lookup. + try { + outboundOrders.insert(outbound); + } catch (DuplicateKeyException duplicate) { + OutboundOrder concurrent = outboundOrders.lockByIdempotency(customerId, request.idempotencyKey().trim()); + if (concurrent == null) throw duplicate; + return replayOneClick(concurrent, requestHash); + } + selectedWarehouseId = validateOneClickItems(customerId, request.items()); Set physicalPalletIds = new HashSet<>(); for (DeliveryRequests.OneClickItem input : request.items()) { InventoryPosition position = inventoryPositions.selectById(input.inventoryPositionId()); @@ -286,6 +326,23 @@ public class DeliveryTransferService { return taskResult(outbound, task); } + private DeliveryResponses.PdaTask replayOneClick(OutboundOrder existing, String requestHash) { + if (!"ONE_CLICK_DROPSHIP".equals(existing.getOutboundType()) + || !requestHash.equals(existing.getRequestHash())) { + throw conflict("相同幂等键不能用于不同的一件代发出库请求"); + } + return taskResult(existing, pdaTasks.lockByOutbound(existing.getCustomerId(), existing.getId())); + } + + String oneClickRequestHash(DeliveryRequests.OneClickOutbound request) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(objectMapper.writeValueAsString(request).getBytes(StandardCharsets.UTF_8))); + } catch (JsonProcessingException | NoSuchAlgorithmException error) { + throw new IllegalStateException("无法生成一件代发出库请求摘要", error); + } + } + public List oneClickCandidates(String customerId, String warehouseId) { return dropshipProfiles.listOutboundCandidates(customerId, StringUtils.hasText(warehouseId) ? warehouseId.trim() : null).stream() @@ -491,8 +548,19 @@ public class DeliveryTransferService { } DropshipOrderProfile profile = dropshipProfiles.findPosition(customerId, request.inventoryPositionId()); if (profile == null) throw conflict("Scanned pallet is not one-click dropship inventory"); - List cartons = StringUtils.hasText(item.getCartonMarks()) - ? Arrays.stream(item.getCartonMarks().split(",")).map(String::trim).filter(StringUtils::hasText) + List submittedMarks = request.cartonMarks() == null ? List.of() : request.cartonMarks(); + List selectedMarks = submittedMarks.stream().map(String::trim).filter(StringUtils::hasText).distinct().sorted().toList(); + Set frozenMarks = new HashSet<>(Arrays.asList(Optional.ofNullable(item.getCartonMarks()).orElse("").split(","))); + if (!submittedMarks.isEmpty() && (selectedMarks.size() != submittedMarks.size() + || selectedMarks.size() != request.packages() + || StringUtils.hasText(item.getCartonMarks()) && !frozenMarks.containsAll(selectedMarks))) { + throw conflict("拣货箱唛必须唯一、数量一致且属于当前出库明细"); + } + if (StringUtils.hasText(item.getCartonMarks()) && selectedMarks.isEmpty()) { + throw conflict("请提交本次实际拣货的指定箱唛"); + } + List cartons = !selectedMarks.isEmpty() + ? selectedMarks.stream() .map(mark -> dropshipCartons.findByProfileAndMark(customerId, profile.getId(), mark)).filter(Objects::nonNull) .filter(carton -> "AVAILABLE".equals(carton.getStatus()) && item.getSkuCode().equals(carton.getSkuCode())).toList() : dropshipCartons.lockAvailable(customerId, profile.getId(), item.getSkuCode(), request.packages()); @@ -572,17 +640,19 @@ public class DeliveryTransferService { || !Set.of("WAIT_PICK", "WAIT_OUTBOUND").contains(i.getStatus()))) { throw conflict("Outbound order cannot be cancelled after execution has started"); } - DeliveryOrder existingDelivery = deliveryOrders.lockByOutbound(customerId, outbound.getId()); - if (existingDelivery != null && !"WAIT_DELIVERY".equals(existingDelivery.getStatus())) + List existingDeliveries = deliveryOrders.lockByOutbound(customerId, outbound.getId()); + if (existingDeliveries.stream().anyMatch(delivery -> !"WAIT_DELIVERY".equals(delivery.getStatus()))) throw conflict("Outbound order cannot be cancelled while delivery is in progress"); LocalDateTime now = LocalDateTime.now(); - DeliveryOrder delivery = existingDelivery; - if (delivery != null) { delivery.setStatus("CANCELLED"); delivery.setVersion(value(delivery.getVersion()) + 1); delivery.setUpdatedAt(now); deliveryOrders.updateById(delivery); } + for (DeliveryOrder delivery : existingDeliveries) { + delivery.setStatus("CANCELLED"); delivery.setVersion(value(delivery.getVersion()) + 1); + delivery.setUpdatedAt(now); deliveryOrders.updateById(delivery); + } OutboundPdaTask task = pdaTasks.lockByOutbound(customerId, outbound.getId()); if (task != null) { task.setStatus("CANCELLED"); task.setUpdatedAt(now); pdaTasks.updateById(task); } outbound.setStatus("CANCELLED"); outbound.setUpdatedAt(now); outboundOrders.updateById(outbound); for (OutboundPalletItem item : outboundItems) { item.setStatus("CANCELLED"); outboundPallets.updateById(item); } - if (delivery != null) { + if (!existingDeliveries.isEmpty()) { for (LastlegOrder order : tailOrders.selectList(new LambdaQueryWrapper().eq(LastlegOrder::getCustomerId, customerId).eq(LastlegOrder::getOutboundOrderNo, outboundNo))) { String from = order.getCurrentStatus(); order.setCurrentStatus("RECEIVED"); order.setSubOrderStatus(null); order.setOutboundOrderNo(null); order.setDeliveryOrderNo(null); order.setUpdatedAt(now); tailOrders.updateById(order); LastlegOrderStatusHistory history = new LastlegOrderStatusHistory(); history.setCustomerId(customerId); history.setOrderId(order.getId()); history.setFromStatus(from); history.setToStatus("RECEIVED"); history.setEventId("CANCEL_OUTBOUND:" + outboundNo); history.setRemark(request.reason().trim()); history.setCreatedAt(now); histories.insert(history); @@ -644,6 +714,8 @@ public class DeliveryTransferService { OutboundPalletItem item) { if (item.getWarehousePalletId() == null) return List.of(); List result = new ArrayList<>(); + Set frozenMarks = new HashSet<>(Arrays.asList( + Optional.ofNullable(item.getCartonMarks()).orElse("").split(","))); for (InventoryPosition position : inventoryPositions.listPallet(customerId, item.getWarehousePalletId())) { long available = value(position.getAvailablePackages()); if (available <= 0 || !warehouseId.equals(position.getOverseasWarehouseId())) continue; @@ -654,7 +726,9 @@ public class DeliveryTransferService { List marks = profile == null ? List.of() : dropshipCartons.listProfile(customerId, profile.getId()) .stream().filter(carton -> "AVAILABLE".equals(carton.getStatus())) .filter(carton -> Objects.equals(item.getSkuCode(), carton.getSkuCode())) + .filter(carton -> !StringUtils.hasText(item.getCartonMarks()) || frozenMarks.contains(carton.getCartonMark())) .map(DropshipCarton::getCartonMark).filter(StringUtils::hasText).distinct().limit(available).toList(); + if (profile != null && marks.isEmpty()) continue; if (!matchesOutboundItem(customerId, warehouseId, item, balance) && marks.isEmpty()) continue; if (marks.isEmpty()) { result.add(pdaPosition(position, item, balance, null, available, value(position.getPickedPackages()))); @@ -669,7 +743,7 @@ public class DeliveryTransferService { InventoryBalance balance, String cartonMark, long available, long picked) { return new DeliveryResponses.PdaPickPosition(position.getId(), position.getPalletNo(), cartonMark, - balance.getSkuCode(), balance.getInboundCode(), available, picked); + item.getSkuCode(), balance.getInboundCode(), available, picked); } private boolean matchesOutboundItem(String customerId, String warehouseId, OutboundPalletItem item, @@ -718,13 +792,13 @@ public class DeliveryTransferService { OutboundOrder outbound = outboundOrders.findGlobalByNo(trim(outboundNo)); if (outbound == null) throw notFound("Outbound order not found"); String customerId = outbound.getCustomerId(); - DeliveryOrder delivery = deliveryOrders.lockByOutbound(customerId, outbound.getId()); - if (delivery == null && "DISPATCH_DELIVERY".equals(outbound.getOutboundType())) { + List deliveries = deliveryOrders.lockByOutbound(customerId, outbound.getId()); + if (deliveries.isEmpty() && "DISPATCH_DELIVERY".equals(outbound.getOutboundType())) { throw new IllegalStateException("Delivery document is missing for dispatch outbound order"); } List pallets = outboundPallets.findByOutbound(customerId, outbound.getId()).stream() .map(this::outboundPalletRow).toList(); - return new DeliveryResponses.OutboundDetail(outboundRow(outbound), delivery == null ? null : deliveryRow(delivery), pallets); + return new DeliveryResponses.OutboundDetail(outboundRow(outbound), deliveries.stream().map(this::deliveryRow).toList(), pallets); } public PageResult deliveryPage(String customerId, DeliveryRequests.Query query) { @@ -747,7 +821,8 @@ public class DeliveryTransferService { public DeliveryResponses.DeliveryDetail deliveryDetail(String customerId, String deliveryNo) { DeliveryOrder delivery = deliveryOrders.findByNo(customerId, deliveryNo); if (delivery == null) throw notFound("Delivery order not found"); - List items = dispatchItems.findByDispatchOrder(customerId, delivery.getDispatchOrderId()); + List items = dispatchItems.findByDispatchOrder(customerId, delivery.getDispatchOrderId()).stream() + .filter(item -> delivery.getDeliveryGroupKey().equals(deliveryGroupKey(item))).toList(); List orderIds = items.stream().map(DispatchItem::getOrderId).filter(Objects::nonNull).distinct().toList(); Map ordersById = orderIds.isEmpty() ? Map.of() : tailOrders.selectBatchIds(orderIds).stream() .filter(order -> customerId.equals(order.getCustomerId())).collect(java.util.stream.Collectors.toMap(LastlegOrder::getId, order -> order)); @@ -789,14 +864,16 @@ public class DeliveryTransferService { } } - private DeliveryOrder requireDelivery(String customerId, Long outboundId) { - DeliveryOrder value = deliveryOrders.lockByOutbound(customerId, outboundId); - if (value == null) throw new IllegalStateException("Delivery document is missing for outbound order"); - return value; + private List requireDeliveries(String customerId, Long outboundId) { + List values = deliveryOrders.lockByOutbound(customerId, outboundId); + if (values.isEmpty()) throw new IllegalStateException("Delivery documents are missing for outbound order"); + return values; } - private DeliveryResponses.TransferResult result(OutboundOrder o, DeliveryOrder d, boolean replayed) { + private DeliveryResponses.TransferResult result(OutboundOrder o, List deliveries, boolean replayed) { return new DeliveryResponses.TransferResult(o.getDispatchNo(), o.getOutboundNo(), o.getStatus(), - d.getDeliveryNo(), d.getStatus(), o.getPalletCount(), o.getPackageCount(), replayed); + o.getPalletCount(), o.getPackageCount(), deliveries.stream().map(d -> new DeliveryResponses.TransferDelivery( + d.getDeliveryNo(), d.getStatus(), d.getDestinationWarehouseId(), d.getDestinationWarehouseCode(), + d.getDestinationWarehouseName(), value(d.getPalletCount()), value(d.getPackageCount()))).toList(), replayed); } private DeliveryResponses.OutboundRow outboundRow(OutboundOrder o) { return new DeliveryResponses.OutboundRow(o.getId(), o.getOutboundNo(), o.getDispatchNo(), o.getOverseasWarehouseId(), o.getLxCodes(), o.getStatus(), o.getPalletCount(), o.getPackageCount(), o.getCreatedAt(), o.getOutboundType()); } private DeliveryResponses.OutboundPalletRow outboundPalletRow(OutboundPalletItem item) { @@ -810,12 +887,18 @@ public class DeliveryTransferService { candidate.getInboundCode(), candidate.getSkuCode(), candidate.getProductName(), value(candidate.getAvailablePackages())); } - private DeliveryResponses.DeliveryRow deliveryRow(DeliveryOrder d) { return new DeliveryResponses.DeliveryRow(d.getId(), d.getCustomerId(), d.getCustomerName(), d.getDeliveryNo(), d.getOutboundNo(), d.getDispatchNo(), d.getOverseasWarehouseId(), d.getOverseasWarehouseName(), d.getStatus(), d.getWaybillNo(), d.getDeliveryMethod(), d.getCarrierName(), d.getPickupMethod(), d.getLoadingType(), d.getVehicleType(), d.getVehicleSize(), d.getVehiclePlate(), d.getDriverName(), d.getDriverPhone(), d.getAppointmentAccount(), d.getSellerName(), value(d.getPalletCount()), value(d.getPackageCount()), d.getTotalWeightKg(), d.getTotalVolumeCbm(), d.getPlannedDeliveryAt(), d.getDispatchedAt(), d.getExpectedArrivalAt(), d.getSignedAt(), d.getRemark(), d.getCreatedAt(), value(d.getVersion()), DeliveryStatusPolicy.allowedActions(d.getStatus()), d.getBolNo(), com.baiyue.lastleg.delivery.model.DeliveryDocumentSnapshot.summary(d.getDocumentsJson())); } + private DeliveryResponses.DeliveryRow deliveryRow(DeliveryOrder d) { return new DeliveryResponses.DeliveryRow(d.getId(), d.getCustomerId(), d.getCustomerName(), d.getDeliveryNo(), d.getOutboundNo(), d.getDispatchNo(), d.getOverseasWarehouseId(), d.getOverseasWarehouseName(), d.getStatus(), d.getWaybillNo(), d.getDeliveryMethod(), d.getCarrierName(), d.getPickupMethod(), d.getLoadingType(), d.getVehicleType(), d.getVehicleSize(), d.getVehiclePlate(), d.getDriverName(), d.getDriverPhone(), d.getAppointmentAccount(), d.getSellerName(), value(d.getPalletCount()), value(d.getPackageCount()), d.getTotalWeightKg(), d.getTotalVolumeCbm(), d.getPlannedDeliveryAt(), d.getDispatchedAt(), d.getExpectedArrivalAt(), d.getSignedAt(), d.getRemark(), d.getCreatedAt(), value(d.getVersion()), DeliveryStatusPolicy.allowedActions(d.getStatus()), d.getBolNo(), com.baiyue.lastleg.delivery.model.DeliveryDocumentSnapshot.summary(d.getDocumentsJson()), d.getDestinationWarehouseId(), d.getDestinationWarehouseCode(), d.getDestinationWarehouseName()); } private DeliveryResponses.DeliverySku sku(LastlegOrderDetail d, LastlegOrder order) { return new DeliveryResponses.DeliverySku(order == null ? null : order.getInboundCode(), order == null ? null : order.getCustomerName(), d.getDestinationWarehouseCode(), null, d.getShipmentNo(), d.getFbaTrackingNo(), d.getSkuCode(), firstText(d.getProductNameZh(), d.getProductNameEn()), value(d.getPackageCount()), d.getGrossWeightKg(), d.getVolumeCbm()); } private DeliveryResponses.DeliveryFeeRow feeRow(OverseasFeeSource f) { return new DeliveryResponses.DeliveryFeeRow(f.getId(), f.getDirection(), f.getFeeItemCode(), f.getOriginalCurrency(), f.getOriginalAmount(), f.getReviewReason()); } private DeliveryResponses.DeliveryOperationRow operationRow(DeliveryOperation o) { return new DeliveryResponses.DeliveryOperationRow(o.getId(), o.getCreatedAt(), o.getOperationNode(), firstText(o.getOperatorName(), o.getOperatorId()), o.getResult()); } private void recordOperation(String customerId, Long deliveryId, String operatorId, String node, String result, LocalDateTime now) { DeliveryOperation operation = new DeliveryOperation(); operation.setCustomerId(customerId); operation.setDeliveryOrderId(deliveryId); operation.setOperationNode(node); operation.setOperatorId(operatorId); operation.setOperatorName(operatorId); operation.setResult(result); operation.setCreatedAt(now); deliveryOperations.insert(operation); } private LastlegOrder firstTailOrder(String customerId, List items) { return items.stream().map(DispatchItem::getOrderId).filter(Objects::nonNull).map(tailOrders::selectById).filter(Objects::nonNull).filter(order -> customerId.equals(order.getCustomerId())).findFirst().orElse(null); } + private String deliveryGroupKey(DispatchItem item) { + String code = firstText(item.getDestinationWarehouseCode(), item.getFbaWarehouseCode()); + if (StringUtils.hasText(code)) return "CODE:" + code.toUpperCase(Locale.ROOT); + if (StringUtils.hasText(item.getDestinationWarehouseId())) return "ID:" + item.getDestinationWarehouseId().trim(); + throw conflict("Dispatch pallet is missing its destination FBA warehouse"); + } private String resolveLxCodes(String customerId, List items) { List inboundCodes = items.stream().map(DispatchItem::getInboundCode) .filter(StringUtils::hasText).map(String::trim).distinct().toList(); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchController.java index 9e6701f27..fe78e7aca 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchController.java @@ -29,7 +29,7 @@ public class DispatchController { @PutMapping("/{dispatchNo}") @PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_EDIT + "')") - @Operation(summary = "Edit vehicle, driver, appointment and route data") + @Operation(summary = "Edit vehicle, driver and appointment data; route is derived from assigned FBA warehouses") public ApiResponse update( @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String dispatchNo, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchRequests.java index 3167ab72e..eb2564c80 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchRequests.java @@ -20,18 +20,18 @@ public final class DispatchRequests { } public record PreArrange(@NotEmpty @Size(max = 500) List<@NotNull Long> planPalletIds, - @Size(max = 255) String route, @Size(max = 64) String fbaAppointmentNo, + @Size(max = 64) String fbaAppointmentNo, @Size(max = 32) String fbaAppointmentStatus, @Size(max = 32) String vehicleAppointmentStatus, @Size(max = 64) String draftToken, @Size(max = 64) String relabelNo) { - public PreArrange(List planPalletIds, String route, String fbaAppointmentNo, + public PreArrange(List planPalletIds, String fbaAppointmentNo, String fbaAppointmentStatus, String vehicleAppointmentStatus) { - this(planPalletIds, route, fbaAppointmentNo, fbaAppointmentStatus, vehicleAppointmentStatus, null, null); + this(planPalletIds, fbaAppointmentNo, fbaAppointmentStatus, vehicleAppointmentStatus, null, null); } - public PreArrange(List planPalletIds, String route, String fbaAppointmentNo, + public PreArrange(List planPalletIds, String fbaAppointmentNo, String fbaAppointmentStatus, String vehicleAppointmentStatus, String draftToken) { - this(planPalletIds, route, fbaAppointmentNo, fbaAppointmentStatus, vehicleAppointmentStatus, draftToken, null); + this(planPalletIds, fbaAppointmentNo, fbaAppointmentStatus, vehicleAppointmentStatus, draftToken, null); } } @@ -39,7 +39,7 @@ public final class DispatchRequests { public DraftPallet(Long planPalletId, String draftToken) { this(planPalletId, draftToken, null); } } - public record DraftContainer(@NotBlank @Size(max = 64) String draftToken, @NotNull @Min(1) @Max(500) Integer capacity) {} + public record DraftContainer(@NotBlank @Size(max = 64) String draftToken, @NotNull Integer capacity) {} public record DraftRelease(@NotBlank @Size(max = 64) String draftToken) { } @@ -51,7 +51,6 @@ public final class DispatchRequests { public record Update(@Size(max = 64) String vehiclePlate, @Size(max = 128) String driverName, @Size(max = 64) String driverPhone, @Size(max = 255) String carrierName, - @Size(max = 255) String route, @Size(max = 64) String fbaAppointmentNo, @Size(max = 32) String fbaAppointmentStatus, @Size(max = 32) String vehicleAppointmentStatus, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java index d76c9d84b..684d3a5e3 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java @@ -134,6 +134,9 @@ public class DispatchService { @Transactional public DispatchContainerResponse createDraftContainer(String operatorId, DispatchRequests.DraftContainer request) { + if (!Set.of(15, 30).contains(request.capacity())) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "车辆大小仅支持15托或30托"); + } DispatchDraft draft = ensureDraft(operatorId, request.draftToken()); int next = draftContainers.findByToken(request.draftToken()).stream().mapToInt(DispatchDraftContainer::getSequenceNo).max().orElse(0) + 1; DispatchDraftContainer c = new DispatchDraftContainer(); c.setId(IdWorker.getId()); c.setDraftId(draft.getId()); c.setDraftToken(request.draftToken()); c.setSequenceNo(next); c.setCapacity(request.capacity()); c.setCreatedAt(LocalDateTime.now()); c.setUpdatedAt(LocalDateTime.now()); draftContainers.insert(c); @@ -313,7 +316,7 @@ public class DispatchService { dispatch.setCreatedBy(trimToNull(operatorId)); dispatch.setCreatedAt(now); } - dispatch.setRoute(StringUtils.hasText(request.route()) ? request.route().trim() : route(selected)); + dispatch.setRoute(candidateRoute(selected)); dispatch.setFbaAppointmentNo(trimToNull(request.fbaAppointmentNo())); dispatch.setFbaAppointmentStatus(defaultStatus(request.fbaAppointmentStatus())); dispatch.setVehicleAppointmentStatus(defaultStatus(request.vehicleAppointmentStatus())); @@ -439,7 +442,7 @@ public class DispatchService { LocalDateTime now = LocalDateTime.now(); DispatchOrder dispatch = new DispatchOrder(); dispatch.setId(IdWorker.getId()); dispatch.setCustomerId(customerId); dispatch.setDispatchNo(orderNoGenerator.next("DISPATCH", warehouse, dispatch.getId())); dispatch.setOverseasWarehouseId(warehouse); dispatch.setOverseasWarehouseName(selected.get(0).overseasWarehouseName()); - dispatch.setRoute(StringUtils.hasText(request.route()) ? request.route().trim() : route(selected)); dispatch.setFbaAppointmentNo(trimToNull(request.fbaAppointmentNo())); dispatch.setFbaAppointmentStatus(defaultStatus(request.fbaAppointmentStatus())); dispatch.setVehicleAppointmentStatus(defaultStatus(request.vehicleAppointmentStatus())); dispatch.setStatus(PENDING_DEPARTURE); dispatch.setItemCount(selected.size()); dispatch.setPalletCount(selected.size()); dispatch.setPackageCount(selected.stream().map(DispatchPalletCandidate::packageCount).filter(Objects::nonNull).mapToInt(Integer::intValue).sum()); dispatch.setPickedPackageCount(0); dispatch.setShippedPackageCount(0); dispatch.setCreatedBy(trimToNull(operatorId)); dispatch.setCreatedAt(now); dispatch.setUpdatedAt(now); orders.insert(dispatch); + dispatch.setRoute(candidateRoute(selected)); dispatch.setFbaAppointmentNo(trimToNull(request.fbaAppointmentNo())); dispatch.setFbaAppointmentStatus(defaultStatus(request.fbaAppointmentStatus())); dispatch.setVehicleAppointmentStatus(defaultStatus(request.vehicleAppointmentStatus())); dispatch.setStatus(PENDING_DEPARTURE); dispatch.setItemCount(selected.size()); dispatch.setPalletCount(selected.size()); dispatch.setPackageCount(selected.stream().map(DispatchPalletCandidate::packageCount).filter(Objects::nonNull).mapToInt(Integer::intValue).sum()); dispatch.setPickedPackageCount(0); dispatch.setShippedPackageCount(0); dispatch.setCreatedBy(trimToNull(operatorId)); dispatch.setCreatedAt(now); dispatch.setUpdatedAt(now); orders.insert(dispatch); for (DispatchPalletCandidate candidate : selected) { DispatchItem item = new DispatchItem(); item.setId(IdWorker.getId()); item.setDispatchOrderId(dispatch.getId()); item.setCustomerId(customerId); item.setWarehousePalletId(candidate.warehousePalletId()); item.setOrderId(candidate.orderId()); item.setOverseasWarehouseId(candidate.overseasWarehouseId()); item.setOverseasWarehouseName(candidate.overseasWarehouseName()); item.setOrderNo(candidate.orderNo()); item.setOrderRefs(candidate.orderRefs()); item.setInboundCode(candidate.inboundCode()); item.setPalletNo(candidate.palletNo()); item.setDestinationWarehouseCode(candidate.destinationWarehouseCode()); item.setDestinationWarehouseName(candidate.destinationWarehouseName()); item.setFbaWarehouseCode(candidate.fbaWarehouseCode()); item.setPackageCount(Objects.requireNonNullElse(candidate.packageCount(), 0)); item.setVolumeCbm(Objects.requireNonNullElse(candidate.volumeCbm(), BigDecimal.ZERO)); item.setSourceStage("RELABEL"); item.setCartonMarks(candidate.relabelCartonMarks()); item.setSourceBusinessType("RELABEL_ORDER"); item.setSourceBusinessNo(request.relabelNo()); item.setCreatedAt(now); items.insert(item); } DispatchPdaTask task = new DispatchPdaTask(); task.setId(IdWorker.getId()); task.setCustomerId(customerId); task.setDispatchOrderId(dispatch.getId()); task.setTaskNo("DPDA" + task.getId()); task.setStatus("PDA_PENDING"); task.setWarehouseId(warehouse); task.setPushedAt(now); task.setCreatedAt(now); task.setUpdatedAt(now); pdaTasks.insert(task); if (candidates.markRelabelPreDispatched(customerId, request.relabelNo()) != 1) throw new ResponseStatusException(HttpStatus.CONFLICT, "换标订单已被其他排车占用"); @@ -514,18 +517,19 @@ public class DispatchService { "Only a pending dispatch can be edited"); } String customerId = dispatch.getCustomerId(); + List assignedItems = items.findByDispatchOrder(customerId, dispatch.getId()); dispatch.setVehiclePlate(trimToNull(request.vehiclePlate())); dispatch.setDriverName(trimToNull(request.driverName())); dispatch.setDriverPhone(trimToNull(request.driverPhone())); dispatch.setCarrierName(trimToNull(request.carrierName())); - dispatch.setRoute(trimToNull(request.route())); + dispatch.setRoute(itemRoute(assignedItems)); dispatch.setFbaAppointmentNo(trimToNull(request.fbaAppointmentNo())); dispatch.setFbaAppointmentStatus(defaultStatus(request.fbaAppointmentStatus())); dispatch.setVehicleAppointmentStatus(defaultStatus(request.vehicleAppointmentStatus())); dispatch.setEstimatedArrivalAt(request.estimatedArrivalAt()); dispatch.setUpdatedAt(LocalDateTime.now()); orders.updateById(dispatch); - return response(dispatch, items.findByDispatchOrder(customerId, dispatch.getId())); + return response(dispatch, assignedItems); } @Transactional @@ -557,6 +561,7 @@ public class DispatchService { dispatch.setPalletCount(remaining.size()); dispatch.setPackageCount(remaining.stream().map(DispatchItem::getPackageCount).filter(Objects::nonNull) .mapToInt(Integer::intValue).sum()); + dispatch.setRoute(itemRoute(remaining)); dispatch.setUpdatedAt(now); orders.updateById(dispatch); return response(dispatch, remaining); @@ -620,7 +625,7 @@ public class DispatchService { String orderNos = itemList.stream().flatMap(item -> splitOrderRefs(item.getOrderRefs(), item.getOrderNo()).stream()) .distinct().collect(Collectors.joining(",")); return new DispatchResponse(order.getId(), order.getDispatchNo(), order.getVehiclePlate(), order.getDriverName(), - order.getDriverPhone(), order.getCarrierName(), orderNos, order.getRoute(), + order.getDriverPhone(), order.getCarrierName(), orderNos, itemRoute(itemList), order.getOverseasWarehouseId(), order.getOverseasWarehouseName(), order.getDepartAt(), order.getFbaAppointmentNo(), order.getFbaAppointmentStatus(), order.getVehicleAppointmentStatus(), order.getEstimatedArrivalAt(), order.getStatus(), Objects.requireNonNullElse(order.getItemCount(), 0), @@ -637,9 +642,24 @@ public class DispatchService { bolLineList == null ? List.of() : bolLineList); } - private static String route(List selected) { - return selected.stream().map(DispatchPalletCandidate::destinationWarehouseCode).filter(StringUtils::hasText) - .distinct().collect(Collectors.joining(" -> ")); + private static String candidateRoute(List selected) { + return selected.stream().map(DispatchService::candidateFbaCode).filter(StringUtils::hasText) + .distinct().collect(Collectors.joining(",")); + } + + private static String itemRoute(List assignedItems) { + return assignedItems.stream().map(DispatchService::itemFbaCode).filter(StringUtils::hasText) + .distinct().collect(Collectors.joining(",")); + } + + private static String candidateFbaCode(DispatchPalletCandidate candidate) { + return StringUtils.hasText(candidate.fbaWarehouseCode()) + ? candidate.fbaWarehouseCode().trim() : trimToNull(candidate.destinationWarehouseCode()); + } + + private static String itemFbaCode(DispatchItem item) { + return StringUtils.hasText(item.getFbaWarehouseCode()) + ? item.getFbaWarehouseCode().trim() : trimToNull(item.getDestinationWarehouseCode()); } private DispatchPalletCandidate requireDraftCandidate(String customerId, Long planPalletId) { 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..b08d07b9d 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); } @@ -488,8 +507,8 @@ public class ReceivingContainerService { accepted += packages; match.allocation().setPickedPackageCount(packages); } + markReceiving(session); } - markReceiving(session); List group = matches.stream().map(DropshipPalletRef::ref).distinct().toList(); List inboundCodes = group.stream().map(value -> value.line().getInboundCode()).distinct().sorted().toList(); int total = matches.stream().mapToInt(value -> value(value.allocation().getPlannedPackageCount())).sum(); @@ -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/looseboard/service/LocalLooseboardService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardService.java index 30b6936be..19e63fcac 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardService.java @@ -1,5 +1,7 @@ package com.baiyue.lastleg.looseboard.service; +import com.baiyue.common.core.container.ContainerSpecificationCatalog; + import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.toolkit.IdWorker; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; @@ -203,7 +205,9 @@ public class LocalLooseboardService { order.setOriginAddress(trim(originAddress)); order.setDestination(destination.trim()); order.setDestinationAddress(trim(destinationAddress)); - order.setContainerType(trim(containerType)); + order.setContainerType(StringUtils.hasText(containerType) + ? ContainerSpecificationCatalog.require(containerType).containerType() + : ContainerSpecificationCatalog.DEFAULT_CONTAINER_TYPE); order.setPackageCount(packageCount); order.setTotalWeightKg(totalWeightKg); order.setTotalVolumeCbm(totalVolumeCbm); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/repository/UnloadCompletedOutboxRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/repository/UnloadCompletedOutboxRepository.java index c0cb73682..279b9e728 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/repository/UnloadCompletedOutboxRepository.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/repository/UnloadCompletedOutboxRepository.java @@ -16,6 +16,7 @@ public class UnloadCompletedOutboxRepository { private static final List OVERSEAS_TO_CN_TYPES = List.of( EventTypes.OVERSEAS_CONTAINER_UNLOADED, EventTypes.OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED, + EventTypes.OVERSEAS_DELIVERY_COMPLETED, EventTypes.WALLET_COMMAND_REQUESTED); @Resource private LastlegEventOutboxMapper mapper; 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/java/com/baiyue/lastleg/trucking/service/UsTruckOrderService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/service/UsTruckOrderService.java index f6aae16a0..c02177466 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/service/UsTruckOrderService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/service/UsTruckOrderService.java @@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.toolkit.IdWorker; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.core.container.ContainerSpecificationCatalog; import com.baiyue.lastleg.trucking.dto.UsTruckOrderRequests; import com.baiyue.lastleg.trucking.dto.UsTruckOrderResponses; import com.baiyue.lastleg.trucking.entity.UsTruckOrder; @@ -84,7 +85,9 @@ public class UsTruckOrderService { order.setTransportType(StringUtils.hasText(request.transportType()) ? norm(request.transportType()) : null); order.setOrigin(trim(request.origin())); order.setOriginAddress(trim(request.originAddress())); order.setDestination(trim(request.destination())); order.setDestinationAddress(trim(request.destinationAddress())); - order.setContainerType(StringUtils.hasText(request.containerType()) ? norm(request.containerType()) : null); + order.setContainerType(StringUtils.hasText(request.containerType()) + ? ContainerSpecificationCatalog.require(request.containerType()).containerType() + : ContainerSpecificationCatalog.DEFAULT_CONTAINER_TYPE); order.setPalletCount(request.palletCount()); order.setPackageCount(value(request.packageCount())); order.setReceivedPalletCount(0); order.setAllocatedPalletCount(0); order.setTotalWeightKg(request.totalWeightKg()); order.setTotalVolumeCbm(request.totalVolumeCbm()); @@ -138,7 +141,8 @@ public class UsTruckOrderService { if (request.transportType() != null) { validateTransport(request.transportType()); order.setTransportType(norm(request.transportType())); } setText(request.origin(), order::setOrigin); setPresent(request.originAddress(), order::setOriginAddress); setText(request.destination(), order::setDestination); setPresent(request.destinationAddress(), order::setDestinationAddress); - setText(request.containerType(), v -> order.setContainerType(norm(v))); + setText(request.containerType(), v -> order.setContainerType( + ContainerSpecificationCatalog.require(v).containerType())); if (request.palletCount() != null) order.setPalletCount(request.palletCount()); if (request.packageCount() != null) order.setPackageCount(request.packageCount()); if (request.totalWeightKg() != null) order.setTotalWeightKg(request.totalWeightKg()); if (request.totalVolumeCbm() != null) order.setTotalVolumeCbm(request.totalVolumeCbm()); 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 51bd73123..c6b85a0c0 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -257,6 +257,10 @@ CREATE TABLE IF NOT EXISTS ll_delivery_order ( overseas_warehouse_id VARCHAR(128) NULL COMMENT '海外仓ID', overseas_warehouse_name VARCHAR(255) NULL COMMENT '海外仓名称快照', customer_name VARCHAR(255) NULL COMMENT '客户名称快照', + destination_warehouse_id VARCHAR(128) NULL COMMENT '本派送单目的FBA仓ID快照', + destination_warehouse_code VARCHAR(128) NOT NULL COMMENT '本派送单目的FBA仓编码快照', + destination_warehouse_name VARCHAR(255) NULL COMMENT '本派送单目的FBA仓名称快照', + delivery_group_key VARCHAR(255) NOT NULL COMMENT '同一出库单内按目的FBA仓拆单的稳定分组键', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_DELIVERY' COMMENT '派送状态:WAIT_DELIVERY待派送及后续正式状态', waybill_no VARCHAR(128) NULL COMMENT '承运运单号', bol_no VARCHAR(128) NULL COMMENT '派送资料BOL号,不作为文件归属标识', @@ -289,7 +293,7 @@ CREATE TABLE IF NOT EXISTS ll_delivery_order ( PRIMARY KEY (id), UNIQUE KEY uk_ll_delivery_no (customer_id,delivery_no), UNIQUE KEY uk_ll_delivery_idempotency (customer_id,idempotency_key), - UNIQUE KEY uk_ll_delivery_outbound (customer_id,outbound_order_id), + UNIQUE KEY uk_ll_delivery_outbound_destination (customer_id,outbound_order_id,delivery_group_key), KEY idx_ll_delivery_list (customer_id,status,created_at) ) ENGINE=InnoDB COMMENT='尾程派送单'; @@ -297,6 +301,12 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab PREPARE delivery_detail_stmt FROM @ddl; EXECUTE delivery_detail_stmt; DEALLOCATE PREPARE delivery_detail_stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='customer_name'), 'SELECT 1', 'ALTER TABLE ll_delivery_order ADD COLUMN customer_name VARCHAR(255) NULL COMMENT ''客户名称快照'' AFTER overseas_warehouse_name')); PREPARE delivery_detail_stmt FROM @ddl; EXECUTE delivery_detail_stmt; DEALLOCATE PREPARE delivery_detail_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='destination_warehouse_code'), 'SELECT 1', 'ALTER TABLE ll_delivery_order ADD COLUMN destination_warehouse_id VARCHAR(128) NULL COMMENT ''本派送单目的FBA仓ID快照'' AFTER customer_name, ADD COLUMN destination_warehouse_code VARCHAR(128) NULL COMMENT ''本派送单目的FBA仓编码快照'' AFTER destination_warehouse_id, ADD COLUMN destination_warehouse_name VARCHAR(255) NULL COMMENT ''本派送单目的FBA仓名称快照'' AFTER destination_warehouse_code, ADD COLUMN delivery_group_key VARCHAR(255) NULL COMMENT ''同一出库单内按目的FBA仓拆单的稳定分组键'' AFTER destination_warehouse_name')); +PREPARE delivery_destination_stmt FROM @ddl; EXECUTE delivery_destination_stmt; DEALLOCATE PREPARE delivery_destination_stmt; +SET @old_delivery_outbound_index = (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND index_name='uk_ll_delivery_outbound'); +SET @new_delivery_destination_index = (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND index_name='uk_ll_delivery_outbound_destination'); +SET @ddl = IF(@new_delivery_destination_index > 0, 'SELECT 1', IF(@old_delivery_outbound_index > 0, 'ALTER TABLE ll_delivery_order DROP INDEX uk_ll_delivery_outbound, ADD UNIQUE KEY uk_ll_delivery_outbound_destination (customer_id,outbound_order_id,delivery_group_key)', 'ALTER TABLE ll_delivery_order ADD UNIQUE KEY uk_ll_delivery_outbound_destination (customer_id,outbound_order_id,delivery_group_key)')); +PREPARE delivery_destination_index_stmt FROM @ddl; EXECUTE delivery_destination_index_stmt; DEALLOCATE PREPARE delivery_destination_index_stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='waybill_no'), 'SELECT 1', 'ALTER TABLE ll_delivery_order ADD COLUMN waybill_no VARCHAR(128) NULL COMMENT ''承运运单号'', ADD COLUMN delivery_method VARCHAR(32) NULL COMMENT ''派送方式'', ADD COLUMN carrier_name VARCHAR(255) NULL COMMENT ''派送承运商'', ADD COLUMN pickup_method VARCHAR(64) NULL COMMENT ''提货方式'', ADD COLUMN loading_type VARCHAR(64) NULL COMMENT ''装车类型'', ADD COLUMN vehicle_type VARCHAR(64) NULL COMMENT ''车辆类型'', ADD COLUMN vehicle_size VARCHAR(64) NULL COMMENT ''车辆大小'', ADD COLUMN vehicle_plate VARCHAR(64) NULL COMMENT ''车牌号'', ADD COLUMN driver_name VARCHAR(128) NULL COMMENT ''司机或车队名称'', ADD COLUMN driver_phone VARCHAR(64) NULL COMMENT ''车队联系电话'', ADD COLUMN appointment_account VARCHAR(128) NULL COMMENT ''预约账号'', ADD COLUMN seller_name VARCHAR(128) NULL COMMENT ''卖家姓名'' AFTER status')); PREPARE delivery_detail_stmt FROM @ddl; EXECUTE delivery_detail_stmt; DEALLOCATE PREPARE delivery_detail_stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='total_weight_kg'), 'SELECT 1', 'ALTER TABLE ll_delivery_order ADD COLUMN total_weight_kg DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT ''总重量(千克)'', ADD COLUMN total_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT ''总体积(立方米)'', ADD COLUMN planned_delivery_at DATETIME(3) NULL COMMENT ''预计派送时间'', ADD COLUMN dispatched_at DATETIME(3) NULL COMMENT ''实际派送时间'', ADD COLUMN expected_arrival_at DATETIME(3) NULL COMMENT ''预计到达时间'', ADD COLUMN signed_at DATETIME(3) NULL COMMENT ''签收时间'', ADD COLUMN remark VARCHAR(1000) NULL COMMENT ''派送备注'' AFTER pallet_count')); @@ -1424,6 +1434,7 @@ CREATE TABLE IF NOT EXISTS ll_outbound_order ( created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', outbound_type VARCHAR(32) NOT NULL DEFAULT 'DISPATCH_DELIVERY' COMMENT '出库类型:派送转出库、一件代发', lx_codes VARCHAR(1000) NULL COMMENT '关联一件代发领星入库单代码汇总', recipient_info VARCHAR(1000) NULL COMMENT '一件代发收件信息', + request_hash CHAR(64) NULL COMMENT '一件代发创建请求摘要,其他出库类型为空', PRIMARY KEY (id), UNIQUE KEY uk_ll_outbound_no (outbound_no), UNIQUE KEY uk_ll_outbound_idempotency (customer_id,idempotency_key), UNIQUE KEY uk_ll_outbound_dispatch (customer_id,dispatch_order_id), KEY idx_ll_outbound_list (customer_id,status,created_at) @@ -1929,7 +1940,7 @@ CREATE TABLE IF NOT EXISTS ll_cargo_discard_item ( order_id BIGINT NOT NULL COMMENT '订单ID', inbound_code VARCHAR(128) NOT NULL COMMENT '入仓单号快照', profile_id BIGINT NOT NULL COMMENT '托盘档案ID', sku_inventory_id BIGINT NOT NULL COMMENT 'SKU库存记录ID', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', product_name VARCHAR(255) NULL COMMENT '产品名称', available_packages_snapshot INT NOT NULL COMMENT '可用箱数快照', discard_packages INT NOT NULL COMMENT '申请废弃箱数', - actual_discard_packages INT NULL COMMENT 'PDA确认实际废弃箱数', pda_status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING/COMPLETED', + actual_discard_packages INT NULL COMMENT 'PDA确认实际废弃箱数', pda_status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT 'PDA处理状态:待处理PENDING、已完成COMPLETED', pda_operator_id VARCHAR(128) NULL COMMENT 'PDA操作人', pda_completed_at DATETIME(3) NULL COMMENT 'PDA完成时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), UNIQUE KEY uk_ll_cargo_discard_item(application_id,profile_id,sku_code), @@ -1953,20 +1964,6 @@ CREATE TABLE IF NOT EXISTS ll_cargo_discard_status_history ( KEY idx_ll_cargo_discard_history(customer_id,application_id,created_at) ) ENGINE=InnoDB COMMENT='货物废弃审批历史'; --- Backfill applications created before cargo discard became a first-class tail-leg order. -INSERT IGNORE INTO ll_order ( - id,order_no,customer_id,order_type,inbound_code,inbound_order_no,source_region,source_inbound_id, - source_order_id,source_order_no,overseas_warehouse_id,overseas_warehouse_name,package_count, - current_status,version,source_event_id,source_loading_plan_id,source_payload_hash,created_at,updated_at -) -SELECT a.id,a.discard_no,a.customer_id,'CARGO_DISCARD',a.inbound_codes,a.discard_no,'LASTLEG_DISCARD', - CONCAT('CARGO_DISCARD:',a.id),CAST(a.id AS CHAR),a.discard_no,a.overseas_warehouse_id,a.overseas_warehouse_name, - a.total_packages,a.status,0,CONCAT('CARGO_DISCARD:',a.id),'CARGO_DISCARD',a.request_hash,a.created_at,a.updated_at -FROM ll_cargo_discard_application a -LEFT JOIN ll_order o ON o.customer_id=a.customer_id AND o.source_region='LASTLEG_DISCARD' - AND o.source_inbound_id=CONCAT('CARGO_DISCARD:',a.id) -WHERE o.id IS NULL; - CREATE TABLE IF NOT EXISTS ll_overseas_procurement_order ( id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', customer_name VARCHAR(255) NULL COMMENT '客户名称快照', order_no VARCHAR(64) NOT NULL COMMENT '订单号', @@ -2010,7 +2007,7 @@ CREATE TABLE IF NOT EXISTS ll_us_truck_order ( origin_address VARCHAR(500) NULL COMMENT '起运地址', destination VARCHAR(255) NOT NULL COMMENT '目的地', destination_address VARCHAR(500) NULL COMMENT '目的地址', - container_type VARCHAR(32) NULL COMMENT '历史兼容设备类型,零担托盘业务不采集', + container_type VARCHAR(32) NOT NULL DEFAULT '40HQ' COMMENT '平台统一柜型:40GP、40HQ、45HQ', pallet_count INT NOT NULL DEFAULT 1 COMMENT '客户申报托盘数', package_count INT NULL COMMENT '可选箱数,零或空值均有效', received_pallet_count INT NOT NULL DEFAULT 0 COMMENT 'PDA实收托盘数', @@ -2088,7 +2085,7 @@ CREATE TABLE IF NOT EXISTS ll_local_looseboard_order ( pallet_required VARCHAR(8) NOT NULL COMMENT '是否需要打盘:YES或NO', transport_type VARCHAR(32) NOT NULL COMMENT '运输类型', origin VARCHAR(255) NOT NULL COMMENT '起运地', origin_address VARCHAR(500) NULL COMMENT '起运详细地址', destination VARCHAR(255) NOT NULL COMMENT '目的地', destination_address VARCHAR(500) NULL COMMENT '目的详细地址、码头或仓号', - container_type VARCHAR(32) NULL COMMENT '柜型', package_count INT NOT NULL DEFAULT 0 COMMENT '货物件数', + container_type VARCHAR(32) NOT NULL DEFAULT '40HQ' COMMENT '平台统一柜型:40GP、40HQ、45HQ', package_count INT NOT NULL DEFAULT 0 COMMENT '货物件数', total_weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT '总重量(千克)', total_volume_cbm DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '总体积(立方米)', budget_amount DECIMAL(18,2) NULL COMMENT '预算总价', currency CHAR(3) NOT NULL DEFAULT 'USD' COMMENT '预算币种', @@ -2536,3 +2533,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/delivery-documents-v1.yaml b/lastleg-service/src/main/resources/openapi/delivery-documents-v1.yaml index 9bad68cfb..70ce3be2c 100644 --- a/lastleg-service/src/main/resources/openapi/delivery-documents-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/delivery-documents-v1.yaml @@ -5,7 +5,7 @@ info: description: | 所有入口只允许平台,customerId 必须为真实派送所属客户;不得按 BOL 文本查找或复用其他客户/派送单文件。 GET /delivery-orders 及 GET /delivery-orders/{deliveryNo} 的 order 行新增 bolNo 和 documentSummary,version/allowedActions 保持原契约。 - 状态 PUT /delivery-orders/{deliveryNo}/status 请求保持 {status,expectedVersion,remark},expectedVersion 必传。 + RECEIPT 签收文件只有在 PUT 保存进入权威资料快照后才自动完成当前派送单;仅上传未保存不会完成。allowedActions 不再返回人工 COMPLETE_DELIVERY。 START_DELIVERY=DELIVERING(派送中);COMPLETE_DELIVERY=DELIVERED(完成并记录签收时间、生成待回访)。同状态重放不重复副作用。 资料不设必传门禁,不以三类齐全作为派送完成条件。业务失败使用统一 ApiResponse(A0422参数、A0409冲突),通常HTTP200。 security: [{ bearerAuth: [] }] 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 ef340b44f..07bf4a5db 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: 按整柜批量生成剩余入库计划 @@ -620,11 +636,11 @@ paths: /api/platform/v1/last-leg/dispatches/{dispatchNo}/transfer-delivery: post: summary: 排车转出库与派送单 - description: 权限 platform:overseas-warehouse:dispatch:transfer-delivery;只按全局唯一 dispatchNo 锁定记录并从记录解析客户范围,不接收 customerId;同一幂等键或已转换排车重复调用返回既有单据。 + description: 权限 platform:overseas-warehouse:dispatch:transfer-delivery;只按全局唯一 dispatchNo 锁定记录并从记录解析客户范围,不接收 customerId;一个排车仍生成一张出库单,并按目的FBA仓分别生成派送单,返回 deliveries 数组;同一幂等键或已转换排车重复调用返回全部既有单据。派送费用只发布一次,避免按拆分派送单重复计费。 security: [{ bearerAuth: [] }] parameters: [{ name: dispatchNo, in: path, required: true, schema: { type: string } }] requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/DeliveryTransfer' } } } } - responses: { '200': { description: 出库与派送单转换结果 }, '404': { description: 排车单不存在 }, '409': { description: 状态、托盘或幂等冲突 } } + responses: { '200': { description: 一张出库单与按目的FBA仓拆分的派送单转换结果 }, '404': { description: 排车单不存在 }, '409': { description: 状态、托盘、目的FBA仓或幂等冲突 } } /api/platform/v1/last-leg/outbound-orders: get: summary: 分页查询出库单 @@ -649,10 +665,10 @@ paths: /api/platform/v1/last-leg/outbound-orders/one-click-dropship/candidates: get: summary: 查询一件代发出库候选库存 - description: 权限 platform:overseas-warehouse:outbound:create;按客户及可选海外仓一次查询服务端权威候选,仅返回 ACTIVE 一件代发档案、OCCUPIED 正可用库存、AVAILABLE 箱唛与 SKU 余额共同覆盖且未被排车、转仓或待执行出库占用的维度。创建命令会再次校验,不信任前端候选快照。 + description: 权限 platform:overseas-warehouse:outbound:create;仅平台身份可查询,customerId 为可选客户筛选条件,未传时跨客户查询,可按海外仓筛选。仅返回 ACTIVE 一件代发档案、OCCUPIED 正可用库存、AVAILABLE 箱唛与 SKU 余额共同覆盖且未被排车、转仓或待执行出库占用的维度。创建命令仍必须指定唯一客户并再次校验客户与仓库,不信任前端候选快照。 security: [{ bearerAuth: [] }] parameters: - - { name: customerId, in: query, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$' } } + - { name: customerId, in: query, required: false, schema: { type: string, pattern: '^[1-9][0-9]*$' } } - { name: warehouseId, in: query, required: false, schema: { type: string } } responses: '200': @@ -664,7 +680,7 @@ paths: description: 权限 platform:overseas-warehouse:outbound:list;只按全局唯一 outboundNo 定位并从记录解析客户范围,不接收 customerId。 security: [{ bearerAuth: [] }] parameters: [{ name: outboundNo, in: path, required: true, schema: { type: string } }] - responses: { '200': { description: '出库单、出库托盘及关联派送单详情;一件代发等独立出库无派送单时 delivery 为 null,排车转派送必须存在派送单;pallets[].palletNo 为 Warehouse 权威实物托盘码' }, '404': { description: 出库单不存在 } } + responses: { '200': { description: '出库单、出库托盘及关联 deliveries 派送单数组;一件代发等独立出库时数组为空,排车转派送按目的FBA仓返回一项或多项;pallets[].palletNo 为 Warehouse 权威实物托盘码' }, '404': { description: 出库单不存在 } } /api/platform/v1/last-leg/outbound-orders/{outboundNo}/cancel: post: summary: 取消未执行出库单 @@ -676,7 +692,7 @@ paths: /api/platform/v1/last-leg/delivery-orders: get: summary: 分页查询平台派送单 - description: 权限 platform:overseas-warehouse:delivery:list;customerId 仅为可选平台筛选条件。每行返回服务端计算的 allowedActions,页面不得自行推导状态门禁。 + description: 权限 platform:overseas-warehouse:delivery:list;customerId 仅为可选平台筛选条件。同一排车按目的FBA仓显示为不同派送单,每行返回目的仓快照和服务端计算的 allowedActions,页面不得自行推导状态门禁;完成动作不再由人工触发。 security: [{ bearerAuth: [] }] responses: { '200': { description: 派送单分页 } } /api/platform/v1/last-leg/delivery-orders/{deliveryNo}: @@ -685,7 +701,7 @@ paths: description: 权限 platform:overseas-warehouse:delivery:list;列表权限包含详情只读;order.allowedActions 为当前可执行动作。 security: [{ bearerAuth: [] }] parameters: [{ name: deliveryNo, in: path, required: true, schema: { type: string } }] - responses: { '200': { description: 派送单详情 }, '404': { description: 派送单不存在 } } + responses: { '200': { description: 派送单详情,SKU与BOL打印数据只包含当前派送单目的FBA仓 }, '404': { description: 派送单不存在 } } /api/platform/v1/last-leg/exception-orders: get: summary: 分页查询异常订单 @@ -762,18 +778,10 @@ paths: parameters: [{ name: deliveryNo, in: path, required: true, schema: { type: string } }] requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/DeliveryMarkReturn' } } } } responses: { '200': { description: 已创建退回异常单 } } - /api/platform/v1/last-leg/delivery-orders/{deliveryNo}/complete: - post: - summary: 标记派送完成并生成回访记录 - description: 权限 platform:overseas-warehouse:delivery:status。仅 WAIT_DELIVERY/DELIVERING 可完成;DELIVERED 同状态重放成功,其他终态或倒退返回 409。 - security: [{ bearerAuth: [] }] - parameters: [{ name: deliveryNo, in: path, required: true, schema: { type: string } }] - requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/DeliveryStatusChange' } } } } - responses: { '200': { description: 派送状态与回访记录 }, '409': { description: 版本或状态冲突 } } /api/platform/v1/last-leg/delivery-orders/{deliveryNo}/status: put: summary: 更新派送状态 - description: 权限 platform:overseas-warehouse:delivery:status。只允许 WAIT_DELIVERY→DELIVERING/DELIVERED/REJECTED/CANCELLED、DELIVERING→DELIVERED/REJECTED;同状态幂等,终态不可倒退。 + description: 权限 platform:overseas-warehouse:delivery:status。人工只允许 WAIT_DELIVERY→DELIVERING/REJECTED/CANCELLED、DELIVERING→REJECTED;DELIVERED 仅由签收资料保存成功自动触发,同状态幂等,终态不可倒退。 security: [{ bearerAuth: [] }] parameters: [{ name: deliveryNo, in: path, required: true, schema: { type: string } }] requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/DeliveryStatusChange' } } } } @@ -1949,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 } @@ -1959,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 } @@ -1976,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' } @@ -2097,7 +2113,6 @@ components: required: [planPalletIds] properties: planPalletIds: { type: array, minItems: 1, maxItems: 500, uniqueItems: true, items: { type: string } } - route: { type: string, nullable: true, maxLength: 255 } fbaAppointmentNo: { type: string, nullable: true, maxLength: 64 } fbaAppointmentStatus: { type: string, nullable: true, maxLength: 32, default: PENDING } vehicleAppointmentStatus: { type: string, nullable: true, maxLength: 32, default: PENDING } @@ -2126,7 +2141,7 @@ components: required: [draftToken, capacity] properties: draftToken: { type: string, minLength: 1, maxLength: 64 } - capacity: { type: integer, minimum: 1, maximum: 500 } + capacity: { type: integer, enum: [15, 30] } SmartDispatchDraft: type: object required: [draftToken, containers] @@ -2144,13 +2159,12 @@ components: palletIds: { type: array, items: { type: string } } DispatchUpdate: type: object - description: 普通编辑字段;不包含 status 或 departAt。 + description: 普通编辑字段;不包含 status、departAt 或路线,路线由已排托盘关联的 FBA 仓自动汇总。 properties: vehiclePlate: { type: string, nullable: true, maxLength: 64 } driverName: { type: string, nullable: true, maxLength: 128 } driverPhone: { type: string, nullable: true, maxLength: 64 } carrierName: { type: string, nullable: true, maxLength: 255 } - route: { type: string, nullable: true, maxLength: 255 } fbaAppointmentNo: { type: string, nullable: true, maxLength: 64 } fbaAppointmentStatus: { type: string, nullable: true, maxLength: 32 } vehicleAppointmentStatus: { type: string, nullable: true, maxLength: 32 } @@ -2167,7 +2181,7 @@ components: pickupMethod: { type: string, maxLength: 64 } loadingType: { type: string, maxLength: 64 } vehicleType: { type: string, nullable: true, maxLength: 64 } - vehicleSize: { type: string, nullable: true, maxLength: 64 } + vehicleSize: { type: string, nullable: true, enum: [15托, 30托] } driverName: { type: string, nullable: true, maxLength: 128 } driverPhone: { type: string, nullable: true, maxLength: 64 } vehiclePlate: { type: string, nullable: true, maxLength: 64 } @@ -2179,13 +2193,13 @@ components: type: object required: [status, expectedVersion] properties: - status: { type: string, enum: [WAIT_DELIVERY, DELIVERING, DELIVERED, REJECTED, CANCELLED] } + status: { type: string, enum: [WAIT_DELIVERY, DELIVERING, REJECTED, CANCELLED] } expectedVersion: { type: integer, format: int64, minimum: 0 } remark: { type: string, nullable: true, maxLength: 500 } DeliveryAllowedAction: type: string - enum: [START_DELIVERY, COMPLETE_DELIVERY, REJECT_DELIVERY, CANCEL_DELIVERY, MARK_RETURN] - description: START/COMPLETE/REJECT/CANCEL 使用通用 status 接口;MARK_RETURN 使用 mark-return 接口。 + enum: [START_DELIVERY, REJECT_DELIVERY, CANCEL_DELIVERY, MARK_RETURN] + description: START/REJECT/CANCEL 使用通用 status 接口;MARK_RETURN 使用 mark-return 接口;派送完成由签收资料保存成功自动触发。 DeliveryMarkReturn: type: object required: [inboundOrderId, reason, returnDirection, items, idempotencyKey] @@ -2266,6 +2280,7 @@ components: remark: { type: string, nullable: true, maxLength: 500 } OneClickOutboundCreate: type: object + description: 同一幂等键只重放相同请求;收件人、明细、箱唛或备注变化返回 A0409。 required: [recipient, items, idempotencyKey] properties: recipient: { type: string, maxLength: 1000 } @@ -2282,6 +2297,11 @@ components: inventoryPositionId: { type: string, pattern: '^[1-9][0-9]*$' } packages: { type: integer, format: int64, minimum: 1 } sku: { type: string, maxLength: 128 } + cartonMarks: + type: array + maxItems: 200 + description: 指定出库箱唛,提供时必须唯一且与箱数一致。 + items: { type: string, minLength: 1, maxLength: 128 } OneClickOutboundCandidate: type: object required: [inventoryPositionId, overseasWarehouseId, palletNo, skuCode, availablePackages] @@ -2608,13 +2628,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 } @@ -2704,7 +2723,7 @@ components: transportType: { type: string, nullable: true } origin: { type: string } destination: { type: string } - containerType: { type: string, nullable: true } + containerType: { type: string, enum: [40GP, 40HQ, 45HQ], description: 平台统一柜型 } palletCount: { type: integer } packageCount: { type: integer } receivedPalletCount: { type: integer } @@ -2760,7 +2779,7 @@ components: transportType: { type: string } origin: { type: string } destination: { type: string } - containerType: { type: string, nullable: true } + containerType: { type: string, enum: [40GP, 40HQ, 45HQ], description: 平台统一柜型 } packageCount: { type: integer } totalWeightKg: { type: number } totalVolumeCbm: { type: number } @@ -3101,7 +3120,7 @@ components: originAddress: { type: string, nullable: true, maxLength: 500 } destination: { type: string, maxLength: 255 } destinationAddress: { type: string, nullable: true, maxLength: 500 } - containerType: { type: string, nullable: true, maxLength: 32 } + containerType: { type: string, enum: [40GP, 40HQ, 45HQ], default: 40HQ, maxLength: 32 } palletCount: { type: integer, minimum: 1 } packageCount: { type: integer, nullable: true, minimum: 0 } totalWeightKg: { type: number, minimum: 0 } @@ -3123,7 +3142,7 @@ components: originAddress: { type: string, nullable: true, maxLength: 500 } destination: { type: string, maxLength: 255 } destinationAddress: { type: string, nullable: true, maxLength: 500 } - containerType: { type: string, nullable: true, maxLength: 32 } + containerType: { type: string, enum: [40GP, 40HQ, 45HQ], maxLength: 32 } palletCount: { type: integer, minimum: 1 } packageCount: { type: integer, minimum: 0 } totalWeightKg: { type: number, minimum: 0 } @@ -3154,7 +3173,7 @@ components: originAddress: { type: string, nullable: true, maxLength: 500 } destination: { type: string, maxLength: 255 } destinationAddress: { type: string, nullable: true, maxLength: 500 } - containerType: { type: string, nullable: true, maxLength: 32 } + containerType: { type: string, enum: [40GP, 40HQ, 45HQ], default: 40HQ, maxLength: 32 } packageCount: { type: integer, minimum: 0 } totalWeightKg: { type: number, minimum: 0 } totalVolumeCbm: { type: number, minimum: 0 } @@ -3177,7 +3196,7 @@ components: originAddress: { type: string, nullable: true, maxLength: 500 } destination: { type: string, maxLength: 255 } destinationAddress: { type: string, nullable: true, maxLength: 500 } - containerType: { type: string, nullable: true, maxLength: 32 } + containerType: { type: string, enum: [40GP, 40HQ, 45HQ], maxLength: 32 } packageCount: { type: integer, minimum: 0 } totalWeightKg: { type: number, minimum: 0 } totalVolumeCbm: { type: number, minimum: 0 } diff --git a/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml index 97fff745b..8f3c5a750 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml @@ -603,6 +603,11 @@ components: inventoryPositionId: { type: string, pattern: '^[0-9]+$' } packages: { type: integer, format: int64, minimum: 1 } idempotencyKey: { type: string, minLength: 1, maxLength: 255 } + cartonMarks: + type: array + maxItems: 500 + description: 本次实际扫描的箱唛。一件代发指定箱唛任务分批拣货时必须提交本次子集,数量与 packages 一致;换标任务仍须提交全部指定箱唛。 + items: { type: string, minLength: 1, maxLength: 255 } PdaOutboundTaskDetail: type: object required: [task, items] diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/customervisit/service/CustomerVisitServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/customervisit/service/CustomerVisitServiceTest.java index b93b9a0ce..b0241ac14 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/customervisit/service/CustomerVisitServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/customervisit/service/CustomerVisitServiceTest.java @@ -6,7 +6,6 @@ import com.baiyue.lastleg.customervisit.mapper.CustomerVisitMapper; import com.baiyue.lastleg.delivery.entity.DeliveryOrder; import com.baiyue.lastleg.delivery.mapper.DeliveryOperationMapper; import com.baiyue.lastleg.delivery.mapper.DeliveryOrderMapper; -import com.baiyue.lastleg.entity.LastlegOrder; import com.baiyue.lastleg.mapper.LastlegOrderMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -56,22 +55,12 @@ class CustomerVisitServiceTest { } @Test - void replaysDeliveredToDeliveredWithoutChangingDeliveryOrAddingHistory() { - DeliveryOrder delivery = delivery("DELIVERED"); - CustomerVisit visit = new CustomerVisit(); - visit.setId(33L); - visit.setStatus("PENDING"); - when(deliveryOrders.lockByNo("1001", "DL-1")).thenReturn(delivery); - when(visits.findByDelivery("1001", 11L)).thenReturn(visit); + void rejectsManualDeliveryCompletionBeforeLoadingTheOrder() { + assertThatThrownBy(() -> service.updateDeliveryStatus("1001", "9001", "DL-1", + new CustomerVisitRequests.DeliveryStatus("DELIVERED", 0L, null))) + .isInstanceOf(ResponseStatusException.class).hasMessageContaining("不支持"); - var result = service.updateDeliveryStatus("1001", "9001", "DL-1", - new CustomerVisitRequests.DeliveryStatus("DELIVERED", 0L, null)); - - assertThat(result.replayed()).isTrue(); - assertThat(result.deliveryStatus()).isEqualTo("DELIVERED"); - assertThat(result.visitId()).isEqualTo(33L); - verify(deliveryOrders, never()).updateById(delivery); - verifyNoInteractions(deliveryOperations); + verifyNoInteractions(deliveryOrders, deliveryOperations); } @Test @@ -89,26 +78,7 @@ class CustomerVisitServiceTest { } @Test - void completingDeliveryUpdatesOnlySubOrderStatusAndKeepsMainFlowStatus() { - DeliveryOrder delivery = delivery("DELIVERING"); - delivery.setVersion(1L); - LastlegOrder tail = new LastlegOrder(); - tail.setId(21L); tail.setCustomerId("1001"); tail.setCurrentStatus("INBOUND_COMPLETED"); - CustomerVisit visit = new CustomerVisit(); visit.setId(33L); visit.setStatus("PENDING"); - when(deliveryOrders.lockByNo("1001", "DL-1")).thenReturn(delivery); - when(lastMileOrders.selectList(org.mockito.ArgumentMatchers.any())).thenReturn(java.util.List.of(tail)); - when(visits.findByDelivery("1001", 11L)).thenReturn(visit); - - var result = service.updateDeliveryStatus("1001", "9001", "DL-1", - new CustomerVisitRequests.DeliveryStatus("DELIVERED", 1L, null)); - - assertThat(result.replayed()).isFalse(); - assertThat(tail.getCurrentStatus()).isEqualTo("INBOUND_COMPLETED"); - assertThat(tail.getSubOrderStatus()).isEqualTo("DELIVERY:DELIVERED"); - } - - @Test - void startMeansDeliveringAndOnlyCompleteSignsAndCreatesVisit() { + void startMeansDeliveringWithoutSigningOrCreatingVisit() { DeliveryOrder delivery = delivery("WAIT_DELIVERY"); delivery.setVersion(0L); when(deliveryOrders.lockByNo("1001", "DL-1")).thenReturn(delivery); var started = service.updateDeliveryStatus("1001", "9001", "DL-1", @@ -118,17 +88,7 @@ class CustomerVisitServiceTest { assertThat(delivery.getDispatchedAt()).isNotNull(); assertThat(delivery.getSignedAt()).isNull(); verify(visits, never()).insert(org.mockito.ArgumentMatchers.any(CustomerVisit.class)); - CustomerVisit visit = new CustomerVisit(); visit.setId(33L); visit.setStatus("PENDING"); - when(visits.findByDelivery("1001", 11L)).thenReturn(visit); - var completed = service.updateDeliveryStatus("1001", "9001", "DL-1", - new CustomerVisitRequests.DeliveryStatus("DELIVERED", 1L, null)); - assertThat(completed.deliveryStatus()).isEqualTo("DELIVERED"); - assertThat(delivery.getSignedAt()).isNotNull(); - assertThat(delivery.getVersion()).isEqualTo(2); - assertThat(completed.visitId()).isEqualTo(33); - assertThat(service.updateDeliveryStatus("1001", "9001", "DL-1", - new CustomerVisitRequests.DeliveryStatus("DELIVERED", 1L, null)).replayed()).isTrue(); - verify(deliveryOrders, org.mockito.Mockito.times(2)).updateById(delivery); + verify(deliveryOrders).updateById(delivery); } private DeliveryOrder delivery(String status) { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliveryLongIdJsonContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliveryLongIdJsonContractTest.java index 609952846..0af56242b 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliveryLongIdJsonContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliveryLongIdJsonContractTest.java @@ -67,6 +67,6 @@ class DeliveryLongIdJsonContractTest { null, null, "PS-1", null, null, null, null, "CREATED", null, null, null, null, null, null, null, null, null, null, null, null, 0, 0, null, null, null, null, null, null, null, null, 0, java.util.List.of(), null, - com.baiyue.lastleg.delivery.model.DeliveryDocumentSnapshot.summary(null)); + com.baiyue.lastleg.delivery.model.DeliveryDocumentSnapshot.summary(null), null, null, null); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliverySchemaContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliverySchemaContractTest.java index 05895baa9..00d4b5cad 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliverySchemaContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliverySchemaContractTest.java @@ -25,6 +25,7 @@ class DeliverySchemaContractTest { assertTrue(schema.contains("create table if not exists ll_outbound_order")); assertTrue(schema.contains("status varchar(32) not null default 'wait_outbound'")); assertTrue(schema.contains("unique key uk_ll_outbound_idempotency (customer_id,idempotency_key)")); + assertTrue(schema.contains("request_hash char(64) null")); assertTrue(schema.contains("unique key uk_ll_outbound_dispatch (customer_id,dispatch_order_id)")); assertTrue(schema.contains("create table if not exists ll_outbound_pallet_item")); assertTrue(schema.contains("unique key uk_ll_outbound_pallet (customer_id,outbound_order_id,warehouse_pallet_id,inbound_code,sku_code)")); @@ -34,7 +35,9 @@ class DeliverySchemaContractTest { assertTrue(schema.contains("create table if not exists ll_delivery_order")); assertTrue(schema.contains("status varchar(32) not null default 'wait_delivery'")); assertTrue(schema.contains("unique key uk_ll_delivery_idempotency (customer_id,idempotency_key)")); - assertTrue(schema.contains("unique key uk_ll_delivery_outbound (customer_id,outbound_order_id)")); + assertTrue(schema.contains("unique key uk_ll_delivery_outbound_destination (customer_id,outbound_order_id,delivery_group_key)")); + assertTrue(schema.contains("destination_warehouse_code varchar(128) not null")); + assertTrue(schema.contains("delivery_group_key varchar(255) not null")); } @Test @@ -52,7 +55,8 @@ class DeliverySchemaContractTest { assertTrue(createEnd > create); String definition = schema.substring(create, createEnd); for (String column : new String[] { - "overseas_warehouse_name", "customer_name", "waybill_no", "delivery_method", + "overseas_warehouse_name", "customer_name", "destination_warehouse_id", + "destination_warehouse_code", "destination_warehouse_name", "delivery_group_key", "waybill_no", "delivery_method", "carrier_name", "pickup_method", "loading_type", "vehicle_type", "vehicle_size", "vehicle_plate", "driver_name", "driver_phone", "appointment_account", "seller_name", "total_weight_kg", "total_volume_cbm", "planned_delivery_at", "dispatched_at", @@ -66,6 +70,7 @@ class DeliverySchemaContractTest { StandardCharsets.UTF_8).toLowerCase(); assertTrue(overseas.contains("begin inline empty-database baseline: lastleg")); assertTrue(overseas.contains(createToken)); + assertTrue(overseas.contains("request_hash char(64) null")); assertTrue(overseas.indexOf(createToken) < overseas.indexOf("assert_phase5_lastleg_identifiers_migratable")); assertTrue(overseas.contains("alter table ll_delivery_order add column overseas_warehouse_name")); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DropshipOutboundCandidateMapperContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DropshipOutboundCandidateMapperContractTest.java index bc3bb4a84..df43dfc50 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DropshipOutboundCandidateMapperContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DropshipOutboundCandidateMapperContractTest.java @@ -19,6 +19,7 @@ class DropshipOutboundCandidateMapperContractTest { assertTrue(sql.contains(token), token); } assertTrue(sql.contains("LEAST(pos.available_packages,s.package_count")); + assertTrue(sql.contains("#{customerId} IS NULL OR #{customerId}='' OR p.customer_id=#{customerId}")); } @Test diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/PlatformDispatchOutboundContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/PlatformDispatchOutboundContractTest.java index 600e871ba..42155e065 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/PlatformDispatchOutboundContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/PlatformDispatchOutboundContractTest.java @@ -36,6 +36,37 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; class PlatformDispatchOutboundContractTest { + @Test + void candidateHttpQueryAcceptsWarehouseWithoutCustomerAndPreservesExplicitFilter() throws Exception { + DeliveryTransferController controller = new DeliveryTransferController(); + var service = mock(com.baiyue.lastleg.delivery.service.DeliveryTransferService.class); + ReflectionTestUtils.setField(controller, "service", service); + MockMvc mvc = MockMvcBuilders.standaloneSetup(controller) + .setCustomArgumentResolvers(new AuthenticationPrincipalArgumentResolver()).build(); + AuthenticatedUser platform = new AuthenticatedUser(1000001L, 0L, 0L, 1L, "platform_admin", + "PLATFORM", "WEB", Set.of(OverseasPermissions.OUTBOUND_CREATE)); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(platform, null, Set.of())); + String path = "/api/platform/v1/last-leg/outbound-orders/one-click-dropship/candidates"; + try { + mvc.perform(get(path).param("warehouseId", "WH-1")).andExpect(status().isOk()); + org.mockito.Mockito.verify(service).oneClickCandidates(null, "WH-1"); + mvc.perform(get(path).param("customerId", "101").param("warehouseId", "WH-1")) + .andExpect(status().isOk()); + org.mockito.Mockito.verify(service).oneClickCandidates("101", "WH-1"); + SecurityContextHolder.clearContext(); + mvc.perform(get(path).param("warehouseId", "WH-1")).andExpect(status().isUnauthorized()); + AuthenticatedUser merchant = new AuthenticatedUser(7L, 100L, 100L, 8L, "merchant", + "MERCHANT", "WEB", Set.of(OverseasPermissions.OUTBOUND_CREATE)); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(merchant, null, Set.of())); + mvc.perform(get(path).param("warehouseId", "WH-1")).andExpect(status().isForbidden()); + org.mockito.Mockito.verifyNoMoreInteractions(service); + } finally { + SecurityContextHolder.clearContext(); + } + } + @Test void outboundDetailAcceptsTheDocumentUrlWithoutPagingParameters() throws Exception { DeliveryTransferController controller = new DeliveryTransferController(); @@ -80,14 +111,14 @@ class PlatformDispatchOutboundContractTest { } assertTrue(Arrays.stream(DispatchController.class.getDeclaredMethods()) .noneMatch(method -> "depart".equals(method.getName()))); - assertEquals(Set.of("vehiclePlate", "driverName", "driverPhone", "carrierName", "route", + assertEquals(Set.of("vehiclePlate", "driverName", "driverPhone", "carrierName", "fbaAppointmentNo", "fbaAppointmentStatus", "vehicleAppointmentStatus", "estimatedArrivalAt"), Arrays.stream(DispatchRequests.Update.class.getRecordComponents()) .map(component -> component.getName()).collect(java.util.stream.Collectors.toSet())); } @Test - void oneClickRequiresCustomerWhileOutboundListKeepsOptionalFilter() throws Exception { + void oneClickCreationRequiresCustomerWhileCandidateAndListFiltersAreOptional() throws Exception { Method create = DeliveryTransferController.class.getMethod("createOneClick", AuthenticatedUser.class, String.class, DeliveryRequests.OneClickOutbound.class); Method candidates = DeliveryTransferController.class.getMethod("oneClickCandidates", AuthenticatedUser.class, @@ -102,7 +133,7 @@ class PlatformDispatchOutboundContractTest { RequestParam pageCustomer = page.getParameters()[1].getAnnotation(RequestParam.class); assertNotNull(createCustomer); assertTrue(createCustomer.required()); - assertTrue(candidates.getParameters()[1].getAnnotation(RequestParam.class).required()); + assertFalse(candidates.getParameters()[1].getAnnotation(RequestParam.class).required()); assertFalse(candidates.getParameters()[2].getAnnotation(RequestParam.class).required()); assertNotNull(pageCustomer); assertFalse(pageCustomer.required()); @@ -181,6 +212,7 @@ class PlatformDispatchOutboundContractTest { assertTrue(yaml.contains("platform:overseas-warehouse:outbound:list")); assertTrue(yaml.contains("platform:overseas-warehouse:outbound:create")); assertTrue(yaml.contains("platform:overseas-warehouse:outbound:cancel")); + assertTrue(yaml.contains("vehicleSize: { type: string, nullable: true, enum: [15托, 30托] }")); } private static void assertPermission(Method method, String permission) { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/model/DeliveryStatusPolicyTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/model/DeliveryStatusPolicyTest.java index 69dc21cf3..94c949b2b 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/model/DeliveryStatusPolicyTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/model/DeliveryStatusPolicyTest.java @@ -8,10 +8,11 @@ class DeliveryStatusPolicyTest { @Test void exposesOnlyServerAuthorizedActionsForEachState() { assertThat(DeliveryStatusPolicy.allowedActions("WAIT_DELIVERY")) - .containsExactly("START_DELIVERY", "COMPLETE_DELIVERY", "REJECT_DELIVERY", "CANCEL_DELIVERY"); + .containsExactly("START_DELIVERY", "REJECT_DELIVERY", "CANCEL_DELIVERY"); assertThat(DeliveryStatusPolicy.allowedActions("DELIVERING")) - .containsExactly("COMPLETE_DELIVERY", "REJECT_DELIVERY", "MARK_RETURN"); + .containsExactly("REJECT_DELIVERY", "MARK_RETURN"); assertThat(DeliveryStatusPolicy.allowedActions("DELIVERED")).containsExactly("MARK_RETURN"); + assertThat(DeliveryStatusPolicy.isEditableTarget("DELIVERED")).isFalse(); assertThat(DeliveryStatusPolicy.allowedActions("RETURN_PENDING")).isEmpty(); assertThat(DeliveryStatusPolicy.allowedActions("CANCELLED")).isEmpty(); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryCompletedOutboxServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryCompletedOutboxServiceTest.java new file mode 100644 index 000000000..1251a4f31 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryCompletedOutboxServiceTest.java @@ -0,0 +1,59 @@ +package com.baiyue.lastleg.delivery.service; + +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.OverseasDeliveryCompletedV1; +import com.baiyue.lastleg.delivery.entity.DeliveryOrder; +import com.baiyue.lastleg.entity.LastlegEventOutbox; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.Instant; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +class DeliveryCompletedOutboxServiceTest { + @Test + void emitsOneEventPerSourceInboundAcrossFbaWarehouses() throws Exception { + LastlegEventOutboxMapper mapper = mock(LastlegEventOutboxMapper.class); + ObjectMapper json = new ObjectMapper().registerModule(new JavaTimeModule()); + DeliveryCompletedOutboxService service = new DeliveryCompletedOutboxService(); + ReflectionTestUtils.setField(service, "outbox", mapper); + ReflectionTestUtils.setField(service, "objectMapper", json); + DeliveryOrder delivery = new DeliveryOrder(); + delivery.setId(1L); delivery.setDeliveryNo("DL-1"); delivery.setVersion(2L); + + service.publish(delivery, List.of(order(11, "101", "201", "IN-1", "ONT8"), + order(12, "102", "202", "IN-2", "ACY2")), Instant.parse("2026-09-15T08:00:00Z")); + + ArgumentCaptor rows = ArgumentCaptor.forClass(LastlegEventOutbox.class); + verify(mapper, times(2)).insert(rows.capture()); + List payloads = rows.getAllValues().stream().map(row -> { + try { + EventEnvelope envelope = json.readValue(row.getPayloadJson(), EventEnvelope.class); + return json.treeToValue(envelope.payload(), OverseasDeliveryCompletedV1.class); + } catch (Exception exception) { throw new RuntimeException(exception); } + }).toList(); + assertThat(payloads).extracting(OverseasDeliveryCompletedV1::sourceInboundId) + .containsExactly("101", "102"); + assertThat(payloads).extracting(OverseasDeliveryCompletedV1::fbaWarehouseCode) + .containsExactly("ONT8", "ACY2"); + } + + private LastlegOrder order(long id, String inboundId, String orderId, String inboundCode, String fba) { + LastlegOrder value = new LastlegOrder(); + value.setId(id); value.setOrderNo("LM-" + id); value.setCustomerId("88"); + value.setSourceInboundId(inboundId); value.setInboundCode(inboundCode); + value.setSourceOrderId(orderId); value.setSourceOrderNo("FL-" + orderId); + value.setRootOrderId(orderId); value.setFbaWarehouseCode(fba); + return value; + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentServiceTest.java index c99dc56cb..ad3d0bcee 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentServiceTest.java @@ -5,6 +5,9 @@ import com.baiyue.lastleg.delivery.entity.*; import com.baiyue.lastleg.delivery.gateway.DeliveryDocumentFileGateway; import com.baiyue.lastleg.delivery.mapper.*; import com.baiyue.lastleg.delivery.model.*; +import com.baiyue.lastleg.customervisit.service.CustomerVisitService; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.*; import org.junit.jupiter.api.extension.ExtendWith; @@ -26,6 +29,9 @@ class DeliveryDocumentServiceTest { @Mock DeliveryDocumentRevisionMapper revisions; @Mock DeliveryOperationMapper operations; @Mock DeliveryDocumentFileGateway files; + @Mock LastlegOrderMapper lastlegOrders; + @Mock DeliveryCompletedOutboxService deliveryCompletedOutbox; + @Mock CustomerVisitService customerVisits; DeliveryDocumentService service; DeliveryOrder order; @BeforeEach void setUp() { @@ -35,6 +41,9 @@ class DeliveryDocumentServiceTest { ReflectionTestUtils.setField(service,"operations",operations); ReflectionTestUtils.setField(service,"files",files); ReflectionTestUtils.setField(service,"objectMapper",new ObjectMapper().findAndRegisterModules()); + ReflectionTestUtils.setField(service,"lastlegOrders",lastlegOrders); + ReflectionTestUtils.setField(service,"deliveryCompletedOutbox",deliveryCompletedOutbox); + ReflectionTestUtils.setField(service,"customerVisits",customerVisits); order = new DeliveryOrder(); order.setId(10L); order.setCustomerId("100"); order.setDeliveryNo("DL-1"); order.setVersion(0L); order.setStatus("DELIVERED"); } @@ -121,6 +130,27 @@ class DeliveryDocumentServiceTest { assertThat(result.uploadedCategoryCount()).isEqualTo(3); assertThat(result.totalCategoryCount()).isEqualTo(3); assertThat(result.categories()).allMatch(c->c.status().equals("UPLOADED")); } + @Test void savingReceiptAutomaticallyCompletesOnlyThisDeliveryAndItsBoundOrders() { + order.setStatus("WAIT_DELIVERY"); + LastlegOrder related = new LastlegOrder(); related.setId(20L); related.setCustomerId("100"); + related.setDeliveryOrderNo("DL-1"); related.setSubOrderStatus("DELIVERY:WAIT_DELIVERY"); + when(orders.lockByNo("100","DL-1")).thenReturn(order); + when(files.metadata("100",10,RECEIPT,13)).thenReturn(file(13)); + when(lastlegOrders.selectList(any())).thenReturn(List.of(related)); + + service.save("100","DL-1","9",request(0,"receipt",List.of(new Binding(RECEIPT,List.of(13L))))); + + assertThat(order.getStatus()).isEqualTo("DELIVERED"); + assertThat(order.getSignedAt()).isNotNull(); + assertThat(related.getSubOrderStatus()).isEqualTo("DELIVERY:DELIVERED"); + verify(lastlegOrders).updateById(related); + verify(deliveryCompletedOutbox).publish(eq(order), eq(List.of(related)), any()); + verify(customerVisits).ensureForCompletedDelivery(eq(order), any()); + ArgumentCaptor operation = ArgumentCaptor.forClass(DeliveryOperation.class); + verify(operations, times(2)).insert(operation.capture()); + assertThat(operation.getAllValues()).extracting(DeliveryOperation::getOperationNode) + .containsExactly("SAVE_DOCUMENTS", "RECEIPT_AUTO_COMPLETE"); + } private Save request(long version,String key,List bindings) { return new Save(version,key,"BOL-1",bindings); } private File file(long id) { return new File(id,"a.pdf","application/pdf",1,LocalDateTime.of(2026,9,9,8,0)); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java index 53dc27116..87d248ed6 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java @@ -91,6 +91,7 @@ class DeliveryTransferServiceTest { @BeforeEach void setUp() { service = new DeliveryTransferService(); + ReflectionTestUtils.setField(service, "objectMapper", new com.fasterxml.jackson.databind.ObjectMapper()); ReflectionTestUtils.setField(service, "dispatchOrders", dispatchOrders); ReflectionTestUtils.setField(service, "dispatchItems", dispatchItems); ReflectionTestUtils.setField(service, "outboundOrders", outboundOrders); @@ -116,7 +117,7 @@ class DeliveryTransferServiceTest { } @Test - void firstTransferCreatesOneOutboundOneDeliveryAndAllPalletSnapshots() { + void firstTransferCreatesOneOutboundAndOneDeliveryPerDestinationFbaWarehouse() { DispatchOrder dispatch = dispatch(101L, "DSP-101", "TENANT-A"); when(dispatchOrders.lockGlobalByDispatchNo("DSP-101")).thenReturn(dispatch); when(dispatchItems.findByDispatchOrder("TENANT-A", 101L)).thenReturn(List.of( @@ -131,7 +132,7 @@ class DeliveryTransferServiceTest { ArgumentCaptor delivery = ArgumentCaptor.forClass(DeliveryOrder.class); ArgumentCaptor pallets = ArgumentCaptor.forClass(OutboundPalletItem.class); verify(outboundOrders).insert(outbound.capture()); - verify(deliveryOrders).insert(delivery.capture()); + verify(deliveryOrders, org.mockito.Mockito.times(2)).insert(delivery.capture()); verify(outboundPallets, org.mockito.Mockito.times(2)).insert(pallets.capture()); assertEquals("WAIT_OUTBOUND", outbound.getValue().getStatus()); @@ -139,15 +140,18 @@ class DeliveryTransferServiceTest { assertEquals(20, outbound.getValue().getPackageCount()); assertEquals("transfer-101", outbound.getValue().getIdempotencyKey()); assertTrue(outbound.getValue().getOutboundNo().matches("OB-WH-\\d{8}-\\d{6}")); - assertEquals(outbound.getValue().getId(), delivery.getValue().getOutboundOrderId()); - assertEquals("WAIT_DELIVERY", delivery.getValue().getStatus()); - assertTrue(delivery.getValue().getDeliveryNo().matches("DL-WH-\\d{8}-\\d{6}")); + assertTrue(delivery.getAllValues().stream().allMatch(value -> outbound.getValue().getId().equals(value.getOutboundOrderId()))); + assertEquals(List.of("ONT8", "LAX9"), delivery.getAllValues().stream().map(DeliveryOrder::getDestinationWarehouseCode).toList()); + assertEquals(List.of(12, 8), delivery.getAllValues().stream().map(DeliveryOrder::getPackageCount).toList()); + assertTrue(delivery.getAllValues().stream().allMatch(value -> "WAIT_DELIVERY".equals(value.getStatus()))); assertEquals(List.of("ONT8", "LAX9"), pallets.getAllValues().stream() .map(OutboundPalletItem::getDestinationWarehouseCode).toList()); assertEquals(List.of("IN-A", "IN-B"), pallets.getAllValues().stream() .map(OutboundPalletItem::getInboundCode).toList()); assertEquals(20, result.packageCount()); assertEquals(2, result.palletCount()); + assertEquals(2, result.deliveries().size()); + verify(feeSources, org.mockito.Mockito.times(1)).publishApproved(any(), any(), any()); assertTrue(!result.replayed()); } @@ -192,14 +196,14 @@ class DeliveryTransferServiceTest { DeliveryOrder delivery = delivery(401L, outbound, "DEL-401"); when(dispatchOrders.lockGlobalByDispatchNo("DSP-101")).thenReturn(dispatch); when(outboundOrders.findByIdempotency("TENANT-A", "transfer-101")).thenReturn(outbound); - when(deliveryOrders.lockByOutbound("TENANT-A", 301L)).thenReturn(delivery); + when(deliveryOrders.lockByOutbound("TENANT-A", 301L)).thenReturn(List.of(delivery)); DeliveryResponses.TransferResult result = service.platformTransfer("DSP-101", "U-1", transferRequest(" transfer-101 ", null)); assertTrue(result.replayed()); assertEquals("OUT-301", result.outboundNo()); - assertEquals("DEL-401", result.deliveryNo()); + assertEquals("DEL-401", result.deliveries().get(0).deliveryNo()); verify(outboundOrders, never()).insert((OutboundOrder) any()); verify(outboundPallets, never()).insert((OutboundPalletItem) any()); verify(deliveryOrders, never()).insert((DeliveryOrder) any()); @@ -229,14 +233,14 @@ class DeliveryTransferServiceTest { item(201L, 501L, "PLT-501", "ONT8", "Ontario", "IN-A", 12))); when(outboundOrders.insert(any(OutboundOrder.class))).thenThrow(new org.springframework.dao.DuplicateKeyException("duplicate")); when(outboundOrders.findByIdempotency("TENANT-A", "transfer-101")).thenReturn(null, winner); - when(deliveryOrders.lockByOutbound("TENANT-A", 301L)).thenReturn(delivery); + when(deliveryOrders.lockByOutbound("TENANT-A", 301L)).thenReturn(List.of(delivery)); DeliveryResponses.TransferResult result = service.platformTransfer("DSP-101", "U-1", transferRequest("transfer-101", null)); assertTrue(result.replayed()); assertEquals("OUT-301", result.outboundNo()); - assertEquals("DEL-401", result.deliveryNo()); + assertEquals("DEL-401", result.deliveries().get(0).deliveryNo()); verify(outboundPallets, never()).insert((OutboundPalletItem) any()); verify(deliveryOrders, never()).insert((DeliveryOrder) any()); } @@ -291,7 +295,7 @@ class DeliveryTransferServiceTest { DeliveryResponses.OutboundDetail detail = service.outboundDetail("OUT-301"); assertEquals("OUTBOUNDED", detail.outbound().status()); - org.junit.jupiter.api.Assertions.assertNull(detail.delivery()); + org.junit.jupiter.api.Assertions.assertTrue(detail.deliveries().isEmpty()); assertEquals("IN-A", detail.pallets().get(0).inboundCode()); assertEquals(4, detail.pallets().get(0).packageCount()); } @@ -311,13 +315,13 @@ class DeliveryTransferServiceTest { OutboundPalletItem pallet = pallet(501L, "IP-ONT8-20260907-000001-P001", "ONT8", "Ontario", "IN-A", 12); pallet.setSkuCode("SKU-1"); when(outboundOrders.findGlobalByNo("OUT-301")).thenReturn(outbound); - when(deliveryOrders.lockByOutbound("TENANT-A", 301L)).thenReturn(delivery); + when(deliveryOrders.lockByOutbound("TENANT-A", 301L)).thenReturn(List.of(delivery)); when(outboundPallets.findByOutbound("TENANT-A", 301L)).thenReturn(List.of(pallet)); DeliveryResponses.OutboundDetail detail = service.outboundDetail("OUT-301"); assertEquals("OUT-301", detail.outbound().outboundNo()); - assertEquals("DEL-401", detail.delivery().deliveryNo()); + assertEquals("DEL-401", detail.deliveries().get(0).deliveryNo()); assertEquals(1, detail.pallets().size()); assertEquals("IP-ONT8-20260907-000001-P001", detail.pallets().get(0).palletNo()); assertEquals("IN-A", detail.pallets().get(0).inboundCode()); @@ -332,7 +336,7 @@ class DeliveryTransferServiceTest { OutboundOrder outbound = outbound(301L, 101L, "DSP-101", "OUT-301", "transfer-101"); DeliveryOrder delivery = delivery(401L, outbound, "DEL-401"); when(outboundOrders.findGlobalByNo("OUT-301")).thenReturn(outbound); - when(deliveryOrders.lockByOutbound("TENANT-A", 301L)).thenReturn(delivery); + when(deliveryOrders.lockByOutbound("TENANT-A", 301L)).thenReturn(List.of(delivery)); DeliveryResponses.OutboundDetail detail = service.outboundDetail("OUT-301"); @@ -360,13 +364,13 @@ class DeliveryTransferServiceTest { when(dispatchOrders.findByDispatchNo("TENANT-A", "DSP-101")).thenReturn(dispatch); when(dispatchOrders.lockById("TENANT-A", 101L)).thenReturn(dispatch); when(outboundOrders.findByIdempotency("TENANT-A", "merchant-key")).thenReturn(outbound); - when(deliveryOrders.lockByOutbound("TENANT-A", 301L)).thenReturn(delivery); + when(deliveryOrders.lockByOutbound("TENANT-A", 301L)).thenReturn(List.of(delivery)); DeliveryResponses.TransferResult result = service.merchantTransfer("TENANT-A", "DSP-101", "U-2", transferRequest(" merchant-key ", null)); assertTrue(result.replayed()); - assertEquals("DEL-401", result.deliveryNo()); + assertEquals("DEL-401", result.deliveries().get(0).deliveryNo()); verify(outboundOrders, never()).insert((OutboundOrder) any()); } @@ -395,6 +399,24 @@ class DeliveryTransferServiceTest { verify(dispatchItems, never()).findByDispatchOrder(any(), any()); } + @Test + void deliveryDetailAndBolSourceRowsContainOnlyItsDestinationFbaWarehouse() { + OutboundOrder outbound = outbound(301L, 101L, "DSP-101", "OUT-301", "transfer-101"); + DeliveryOrder ont8 = delivery(401L, outbound, "DEL-ONT8"); + DispatchItem ont8Item = item(201L, 501L, "PLT-501", "ONT8", "Ontario", "IN-A", 12); + ont8Item.setOrderId(901L); + DispatchItem lax9Item = item(202L, 502L, "PLT-502", "LAX9", "Los Angeles", "IN-B", 8); + lax9Item.setOrderId(902L); + when(deliveryOrders.findByNo("TENANT-A", "DEL-ONT8")).thenReturn(ont8); + when(dispatchItems.findByDispatchOrder("TENANT-A", 101L)).thenReturn(List.of(ont8Item, lax9Item)); + when(tailOrders.selectBatchIds(List.of(901L))).thenReturn(List.of()); + + DeliveryResponses.DeliveryDetail detail = service.deliveryDetail("TENANT-A", "DEL-ONT8"); + + assertEquals("ONT8", detail.order().destinationWarehouseCode()); + verify(tailOrders).selectBatchIds(List.of(901L)); + } + @Test void globalOutboundCancelIsIdempotentForAnUnexecutedOrder() { OutboundOrder outbound = outbound(301L, null, null, "OUT-301", "one-click-key"); @@ -440,6 +462,7 @@ class DeliveryTransferServiceTest { .thenReturn(List.of(new DropshipCarton())); InventoryPosition position = new InventoryPosition(); position.setId(900L + i); position.setCustomerId("TENANT-A"); position.setBalanceId(800L + i); + position.setOverseasWarehouseId("WH-1"); position.setPalletId(501L); position.setPalletNo("DP-SHARED"); InventoryBalance balance = new InventoryBalance(); balance.setCustomerId("TENANT-A"); balance.setInboundCode("IN-" + i); @@ -460,6 +483,10 @@ class DeliveryTransferServiceTest { @Test void oneClickOutboundRejectsSummarySkuWithoutAvailableDropshipCartons() { + var generator = org.mockito.Mockito.mock(LastlegOrderNoGenerator.class); + ReflectionTestUtils.setField(service, "orderNoGenerator", generator); + InventoryPosition position = new InventoryPosition(); position.setCustomerId("TENANT-A"); position.setOverseasWarehouseId("WH-1"); + when(inventoryPositions.selectById(901L)).thenReturn(position); DropshipOutboundCandidate candidate = dropshipCandidate(901L, 1001L, "WH-1", "PLT-501", "MIXED", 1L); when(dropshipProfiles.lockOutboundCandidate("TENANT-A", 901L, "MIXED")).thenReturn(candidate); when(dropshipCartons.lockAvailable("TENANT-A", 1001L, "MIXED", 1L)).thenReturn(List.of()); @@ -470,6 +497,45 @@ class DeliveryTransferServiceTest { assertEquals(409, exception.getStatusCode().value()); assertTrue(exception.getReason().contains("一件代发可用箱数不足")); + verify(outboundPallets, never()).insert(any(OutboundPalletItem.class)); + } + + @Test + void concurrentOneClickCreateReplaysCurrentCommittedRowBeforeTakingInventoryLocks() { + var generator = org.mockito.Mockito.mock(LastlegOrderNoGenerator.class); + ReflectionTestUtils.setField(service, "orderNoGenerator", generator); + var request = new DeliveryRequests.OneClickOutbound("recipient", List.of( + new DeliveryRequests.OneClickItem(901L, 2L, "SKU-1", List.of("A", "B"))), "remark", "race-key"); + OutboundOrder winner = outbound(301L, null, null, "OUT-WINNER", "race-key"); + winner.setOutboundType("ONE_CLICK_DROPSHIP"); winner.setRequestHash(service.oneClickRequestHash(request)); + OutboundPdaTask task = new OutboundPdaTask(); task.setId(701L); task.setTaskNo("PDA-WINNER"); task.setStatus("PENDING"); + InventoryPosition position = new InventoryPosition(); position.setCustomerId("TENANT-A"); position.setOverseasWarehouseId("WH-1"); + when(inventoryPositions.selectById(901L)).thenReturn(position); + when(outboundOrders.insert(any(OutboundOrder.class))).thenThrow(new org.springframework.dao.DuplicateKeyException("race")); + when(outboundOrders.lockByIdempotency("TENANT-A", "race-key")).thenReturn(winner); + when(pdaTasks.lockByOutbound("TENANT-A", 301L)).thenReturn(task); + assertEquals("PDA-WINNER", service.createOneClick("TENANT-A", "U-1", request).taskNo()); + verify(dropshipProfiles, never()).lockOutboundCandidate(any(), any(), any()); + verify(outboundPallets, never()).insert(any(OutboundPalletItem.class)); + verify(pdaTasks, never()).insert(any(OutboundPdaTask.class)); + } + + @Test + void oneClickReplayRejectsChangedRecipientItemsMarksAndRemark() { + var item = new DeliveryRequests.OneClickItem(901L, 2L, "SKU-1", List.of("A", "B")); + var request = new DeliveryRequests.OneClickOutbound("recipient", List.of(item), "remark", "key"); + OutboundOrder existing = outbound(301L, null, null, "OUT-1", "key"); + existing.setOutboundType("ONE_CLICK_DROPSHIP"); existing.setRequestHash(service.oneClickRequestHash(request)); + when(outboundOrders.findByIdempotency("TENANT-A", "key")).thenReturn(existing); + for (var changed : List.of( + new DeliveryRequests.OneClickOutbound("changed", List.of(item), "remark", "key"), + new DeliveryRequests.OneClickOutbound("recipient", List.of(item), "changed", "key"), + new DeliveryRequests.OneClickOutbound("recipient", List.of(new DeliveryRequests.OneClickItem(902L, 2L, "SKU-1", List.of("A", "B"))), "remark", "key"), + new DeliveryRequests.OneClickOutbound("recipient", List.of(new DeliveryRequests.OneClickItem(901L, 2L, "SKU-1", List.of("A", "C"))), "remark", "key"))) { + assertEquals(409, assertThrows(ResponseStatusException.class, + () -> service.createOneClick("TENANT-A", "U-1", changed)).getStatusCode().value()); + } + verify(inventoryPositions, never()).selectById(any()); verify(outboundOrders, never()).insert(any(OutboundOrder.class)); } @@ -491,9 +557,22 @@ class DeliveryTransferServiceTest { verify(inventoryPositions, never()).selectById(any()); } + @Test + void oneClickCandidatesAllowPlatformWarehouseQueryWithoutCustomer() { + DropshipOutboundCandidate first = dropshipCandidate(901L, 1001L, "WH-1", "DS-A001", "SKU-1", 1L); + DropshipOutboundCandidate second = dropshipCandidate(902L, 1002L, "WH-1", "DS-A002", "SKU-1", 3L); + when(dropshipProfiles.listOutboundCandidates(null, "WH-1")).thenReturn(List.of(first, second)); + + var result = service.oneClickCandidates(null, " WH-1 "); + + assertEquals(2, result.size()); + assertEquals("DS-A001", result.get(0).palletNo()); + assertEquals("DS-A002", result.get(1).palletNo()); + verify(dropshipProfiles).listOutboundCandidates(null, "WH-1"); + } + @Test void oneClickCreationRejectsOrdinaryInventoryBeforeAnyWrite() { - when(dropshipProfiles.lockOutboundCandidate("TENANT-A", 901L, "MIXED")).thenReturn(null); ResponseStatusException exception = assertThrows(ResponseStatusException.class, () -> service.createOneClick( "TENANT-A", "U-1", new DeliveryRequests.OneClickOutbound("recipient", List.of( @@ -619,6 +698,12 @@ class DeliveryTransferServiceTest { assertEquals("SKU-1", projected.skuCode()); assertEquals("IN-A", projected.inboundCode()); assertEquals(1L, projected.availablePackages()); + + item.setCartonMarks("CTN-B"); + assertEquals(List.of("CTN-B"), service.pdaTaskDetail("WH-1", "PDA-701").items().get(0).positions() + .stream().map(DeliveryResponses.PdaPickPosition::cartonMark).toList()); + second.setStatus("SHIPPED"); + assertTrue(service.pdaTaskDetail("WH-1", "PDA-701").items().get(0).positions().isEmpty()); } @Test @@ -647,6 +732,7 @@ class DeliveryTransferServiceTest { DeliveryResponses.PdaTaskDetail detail = service.pdaTaskDetail("WH-1", "PDA-701"); assertEquals("CTN-A", detail.items().get(0).positions().get(0).cartonMark()); + assertEquals("SKU-1", detail.items().get(0).positions().get(0).skuCode()); } @Test @@ -658,13 +744,15 @@ class DeliveryTransferServiceTest { OutboundPdaTask task = new OutboundPdaTask(); task.setId(701L); task.setCustomerId("TENANT-A"); task.setOutboundOrderId(301L); task.setTaskNo("PDA-701"); task.setStatus("PENDING"); OutboundPalletItem item = pallet(501L, "DP-1", "ONT8", "Ontario", "IN-A", 2); - item.setSkuCode("SKU-1"); + item.setSkuCode("SKU-1"); item.setCartonMarks("BOX-A,BOX-B"); InventoryPosition position = new InventoryPosition(); position.setId(901L); position.setCustomerId("TENANT-A"); position.setBalanceId(801L); position.setPalletId(501L); position.setOverseasWarehouseId("WH-1"); InventoryBalance balance = new InventoryBalance(); balance.setCustomerId("TENANT-A"); balance.setInboundCode("IN-A"); DropshipOrderProfile profile = new DropshipOrderProfile(); profile.setId(1001L); DropshipCarton first = new DropshipCarton(); first.setId(1101L); first.setProductQuantity(7L); DropshipCarton second = new DropshipCarton(); second.setId(1102L); second.setProductQuantity(13L); + first.setStatus("AVAILABLE"); first.setSkuCode("SKU-1"); first.setCartonMark("BOX-A"); + second.setStatus("AVAILABLE"); second.setSkuCode("SKU-1"); second.setCartonMark("BOX-B"); when(pdaTasks.lockByNoAndWarehouse("PDA-701", "WH-1")).thenReturn(task); when(outboundOrders.selectById(301L)).thenReturn(outbound); when(outboundOrders.lockByNo("TENANT-A", "OUT-301")).thenReturn(outbound); @@ -672,12 +760,18 @@ class DeliveryTransferServiceTest { when(inventoryPositions.selectById(901L)).thenReturn(position); when(inventoryBalances.selectById(801L)).thenReturn(balance); when(dropshipProfiles.findPosition("TENANT-A", 901L)).thenReturn(profile); - when(dropshipCartons.lockAvailable("TENANT-A", 1001L, "SKU-1", 1L)).thenReturn(List.of(first), List.of(second)); + when(dropshipCartons.findByProfileAndMark("TENANT-A", 1001L, "BOX-A")).thenReturn(first); + when(dropshipCartons.findByProfileAndMark("TENANT-A", 1001L, "BOX-B")).thenReturn(second); when(dropshipSkuInventory.deduct("TENANT-A", 1001L, "SKU-1", 1L, 7L)).thenReturn(1); when(dropshipSkuInventory.deduct("TENANT-A", 1001L, "SKU-1", 1L, 13L)).thenReturn(1); when(outboundPallets.findByOutbound("TENANT-A", 301L)).thenReturn(List.of(item)); - service.pdaPick("WH-1", "U-1", "PDA-701", new DeliveryRequests.PdaPick(item.getId(), 901L, 1L, "pick-7")); - service.pdaPick("WH-1", "U-1", "PDA-701", new DeliveryRequests.PdaPick(item.getId(), 901L, 1L, "pick-13")); + assertThrows(ResponseStatusException.class, () -> service.pdaPick("WH-1", "U-1", "PDA-701", + new DeliveryRequests.PdaPick(item.getId(), 901L, 1L, "wrong-mark", List.of("WRONG-BOX")))); + service.pdaPick("WH-1", "U-1", "PDA-701", new DeliveryRequests.PdaPick(item.getId(), 901L, 1L, "pick-7", List.of("BOX-A"))); + assertEquals(1, item.getPickedPackageCount()); assertEquals("AVAILABLE", second.getStatus()); + assertThrows(ResponseStatusException.class, () -> service.pdaPick("WH-1", "U-1", "PDA-701", + new DeliveryRequests.PdaPick(item.getId(), 901L, 1L, "repeat-box-new-key", List.of("BOX-A")))); + service.pdaPick("WH-1", "U-1", "PDA-701", new DeliveryRequests.PdaPick(item.getId(), 901L, 1L, "pick-13", List.of("BOX-B"))); verify(dropshipSkuInventory).deduct("TENANT-A", 1001L, "SKU-1", 1L, 7L); verify(dropshipSkuInventory).deduct("TENANT-A", 1001L, "SKU-1", 1L, 13L); assertEquals("SHIPPED", first.getStatus()); assertEquals("SHIPPED", second.getStatus()); @@ -779,6 +873,8 @@ class DeliveryTransferServiceTest { value.setOutboundOrderId(outbound.getId()); value.setOutboundNo(outbound.getOutboundNo()); value.setDispatchOrderId(outbound.getDispatchOrderId()); value.setDispatchNo(outbound.getDispatchNo()); value.setOverseasWarehouseId(outbound.getOverseasWarehouseId()); value.setStatus("WAIT_DELIVERY"); + value.setDestinationWarehouseId("DEST-ONT8"); value.setDestinationWarehouseCode("ONT8"); + value.setDestinationWarehouseName("Ontario"); value.setDeliveryGroupKey("CODE:ONT8"); value.setPalletCount(outbound.getPalletCount()); value.setPackageCount(outbound.getPackageCount()); value.setCreatedAt(LocalDateTime.now()); return value; diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/SmartDispatchOpenApiContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/SmartDispatchOpenApiContractTest.java index 4817f8d90..24fac6a54 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/SmartDispatchOpenApiContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/SmartDispatchOpenApiContractTest.java @@ -25,11 +25,18 @@ class SmartDispatchOpenApiContractTest { assertTrue(yaml.contains("/api/platform/v1/last-leg/dispatches/drafts/{draftToken}/containers/{containerId}:")); assertTrue(yaml.contains("draftContainerId:")); assertTrue(yaml.contains("SmartDispatchDraftContainer:")); + assertTrue(yaml.contains("capacity: { type: integer, enum: [15, 30] }")); assertTrue(yaml.contains("SmartDispatchDraft:")); assertTrue(yaml.contains("platform:overseas-warehouse:inbound-order:list")); assertTrue(yaml.contains("platform:overseas-warehouse:inbound-order:smart-dispatch")); assertTrue(yaml.contains("SmartDispatchPreArrange")); assertTrue(yaml.contains("SmartDispatchCandidate")); + String preArrangeSchema = yaml.substring(yaml.indexOf(" SmartDispatchPreArrange:"), + yaml.indexOf(" SmartDispatchDraftPallet:")); + String updateSchema = yaml.substring(yaml.indexOf(" DispatchUpdate:"), + yaml.indexOf(" DeliveryTransfer:")); + assertFalse(preArrangeSchema.contains("route:")); + assertFalse(updateSchema.contains("route:")); assertTrue(yaml.contains("name: planIds")); String planIdsParameter = yaml.substring(yaml.indexOf("name: planIds"), yaml.indexOf("name: orderNo", yaml.indexOf("name: planIds"))); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchControllerTest.java index aef9e536f..4fb109ff9 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchControllerTest.java @@ -53,7 +53,7 @@ class SmartDispatchControllerTest { @Test void preArrangeResolvesSingleCustomerAndAuthenticatedOperator() { DispatchRequests.PreArrange request = new DispatchRequests.PreArrange( - List.of(10L, 11L), "ONT8 -> LAX9", "APT-1", "SUCCESS", "PENDING"); + List.of(10L, 11L), "APT-1", "SUCCESS", "PENDING"); when(service.resolveCustomerScopeForPallets(List.of(10L, 11L))).thenReturn("100"); controller.preArrange(platform(), request); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintServiceTest.java index bd39901b4..c0d40b167 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintServiceTest.java @@ -75,4 +75,34 @@ class DispatchBolPrintServiceTest { assertThat(document.shipFromAddress()).isEqualTo( "10767 7th Street, Rancho Cucamonga, California 91730, US")); } + + @Test + void createsOneBolDocumentPerFbaWarehouse() { + String warehouseId = "193000000000200003"; + DispatchResponse detail = new DispatchResponse(1L, "DS-WH-20260911-624449", "DEV-23653906", + null, null, "五月开发验收车队", null, null, warehouseId, "洛杉矶仓", null, + null, null, null, LocalDateTime.of(2026, 9, 12, 2, 40, 20), "PENDING_DEPARTURE", + 2, 2, 2, 0, 0, 2, null, LocalDateTime.of(2026, 9, 11, 10, 0), List.of(), + List.of( + new DispatchResponse.BolLine("TSTU3539519", "DP-001", "ABE2", "ABE2 Warehouse", + "NYEXX2609110214", null, null, 1), + new DispatchResponse.BolLine("TSTU3539519", "DP-002", "ONT8", "ONT8 Warehouse", + "NYEXX2609110215", null, null, 1))); + var warehouse = new DomesticWarehouseDirectoryClient.Warehouse(Long.parseLong(warehouseId), 0L, + "US-LAX", "洛杉矶仓", "OVERSEAS", "US", "ACTIVE"); + when(dispatches.platformDetail(detail.dispatchNo())).thenReturn(detail); + when(warehouses.activeWarehouse(Long.parseLong(warehouseId), "internal-test-token")) + .thenReturn(ApiResponse.success(new DomesticWarehouseDirectoryClient.Detail(warehouse, List.of()))); + when(print.generateDispatchBol(eq("internal-test-token"), eq("request-2"), any())) + .thenReturn(ResponseEntity.ok().contentType(MediaType.APPLICATION_PDF) + .header("X-Print-Page-Count", "2").body("%PDF-".getBytes(StandardCharsets.US_ASCII))); + + service.generate(detail.dispatchNo(), "request-2"); + + ArgumentCaptor request = ArgumentCaptor.forClass(PrintPdfClient.Request.class); + verify(print).generateDispatchBol(eq("internal-test-token"), eq("request-2"), request.capture()); + assertThat(request.getValue().documents().stream() + .map(PrintPdfClient.Document::fbaWarehouseCode).toList()) + .containsExactly("ABE2", "ONT8"); + } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java index 1d3aa7415..f40235374 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java @@ -101,6 +101,7 @@ class DispatchServiceTest { assertNotNull(DispatchService.class.getMethod("preArrange", String.class, String.class, DispatchRequests.PreArrange.class).getAnnotation(Transactional.class)); when(candidates.lockSelected("100", List.of(10L))).thenReturn(List.of(candidate(10L, null, "ONT8"))); + when(items.findByDispatchOrder(eq("100"), any())).thenReturn(List.of(item(11L, "ONT8"))); var result = service.preArrange("100", "7", new DispatchRequests.PreArrange(List.of(10L), null, null, null, null)); @@ -119,6 +120,22 @@ class DispatchServiceTest { verify(items).insert(any(DispatchItem.class)); } + @Test + void preArrangeBuildsCommaSeparatedDistinctFbaRouteFromAssignedPallets() { + when(candidates.lockSelected("100", List.of(10L, 11L, 12L))).thenReturn(List.of( + candidate(10L, null, "ONT8"), candidate(11L, null, "ABE4"), candidate(12L, null, "ONT8"))); + when(items.findByDispatchOrder(eq("100"), any())).thenReturn(List.of( + item(21L, "ONT8"), item(22L, "ABE4"), item(23L, "ONT8"))); + + DispatchResponse result = service.preArrange("100", "7", + new DispatchRequests.PreArrange(List.of(10L, 11L, 12L), null, null, null)); + + assertEquals("ONT8,ABE4", result.route()); + ArgumentCaptor dispatch = ArgumentCaptor.forClass(DispatchOrder.class); + verify(orders).insert(dispatch.capture()); + assertEquals("ONT8,ABE4", dispatch.getValue().getRoute()); + } + @Test void movingPalletIntoDraftImmediatelyCreatesExpiringExclusiveLock() { when(candidates.lockSelected("100", List.of(10L))).thenReturn(List.of(candidate(10L, null, "ONT8"))); @@ -197,7 +214,7 @@ class DispatchServiceTest { when(locks.lockActive("100", "PLAN:10")).thenReturn(lock); when(drafts.lockActive("draft-1", "7")).thenReturn(draft); - service.preArrange("100", "7", new DispatchRequests.PreArrange(List.of(10L), null, null, null, null, "draft-1")); + service.preArrange("100", "7", new DispatchRequests.PreArrange(List.of(10L), null, null, null, "draft-1")); assertNotNull(lock.getDispatchOrderId()); assertEquals("draft-1", lock.getDraftToken()); @@ -252,6 +269,17 @@ class DispatchServiceTest { verify(locks).findActiveByDraftContainer("draft-1", 21L); } + @Test + void draftContainerOnlyAcceptsFifteenOrThirtyPalletVehicles() { + ResponseStatusException exception = assertThrows(ResponseStatusException.class, + () -> service.createDraftContainer("7", new DispatchRequests.DraftContainer("draft-1", 14))); + + assertEquals(409, exception.getStatusCode().value()); + assertEquals("车辆大小仅支持15托或30托", exception.getReason()); + verify(drafts, never()).lockActive(any(), any()); + verify(draftContainers, never()).insert(any(DispatchDraftContainer.class)); + } + @Test void rejectsPalletAlreadyLockedByAnotherDispatch() { when(candidates.lockSelected("100", List.of(10L))).thenReturn(List.of(candidate(10L, null, "ONT8"))); @@ -315,13 +343,18 @@ class DispatchServiceTest { dispatch.setId(1L); dispatch.setCustomerId("100"); dispatch.setDispatchNo("PC1"); dispatch.setStatus(DispatchService.PENDING_DEPARTURE); when(orders.lockGlobalByDispatchNo("PC1")).thenReturn(dispatch); - DispatchRequests.Update request = new DispatchRequests.Update("ABC-1", "Driver", null, null, null, + DispatchItem ont8 = item(11L, "ONT8"); + DispatchItem abe4 = item(12L, "ABE4"); + when(items.findByDispatchOrder("100", 1L)).thenReturn(List.of(ont8, abe4, item(13L, "ONT8"))); + DispatchRequests.Update request = new DispatchRequests.Update("ABC-1", "Driver", null, null, null, null, null, LocalDateTime.now()); DispatchResponse result = service.update("PC1", request); assertEquals(DispatchService.PENDING_DEPARTURE, dispatch.getStatus()); assertEquals("ABC-1", result.vehiclePlate()); + assertEquals("ONT8,ABE4", result.route()); + assertEquals("ONT8,ABE4", dispatch.getRoute()); verify(orders).updateById(dispatch); verify(items).findByDispatchOrder("100", 1L); } @@ -332,7 +365,7 @@ class DispatchServiceTest { dispatch.setId(1L); dispatch.setCustomerId("100"); dispatch.setDispatchNo("PC1"); dispatch.setStatus("DEPARTED"); when(orders.lockGlobalByDispatchNo("PC1")).thenReturn(dispatch); - DispatchRequests.Update request = new DispatchRequests.Update(null, null, null, null, null, + DispatchRequests.Update request = new DispatchRequests.Update(null, null, null, null, null, null, null, null); ResponseStatusException exception = assertThrows(ResponseStatusException.class, @@ -431,4 +464,11 @@ class DispatchServiceTest { "LIP1-P001", destination, destination, destination, destination, packageCount, new BigDecimal("1.2000"), warehousePalletId == null ? "PLAN" : "RECEIVED"); } + + private DispatchItem item(long id, String fbaWarehouseCode) { + DispatchItem item = new DispatchItem(); + item.setId(id); + item.setFbaWarehouseCode(fbaWarehouseCode); + return item; + } } 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..bc2e38cc8 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 @@ -846,6 +853,40 @@ class ReceivingContainerServiceBehaviorTest { .containsExactly("SKU-A", "SKU-B", "SKU-C"); } + @Test + void mixedContainerFbaBulkNeverReceivesDropshipOrOrdinaryOverseasLines() { + InboundPlanLine fba = line("FBA"); + InboundPlanLine dropship = line("FBA"); + dropship.setId(20L); dropship.setOrderId(2L); dropship.setInboundCode("IN-DROPSHIP"); + InboundPlanLine overseas = line("OVERSEAS"); + overseas.setId(30L); overseas.setOrderId(3L); overseas.setInboundCode("IN-OVERSEAS"); + LastlegOrder dropshipOrder = order("FBA"); + dropshipOrder.setId(2L); dropshipOrder.setOrderType("ONE_CLICK_DROPSHIP"); + LastlegOrder overseasOrder = order("OVERSEAS"); overseasOrder.setId(3L); + when(sessions.lockActiveByOperator("OPERATOR:7")).thenReturn(session("CONT-A", "SIGNED")); + when(plans.selectList(any())).thenReturn(List.of(plan("CONT-A"))); + when(lines.selectList(any())).thenReturn(List.of(dropship, overseas, fba)); + when(orders.selectById(1L)).thenReturn(order("FBA")); + when(orders.selectById(2L)).thenReturn(dropshipOrder); + when(orders.selectById(3L)).thenReturn(overseasOrder); + when(orderDetails.selectList(any())).thenReturn(List.of(detail("FBA-SKU", 2))); + stubReservedFbaPallet(); + when(workflow.receiveBulkResolved(any(), any(), any(), any(), anyInt(), any(), any())) + .thenAnswer(invocation -> invocation.getArgument(4)); + when(sessions.updateById(any(ReceivingContainerSession.class))).thenReturn(1); + when(operations.insert(any(ReceivingContainerOperation.class))).thenReturn(1); + + var result = service.receiveBulk("7", "receiver", + new ReceivingContainerRequests.BulkReceive("IN-1", "PAL-1", 2, "mixed-fba-receive")); + + assertThat(result.acceptedPackages()).isEqualTo(2); + ArgumentCaptor captured = ArgumentCaptor.forClass(InboundPlanLine.class); + verify(workflow).receiveBulkResolved(any(), any(), captured.capture(), any(), + org.mockito.ArgumentMatchers.eq(2), any(), any()); + assertThat(captured.getValue()).isSameAs(fba); + verifyNoInteractions(dropshipProfiles, dropshipCartons); + } + @Test void dropshipCartonResolvesItsCompleteDpInsteadOfAPlanOrCustomer() { stubCurrent("OVERSEAS", "CONT-A"); @@ -898,12 +939,36 @@ class ReceivingContainerServiceBehaviorTest { assertThat(result.receivedPackages()).isEqualTo(2); assertThat(result.pendingPackages()).isZero(); assertThat(result.palletCode()).isEqualTo("DP-260909-0001"); + var duplicate = service.receiveWholePallet("7", "receiver", + new ReceivingContainerRequests.WholePalletReceive("DP-260909-0001", "whole-new-key")); + assertThat(duplicate.result()).isEqualTo("ALREADY_RECEIVED"); + assertThat(duplicate.acceptedPackages()).isZero(); + verify(sessions, org.mockito.Mockito.times(1)).updateById(any(ReceivingContainerSession.class)); verify(workflow).pickWholePallet(org.mockito.ArgumentMatchers.eq("100"), org.mockito.ArgumentMatchers.eq("PLAN-1"), org.mockito.ArgumentMatchers.eq(30L), any(com.baiyue.lastleg.inboundplan.pda.dto.PdaRequests.WholePalletOperation.class)); 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/looseboard/service/LocalLooseboardServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardServiceTest.java index cc5d4f4aa..c09d2c968 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardServiceTest.java @@ -1,6 +1,7 @@ package com.baiyue.lastleg.looseboard.service; import com.baiyue.lastleg.looseboard.dto.LocalLooseboardRequests; +import com.baiyue.common.core.exception.BizException; import com.baiyue.lastleg.looseboard.entity.LocalLooseboardHistory; import com.baiyue.lastleg.looseboard.entity.LocalLooseboardOrder; import com.baiyue.lastleg.looseboard.entity.LocalLooseboardOperation; @@ -85,6 +86,7 @@ class LocalLooseboardServiceTest { assertEquals("100", detail.summary().customerId()); assertEquals(LocalLooseboardService.PENDING_ACCEPTANCE, detail.summary().status()); assertEquals("SHARED_TRUCK", detail.summary().transportMode()); + assertEquals("40HQ", detail.summary().containerType()); verify(lastlegOrders).insert(org.mockito.ArgumentMatchers.argThat(value -> "LOCAL_LOOSEBOARD".equals(value.getOrderType()) && value.getSourceOrderNo().equals("LB-WH-20260901-000001") @@ -94,6 +96,22 @@ class LocalLooseboardServiceTest { "CREATE".equals(value.getOperationType()))); } + @Test + void createDefaultsTheContainerTypeAndRejectsRemovedTypes() { + AtomicReference saved = new AtomicReference<>(); + when(orders.insert(any(LocalLooseboardOrder.class))).thenAnswer(call -> { + saved.set(call.getArgument(0)); + return 1; + }); + when(orders.findNo(anyString(), anyString())).thenAnswer(call -> saved.get()); + when(histories.listOrder(anyString(), any())).thenReturn(List.of()); + + var detail = service.create("100", "7", create("default-type", null)); + + assertEquals("40HQ", detail.summary().containerType()); + assertThrows(BizException.class, () -> service.create("100", "7", create("retired-type", "40HC"))); + } + @Test void ordinaryUpdateCannotChangeAcceptedOrder() { LocalLooseboardOrder order = order(LocalLooseboardService.DISPATCHED, 0L); @@ -174,8 +192,12 @@ class LocalLooseboardServiceTest { } private LocalLooseboardRequests.Create create(String key) { + return create(key, "40HQ"); + } + + private LocalLooseboardRequests.Create create(String key, String containerType) { return new LocalLooseboardRequests.Create("SHARED_TRUCK", "YES", "WAREHOUSE_TO_WAREHOUSE", - "Los Angeles", "Address A", "Ontario", "Address B", "40HQ", 12, + "Los Angeles", "Address A", "Ontario", "Address B", containerType, 12, new BigDecimal("100.000"), new BigDecimal("2.500000"), new BigDecimal("500.00"), "USD", "Alice", "+1-555-0100", LocalDate.now().plusDays(2), null, key); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelDocumentServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelDocumentServiceTest.java index af5f21d97..40f12255f 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelDocumentServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelDocumentServiceTest.java @@ -28,11 +28,13 @@ class RelabelDocumentServiceTest { when(applications.lockNo("101","RL-1")).thenReturn(application);when(applications.findNo("101","RL-1")).thenReturn(application); } @Test void totalUploadPersistsReturnedMetadataAndExposesSummary(){ - when(files.upload("101","OVERSEAS_RELABEL_TOTAL_DOCUMENT","11",7L,pdf)).thenReturn(new RelabelDocumentFileGateway.UploadedFile(99L,"labels.pdf","application/pdf",15)); + when(files.upload("101","OVERSEAS_RELABEL_CARTON_TOTAL_DOCUMENT","11",7L,pdf)).thenReturn(new RelabelDocumentFileGateway.UploadedFile(99L,"labels.pdf","application/pdf",15)); var result=service.uploadTotalFile("101","RL-1",7L,pdf); assertThat(result.application().totalFileId()).isEqualTo("99"); assertThat(result.application().totalFileName()).isEqualTo("labels.pdf"); assertThat(result.application().totalFileUrl()).endsWith("/files/99/download?scope=TOTAL"); + assertThat(application.getCartonTotalFileId()).isEqualTo("99"); + verify(files).upload("101","OVERSEAS_RELABEL_CARTON_TOTAL_DOCUMENT","11",7L,pdf); verify(applications).updateById(application); } @Test void itemUploadBindsOwnCustomerApplicationAndDistinctCategory(){ diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/security/Phase5AdminPermissionContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/security/Phase5AdminPermissionContractTest.java index 7566c3960..ca1d65678 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/security/Phase5AdminPermissionContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/security/Phase5AdminPermissionContractTest.java @@ -14,6 +14,7 @@ import java.util.Map; import java.util.stream.Collectors; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; class Phase5AdminPermissionContractTest { @@ -34,14 +35,14 @@ class Phase5AdminPermissionContractTest { } @Test - void customerVisitAndDeliveryStatusEndpointsUseIndependentAuthorities() { + void customerVisitAndEditableDeliveryStatusEndpointsUseIndependentAuthorities() { Map permissions = permissions(CustomerVisitController.class); assertEquals(authority(OverseasPermissions.CUSTOMER_VISIT_LIST), permissions.get("page")); assertEquals(authority(OverseasPermissions.CUSTOMER_VISIT_LIST), permissions.get("detail")); assertEquals(authority(OverseasPermissions.CUSTOMER_VISIT_EDIT), permissions.get("save")); assertEquals(authority(OverseasPermissions.CUSTOMER_VISIT_EDIT), permissions.get("upload")); - assertEquals(authority(OverseasPermissions.DELIVERY_STATUS), permissions.get("completeDelivery")); + assertFalse(permissions.containsKey("completeDelivery")); assertEquals(authority(OverseasPermissions.DELIVERY_STATUS), permissions.get("updateDeliveryStatus")); } @@ -76,6 +77,7 @@ class Phase5AdminPermissionContractTest { }) { assertTrue(yaml.contains(path), path); } + assertFalse(yaml.contains("/api/platform/v1/last-leg/delivery-orders/{deliveryNo}/complete:")); } private static Map permissions(Class controller) { 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/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/service/UsTruckOrderServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/service/UsTruckOrderServiceTest.java index a64b113c6..971e5975a 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/service/UsTruckOrderServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/service/UsTruckOrderServiceTest.java @@ -1,5 +1,7 @@ package com.baiyue.lastleg.trucking.service; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.lastleg.service.LastlegOrderNoGenerator; import com.baiyue.lastleg.trucking.dto.UsTruckOrderRequests; import com.baiyue.lastleg.trucking.entity.UsTruckOrder; import com.baiyue.lastleg.trucking.entity.UsTruckOrderOperation; @@ -18,6 +20,8 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.test.util.ReflectionTestUtils; import org.springframework.web.server.ResponseStatusException; +import java.math.BigDecimal; +import java.time.LocalDate; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.HashMap; @@ -31,6 +35,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.when; +import static org.mockito.Mockito.lenient; @ExtendWith(MockitoExtension.class) class UsTruckOrderServiceTest { @@ -38,6 +43,7 @@ class UsTruckOrderServiceTest { @Mock UsTruckOrderPalletMapper pallets; @Mock UsTruckOrderStatusHistoryMapper histories; @Mock UsTruckOrderOperationMapper operations; + @Mock LastlegOrderNoGenerator orderNoGenerator; private final List palletRows = new ArrayList<>(); private final List historyRows = new ArrayList<>(); private final Map operationRows = new HashMap<>(); @@ -52,20 +58,21 @@ class UsTruckOrderServiceTest { ReflectionTestUtils.setField(service, "histories", histories); ReflectionTestUtils.setField(service, "operations", operations); ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules()); + ReflectionTestUtils.setField(service, "orderNoGenerator", orderNoGenerator); order = order(UsTruckOrderService.PENDING_REVIEW, 0L, 2); - when(orders.lockNo("100", "TK1")).thenAnswer(call -> order); + lenient().when(orders.lockNo("100", "TK1")).thenAnswer(call -> order); when(orders.findNo("100", "TK1")).thenAnswer(call -> order); - when(pallets.listOrder("100", 1L)).thenAnswer(call -> palletRows); - when(histories.listOrder("100", 1L)).thenAnswer(call -> historyRows); + when(pallets.listOrder(anyString(), anyLong())).thenAnswer(call -> palletRows); + lenient().when(histories.listOrder("100", 1L)).thenAnswer(call -> historyRows); when(histories.insert(any(UsTruckOrderStatusHistory.class))).thenAnswer(call -> { historyRows.add(call.getArgument(0)); return 1; }); - when(pallets.insert(any(UsTruckOrderPallet.class))).thenAnswer(call -> { + lenient().when(pallets.insert(any(UsTruckOrderPallet.class))).thenAnswer(call -> { UsTruckOrderPallet row = call.getArgument(0); palletRows.add(row); return 1; }); - when(operations.findKey(anyString(), anyLong(), anyString())) + lenient().when(operations.findKey(anyString(), anyLong(), anyString())) .thenAnswer(call -> operationRows.get(call.getArgument(2))); - when(operations.insert(any(UsTruckOrderOperation.class))).thenAnswer(call -> { + lenient().when(operations.insert(any(UsTruckOrderOperation.class))).thenAnswer(call -> { UsTruckOrderOperation row = call.getArgument(0); operationRows.put(row.getIdempotencyKey(), row); return 1; }); UsTruckOrderStatusHistory created = new UsTruckOrderStatusHistory(); @@ -73,6 +80,21 @@ class UsTruckOrderServiceTest { created.setToStatus(UsTruckOrderService.PENDING_REVIEW); historyRows.add(created); } + @Test + void createDefaultsTheContainerTypeAndRejectsRemovedTypes() { + when(orderNoGenerator.next(anyString(), anyString(), anyLong())).thenReturn("TK1"); + when(histories.listOrder(anyString(), anyLong())).thenReturn(historyRows); + when(orders.insert(any(UsTruckOrder.class))).thenAnswer(call -> { + order = call.getArgument(0); + return 1; + }); + + var detail = service.create("100", "operator", create(null, "default-type")); + + assertEquals("40HQ", detail.summary().containerType()); + assertThrows(BizException.class, () -> service.create("100", "operator", create("40HC", "retired-type"))); + } + @Test void completeLifecycleUsesFactsIdempotencyOperatorsAndCannotReverseTerminal() { var review = new UsTruckOrderRequests.Review(0L, "APPROVE", "review-1", "approve"); @@ -165,6 +187,12 @@ class UsTruckOrderServiceTest { return new UsTruckOrderRequests.Command(version, key, key); } + private UsTruckOrderRequests.Create create(String containerType, String key) { + return new UsTruckOrderRequests.Create("PORT_PICKUP", "Los Angeles", null, "Ontario", null, + containerType, 1, 0, BigDecimal.ZERO, BigDecimal.ZERO, null, "USD", "Alice", + "+1-555-0100", LocalDate.now().plusDays(1), null, key); + } + private UsTruckOrder order(String status, long version, int pallets) { UsTruckOrder value = new UsTruckOrder(); value.setId(1L); value.setCustomerId("100"); value.setOrderNo("TK1"); diff --git a/order/AGENTS.md b/order/AGENTS.md index c7699e277..045a0f685 100644 --- a/order/AGENTS.md +++ b/order/AGENTS.md @@ -19,6 +19,7 @@ | 申报同步、渠道履约/计价门禁 | `service/OrderFulfillmentChannelChangeGuard.java`、`OrderPricingChannelGuard.java`、`OrderInboundOutboxPublisher.java`,内部客户端 `FirstlegInboundClient.java`、`FirstlegPricingClient.java`;审核后通过版本化 `ORDER_DECLARATION_UPDATED_V1` 同步 Firstleg | | 入仓码业务关系摘要 | `service/InboundCodeAssociationService.java`、`dto/InboundCodeAssociationSummary.java`、平台/客户 `FirstLegOrderController` 的 `/inbound-code-associations/{inboundCode}`;客户入口必须按认证客户隔离 | | 订单编排/状态/幂等 | `service/OrderApplicationService.java`、`entity/OrderStatus.java` | +| 海外派送完成内部回写 | `controller/InternalOrderFulfillmentCompletionController.java`、`service/OrderFulfillmentCompletionService.java`;只接受 Firstleg 内部服务身份和已校验的来源订单/入仓码快照,幂等将对应订单推进为 `COMPLETED` 并写状态历史 | | 商户订单列表、批量草稿与中文 XLSX 导入 | `service/MerchantInboundOrderListService.java`、`MerchantBatchDraftService.java`、`MerchantBatchDraftItemService.java`、`MerchantBatchImportService.java`;动态目录客户端 `client/MerchantWarehouseCatalogClient.java`、`MerchantPricingCatalogClient.java`,客户地址由 `CustomerAddressService.java` 提供;接口见 `controller/MerchantFirstLegOrderController.java` 的 `/batch-imports/template`、`/batch-imports` | | 入库号生成 | `config/InboundCodeModeProvider.java`、`mapper/InboundCodeSequenceMapper.java`、`repository/OrderRepository.java`、`service/OrderApplicationService.java` | | 下单计价渠道校验 | `service/OrderPricingChannelGuard.java`、`client/FirstlegPricingClient.java` | diff --git a/order/src/main/java/com/baiyue/order/controller/InternalOrderFulfillmentCompletionController.java b/order/src/main/java/com/baiyue/order/controller/InternalOrderFulfillmentCompletionController.java new file mode 100644 index 000000000..d45535f0a --- /dev/null +++ b/order/src/main/java/com/baiyue/order/controller/InternalOrderFulfillmentCompletionController.java @@ -0,0 +1,36 @@ +package com.baiyue.order.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.order.service.OrderFulfillmentCompletionService; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RestController; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; + +@RestController +@RequiredArgsConstructor +public class InternalOrderFulfillmentCompletionController { + private final OrderFulfillmentCompletionService service; + @Value("${baiyue.security.internal-service-token}") private String internalToken; + private byte[] expectedToken; + + @PostConstruct void initializeToken() { expectedToken = internalToken.getBytes(StandardCharsets.UTF_8); } + + @PostMapping("/internal/order/v1/fulfillment-completions") + public ApiResponse complete( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @RequestBody OrderFulfillmentCompletionService.Request request) { + if (token == null || !MessageDigest.isEqual(expectedToken, token.getBytes(StandardCharsets.UTF_8))) { + throw new BizException(ResultCode.FORBIDDEN, "内部服务凭证无效"); + } + return ApiResponse.success(service.complete(request)); + } +} diff --git a/order/src/main/java/com/baiyue/order/dto/UnifiedOrderQuery.java b/order/src/main/java/com/baiyue/order/dto/UnifiedOrderQuery.java index 27b61e6ee..c052e646d 100644 --- a/order/src/main/java/com/baiyue/order/dto/UnifiedOrderQuery.java +++ b/order/src/main/java/com/baiyue/order/dto/UnifiedOrderQuery.java @@ -13,7 +13,7 @@ public record UnifiedOrderQuery( @Size(max = 64) String keyword, @Size(max = 64) String orderNo, @Schema(allowableValues = {"PENDING_REVIEW", "WAIT_MEASURE", "WAIT_PALLETIZE", "INBOUNDED", - "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED"}) + "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED", "COMPLETED"}) @Size(max = 32) String mainStatus, Long customerId, Long receivingWarehouseId, diff --git a/order/src/main/java/com/baiyue/order/mapper/OrderMapper.java b/order/src/main/java/com/baiyue/order/mapper/OrderMapper.java index e7c88dec6..2fccbe52a 100644 --- a/order/src/main/java/com/baiyue/order/mapper/OrderMapper.java +++ b/order/src/main/java/com/baiyue/order/mapper/OrderMapper.java @@ -12,6 +12,9 @@ import java.util.Map; */ @Mapper public interface OrderMapper extends BaseMapper{ + @Select("SELECT * FROM ord_order WHERE tenant_id=#{tenantId} AND id=#{orderId} AND deleted=0 FOR UPDATE") + OrderEntity lockOwned(@Param("tenantId") long tenantId, @Param("orderId") long orderId); + @Select(""" SELECT COUNT(*) AS total_submitted_orders, COALESCE(SUM(CASE WHEN submitted_at >= #{monthStart} diff --git a/order/src/main/java/com/baiyue/order/service/OrderFulfillmentCompletionService.java b/order/src/main/java/com/baiyue/order/service/OrderFulfillmentCompletionService.java new file mode 100644 index 000000000..41ef25794 --- /dev/null +++ b/order/src/main/java/com/baiyue/order/service/OrderFulfillmentCompletionService.java @@ -0,0 +1,76 @@ +package com.baiyue.order.service; + +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.order.entity.OrderEntity; +import com.baiyue.order.entity.OrderStatus; +import com.baiyue.order.entity.OrderStatusHistoryEntity; +import com.baiyue.order.mapper.OrderMapper; +import com.baiyue.order.mapper.OrderStatusHistoryMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.Objects; +import java.util.Set; + +/** 接受国内 Firstleg 验证后的海外派送完成事实,推进对应订单。 */ +@Service +public class OrderFulfillmentCompletionService { + private static final Set COMPLETABLE = Set.of( + OrderStatus.APPROVED, OrderStatus.ACCEPTED, OrderStatus.PROCESSING); + @Resource private OrderMapper orders; + @Resource private OrderStatusHistoryMapper histories; + + @Transactional + public Result complete(Request request) { + long tenantId = positive(request.tenantId(), "客户 ID 无效"); + long orderId = positive(request.orderId(), "订单 ID 无效"); + OrderEntity order = orders.lockOwned(tenantId, orderId); + if (order == null) throw new BizException(ResultCode.NOT_FOUND, "来源订单不存在"); + if (!Objects.equals(order.getOrderNo(), request.orderNo()) + || !Objects.equals(order.getInboundCode(), request.inboundCode())) { + throw new BizException(ResultCode.CONFLICT, "派送完成事件与来源订单标识不一致"); + } + if (order.getStatus() == OrderStatus.COMPLETED) { + return new Result(orderId, order.getStatus().name(), true); + } + if (!COMPLETABLE.contains(order.getStatus())) { + throw new BizException(ResultCode.CONFLICT, "来源订单当前状态不允许完成: " + order.getStatus()); + } + OrderStatus from = order.getStatus(); + LocalDateTime completedAt = LocalDateTime.ofInstant(request.completedAt(), ZoneOffset.UTC); + int version = order.getVersion() == null ? 0 : order.getVersion(); + int updated = orders.update(null, new LambdaUpdateWrapper() + .eq(OrderEntity::getTenantId, tenantId).eq(OrderEntity::getId, orderId) + .eq(OrderEntity::getVersion, version).eq(OrderEntity::getDeleted, false) + .set(OrderEntity::getStatus, OrderStatus.COMPLETED) + .set(OrderEntity::getCompletedAt, completedAt) + .set(OrderEntity::getUpdatedBy, 0L).set(OrderEntity::getUpdatedAt, completedAt) + .setSql("version=version+1")); + if (updated != 1) throw new BizException(ResultCode.CONFLICT, "来源订单版本已变化,请重试"); + OrderStatusHistoryEntity history = new OrderStatusHistoryEntity(); + history.setId(IdWorker.getId()); history.setTenantId(tenantId); history.setOrderId(orderId); + history.setFromStatus(from); history.setToStatus(OrderStatus.COMPLETED); + history.setAction("OVERSEAS_DELIVERED"); + history.setReason("海外派送单 " + request.deliveryNo() + " 已签收"); + history.setOperatorId(0L); history.setOperatorName("OVERSEAS_EVENT_BRIDGE"); + history.setOccurredAt(completedAt); histories.insert(history); + return new Result(orderId, OrderStatus.COMPLETED.name(), false); + } + + private static long positive(String value, String message) { + try { long parsed = Long.parseLong(value); if (parsed > 0) return parsed; } + catch (RuntimeException ignored) { } + throw new BizException(ResultCode.BAD_REQUEST, message); + } + + public record Request(String tenantId, String orderId, String orderNo, String inboundCode, + String deliveryNo, java.time.Instant completedAt, String eventId, + String payloadHash) { } + public record Result(Long orderId, String status, boolean replayed) { } +} diff --git a/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java b/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java index fd7d4a5ff..d62472c54 100644 --- a/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java +++ b/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java @@ -38,7 +38,7 @@ import java.util.stream.Collectors; public class UnifiedOrderQueryService { private static final Set MAIN_STATUSES = Set.of( "PENDING_REVIEW", "WAIT_MEASURE", "WAIT_PALLETIZE", "INBOUNDED", "WAIT_SPLIT", - "LOADING", "LOADED", "DELIVERED"); + "LOADING", "LOADED", "DELIVERED", "COMPLETED"); private static final Set REVIEW_STATUSES = Set.of("UNRECORDED", "PENDING", "APPROVED", "REJECTED"); private static final List ACTIVE_ORDER_STATUSES = List.copyOf(EnumSet.of( OrderStatus.SUBMITTED, OrderStatus.APPROVED, OrderStatus.ACCEPTED, @@ -81,7 +81,8 @@ public class UnifiedOrderQueryService { return pageFromFirstleg(tenantId, query, includeCoLoadGroups); } List statuses = "PENDING_REVIEW".equals(query.mainStatus()) - ? List.of(OrderStatus.SUBMITTED) : ACTIVE_ORDER_STATUSES; + ? List.of(OrderStatus.SUBMITTED) + : "COMPLETED".equals(query.mainStatus()) ? List.of(OrderStatus.COMPLETED) : ACTIVE_ORDER_STATUSES; PageResult source = orders.unifiedPage(tenantId, query, statuses); List sourceOrderIds = source.records().stream().map(OrderEntity::getId).toList(); Map inboundByOrder = "PENDING_REVIEW".equals(query.mainStatus()) @@ -335,8 +336,9 @@ public class UnifiedOrderQueryService { } FirstlegUnifiedOrderClient.Inbound inbound = summary.inbound(); boolean pendingReview = order.getStatus() == OrderStatus.SUBMITTED; - String mainStatus = pendingReview ? "PENDING_REVIEW" : summary.mainStatus(); - String nextAction = pendingReview ? "APPROVE_ORDER" : summary.nextAction(); + boolean completed = order.getStatus() == OrderStatus.COMPLETED; + String mainStatus = pendingReview ? "PENDING_REVIEW" : completed ? "COMPLETED" : summary.mainStatus(); + String nextAction = pendingReview ? "APPROVE_ORDER" : completed ? null : summary.nextAction(); return new UnifiedOrderResponse.Row(order.getId(), order.getVersion(), order.getTenantId(), order.getOrderNo(), mainStatus, nextAction, Boolean.TRUE.equals(summary.customerFeeConfirmationRequired()), relation(order, childCounts), order.getStatus(), order.getCustomerId(), order.getCustomerName(), @@ -366,8 +368,9 @@ public class UnifiedOrderQueryService { CargoSummary cargo, UnifiedOrderResponse.CoLoadGroupSummary coLoadGroup) { var inbound = summary.inbound(); + boolean completed = order != null && order.getStatus() == OrderStatus.COMPLETED; return new UnifiedOrderResponse.Row(inbound.orderId(), order == null ? null : order.getVersion(), inbound.tenantId(), inbound.orderNo(), - summary.mainStatus(), summary.nextAction(), Boolean.TRUE.equals(summary.customerFeeConfirmationRequired()), relation(order, childCounts), + completed ? "COMPLETED" : summary.mainStatus(), completed ? null : summary.nextAction(), Boolean.TRUE.equals(summary.customerFeeConfirmationRequired()), relation(order, childCounts), parseOrderStatus(inbound.orderStatusSnapshot()), inbound.customerId(), inbound.customerName(), inbound.channelCode(), inbound.channelName(), order != null && Boolean.TRUE.equals(order.getChannelChanged()), inbound.warehouseId(), @@ -424,6 +427,7 @@ public class UnifiedOrderQueryService { private String orderMainStatus(OrderStatus status) { if (status == null) return null; return status == OrderStatus.SUBMITTED ? "PENDING_REVIEW" + : status == OrderStatus.COMPLETED ? "COMPLETED" : ACTIVE_ORDER_STATUSES.contains(status) ? "WAIT_MEASURE" : null; } @@ -559,7 +563,8 @@ public class UnifiedOrderQueryService { } private boolean requiresFirstlegQuery(UnifiedOrderQuery query) { - return (StringUtils.hasText(query.mainStatus()) && !"PENDING_REVIEW".equals(query.mainStatus())) + return (StringUtils.hasText(query.mainStatus()) + && !Set.of("PENDING_REVIEW", "COMPLETED").contains(query.mainStatus())) || query.expectedArrivalDate() != null || StringUtils.hasText(query.feeReviewStatus()) || StringUtils.hasText(query.containerNo()) || StringUtils.hasText(query.cabinetNo()) || query.sailingDate() != null || query.loaded() != null diff --git a/order/src/main/resources/openapi/order-v1.yaml b/order/src/main/resources/openapi/order-v1.yaml index b81801bef..eb5873cb7 100644 --- a/order/src/main/resources/openapi/order-v1.yaml +++ b/order/src/main/resources/openapi/order-v1.yaml @@ -6,6 +6,37 @@ info: security: - bearerAuth: [] paths: + /internal/order/v1/fulfillment-completions: + post: + security: [] + summary: Firstleg 回传海外派送完成并结束对应订单 + description: >- + 仅接受内部服务令牌。请求必须携带海外事件已验证的来源订单 ID、订单号、入仓码、 + FBA 派送单号和完成时间;只推进该派送单关联的订单,COMPLETED 重放返回成功。 + parameters: + - name: X-Internal-Service-Token + in: header + required: true + schema: {type: string} + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [tenantId, orderId, orderNo, inboundCode, deliveryNo, completedAt, eventId, payloadHash] + properties: + tenantId: {type: string, pattern: '^[1-9][0-9]*$'} + orderId: {type: string, pattern: '^[1-9][0-9]*$'} + orderNo: {type: string} + inboundCode: {type: string} + deliveryNo: {type: string} + completedAt: {type: string, format: date-time} + eventId: {type: string, format: uuid} + payloadHash: {type: string, pattern: '^[0-9a-fA-F]{64}$'} + responses: + '200': {description: 对应订单已完成或幂等重放成功} + '403': {description: 内部服务令牌无效} /internal/order/v1/cargoes/{tenantId}/{orderId}: get: security: [] @@ -771,7 +802,7 @@ components: tenantId: {type: string, pattern: '^[1-9][0-9]*$'} keyword: {type: string, maxLength: 64} orderNo: {type: string, maxLength: 64} - mainStatus: {type: string, enum: [PENDING_REVIEW, WAIT_MEASURE, WAIT_PALLETIZE, INBOUNDED, WAIT_SPLIT, LOADING, LOADED, DELIVERED]} + mainStatus: {type: string, enum: [PENDING_REVIEW, WAIT_MEASURE, WAIT_PALLETIZE, INBOUNDED, WAIT_SPLIT, LOADING, LOADED, DELIVERED, COMPLETED]} customerId: {type: string, pattern: '^[1-9][0-9]*$'} receivingWarehouseId: {type: string, pattern: '^[1-9][0-9]*$'} destinationWarehouseId: {type: string, pattern: '^[1-9][0-9]*$'} @@ -847,7 +878,7 @@ components: asOf: {type: string, format: date-time} UnifiedMainStatus: type: string - enum: [PENDING_REVIEW, WAIT_MEASURE, WAIT_PALLETIZE, INBOUNDED, WAIT_SPLIT, LOADING, LOADED, DELIVERED] + enum: [PENDING_REVIEW, WAIT_MEASURE, WAIT_PALLETIZE, INBOUNDED, WAIT_SPLIT, LOADING, LOADED, DELIVERED, COMPLETED] description: >- 统一订单八态产品主状态。ONE_CLICK_DROPSHIP 测量计费完成后进入 WAIT_PALLETIZE,封托完成后进入 WAIT_SPLIT; STANDARD 保持既有流程,INBOUNDED 仅兼容存量已入库记录。 @@ -1358,7 +1389,7 @@ components: description: 统一订单八态主状态筛选;WAIT_PALLETIZE、WAIT_SPLIT 与存量兼容状态 INBOUNDED 独立查询 schema: type: string - enum: [PENDING_REVIEW, WAIT_MEASURE, WAIT_PALLETIZE, INBOUNDED, WAIT_SPLIT, LOADING, LOADED, DELIVERED] + enum: [PENDING_REVIEW, WAIT_MEASURE, WAIT_PALLETIZE, INBOUNDED, WAIT_SPLIT, LOADING, LOADED, DELIVERED, COMPLETED] UnifiedInboundCode: {name: inboundCode, in: query, required: false, description: 订单创建时生成的入仓码,所有订单状态均可查询, schema: {type: string, maxLength: 40}} UnifiedInternalOrderStatus: diff --git a/order/src/test/java/com/baiyue/order/controller/UnifiedOrderApiContractTest.java b/order/src/test/java/com/baiyue/order/controller/UnifiedOrderApiContractTest.java index 90086d33e..07a5ecf2d 100644 --- a/order/src/test/java/com/baiyue/order/controller/UnifiedOrderApiContractTest.java +++ b/order/src/test/java/com/baiyue/order/controller/UnifiedOrderApiContractTest.java @@ -28,7 +28,7 @@ class UnifiedOrderApiContractTest { } @Test - void documentsEightProductMainStatuses() throws Exception { + void documentsProductMainStatusesThroughCompletion() throws Exception { String yaml = new ClassPathResource("openapi/order-v1.yaml") .getContentAsString(StandardCharsets.UTF_8); Map document = stringMap(new Yaml().load(yaml)); @@ -43,7 +43,7 @@ class UnifiedOrderApiContractTest { Map schema = stringMap(mainStatus.get("schema")); assertThat(stringList(schema.get("enum"))).containsExactly( "PENDING_REVIEW", "WAIT_MEASURE", "WAIT_PALLETIZE", "INBOUNDED", "WAIT_SPLIT", - "LOADING", "LOADED", "DELIVERED"); + "LOADING", "LOADED", "DELIVERED", "COMPLETED"); assertThat(mainStatus.get("description").toString()) .contains("WAIT_PALLETIZE", "WAIT_SPLIT", "INBOUNDED"); @@ -51,7 +51,7 @@ class UnifiedOrderApiContractTest { Map responseStatus = stringMap(schemas.get("UnifiedMainStatus")); assertThat(stringList(responseStatus.get("enum"))).containsExactly( "PENDING_REVIEW", "WAIT_MEASURE", "WAIT_PALLETIZE", "INBOUNDED", "WAIT_SPLIT", - "LOADING", "LOADED", "DELIVERED"); + "LOADING", "LOADED", "DELIVERED", "COMPLETED"); assertThat(responseStatus.get("description").toString()) .contains("WAIT_PALLETIZE", "WAIT_SPLIT", "INBOUNDED"); Schema queryAnnotation = UnifiedOrderQuery.class.getDeclaredField("mainStatus") @@ -59,7 +59,7 @@ class UnifiedOrderApiContractTest { assertThat(queryAnnotation).isNotNull(); assertThat(queryAnnotation.allowableValues()).containsExactly( "PENDING_REVIEW", "WAIT_MEASURE", "WAIT_PALLETIZE", "INBOUNDED", "WAIT_SPLIT", - "LOADING", "LOADED", "DELIVERED"); + "LOADING", "LOADED", "DELIVERED", "COMPLETED"); Map row = stringMap(schemas.get("UnifiedOrderRow")); assertThat(stringMap(row.get("properties"))).containsKeys( diff --git a/order/src/test/java/com/baiyue/order/service/OrderFulfillmentCompletionServiceTest.java b/order/src/test/java/com/baiyue/order/service/OrderFulfillmentCompletionServiceTest.java new file mode 100644 index 000000000..8651dbc72 --- /dev/null +++ b/order/src/test/java/com/baiyue/order/service/OrderFulfillmentCompletionServiceTest.java @@ -0,0 +1,63 @@ +package com.baiyue.order.service; + +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.baiyue.order.entity.OrderEntity; +import com.baiyue.order.entity.OrderStatus; +import com.baiyue.order.entity.OrderStatusHistoryEntity; +import com.baiyue.order.mapper.OrderMapper; +import com.baiyue.order.mapper.OrderStatusHistoryMapper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.Instant; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class OrderFulfillmentCompletionServiceTest { + @Mock private OrderMapper orders; + @Mock private OrderStatusHistoryMapper histories; + @InjectMocks private OrderFulfillmentCompletionService service; + + @BeforeAll + static void initializeTableMetadata() { + TableInfoHelper.initTableInfo( + new MapperBuilderAssistant(new MybatisConfiguration(), "order-fulfillment-completion-test"), + OrderEntity.class); + } + + @Test + void completesOrderWithHistoryActionThatFitsThePersistedColumn() { + OrderEntity order = new OrderEntity(); + order.setId(201L); + order.setTenantId(11L); + order.setOrderNo("FL201"); + order.setInboundCode("IN-201"); + order.setStatus(OrderStatus.PROCESSING); + order.setVersion(7); + when(orders.lockOwned(11L, 201L)).thenReturn(order); + when(orders.update(isNull(), any(LambdaUpdateWrapper.class))).thenReturn(1); + when(histories.insert(any(OrderStatusHistoryEntity.class))).thenReturn(1); + + var result = service.complete(new OrderFulfillmentCompletionService.Request( + "11", "201", "FL201", "IN-201", "DL-201", Instant.parse("2026-09-15T12:00:00Z"), + "event-201", "sha256:test")); + + assertThat(result.status()).isEqualTo("COMPLETED"); + ArgumentCaptor history = ArgumentCaptor.forClass(OrderStatusHistoryEntity.class); + verify(histories).insert(history.capture()); + assertThat(history.getValue().getAction()).isEqualTo("OVERSEAS_DELIVERED").hasSizeLessThanOrEqualTo(24); + } +} diff --git a/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java b/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java index a3c60af43..685449005 100644 --- a/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java +++ b/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java @@ -106,6 +106,26 @@ class UnifiedOrderQueryServiceTest { }); } + @Test + void completedOrderOverridesDeliveredInboundProjectionAndSupportsMainStatusFilter() { + UnifiedOrderQuery query = query("COMPLETED"); + OrderEntity order = order(OrderStatus.COMPLETED); + var summary = summary("DELIVERED", null); + when(orders.unifiedPage(eq(10001L), eq(query), eq(List.of(OrderStatus.COMPLETED)))) + .thenReturn(new PageResult<>(List.of(order), 1, 20, 1, 1)); + when(firstleg.summaries(eq("internal-token"), any())) + .thenReturn(ApiResponse.success(List.of(summary))); + + var result = service.page(10001L, query); + + assertThat(result.records()).singleElement().satisfies(row -> { + assertThat(row.mainStatus()).isEqualTo("COMPLETED"); + assertThat(row.internalOrderStatus()).isEqualTo(OrderStatus.COMPLETED); + assertThat(row.nextAction()).isNull(); + }); + verify(firstleg, never()).page(any(), any()); + } + @Test void newlyCreatedOrderUsesItsOwnCargoPackageCountBeforeInboundExists() { UnifiedOrderQuery query = query(null); diff --git a/scripts/README.md b/scripts/README.md index 11b2424ed..197163154 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -1,5 +1,7 @@ # 后端脚本管理与全量 HTTP 验收索引 +`mixed-container-outbound-test-env.mjs` 是经明确授权的测试环境留存混柜出库续跑入口:参数为 `source-report containerNo MIXED-FBA|MIXED-DROPSHIP output-report`,凭据由显式 `node --env-file` 注入。FBA 分支实时校验既有收货数据,创建排车、出库、派送及临时 PDA 账号,执行拣货、发车、测试签收文件保存和国内状态回查;一件代发分支只走专用候选、两托四箱出库、PDA 幂等拣货和完成回查,并明确记录派送未覆盖,不返回全流程成功退出码。finally 停用临时账号,不删除业务数据。输出报告作为检查点,失败后须先核对其中业务单号和实时状态,再用同一输出文件续跑;不得删除报告后盲目重试。它不代表普通海外仓目的地转换或所有分支签收验收通过。 + 全量验收只认测试环境 HTTP 脚本。它们通过 Gateway 调用已部署服务,按业务模块执行真实登录、权限、状态、幂等、跨服务和数据链路检查;脚本通过后,剩余工作是 Admin/Client 的真实接口调用与浏览器样式验收,以及 PDA 页面/设备验收。 进入本目录维护或运行脚本前先读 `scripts/AGENTS.md`;本文件是脚本入口、用途和副作用的唯一总索引,具体模块说明继续下沉到相邻 README。 diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index 7ceab527f..d8b004768 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', 'mixed-container-receiving'] const AVAILABLE_MODULES = new Set([...CONFIGURED_MODULES, ...OPTIONAL_MODULES]) const A0_LOCATION_PAGE_SIZE = 200 const A0_MAX_LOCATION_MUTATIONS = 8 @@ -43,6 +43,8 @@ export function planA0LocationIsolation(locations, requiredSlots = 1, const MODULE_DEPENDENCIES = new Map([ ['dropship-pallet-matrix', ['merchant-qualification-fixture']], + ['dropship-browser-fixture', ['merchant-qualification-fixture']], + ['mixed-container-receiving', ['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 e44e2811d..08ba8bb13 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -1,5 +1,7 @@ # Admin 测试环境 HTTP 模块 +`phase5-overseas-fulfillment.mjs` 的 `pickAll`、`saveReceiptAndComplete` 也供显式授权的 `../mixed-container-outbound-test-env.mjs` 留存混柜续跑入口复用;仍使用正式 PDA 拣货和签收文件绑定接口,不代替全模块验收。 + 总入口是 `../admin-test-env-acceptance.mjs`。每个业务模块使用独立的同名 `.mjs` 文件,避免多个对话同时修改总入口。 需要为人工联调批量准备同一客户、同一国内仓和同一渠道的 `WAIT_SPLIT` 数据时,使用[《批量待分柜数据生成指南》](批量待分柜数据生成指南.md)。该入口通过正式下单、入库、称重和测量接口生成数据,支持 FBA、海外仓、私人地址及一件代发等稳定随机组合;不得直接写库或用于生产环境。 @@ -37,11 +39,27 @@ export async function run(context) { ## 可用上下文 +混柜收货的每一步均区分同键同载荷重放、同键异载荷冲突和同货物新键再次扫码:普通海外仓重复箱返回冲突,FBA 相同累计数和代发同托新键返回零增量;随后回查完整会话,确保三票事实均不变。 + +`mixed-container-receiving.mjs` 通过总入口 `--modules mixed-container-receiving` 执行,依赖客户资质准备。新建一票双托一件代发、一票普通 FBA、一票普通海外仓订单,固定同一起运仓和渠道,经正式接口混装同柜、完成国内装柜和船节点、按柜生成海外计划。使用本轮 PDA 身份交替执行 FBA 累计收货、代发整托收货、普通海外仓逐箱收货;每步核对全部票进度、同键重放和未齐正常完成拒绝。终点为 `MIXED_CONTAINER_INBOUND_COMPLETED`,不创建后续出库或派送,不代表仓储转运单混装。报告复用 `createdResources.dropshipPalletMatrix`;失败保留阻断,成功数据保留,finally 停用本轮 PDA 操作员。真实运行须环境授权,离线合同 `mixed-container-receiving-contract.test.mjs` 不能代替真实 HTTP。 + +一件代发矩阵出库前运行 `dropship-outbound-edge-cases.mjs` 的候选查询探针:先模拟平台首次打开页面只传海外仓,再传客户筛选,要求本轮每票全部托盘、箱数、仓位及 SKU 事实一致,并核对仓库与指定客户隔离。缺托、错误客户/仓库、A0400 或筛选后仓位漂移均阻断;候选达到服务端上限导致本轮数据不可见也不能按空场景通过。该探针不证明跨客户混柜造数已覆盖。 + +一件代发矩阵在每个剩余实物托盘收货前核对待收箱数,并以 `allowDifference=false` 尝试正常完成:必须返回 A0409,回查会话事实不变后才继续收货。这不覆盖显式差异完成,不得把正常完成门禁解读成系统禁止一切差异结案。 + +每托首次收货及重复扫描后,矩阵按本轮已提交的来源托盘、箱唛和入仓码独立计算期望进度,回查柜级及每票的总箱数、实收、待收三项。柜级总数正确但串票、重复行、缺票或逐票待收未刷新仍判失败。离线验证器包含三票交替进度样本,但该样本不等于三类混柜真实 HTTP 验收。 + +`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`,不能代替海外收货及一件代发专用出库的完整验收。 - 一件代发任务 `Task.pallets` 只列实际参与绑箱的托盘;未使用的预生成托盘通过 `/api/platform/v1/first-leg/pda-dropship/inbound-orders/{inboundId}/pallets?accountId=...` 读取,客户范围取本轮任务。脚本同时核对 `generatedPalletCount/palletSetupReady`,不因空的任务托盘列表新增资源。 + +- `orders.mjs` 创建草稿前读取正式 `inbound-code-policy`:自动模式不传入仓码,手工模式按本轮 marker 生成短且稳定的测试入仓码,确保箱唛二维码长度有效;策略读取失败或矛盾立即停止,不修改系统全局配置、不重用历史订单。 + +- `dropship-outbound-edge-cases.mjs` 是 `dropship-pallet-matrix` 内部探针,不单独运行:使用本轮库存验证超量、错误 SKU、不存在/重复箱唛、箱数不符、同键异载荷、未拣齐完成、并发同键创建、未拣取消和重放。单票单托额外先拣一箱再拣剩余箱;部分拣货被拒时仍保留失败及部署阻断,只有回查冻结事实无变化才继续整条明细拣货以采集后续证据,不能将此计为分批通过。多票共托使用同一 SKU,来源区分必须依赖入仓码。成功数据保留,未拣并发探针出库单正常取消。本扩展不代表跨客户、跨仓、多 SKU 混箱、逐箱不等件数、跨区故障注入均已覆盖,结论以当轮真实报告为准。 - `phase3-container-documents` 从本轮货柜详情读取渠道:海铁验证 DO 上传被明确拒绝且聚合中没有 DO,其他渠道继续验证 DO 上传、重放及零漂移;缺渠道不猜测适用性。 - `phase4-overseas-pda-operations` 用三个独立逐箱任务分别验证正常换标、跳过和异常,异常不得重复使用前两箱。前一步失败时保留失败并停止该链,不继续使用空响应版本。 - 海外 PDA 按真实柜号去重执行签收,缺少一件代发生产者不得用 FBA 生产者替代;原生产矩阵失败仍保留。P4 解绑样本保留为 `UNBOUND`,P5 只从本轮已完成同柜、同目的仓的入仓码中选择三箱实物托盘库存,排除 `UNBOUND` 和重复仓位,不借用历史订单。 @@ -140,7 +158,7 @@ export async function run(context) { | 海外业务 HTTP 生命周期 | `phase4-overseas-business.mjs` | `phase4-overseas-business-contract.test.mjs` | | 海外主数据 HTTP 生命周期 | `phase4-overseas-master-data.mjs` | `phase4-overseas-master-data-contract.test.mjs` | | 目的港码头拖车与船节点终态 | `phase5-terminal-drayage.mjs`(PDA 卸柜前复用 `ACTIVE + TRUCKING` 主数据完成选车队、预约和实际提柜,卸柜后完成实际还柜,并覆盖四命令幂等/历史)、`phase4-carrier-nodes.mjs`(PDA 前补录至 `PICKUP_READY`;拖车完成后只读核验 `PICKED_UP/EMPTY_RETURNED` 和停止语义) | `phase5-terminal-drayage-contract.test.mjs`、`phase4-carrier-nodes-contract.test.mjs`;不再由船节点脚本直接伪造提柜和还柜 | -| 海外排车、出库、派送、异常与回访 HTTP 生命周期 | `phase5-overseas-fulfillment.mjs`(复用本轮海外仓已收货托盘和预排单,正式创建临时 PDA 操作员并在 `finally` 停用;覆盖排车编辑、转出库/派送、PDA 全量拣货/发车、派送完成、客户回访、人工异常及派送退回) | `phase5-overseas-fulfillment-contract.test.mjs` | +| 海外排车、出库、派送、异常与回访 HTTP 生命周期 | `phase5-overseas-fulfillment.mjs`(复用本轮海外仓已收货托盘和预排单,正式创建临时 PDA 操作员并在 `finally` 停用;覆盖排车编辑、按 FBA 目的仓转出库/拆分派送单、PDA 全量拣货/发车、逐单签收资料保存自动完成、头程入仓与订单跨区回推、客户回访、人工异常及派送退回) | `phase5-overseas-fulfillment-contract.test.mjs` | | 初始化基线(仅离线) | — | `initialization-baseline-contract.test.mjs` | 表中的 `*-fixture.mjs` 是被业务模块复用的测试数据前置,不是新增的独立全量入口。执行 HTTP 时仍从 `../admin-test-env-acceptance.mjs` 进入;`*contract.test.mjs` 只做离线脚本结构和数据归属回归。 @@ -165,7 +183,7 @@ node scripts/admin-test-env-acceptance.mjs --gateway --ove 只有已登记在 `../admin-test-env-acceptance.mjs` 的 `CONFIGURED_MODULES` 中的名称才能用于 `--modules`;目录中存在同名 `.mjs` 不代表总入口已注册。新增或改名 HTTP 模块时必须同步总入口、依赖展开、离线入口合同和本表;尚未注册的实验脚本不得写成可执行验收入口。 -海外仓出库与派送履约链定向入口为 `--modules phase5-overseas-fulfillment`。依赖展开固定从本轮国内货柜履约装柜完成、海外仓业务跨区接柜和海外 PDA 实收入库生成真实库存,再由海外仓业务智能预排创建 marker 专属 `PENDING_DEPARTURE` 排车。海外仓出库与派送模块只消费该本轮排车,先补齐车辆与司机,原子转为 `WAIT_OUTBOUND + WAIT_DELIVERY`,随后使用绑定同一海外仓的临时 PDA 身份执行全量拣货和发车,使出库单进入 `OUTBOUNDED`;最后覆盖派送完成、回访、人工异常与退回海外仓。所有幂等重放都核对业务单号或数量不漂移,真实前置不足必须形成 deployment blocker,临时 PDA 身份无论成功失败都由正式 IAM 状态接口停用。 +海外仓出库与派送履约链定向入口为 `--modules phase5-overseas-fulfillment`。依赖展开固定从本轮国内货柜履约装柜完成、海外仓业务跨区接柜和海外 PDA 实收入库生成真实库存,再由海外仓业务智能预排创建 marker 专属 `PENDING_DEPARTURE` 排车。海外仓出库与派送模块只消费该本轮排车,先补齐车辆与司机,原子转为一张 `WAIT_OUTBOUND` 出库单和按 FBA 目的仓拆分的 `WAIT_DELIVERY` 派送单,随后使用绑定同一海外仓的临时 PDA 身份执行全量拣货和发车,使出库单进入 `OUTBOUNDED`;最后逐张派送单保存签收文件自动完成,轮询核对头程入仓单与统一订单跨区回推,并覆盖回访、人工异常与退回海外仓。所有幂等重放都核对业务单号或数量不漂移,真实前置不足必须形成 deployment blocker,临时 PDA 身份无论成功失败都由正式 IAM 状态接口停用。 ```powershell node --env-file="$env:USERPROFILE\.codex\chongchong-global-dev-acceptance.env" scripts/admin-test-env-acceptance.mjs --modules phase5-overseas-fulfillment diff --git a/scripts/admin-test-env-modules/coload-groups.mjs b/scripts/admin-test-env-modules/coload-groups.mjs index e8ef138b2..360fd46c5 100644 --- a/scripts/admin-test-env-modules/coload-groups.mjs +++ b/scripts/admin-test-env-modules/coload-groups.mjs @@ -93,8 +93,8 @@ async function createSecondGroup(context, third, fourth) { async function createAssignedPushedPlan(context, fixture, groupId, suffix) { const simulationResponse = await context.request('POST', CONTAINERS, { - channelGroupCode: channelGroup(fixture.channelCode), warehouseId: fixture.warehouseId, containerType: '40HC', - capacityCbm: 76, maxWeightKg: 28000, + channelGroupCode: channelGroup(fixture.channelCode), warehouseId: fixture.warehouseId, containerType: '40HQ', + capacityCbm: 68, maxWeightKg: 19500, }) const simulation = context.apiSuccess(MODULE, `${suffix} 创建组装柜模拟柜`, simulationResponse, evidence(simulationResponse), OPTIONS) @@ -119,8 +119,8 @@ async function createAssignedPushedPlan(context, fixture, groupId, suffix) { async function createNormalPushedPlan(context, fixture, suffix) { const simulationResponse = await context.request('POST', CONTAINERS, { - channelGroupCode: channelGroup(fixture.channelCode), warehouseId: fixture.warehouseId, containerType: '40HC', - capacityCbm: 76, maxWeightKg: 28000, + channelGroupCode: channelGroup(fixture.channelCode), warehouseId: fixture.warehouseId, containerType: '40HQ', + capacityCbm: 68, maxWeightKg: 19500, }) const simulation = context.apiSuccess(MODULE, `${suffix} 创建普通单模拟柜`, simulationResponse, evidence(simulationResponse), OPTIONS) @@ -174,8 +174,8 @@ async function createReleasedBookingAndLink(context, fixture, chains) { const draft = oceanBookingDraft(context, suffix, master, { bookingAgentId: id(agent.id), bookingAgentName: agent.name, shippingCompanyId: id(carrier.id), shippingCompanyName: carrier.name, carrierCode: 'MATSON', - containerRequirements: [{ containerType: '40HC', containerKind: 'FCL', quantity: chains.length, - capacityCbm: 76, maxWeightKg: 28000 }], + containerRequirements: [{ containerType: '40HQ', containerKind: 'FCL', quantity: chains.length, + capacityCbm: 68, maxWeightKg: 19500 }], }) const createResponse = await context.request('POST', '/api/platform/v1/ocean-bookings', { expectedVersion: 0, data: draft }, undefined, @@ -205,7 +205,7 @@ async function createReleasedBookingAndLink(context, fixture, chains) { }, undefined, { 'Idempotency-Key': `${context.marker}-coload-pda-booking-release`.slice(0, 128) }) const released = context.apiSuccess(MODULE, 'PDA链放舱并冻结两柜需求', releaseResponse, evidence(releaseResponse), OPTIONS) - const requirement = (released?.containerRequirements || []).find((item) => item?.containerType === '40HC') + const requirement = (released?.containerRequirements || []).find((item) => item?.containerType === '40HQ') context.assert(MODULE, '放舱需求数量覆盖短拣与足量两柜', releaseResponse, released?.oceanBooking?.status === 'RELEASED' && Number(requirement?.quantity) === chains.length, `status=${released?.oceanBooking?.status || 'missing'}, quantity=${requirement?.quantity ?? 'missing'}`, OPTIONS) @@ -510,10 +510,10 @@ export async function run(context) { `status=${queried.data?.status || 'missing'}, members=${summaryInboundCodes(queried.data).join('|')}`, OPTIONS) const simulationResponse = await context.request('POST', '/api/platform/v1/containers', { - channelGroupCode: channelGroup(first.channelCode), warehouseId: first.warehouseId, containerType: '20GP', - capacityCbm: 33, maxWeightKg: 28000, + channelGroupCode: channelGroup(first.channelCode), warehouseId: first.warehouseId, containerType: '40GP', + capacityCbm: 58, maxWeightKg: 19500, }) - const simulation = context.apiSuccess(MODULE, '创建容量拒绝专用20GP模拟柜', simulationResponse, + const simulation = context.apiSuccess(MODULE, '创建容量拒绝专用40GP模拟柜', simulationResponse, evidence(simulationResponse), OPTIONS) let bookingId = id(simulation?.id) if (!bookingId) return @@ -573,7 +573,7 @@ export async function run(context) { context.assert(MODULE, '推荐候选包含当前目标同柜组且成员身份精确', isolatedCandidateResponse, candidateGroupIds.includes(groupId) && JSON.stringify(targetCandidateInbounds) === JSON.stringify([first.inboundId, second.inboundId].sort()), - `groupId=${groupId}, candidateGroupIds=${candidateGroupIds.join('|') || 'none'}, members=${targetCandidateInbounds.join('|')}, capacity=33cbm/28000kg`, OPTIONS) + `groupId=${groupId}, candidateGroupIds=${candidateGroupIds.join('|') || 'none'}, members=${targetCandidateInbounds.join('|')}, capacity=58cbm/19500kg`, OPTIONS) const allCandidateVolume = isolatedCandidates .reduce((sum, item) => sum + Number(item?.availableVolumeCbm || 0), 0) context.assert(MODULE, '推荐候选总量形成容量竞争场景', isolatedCandidateResponse, @@ -601,10 +601,10 @@ export async function run(context) { `targetGroupId=${groupId}, assignedMembers=${capacityGroupItems.map((item) => id(item?.inboundId)).join('|') || 'none'}, before=${planSignature(beforeRecommend)}, after=${planSignature(capacityRecommendation)}`, OPTIONS) const recommendSimulationResponse = await context.request('POST', '/api/platform/v1/containers', { - channelGroupCode: channelGroup(first.channelCode), warehouseId: first.warehouseId, containerType: '40HC', - capacityCbm: 76, maxWeightKg: 28000, + channelGroupCode: channelGroup(first.channelCode), warehouseId: first.warehouseId, containerType: '40HQ', + capacityCbm: 68, maxWeightKg: 19500, }) - const recommendSimulation = context.apiSuccess(MODULE, '创建智能推荐整组成功专用40HC模拟柜', + const recommendSimulation = context.apiSuccess(MODULE, '创建智能推荐整组成功专用40HQ模拟柜', recommendSimulationResponse, evidence(recommendSimulationResponse), OPTIONS) const recommendBookingId = id(recommendSimulation?.id) if (!recommendBookingId) return @@ -647,10 +647,10 @@ export async function run(context) { } const workingSimulationResponse = await context.request('POST', '/api/platform/v1/containers', { - channelGroupCode: channelGroup(first.channelCode), warehouseId: first.warehouseId, containerType: '40HC', - capacityCbm: 76, maxWeightKg: 28000, + channelGroupCode: channelGroup(first.channelCode), warehouseId: first.warehouseId, containerType: '40HQ', + capacityCbm: 68, maxWeightKg: 19500, }) - const workingSimulation = context.apiSuccess(MODULE, '创建PDA主链40HC模拟柜', workingSimulationResponse, + const workingSimulation = context.apiSuccess(MODULE, '创建PDA主链40HQ模拟柜', workingSimulationResponse, evidence(workingSimulationResponse), OPTIONS) bookingId = id(workingSimulation?.id) if (!bookingId) return @@ -966,8 +966,8 @@ export async function run(context) { const uiGroup = context.apiSuccess(MODULE, '为Admin页面保留ACTIVE未分配跨目的仓组', uiGroupResponse, evidence(uiGroupResponse), OPTIONS) const uiSimulationResponse = await context.request('POST', CONTAINERS, { - channelGroupCode: channelGroup(fifth.channelCode), warehouseId: fifth.warehouseId, containerType: '40HC', - capacityCbm: 76, maxWeightKg: 28000, + channelGroupCode: channelGroup(fifth.channelCode), warehouseId: fifth.warehouseId, containerType: '40HQ', + capacityCbm: 68, maxWeightKg: 19500, }) const uiSimulation = context.apiSuccess(MODULE, '为Admin页面保留可编辑智能装柜柜次', uiSimulationResponse, evidence(uiSimulationResponse), OPTIONS) diff --git a/scripts/admin-test-env-modules/container-specification-fixture-contract.test.mjs b/scripts/admin-test-env-modules/container-specification-fixture-contract.test.mjs index 378fc107e..e12dd869b 100644 --- a/scripts/admin-test-env-modules/container-specification-fixture-contract.test.mjs +++ b/scripts/admin-test-env-modules/container-specification-fixture-contract.test.mjs @@ -21,15 +21,15 @@ test('HTTP fixtures use the canonical primary and mismatch container specificati const primarySources = productionFixtures.map(source).join('\n') const containerFlow = source('phase3-container-loading.mjs') - assert.match(primarySources, /containerType: '40HC'[\s\S]*?capacityCbm: 76[\s\S]*?maxWeightKg: 28000/) - assert.match(containerFlow, /containerType: '20GP'[\s\S]*?capacityCbm: 33[\s\S]*?maxWeightKg: 28000/) + assert.match(primarySources, /containerType: '40HQ'[\s\S]*?capacityCbm: 68[\s\S]*?maxWeightKg: 19500/) + assert.match(containerFlow, /containerType: '40GP'[\s\S]*?capacityCbm: 58[\s\S]*?maxWeightKg: 19500/) }) test('HTTP fixtures do not reintroduce retired aliases or arbitrary specifications', () => { for (const name of productionFixtures) { const fixture = source(name) - assert.doesNotMatch(fixture, /40HQ|45HQ/, name) - assert.doesNotMatch(fixture, /capacityCbm:\s*(?:100|68|28)(?:\D|$)/, name) + assert.doesNotMatch(fixture, /20GP|40HC|40NR|45HC/, name) + assert.doesNotMatch(fixture, /capacityCbm:\s*(?:100|76|67|33|28)(?:\D|$)/, name) assert.doesNotMatch(fixture, /maxWeightKg:\s*(?:100000|22000)(?:\D|$)/, name) } }) @@ -37,13 +37,11 @@ test('HTTP fixtures do not reintroduce retired aliases or arbitrary specificatio test('the frozen catalog is complete and unambiguous', () => { // Keep every future fixture producer constrained to the same product catalog as Admin and Firstleg. const catalog = Object.freeze({ - '20GP': Object.freeze({ capacityCbm: 33, maxWeightKg: 28000 }), - '40GP': Object.freeze({ capacityCbm: 67, maxWeightKg: 28000 }), - '40HC': Object.freeze({ capacityCbm: 76, maxWeightKg: 28000 }), - '40NR': Object.freeze({ capacityCbm: 67, maxWeightKg: 28000 }), - '45HC': Object.freeze({ capacityCbm: 86, maxWeightKg: 28000 }), + '40GP': Object.freeze({ capacityCbm: 58, maxWeightKg: 19500 }), + '40HQ': Object.freeze({ capacityCbm: 68, maxWeightKg: 19500 }), + '45HQ': Object.freeze({ capacityCbm: 86, maxWeightKg: 19500 }), }) - assert.deepEqual(Object.keys(catalog), ['20GP', '40GP', '40HC', '40NR', '45HC']) - assert.equal(new Set(Object.keys(catalog)).size, 5) + assert.deepEqual(Object.keys(catalog), ['40GP', '40HQ', '45HQ']) + assert.equal(new Set(Object.keys(catalog)).size, 3) }) 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-outbound-edge-cases.mjs b/scripts/admin-test-env-modules/dropship-outbound-edge-cases.mjs new file mode 100644 index 000000000..9e36a4786 --- /dev/null +++ b/scripts/admin-test-env-modules/dropship-outbound-edge-cases.mjs @@ -0,0 +1,170 @@ +// Internal probes invoked only by the registered dropship matrix and its fresh fixtures. +// A failed expectation remains a deployment blocker. Continue other probes only when +// authoritative task/inventory snapshots prove the failed request changed no state. +const MODULE = 'dropship-pallet-matrix' +const OPTIONS = { scenario: 'dropship-outbound-edge-cases', deploymentBlocker: true } +const BASE = '/api/platform/v1/last-leg/outbound-orders' + +// Expected progress comes from submitted fixture operations, never the server response. +export function receivingProgressMatches(snapshot, expected) { + const count = value => (typeof value === 'number' || typeof value === 'string' && /^\d+$/.test(value)) + && Number.isSafeInteger(Number(value)) && Number(value) >= 0 ? Number(value) : NaN + if (!Array.isArray(snapshot?.inbounds) || !Array.isArray(expected) || !expected.length) return false + if (new Set(expected.map(row => row.inboundCode)).size !== expected.length + || snapshot.inbounds.length !== expected.length + || new Set(snapshot.inbounds.map(row => row.inboundCode)).size !== expected.length) return false + let total = 0 + let received = 0 + for (const row of expected) { + if (!Number.isInteger(row.total) || !Number.isInteger(row.received) + || row.total <= 0 || row.received < 0 || row.received > row.total) return false + const actual = snapshot.inbounds.find(item => item.inboundCode === row.inboundCode) + if (!actual || count(actual.totalPackages) !== row.total + || count(actual.receivedPackages) !== row.received + || count(actual.pendingPackages) !== row.total - row.received) return false + total += row.total + received += row.received + } + return count(snapshot.totalPackages) === total && count(snapshot.receivedPackages) === received + && count(snapshot.pendingPackages) === total - received +} + +function record(edge, name, response, passed, evidence = '') { + edge.context.assert(MODULE, `${edge.label}${name}`, response, passed, evidence, OPTIONS) +} + +async function request(edge, method, path, body, pda = false) { + return edge.context.requestOverseas(method, path, body, {}, pda ? edge.token : edge.context.overseasToken) +} + +async function candidates(edge) { + const rows = await edge.call('边界测试回查本轮库存候选', 'GET', edge.candidatePath) + return rows.filter(row => edge.group.some(x => x.source.inboundCode === row.inboundCode)) + .map(row => ({ position: row.inventoryPositionId, sku: row.skuCode, + inbound: row.inboundCode, packages: row.availablePackages })) + .sort((a, b) => `${a.position}:${a.sku}`.localeCompare(`${b.position}:${b.sku}`)) +} + +async function taskSnapshot(edge) { + return edge.call('边界测试回查出库冻结事实', 'GET', `${BASE}/${edge.task.outboundNo}`) +} + +export async function outboundCandidateGuards(edge) { + const warehouse = encodeURIComponent(edge.warehouseId) + const customer = encodeURIComponent(edge.customerId) + const paths = [ + ['平台首次查询不传客户', `${BASE}/one-click-dropship/candidates?warehouseId=${warehouse}`], + ['平台指定客户查询', `${BASE}/one-click-dropship/candidates?customerId=${customer}&warehouseId=${warehouse}`], + ] + const snapshots = [] + for (const [name, path] of paths) { + const rows = await edge.call(name, 'GET', path) + edge.check(`${edge.label}${name}返回候选数组`, null, Array.isArray(rows)) + edge.check(`${edge.label}${name}仓库隔离`, null, + rows.every(row => String(row.overseasWarehouseId) === String(edge.warehouseId))) + if (name === '平台指定客户查询') { + edge.check(`${edge.label}指定客户不返回其他客户库存`, null, + rows.every(row => String(row.customerId) === String(edge.customerId))) + } + const own = rows.filter(row => edge.group.some(x => x.source.inboundCode === row.inboundCode)) + for (const record of edge.group) { + const selected = own.filter(row => row.inboundCode === record.source.inboundCode) + const expectedPallets = [...new Set(record.task.pallets.map(p => p.palletNo))].sort() + const actualPallets = [...new Set(selected.map(row => row.palletNo))].sort() + edge.check(`${record.label}${name}全部托盘与箱数完整`, null, + JSON.stringify(expectedPallets) === JSON.stringify(actualPallets) + && selected.reduce((sum, row) => sum + Number(row.availablePackages), 0) === record.expectedCartonMarks.length + && selected.every(row => String(row.customerId) === String(edge.customerId))) + } + snapshots.push(own.map(row => ({ inbound: row.inboundCode, pallet: row.palletNo, + position: String(row.inventoryPositionId), sku: row.skuCode, packages: Number(row.availablePackages) })) + .sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)))) + } + edge.check(`${edge.label}客户筛选前后本轮库存事实一致`, null, + JSON.stringify(snapshots[0]) === JSON.stringify(snapshots[1])) +} + +export async function outboundCreationGuards(edge) { + const before = await candidates(edge) + const first = edge.body.items[0] + const cases = [ + ['超量创建', { ...first, packages: first.packages + 100, cartonMarks: [] }], + ['错误SKU创建', { ...first, sku: `WRONG-${edge.context.marker}` }], + ['不存在箱唛创建', { ...first, packages: 1, cartonMarks: [`MISSING-${edge.context.marker}`] }], + ['重复箱唛创建', { ...first, packages: 2, cartonMarks: [first.cartonMarks[0], first.cartonMarks[0]] }], + ['箱数与箱唛不符创建', { ...first, packages: first.packages + 1 }], + ] + for (const [index, [name, input]] of cases.entries()) { + const response = await request(edge, 'POST', `${BASE}/one-click-dropship?customerId=${edge.customerId}`, + { ...edge.body, items: [input], idempotencyKey: `${edge.body.idempotencyKey}-invalid-${index}` }) + record(edge, `${name}返回A0409`, response, response.body?.code === 'A0409') + const after = await candidates(edge) + edge.check(`${edge.label}${name}后库存候选零漂移`, null, JSON.stringify(before) === JSON.stringify(after)) + } + // Two real concurrent requests, same fixture and same key; no parallel fixture workers. + const body = { ...edge.body, idempotencyKey: `${edge.body.idempotencyKey}-race-cancel` } + const create = () => request(edge, 'POST', `${BASE}/one-click-dropship?customerId=${edge.customerId}`, body) + const responses = await Promise.all([create(), create()]) + const tasks = responses.filter(r => r.body?.code === '200').map(r => r.body.data) + record(edge, '并发同键创建均成功且只生成一个任务', responses[1], + tasks.length === 2 && tasks[0].taskNo === tasks[1].taskNo && tasks[0].outboundNo === tasks[1].outboundNo) + edge.check(`${edge.label}并发创建至少一个可回查结果`, null, tasks.length > 0) + for (const outboundNo of new Set(tasks.map(t => t.outboundNo))) { + for (let replay = 0; replay < 2; replay++) { + const cancelled = await request(edge, 'POST', `${BASE}/${outboundNo}/cancel`, { reason: '本轮未拣取消及重放验收' }) + edge.check(`${edge.label}未拣取消及重复取消成功`, cancelled, cancelled.body?.code === '200') + } + const detail = await edge.call('回查取消任务状态', 'GET', `${BASE}/${outboundNo}`) + edge.check(`${edge.label}取消后明细全部取消`, null, detail.outbound?.status === 'CANCELLED' + && detail.pallets.every(p => p.status === 'CANCELLED' && Number(p.pickedPackageCount) === 0)) + } + edge.check(`${edge.label}取消后逐票SKU可用箱数恢复`, null, + JSON.stringify(before) === JSON.stringify(await candidates(edge))) +} + +export async function outboundTaskGuards(edge) { + const before = await taskSnapshot(edge) + const first = edge.body.items[0] + const changedBodies = [ + ['收件人', { ...edge.body, recipient: `${edge.body.recipient}-changed` }], + ['备注', { ...edge.body, remark: `${edge.body.remark || ''}-changed` }], + ['明细', { ...edge.body, items: [{ ...first, packages: first.packages + 1 }, ...edge.body.items.slice(1)] }], + ['箱唛', { ...edge.body, items: [{ ...first, cartonMarks: [`CHANGED-${edge.context.marker}`, ...first.cartonMarks.slice(1)] }, ...edge.body.items.slice(1)] }], + ] + for (const [field, body] of changedBodies) { + const changed = await request(edge, 'POST', `${BASE}/one-click-dropship?customerId=${edge.customerId}`, body) + record(edge, `出库创建同键异载荷返回A0409:${field}`, changed, changed.body?.code === 'A0409') + edge.check(`${edge.label}同键修改${field}后出库事实零漂移`, null, + JSON.stringify(before) === JSON.stringify(await taskSnapshot(edge))) + } + const path = `/api/pda/v1/last-leg/outbound-tasks/${edge.task.taskNo}` + const incomplete = await request(edge, 'POST', `${path}/complete`, + { idempotencyKey: `${edge.body.idempotencyKey}-incomplete` }, true) + record(edge, '未拣齐禁止完成出库', incomplete, incomplete.body?.code === 'A0409') + edge.check(`${edge.label}未拣齐完成失败后零漂移`, null, + JSON.stringify(before) === JSON.stringify(await taskSnapshot(edge))) +} + +export async function probePartialPick(edge) { + const before = await taskSnapshot(edge) + const path = `/api/pda/v1/last-leg/outbound-tasks/${edge.task.taskNo}/pick` + const partial = { ...edge.pick, packages: 1, cartonMarks: edge.pick.cartonMarks.slice(0, 1), + idempotencyKey: `${edge.pick.idempotencyKey}-partial` } + const result = await request(edge, 'POST', path, partial, true) + record(edge, '四箱任务先拣一箱成功', result, result.body?.code === '200') + const after = await taskSnapshot(edge) + if (result.body?.code !== '200') { + edge.check(`${edge.label}分批拣货失败后无库存明细变化`, null, JSON.stringify(before) === JSON.stringify(after)) + // Keep the failed assertion; whole-item picking below only continues later coverage. + return 0 + } + edge.check(`${edge.label}分批首箱只累计一箱`, null, + after.pallets.reduce((sum, p) => sum + Number(p.pickedPackageCount), 0) === 1) + const replay = await request(edge, 'POST', path, partial, true) + record(edge, '分批首箱同键重放成功', replay, replay.body?.code === '200') + edge.check(`${edge.label}分批重放不重复扣减`, null, JSON.stringify(after) === JSON.stringify(await taskSnapshot(edge))) + const cancel = await request(edge, 'POST', `${BASE}/${edge.task.outboundNo}/cancel`, { reason: '已拣取消拦截验收' }) + record(edge, '部分已拣禁止取消', cancel, cancel.body?.code === 'A0409') + edge.check(`${edge.label}已拣取消失败后零漂移`, null, JSON.stringify(after) === JSON.stringify(await taskSnapshot(edge))) + return 1 +} 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 5648b8a69..7092fb209 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 @@ -3,6 +3,107 @@ import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import { normalizeModuleOrder } from '../admin-test-env-acceptance.mjs' import { sameOutboundTask } from './dropship-pallet-matrix.mjs' +import { receivingProgressMatches, outboundCandidateGuards, probePartialPick } from './dropship-outbound-edge-cases.mjs' + +test('receiving verifier follows three independent inbound codes through interleaved progress', () => { + const expected = [{ inboundCode: 'FBA', total: 5, received: 0 }, + { inboundCode: 'DROPSHIP', total: 4, received: 0 }, { inboundCode: 'OVERSEAS', total: 3, received: 0 }] + for (const [index, count] of [[0, 2], [1, 1], [2, 3], [0, 3], [1, 3]]) { + expected[index].received += count + const snapshot = { totalPackages: 12, receivedPackages: expected.reduce((sum, x) => sum + x.received, 0), + pendingPackages: expected.reduce((sum, x) => sum + x.total - x.received, 0), + inbounds: expected.map(x => ({ inboundCode: x.inboundCode, totalPackages: x.total, + receivedPackages: x.received, pendingPackages: x.total - x.received })) } + assert.equal(receivingProgressMatches(snapshot, expected), true) + const stale = structuredClone(snapshot) + stale.inbounds[index].pendingPackages += 1 + assert.equal(receivingProgressMatches(stale, expected), false) + const swapped = structuredClone(snapshot) + swapped.inbounds[0].receivedPackages += 1 + swapped.inbounds[1].receivedPackages -= 1 + assert.equal(receivingProgressMatches(swapped, expected), false, 'correct container total cannot conceal wrong ownership') + const duplicate = structuredClone(snapshot) + duplicate.inbounds[2] = duplicate.inbounds[0] + assert.equal(receivingProgressMatches(duplicate, expected), false) + } + assert.equal(receivingProgressMatches({ inbounds: [] }, []), false) +}) + +test('receiving verifier does not interpret absent or malformed progress as zero', () => { + const expected = [{ inboundCode: 'IN', total: 4, received: 0 }] + const good = { totalPackages: 4, receivedPackages: 0, pendingPackages: 4, + inbounds: [{ inboundCode: 'IN', totalPackages: 4, receivedPackages: 0, pendingPackages: 4 }] } + for (const value of [null, undefined, '', false, -1, 0.5]) { + const snapshot = structuredClone(good) + snapshot.inbounds[0].receivedPackages = value + assert.equal(receivingProgressMatches(snapshot, expected), false) + } + assert.equal(receivingProgressMatches(good, expected), true) +}) + +function candidateProbe(transform = rows => rows) { + const rows = ['P1', 'P2'].map((palletNo, index) => ({ palletNo, inventoryPositionId: index + 1, + inboundCode: 'IN-1', skuCode: 'SKU', availablePackages: 2, customerId: 'C1', overseasWarehouseId: 'W1' })) + const paths = [] + return { paths, edge: { label: 'MULTI', customerId: 'C1', warehouseId: 'W1', + group: [{ label: 'MULTI', source: { inboundCode: 'IN-1' }, + task: { pallets: [{ palletNo: 'P1' }, { palletNo: 'P2' }] }, expectedCartonMarks: ['a', 'b', 'c', 'd'] }], + check: (name, _response, passed) => assert.ok(passed, name), + call: async (_name, method, path) => { + assert.equal(method, 'GET'); paths.push(path) + return transform(structuredClone(rows), path) + }, + } } +} + +test('candidate probe sends the actual platform query without customer and checks both pallets', async () => { + const { edge, paths } = candidateProbe() + await outboundCandidateGuards(edge) + assert.equal(new URL(paths[0], 'https://example.invalid').searchParams.has('customerId'), false) + assert.equal(new URL(paths[1], 'https://example.invalid').searchParams.get('customerId'), 'C1') +}) + +for (const [name, transform] of [ + ['missing second pallet', rows => rows.slice(0, 1)], + ['wrong warehouse', rows => rows.map(row => ({ ...row, overseasWarehouseId: 'W2' }))], + ['wrong customer', rows => rows.map(row => ({ ...row, customerId: 'C2' }))], + ['bad request response', () => ({ code: 'A0400', message: 'Bad request' })], + ['candidate identity drift after filtering', (rows, path) => path.includes('customerId=') + ? rows.map(row => ({ ...row, inventoryPositionId: row.inventoryPositionId + 10 })) : rows], +]) { + test(`candidate probe fails closed on ${name}`, async () => { + await assert.rejects(outboundCandidateGuards(candidateProbe(transform).edge)) + }) +} + +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 }] } + const edge = { + label: 'SINGLE-SINGLE', task: { outboundNo: 'OD', taskNo: 'PDA' }, + pick: { packages: 4, cartonMarks: ['A', 'B', 'C', 'D'], idempotencyKey: 'pick' }, + context: { assert: (_module, name, _response, passed) => { if (!passed) failures.push(name) }, + requestOverseas: async () => ({ body: { code: 'A0409' } }) }, + check: (_name, _response, passed) => assert.ok(passed), + call: async () => structuredClone(snapshot), + } + assert.equal(await probePartialPick(edge), 0) + assert.equal(failures.length, 1) + assert.match(failures[0], /四箱任务先拣一箱成功/) + let calls = 0 + edge.call = async () => ({ pallets: [{ pickedPackageCount: calls++ }] }) + await assert.rejects(probePartialPick(edge)) +}) test('outbound replay preserves business identity and database millisecond timestamps', () => { const task = { id: '1', taskNo: 'PDA1', outboundNo: 'OD1', status: 'PENDING', pushedAt: '2026-09-14T20:24:45.920278604' } @@ -15,6 +116,49 @@ test('outbound replay preserves business identity and database millisecond times }) const source = readFileSync(new URL('./dropship-pallet-matrix.mjs', import.meta.url), 'utf8') +const edges = readFileSync(new URL('./dropship-outbound-edge-cases.mjs', import.meta.url), 'utf8') + +test('matrix runs candidate probes before selecting inventory or creating outbound', () => { + const probe = source.indexOf('await outboundCandidateGuards(') + assert.ok(probe > 0) + assert.ok(probe < source.indexOf("const all = await call('读取专用出库可用库存'")) +}) + +test('each remaining pallet tests normal completion rejection and zero drift before receiving', () => { + const loop = source.slice(source.indexOf('for (const [index, sourcePalletNo] of sourceNos.entries())'), + source.indexOf("check(`${label}整柜实收总箱数守恒`")) + assert.match(loop, /allowDifference: false/) + assert.match(loop, /premature.body\?\.code === 'A0409'/) + assert.match(loop, /JSON.stringify\(unchanged\) === JSON.stringify\(before\)/) + assert.ok(loop.indexOf('JSON.stringify(unchanged)') < loop.indexOf("const received = await call")) +}) + +test('outbound edge probes preserve failures and require zero drift before continuing', () => { + assert.match(edges, /deploymentBlocker: true/) + assert.match(edges, /四箱任务先拣一箱成功/) + assert.match(edges, /分批拣货失败后无库存明细变化/) + assert.match(edges, /JSON.stringify\(before\) === JSON.stringify\(after\)/) + assert.match(source, /await probePartialPick/) + assert.match(source, /pick.packages -= count/) + assert.match(source, /pick.cartonMarks = pick.cartonMarks.slice\(count\)/) +}) + +test('outbound creation probes cover concurrent idempotency, invalid cartons and cancellation', () => { + assert.match(edges, /Promise.all\(\[create\(\), create\(\)\]\)/) + for (const scenario of ['超量创建', '错误SKU创建', '不存在箱唛创建', '重复箱唛创建', + '箱数与箱唛不符创建', '出库创建同键异载荷返回A0409', '未拣齐禁止完成出库', '部分已拣禁止取消']) { + assert.ok(edges.includes(scenario)) + } + assert.match(edges, /取消后逐票SKU可用箱数恢复/) + assert.match(source, /await outboundCreationGuards\(edge\)/) + assert.match(source, /await outboundTaskGuards\(\{ ...edge, task \}\)/) +}) + +test('two inbound orders explicitly share a SKU without losing their separate identities', () => { + assert.match(source, /label.startsWith\('MULTI-MERGE-'\)/) + assert.match(source, /skuCodes = \[`SKU-SHARED-\$\{marker\}`/) + assert.match(source, /row.inboundCode === candidate.inboundCode/) +}) test('dedicated outbound uses exact source cartons and verifies completion and stock deduction', () => { assert.match(source, /await outboundOverseas\(group, picker.token\)/) @@ -53,6 +197,9 @@ test('allocation checks both physical summaries and uncollapsed per-inbound quan }) test('each scenario reaches physical loading through released booking and asynchronous completion', () => { + assert.match(source, /装柜前保存柜级预计到仓时间/) + assert.match(source, /capacityCbm: 68, maxWeightKg: 19500, estimatedWarehouseArrivalAt/) + assert.ok(source.indexOf('装柜前保存柜级预计到仓时间') < source.indexOf('读取自动生成装柜计划')) assert.match(source, /ocean-booking-link/) assert.match(source, /readiness\?\.canConfirm === true/) assert.match(source, /action: 'PICK_PALLET'/) diff --git a/scripts/admin-test-env-modules/dropship-pallet-matrix.mjs b/scripts/admin-test-env-modules/dropship-pallet-matrix.mjs index 1f939fefe..c459da015 100644 --- a/scripts/admin-test-env-modules/dropship-pallet-matrix.mjs +++ b/scripts/admin-test-env-modules/dropship-pallet-matrix.mjs @@ -11,6 +11,8 @@ import { run as completeCustoms } from './phase3-customs.mjs' import { run as verifyCarrierQuery } from './phase3-carrier-schedule-queries.mjs' import { runUntilPickupReady } from './phase4-carrier-nodes.mjs' import { resolvePickerAccess, createPicker, disablePicker, uploadPhotos } from './phase4-overseas-pda.mjs' +import { receivingProgressMatches, outboundCandidateGuards, outboundCreationGuards, outboundTaskGuards, probePartialPick } from './dropship-outbound-edge-cases.mjs' +import { receiveMixedContainer } from './mixed-container-receiving.mjs' const MODULE = 'dropship-pallet-matrix' const BASE = '/api/platform/v1/first-leg/pda-dropship' @@ -29,6 +31,18 @@ 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) +} + +export async function runMixedContainer(context) { + return runWorkflow(context, false, true) +} + +async function runWorkflow(context, stopBeforeOverseasPda, mixedContainer = false) { const marker = context.marker const savedOptions = context.options.prepareDropshipManualCount const savedContext = { ...context.context } @@ -39,22 +53,40 @@ export async function run(context) { context.assert(MODULE, name, response, passed, details, OPTIONS) if (!passed) throw new Error(name) } - async function prepare(label, palletCount, mode = {}) { + async function prepare(label, palletCount, mode = {}, destinationType = null) { context.marker = `${marker}-${label}` for (const key of ['orders', 'inboundMeasurement', 'ocrInbound', 'testInbound', 'dropshipManualCount']) { delete context.createdResources[key] } for (const key of ['inboundWeightOcr', 'ocrInbound', 'testInbound', 'createdInbound']) delete context.context[key] - context.context.orderDestinationType = 'OVERSEAS' - context.context.orderFulfillmentType = 'ONE_CLICK_DROPSHIP' + context.context.orderDestinationType = destinationType || 'OVERSEAS' + context.context.orderFulfillmentType = destinationType ? 'STANDARD' : 'ONE_CLICK_DROPSHIP' context.context.orderProfile = { taxMode: 'BUY_DECLARATION', pickupMethod: 'SELF_EXPRESS', containerMode: 'LCL' } context.context.waitSplitVariant = { skuCount: 1, packageCountPerSku: 4, packageCounts: [4], palletCount } - context.options.prepareDropshipManualCount = true + if (label.startsWith('MULTI-MERGE-')) { + context.context.waitSplitVariant.skuCodes = [`SKU-SHARED-${marker}`.slice(0, 64)] + } + context.options.prepareDropshipManualCount = !destinationType const start = context.steps.length await context.executeModule(`${MODULE}-${label}-order`, () => createOrder(context)) await context.executeModule(`${MODULE}-${label}-measurement`, () => measure(context)) check(`${label}本轮订单和测量前置全部通过`, null, !context.steps.slice(start).some((step) => !step.passed)) + if (destinationType) { + const measurement = { ...context.createdResources.inboundMeasurement } + const order = { ...context.createdResources.orders } + const response = await context.request('GET', + `/api/platform/inbounds/${measurement.inboundId}?tenantId=${measurement.tenantId}`) + const detail = context.apiSuccess(MODULE, `${label}普通订单待分柜快照`, response, '', OPTIONS) + check(`${label}普通订单四箱待分柜且类型未漂移`, response, + detail?.summary?.processStatus === 'WAIT_SPLIT' && measurement.actualPackageCount === 4 + && order.fulfillmentType === 'STANDARD' && order.destinationType === destinationType) + const record = { label, order, measurement, summary: detail.summary, task: null, + source: { inboundId: String(measurement.inboundId), inboundCode: detail.summary.inboundCode }, + expectedCartonMarks: Array.from({ length: 4 }, (_, index) => `${detail.summary.inboundCode}#4-${index + 1}`) } + records.push(record) + return record + } const source = context.createdResources.dropshipManualCount check(`${label}存在本轮未清点任务`, null, Boolean(source?.taskId)) const task = await completeDropshipPalletization(context, { @@ -100,19 +132,29 @@ export async function run(context) { check(`${label}${name}成功`, response, response?.body?.code === '200' && Boolean(value)) return value } - const pallets = [...new Map(group.flatMap((item) => item.task.pallets) + const pallets = [...new Map(group.flatMap((item) => item.task?.pallets || []) .map((pallet) => [String(pallet.palletId), pallet])).values()] for (const record of group) { const fee = await ensureInitialCalculated(context, { ...record.measurement, - tenantId: String(record.task.tenantId), inboundId: record.source.inboundId, + tenantId: String(record.order.tenantId), inboundId: record.source.inboundId, finalStatus: 'APPROVED', processStatus: record.summary.processStatus }, OPTIONS) check(`${record.label}初始费用已计算`, null, Boolean(fee)) } const summary = group[0].summary - const cabinet = await command('创建本场景模拟柜', 'POST', '/api/platform/v1/containers', { + let cabinet = await command('创建本场景模拟柜', 'POST', '/api/platform/v1/containers', { channelGroupCode: channelGroup(summary.channelCode), warehouseId: String(summary.warehouseId), - containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000, + containerType: '40HQ', capacityCbm: 68, maxWeightKg: 19500, }) + // Arrival is frozen by the loading event; prepare it while the cabinet is editable. + const estimatedWarehouseArrivalAt = `${new Date(Date.now() + 86400000).toISOString().slice(0, 10)}T10:00:00` + cabinet = await command('装柜前保存柜级预计到仓时间', 'PUT', `/api/platform/v1/containers/${cabinet.id}`, { + version: Number(cabinet.version), data: { + warehouseId: String(summary.warehouseId), containerType: '40HQ', + capacityCbm: 68, maxWeightKg: 19500, estimatedWarehouseArrivalAt, + }, + }) + check(`${label}预计到仓时间已写入正式柜资料`, null, + cabinet.estimatedWarehouseArrivalAt?.slice(0, 16) === estimatedWarehouseArrivalAt.slice(0, 16)) let plan = await command('读取自动生成装柜计划', 'GET', `${smart}/plans/by-booking/${cabinet.id}`) const path = `${smart}/plans/${plan.id}` async function conserve(name) { @@ -124,14 +166,14 @@ export async function run(context) { check(`${label}${name}没有混入其他订单`, null, detail.items?.every((item) => expectedIds.has(String(item.inboundId)))) check(`${label}${name}整托汇总不重复计算箱数`, null, - plan.items?.length === pallets.length + (mixedContainer || plan.items?.length === pallets.length) && plan.items.reduce((sum, item) => sum + Number(item.packageCount), 0) === group.reduce((sum, record) => sum + record.expectedCartonMarks.length, 0)) for (const record of group) { const actual = detail.items.filter((item) => String(item.inboundId) === record.source.inboundId) check(`${record.label}${name}分配箱数与托盘身份正确`, null, actual.reduce((sum, item) => sum + Number(item.packageCount), 0) === record.expectedCartonMarks.length - && actual.every((item) => record.task.pallets.some((pallet) => String(pallet.palletId) === String(item.palletId)))) + && (!record.task || actual.every((item) => record.task.pallets.some((pallet) => String(pallet.palletId) === String(item.palletId))))) } } for (const pallet of pallets) { @@ -140,7 +182,16 @@ export async function run(context) { operationId: `${context.marker}-add-${pallet.palletId}`, }) } + if (mixedContainer) { + for (const record of group.filter(item => !item.task)) { + plan = await command(`${record.label}普通订单按整票箱数加入`, 'POST', `${path}/inbound-assignments`, { + inboundId: record.source.inboundId, packageCount: record.expectedCartonMarks.length, + expectedVersion: Number(plan.version), operationId: `${context.marker}-add-${record.label}`, + }) + } + } await conserve('手工加入后') + if (!mixedContainer) { for (const pallet of pallets) { plan = await command('按物理托盘完整移除', 'DELETE', `${path}/pallet-assignments/${pallet.palletId}`, { expectedPlanVersion: Number(plan.version), operationId: `${context.marker}-remove-${pallet.palletId}`, @@ -152,13 +203,14 @@ export async function run(context) { customerId: String(group[0].task.tenantId), inboundCode: group[0].source.inboundCode, }) await conserve('智能推荐后') + } for (const record of group) record.loading = { cabinet, plan, allocationVerified: true } const carrier = await ensureMatsonCarrierPartner(context, MODULE, OPTIONS) const agent = await resolveOceanBookingAgent(context, MODULE, 'MATSON', OPTIONS) check(`${label}真实船司和订舱代理齐全`, null, Boolean(carrier?.id && agent?.id)) const suffix = markerSuffix(context.marker) const draft = oceanBookingDraft(context, suffix, { - customerId: String(group[0].task.tenantId), customerName: summary.customerName, + customerId: String(group[0].order.tenantId), customerName: summary.customerName, warehouseId: String(summary.warehouseId), channelCode: summary.channelCode, }, { carrierCode: 'MATSON', shippingCompanyId: String(carrier.id), shippingCompanyName: carrier.name, bookingAgentId: String(agent.id), bookingAgentName: agent.name }) @@ -199,10 +251,14 @@ export async function run(context) { warehouseId: String(summary.warehouseId), loadingPlanId: String(plan.id), containerBookingId: String(cabinet.id), expectedVersion: Number(claimed.version), inventorySnapshotToken: inventory.snapshotToken, items: claimed.items.map((item) => ({ itemId: String(item.id), version: Number(item.version), - action: 'PICK_PALLET', palletNo: item.palletNo })), + ...(item.unitType === 'SEALED_PALLET' + ? { action: 'PICK_PALLET', palletNo: item.palletNo } + : { action: 'PICK', pickedPackageCount: Number(item.plannedPackageCount) }) })), } check(`${label}PDA全部任务均为封托单元`, null, - claimed.items.length > 0 && claimed.items.every((item) => item.unitType === 'SEALED_PALLET')) + claimed.items.length > 0 && (mixedContainer + ? claimed.items.some(item => item.unitType === 'SEALED_PALLET') && claimed.items.some(item => item.unitType !== 'SEALED_PALLET') + : claimed.items.every((item) => item.unitType === 'SEALED_PALLET'))) const picked = await command('整托拣货', 'POST', `${pda}/batch`, batch, { 'Idempotency-Key': `${context.marker}-pick` }) check(`${label}整托拣货等待最终确认`, null, picked.workflowStatus === 'AWAITING_REVIEW') @@ -224,7 +280,7 @@ export async function run(context) { `status=${finalTask?.taskStatus}, attempts=${terminal.attempts}`) for (const record of group) { const inbound = await command(`${record.label}回查入仓单实装状态`, 'GET', - `/api/platform/inbounds/${record.source.inboundId}?tenantId=${record.task.tenantId}`) + `/api/platform/inbounds/${record.source.inboundId}?tenantId=${record.order.tenantId}`) check(`${record.label}装柜完成回写`, null, inbound.summary?.processStatus === 'LOADED') record.loading = { cabinet: linked, plan, task: finalTask, completed, booking: released, allocationVerified: true, loadingVerified: true, containerNo } @@ -233,10 +289,10 @@ export async function run(context) { async function progressShipping(group) { const record = group[0] context.marker = `${marker}-${record.label}-SHIPPING` - context.createdResources.orders = { ...record.order, tenantId: String(record.task.tenantId), + context.createdResources.orders = { ...record.order, tenantId: String(record.order.tenantId), inboundId: record.source.inboundId, warehouseId: String(record.summary.warehouseId) } context.createdResources.phase3Loading = { - marker: context.marker, tenantId: String(record.task.tenantId), orderId: record.order.orderId, + marker: context.marker, tenantId: String(record.order.tenantId), orderId: record.order.orderId, inboundId: record.source.inboundId, warehouseId: String(record.summary.warehouseId), containerBookingId: String(record.loading.cabinet.id), loadingPlanId: String(record.loading.plan.id), oceanBookingId: String(record.loading.booking.oceanBooking.id), @@ -258,7 +314,7 @@ export async function run(context) { String(inbound.inboundId) === String(item.source.inboundId) && inbound.inboundStatus === 'LOADED')) && detail.timeline?.some((item) => item.module === 'OUTBOUND_DISPATCH' && item.action === 'CONFIRM_DISPATCH')) context.createdResources.phase3OutboundDispatch = { marker: context.marker, containerBookingId: bookingId, - tenantId: String(record.task.tenantId), confirmedStatus: aggregate.dispatchStatus } + tenantId: String(record.order.tenantId), confirmedStatus: aggregate.dispatchStatus } } for (const [name, action] of [['customs', completeCustoms], ['departure', confirmDomesticDeparture], ['carrier-query', verifyCarrierQuery], ['pickup-ready', runUntilPickupReady]]) { @@ -288,16 +344,19 @@ export async function run(context) { return data } let summary + let summaryResponse for (let attempt = 0; attempt < 60; attempt++) { const response = await context.requestOverseas('GET', `${base}/orders/container-summaries/${bookingId}`) + summaryResponse = response summary = response?.body?.code === '200' ? response.body.data : null if (summary?.orders?.length === group.length) break if (attempt < 59) await new Promise((resolve) => setTimeout(resolve, 1000)) } - check(`${label}跨区事件逐票建单完整`, null, + check(`${label}跨区事件逐票建单完整`, summaryResponse, summary?.orders?.length === group.length && group.every((record) => summary.orders.some((order) => order.inboundCode === record.source.inboundCode - && String(order.customerId) === String(record.task.tenantId)))) + && String(order.customerId) === String(record.order.tenantId))), + `containerNo=${loading.containerNo}, expectedOrders=${group.length}, actualOrders=${summary?.orders?.length ?? 'unavailable'}`) const preview = await call('入库计划预览', 'GET', `${base}/inbound-plans/container-preview?sourceContainerBookingId=${bookingId}`) check(`${label}全柜入库计划就绪`, null, preview.ready === true && Number(preview.totalOrderCount) === group.length) const body = { sourceContainerBookingId: bookingId, snapshotVersion: Number(preview.snapshotVersion), @@ -309,10 +368,10 @@ export async function run(context) { const matching = plans.filter((plan) => plan.lines?.some((line) => line.inboundCode === record.source.inboundCode)) check(`${record.label}计划精确关联来源入仓码`, null, matching.length === 1) const target = matching[0] - const expectedNos = record.task.pallets.map((pallet) => pallet.palletNo).sort() + const expectedNos = record.task?.pallets.map((pallet) => pallet.palletNo).sort() const actualNos = (target.pallets || []).map((pallet) => pallet.palletNo).sort() check(`${record.label}来源托盘完整绑定海外真实资源`, null, - JSON.stringify(expectedNos) === JSON.stringify(actualNos) + (expectedNos ? JSON.stringify(expectedNos) === JSON.stringify(actualNos) : actualNos.length > 0) && target.pallets.every((pallet) => pallet.warehousePalletId && pallet.warehouseLocationId)) record.overseas = { plan: target, warehouseId: String(preview.overseasWarehouseId), summary: summary.orders.find((order) => order.inboundCode === record.source.inboundCode), @@ -361,9 +420,35 @@ export async function run(context) { const signed = await call('照片绑定并签收', 'POST', `${base}/current/sign`, { photos, idempotencyKey: `${context.marker}-sign` }) check(`${label}签收成功`, null, signed.status === 'SIGNED') + if (mixedContainer) { + const completed = await receiveMixedContainer({ group, call, check, marker: context.marker, + request: (method, path, body) => context.requestOverseas(method, path, body, {}, picker.token) }) + for (const item of group) { + const stock = await call(`${item.label}混柜完成后按入仓码查询库存`, 'POST', + '/api/pda/v1/last-leg/inventory/resolve', { code: item.source.inboundCode }) + check(`${item.label}库存归属和真实托盘绑定正确且箱数核平`, null, + stock.queryCode === item.source.inboundCode && Array.isArray(stock.items) && stock.items.length > 0 + && stock.items.every(row => row.inboundCode === item.source.inboundCode + && String(row.customerId) === String(item.order.tenantId) + && row.locationId && item.overseas.plan.pallets.some(p => String(p.warehousePalletId) === String(row.palletId))) + && stock.items.reduce((sum, row) => sum + Number(row.availablePackages), 0) === item.expectedCartonMarks.length) + item.overseas.receiving = { completed, stock, receivingVerified: true } + } + return + } const sourceNos = [...new Set(group.flatMap((item) => item.task.pallets.map((pallet) => pallet.palletNo)))] + const receivedPallets = new Set() let accepted = 0 for (const [index, sourcePalletNo] of sourceNos.entries()) { + const before = await call('未齐收货前回查柜级事实', 'GET', `${base}/current`) + check(`${label}剩余托盘待收箱数准确`, null, + Number(before.pendingPackages) === group.reduce((sum, item) => sum + item.expectedCartonMarks.length, 0) - accepted) + const premature = await context.requestOverseas('POST', `${base}/current/complete`, + { idempotencyKey: `${context.marker}-premature-${index}`, allowDifference: false }, {}, picker.token) + check(`${label}未收齐不得按正常完成提交`, premature, premature.body?.code === 'A0409') + const unchanged = await call('未齐完成被拒后回查柜级事实', 'GET', `${base}/current`) + check(`${label}未齐完成拒绝后事实不变且可继续收货`, null, + JSON.stringify(unchanged) === JSON.stringify(before)) const body = { sourcePalletNo, idempotencyKey: `${context.marker}-pallet-${index}` } const received = await call('按来源实物托盘整托收货', 'POST', `${base}/current/receipts/pallet`, body) accepted += Number(received.acceptedPackages) @@ -373,6 +458,14 @@ export async function run(context) { { sourcePalletNo, idempotencyKey: `${context.marker}-duplicate-${index}` }) check(`${label}同托重复扫描不重复收货`, null, duplicate.result === 'ALREADY_RECEIVED' && Number(duplicate.acceptedPackages) === 0) + receivedPallets.add(sourcePalletNo) + const progress = await call('每托收货及重扫后回查逐票进度', 'GET', `${base}/current`) + const expected = group.map(record => ({ inboundCode: record.source.inboundCode, + total: record.expectedCartonMarks.length, + received: new Set(record.task.pallets.filter(p => receivedPallets.has(p.palletNo)) + .flatMap(p => p.cartons || []).filter(c => String(c.inboundId) === String(record.source.inboundId)) + .map(c => c.boxCode)).size })) + check(`${label}每托实收不串票且柜级与逐票剩余同步`, null, receivingProgressMatches(progress, expected)) } check(`${label}整柜实收总箱数守恒`, null, accepted === group.reduce((sum, item) => sum + item.expectedCartonMarks.length, 0)) @@ -404,6 +497,7 @@ export async function run(context) { return value } const candidatePath = `${base}/one-click-dropship/candidates?customerId=${customerId}&warehouseId=${warehouseId}` + await outboundCandidateGuards({ context, group, customerId, warehouseId, label, check, call }) const all = await call('读取专用出库可用库存', 'GET', candidatePath) const selected = all.filter((candidate) => group.some((record) => record.source.inboundCode === candidate.inboundCode)) const inputs = selected.map((candidate) => { @@ -422,10 +516,13 @@ export async function run(context) { .reduce((sum, item) => sum + Number(item.availablePackages), 0) === record.expectedCartonMarks.length)) const body = { recipient: `HTTP acceptance ${context.marker}`, items: inputs, remark: context.marker, idempotencyKey: `${context.marker}-outbound-create` } + const edge = { context, group, token, customerId, warehouseId, candidatePath, body, label, check, call } + await outboundCreationGuards(edge) const task = await call('创建一件代发专用出库', 'POST', `${base}/one-click-dropship?customerId=${customerId}`, body) for (const record of group) record.outbound = { task, selected, completed: false } const replay = await call('专用出库创建同键重放', 'POST', `${base}/one-click-dropship?customerId=${customerId}`, body) check(`${label}专用出库重放不重复建单`, null, sameOutboundTask(replay, task)) + await outboundTaskGuards({ ...edge, task }) const pdaPath = `/api/pda/v1/last-leg/outbound-tasks/${task.taskNo}` const detail = await call('PDA读取专用出库任务', 'GET', pdaPath, undefined, true) check(`${label}PDA出库明细完整`, null, detail.items?.length === inputs.length) @@ -438,6 +535,11 @@ export async function run(context) { const pick = { outboundPalletItemId: item.outboundPalletItemId, inventoryPositionId: candidate.inventoryPositionId, packages: inputs[index].packages, cartonMarks: inputs[index].cartonMarks, idempotencyKey: `${context.marker}-outbound-pick-${index}` } + if (label === 'SINGLE-SINGLE' && index === 0) { + const count = await probePartialPick({ ...edge, task, pick }) + pick.packages -= count + pick.cartonMarks = pick.cartonMarks.slice(count) + } const picked = await call('PDA专用出库拣货', 'POST', `${pdaPath}/pick`, pick, true) const repeated = await call('PDA出库拣货同键重放', 'POST', `${pdaPath}/pick`, pick, true) check(`${label}PDA出库拣货重放不重复扣减`, null, sameOutboundTask(repeated, picked)) @@ -460,6 +562,33 @@ export async function run(context) { for (const record of group) record.outbound = { task: completed, selected, detail: outbound, completed: true } } try { + if (mixedContainer) { + const dropship = await prepare('MIXED-DROPSHIP', 2) + await verify(dropship, 2) + context.context.waitSplitRoute = { originWarehouseName: dropship.order.receivingWarehouseName, + channelCode: dropship.order.channelCode } + const fba = await prepare('MIXED-FBA', 1, {}, 'FBA') + const overseas = await prepare('MIXED-OVERSEAS', 1, {}, 'OVERSEAS') + const group = [dropship, fba, overseas] + check('混柜三票属于同一起运仓和渠道组', null, group.every(r => + String(r.summary.warehouseId) === String(dropship.summary.warehouseId) + && channelGroup(r.summary.channelCode) === channelGroup(dropship.summary.channelCode))) + await allocate(group) + await progressShipping(group) + await convertOverseas(group) + await receiveOverseas(group) + context.createdResources.dropshipPalletMatrix.stage = 'MIXED_CONTAINER_INBOUND_COMPLETED' + return + } + 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/firstleg-container-fee-allocation.mjs b/scripts/admin-test-env-modules/firstleg-container-fee-allocation.mjs index 0343a9ff2..5179848e3 100644 --- a/scripts/admin-test-env-modules/firstleg-container-fee-allocation.mjs +++ b/scripts/admin-test-env-modules/firstleg-container-fee-allocation.mjs @@ -142,7 +142,7 @@ async function provisionCrossCustomerPlan(context) { || first.channelCode !== second.channelCode) return null const containerResponse = await context.request('POST', '/api/platform/v1/containers', { channelGroupCode: channelGroup(first.channelCode), warehouseId: id(first.receivingWarehouseId), - containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000, + containerType: '40HQ', capacityCbm: 68, maxWeightKg: 19500, }) const container = context.apiSuccess(MODULE, '创建跨客户费用分摊专用模拟柜', containerResponse, evidence(containerResponse), OPTIONS) diff --git a/scripts/admin-test-env-modules/fulfillment-prototype-field-matrix-contract.test.mjs b/scripts/admin-test-env-modules/fulfillment-prototype-field-matrix-contract.test.mjs index 276c88666..b5d98d353 100644 --- a/scripts/admin-test-env-modules/fulfillment-prototype-field-matrix-contract.test.mjs +++ b/scripts/admin-test-env-modules/fulfillment-prototype-field-matrix-contract.test.mjs @@ -82,8 +82,8 @@ test('正式 DTO/OpenAPI 暴露矩阵使用的稳定字段', () => { test('字段校验器接受正式完整投影并精确暴露缺字段', () => { const samples = [ - [containerFieldGaps, { id: ids.container, cabinetNo: 'CAB-1', containerNo: 'MSCU1234567', warehouseId: ids.id, warehouseName: '宁波仓', channelCode: 'EXX', containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000, planStatus: 'EDITING' }, { requireContainerNo: true }, 'warehouseName'], - [preloadFieldGaps, { planId: ids.plan, containerBookingId: ids.container, preloadNo: 'PL-1', cabinetNo: 'CAB-1', customers: [{ accountId: ids.id, customerName: '测试客户' }], warehouseName: '宁波仓', containerType: '40HC', status: 'EDITING', skuCount: 1, totalPackageCount: 1, totalVolumeCbm: 1, totalGrossWeightKg: 10, createdAt: at }, undefined, 'customers'], + [containerFieldGaps, { id: ids.container, cabinetNo: 'CAB-1', containerNo: 'MSCU1234567', warehouseId: ids.id, warehouseName: '宁波仓', channelCode: 'EXX', containerType: '40HQ', capacityCbm: 68, maxWeightKg: 19500, planStatus: 'EDITING' }, { requireContainerNo: true }, 'warehouseName'], + [preloadFieldGaps, { planId: ids.plan, containerBookingId: ids.container, preloadNo: 'PL-1', cabinetNo: 'CAB-1', customers: [{ accountId: ids.id, customerName: '测试客户' }], warehouseName: '宁波仓', containerType: '40HQ', status: 'EDITING', skuCount: 1, totalPackageCount: 1, totalVolumeCbm: 1, totalGrossWeightKg: 10, createdAt: at }, undefined, 'customers'], [loadingListFieldGaps, { planId: ids.plan, containerBookingId: ids.container, loadingListNo: 'LL-1', containerNo: 'MSCU1234567', cabinetNo: 'CAB-1', warehouseName: '宁波仓', status: 'COMPLETED', totalPackageCount: 1, totalGrossWeightKg: 10, totalNetWeightKg: 9, totalVolumeCbm: 1, totalQuantity: 2, createdAt: at }, undefined, 'containerNo'], [oceanBookingFieldGaps, { id: ids.id, bookingNo: 'BK-1', status: 'RELEASED', vesselName: 'VESSEL', voyageNo: 'V1', estimatedSailingAt: at, estimatedArrivalAt: at, customerId: ids.id, customerName: '测试客户', shippingCompanyId: null, shippingCompanyName: null, bookingAgentId: ids.id, bookingAgentName: '代理' }, undefined, 'voyageNo'], [portScheduleFieldGaps, { portSchedule: { schedule: { id: ids.id, scheduleNo: 'PS-1', sourceName: '船公司', vesselName: 'VESSEL', voyageNo: 'V1', departurePortCode: 'CNNGB', destinationPortCode: 'USLAX', etdAt: at, etaAt: at, observedAt: at, updatedAt: at, expiresAt: at }, expired: false } }, undefined, 'sourceName'], diff --git a/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs b/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs index 3e714198c..aa0af1f0c 100644 --- a/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs +++ b/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs @@ -159,6 +159,12 @@ test('海外空库基线为固定平台管理员提供完整version0授权投影 assert.equal(inline.replace(/\r\n/g, '\n').trim(), lastlegBaseline.replace(/\r\n/g, '\n').trim()) }) +test('当前海外基线不从废弃申请回填历史尾程订单', () => { + for (const source of [lastlegBaseline, overseasSql]) { + assert.doesNotMatch(source, /INSERT IGNORE INTO ll_order\s*\([\s\S]*?SELECT a\.id,a\.discard_no/) + } +}) + test('国内统一初始化补齐入库费用人工调整基线字段', () => { assert.match(sql, /baseline_chargeable_quantity DECIMAL\(18,6\) NOT NULL/) assert.match(sql, /SET baseline_chargeable_quantity = chargeable_quantity[\s\S]*?WHERE baseline_chargeable_quantity IS NULL/) diff --git a/scripts/admin-test-env-modules/loading-history-noop-contract.test.mjs b/scripts/admin-test-env-modules/loading-history-noop-contract.test.mjs index 4e7e4fbeb..55727ddb2 100644 --- a/scripts/admin-test-env-modules/loading-history-noop-contract.test.mjs +++ b/scripts/admin-test-env-modules/loading-history-noop-contract.test.mjs @@ -44,7 +44,7 @@ test('backend never appends a generic STATUS_UPDATE and current operation replay assert.match(smartLoadingTest, /void replaysBeforeVersionCheck\(\)/) assert.match(smartLoadingTest, /void exactWarehouseRemarkReplayIsReadOnly\(\)/) assert.match(smartLoadingTest, /replaysBeforeVersionCheck[\s\S]{0,1200}verify\(repository, never\(\)\)\.candidate/) - assert.match(smartLoadingTest, /exactWarehouseRemarkReplayIsReadOnly[\s\S]{0,1200}verify\(repository, never\(\)\)\.updateWarehouseRemark/) + assert.match(smartLoadingTest, /exactWarehouseRemarkReplayIsReadOnly[\s\S]{0,1200}verify\(repository, never\(\)\)\.updateRemarks/) }) test('operationId is projected end-to-end for identity-based frontend deduplication', () => { 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/manual-state-loading-pda.mjs b/scripts/admin-test-env-modules/manual-state-loading-pda.mjs index d13797cd0..80742c422 100644 --- a/scripts/admin-test-env-modules/manual-state-loading-pda.mjs +++ b/scripts/admin-test-env-modules/manual-state-loading-pda.mjs @@ -118,18 +118,18 @@ async function createConfirmedContainer(context, source, label) { bookingAgentId: id(agent.id), bookingAgentName: agent.name, departurePortCode: 'CNSZX', vesselName: `人工船-${value}`.slice(0, 128), voyageNo: `V-${value}`.slice(0, 64), departurePortName: '深圳', destinationPortCode: 'USLAX', destinationPortName: '洛杉矶', - placeOfDelivery: 'LAX', serviceTerm: 'CY-CY', freightTerm: 'NINGBO', + placeOfDelivery: 'LAX', estimatedSailingAt: '2026-09-01T08:00:00', estimatedArrivalAt: '2026-09-20T08:00:00', remark: `${context.marker}-${label}`, parties: [{ partyType: 'SHIPPER', partyName: `发货方-${value}` }, { partyType: 'CONSIGNEE', partyName: `收货方-${value}` }, { partyType: 'NOTIFY_PARTY', partyName: `通知方-${value}` }], - containerRequirements: [{ containerType: '40HC', containerKind: 'FCL', quantity: 1, capacityCbm: 76, - maxWeightKg: 28000 }], + containerRequirements: [{ containerType: '40HQ', containerKind: 'FCL', quantity: 1, capacityCbm: 68, + maxWeightKg: 19500 }], } const simulationResponse = await context.request('POST', CONTAINERS, { - channelGroupCode: channelGroup(summary.channelCode), warehouseId: source.warehouseId, containerType: '40HC', - capacityCbm: 76, maxWeightKg: 28000, + channelGroupCode: channelGroup(summary.channelCode), warehouseId: source.warehouseId, containerType: '40HQ', + capacityCbm: 68, maxWeightKg: 19500, }) const simulation = context.apiSuccess(MODULE, `${label} 新增内部模拟柜`, simulationResponse, '', OPTIONS) const containerId = id(simulation?.id) @@ -157,7 +157,7 @@ async function createConfirmedContainer(context, source, label) { context.assert(MODULE, `${label} 订舱为 RELEASED 且不预生成真实柜`, releasedResponse, confirmed?.oceanBooking?.status === 'RELEASED' && (!confirmed?.containers || confirmed.containers.length === 0), `status=${confirmed?.oceanBooking?.status || 'missing'}`, OPTIONS) - const requirement = confirmed?.containerRequirements?.find?.((item) => item.containerType === '40HC') + const requirement = confirmed?.containerRequirements?.find?.((item) => item.containerType === '40HQ') if (!requirement?.id) throw new Error(`${label} 放舱订舱未返回柜型需求`) const linkedResponse = await context.request('POST', `${CONTAINERS}/${containerId}/ocean-booking-link`, { oceanBookingId: bookingId, requirementId: id(requirement.id), containerNo, diff --git a/scripts/admin-test-env-modules/manual-state-ocean-trucking-contract.test.mjs b/scripts/admin-test-env-modules/manual-state-ocean-trucking-contract.test.mjs index d6e88e869..b817a9550 100644 --- a/scripts/admin-test-env-modules/manual-state-ocean-trucking-contract.test.mjs +++ b/scripts/admin-test-env-modules/manual-state-ocean-trucking-contract.test.mjs @@ -14,10 +14,17 @@ test('manual matrix retains only DRAFT and RELEASED ocean booking states', () => test('truck matrix remains independent while retaining its own lifecycle', () => { for (const status of ['DRAFT', 'PENDING_DISPATCH', 'DISPATCHED', 'PICKUP_IN_PROGRESS', - 'ARRIVED_WAREHOUSE', 'LOADING', 'DEPARTED', 'COMPLETED', 'RESCHEDULED', 'EXCEPTION', 'CANCELLED']) { + 'ARRIVED_WAREHOUSE', 'LOADING', 'RESCHEDULED', 'EXCEPTION', 'CANCELLED']) { assert.match(source, new RegExp(`['\"]${status}['\"]`)) } assert.match(source, /page=1&size=100&status=/) assert.match(source, /category=TRUCKING&status=ACTIVE/) assert.match(source, /carrierId: id\(carrier\.id\), carrierName: carrier\.name/) + const completion = readFileSync(new URL('../../firstleg/src/main/java/com/baiyue/firstleg/service/TruckAppointmentCompletionService.java', import.meta.url), 'utf8') + const statuses = readFileSync(new URL('../../firstleg/src/main/java/com/baiyue/firstleg/domain/TruckAppointmentStatus.java', import.meta.url), 'utf8') + assert.doesNotMatch(statuses, /\bDEPARTED\b/) + assert.match(statuses, /\bCOMPLETED\b/) + assert.match(completion, /if \(!allDeparted\(rows\)\) return/) + assert.match(completion, /history\.setAction\("AUTO_COMPLETE"\)/) + assert.match(completion, /history\.setToStatus\("COMPLETED"\)/) }) diff --git a/scripts/admin-test-env-modules/manual-state-ocean-trucking.mjs b/scripts/admin-test-env-modules/manual-state-ocean-trucking.mjs index 18dcbe782..c5949eb2d 100644 --- a/scripts/admin-test-env-modules/manual-state-ocean-trucking.mjs +++ b/scripts/admin-test-env-modules/manual-state-ocean-trucking.mjs @@ -46,7 +46,7 @@ function oceanPayload(context, template, index) { bookingAgentName: source.bookingAgentId ? source.bookingAgentName : null, departurePortCode: source.departurePortCode, departurePortName: source.departurePortName, destinationPortCode: source.destinationPortCode, destinationPortName: source.destinationPortName, - placeOfDelivery: source.placeOfDelivery, serviceTerm: source.serviceTerm, freightTerm: source.freightTerm, + placeOfDelivery: source.placeOfDelivery, estimatedSailingAt: '2026-09-16T08:00:00', estimatedArrivalAt: '2026-10-16T08:00:00', remark: `${context.marker} 人工列表状态 ${index}`, parties: template.parties.map(({ partyType, partyName, address, contactName, contactPhone }) => diff --git a/scripts/admin-test-env-modules/mixed-container-receiving-contract.test.mjs b/scripts/admin-test-env-modules/mixed-container-receiving-contract.test.mjs new file mode 100644 index 000000000..189c78773 --- /dev/null +++ b/scripts/admin-test-env-modules/mixed-container-receiving-contract.test.mjs @@ -0,0 +1,98 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { normalizeModuleOrder } from '../admin-test-env-acceptance.mjs' +import { mixedReceivingSchedule, receiveMixedContainer } from './mixed-container-receiving.mjs' + +function fixtures() { + const record = (type, fulfillmentType = 'STANDARD') => ({ label: type, + order: { destinationType: type, fulfillmentType }, source: { inboundId: type, inboundCode: type }, + expectedCartonMarks: [1, 2, 3, 4].map(i => `${type}#4-${i}`), + overseas: { plan: { pallets: [{ palletNo: `PAL-${type}` }] } } }) + const d = record('DROPSHIP', 'ONE_CLICK_DROPSHIP') + d.task = { pallets: [{ palletNo: 'DP-1', cartons: [{ inboundId: 'DROPSHIP', boxCode: 'DROPSHIP#4-1' }] }, + { palletNo: 'DP-2', cartons: [2, 3, 4].map(i => ({ inboundId: 'DROPSHIP', boxCode: `DROPSHIP#4-${i}` })) }] } + return [d, record('FBA'), record('OVERSEAS')] +} + +test('mixed receiving is registered with prerequisites, not transfer or downstream delivery', () => { + assert.deepEqual(normalizeModuleOrder(['mixed-container-receiving']), ['merchant-qualification-fixture', 'mixed-container-receiving']) + const source = readFileSync(new URL('dropship-pallet-matrix.mjs', import.meta.url), 'utf8') + const start = source.indexOf("const dropship = await prepare('MIXED-DROPSHIP'") + const branch = source.slice(start, source.indexOf('if (stopBeforeOverseasPda)', start)) + for (const entry of ["'MIXED-FBA', 1, {}, 'FBA'", "'MIXED-OVERSEAS', 1, {}, 'OVERSEAS'", + 'allocate(group)', 'progressShipping(group)', 'convertOverseas(group)', 'receiveOverseas(group)', + 'MIXED_CONTAINER_INBOUND_COMPLETED']) assert.ok(branch.includes(entry), entry) + assert.doesNotMatch(branch, /outboundOverseas|warehouse-transfers/) + assert.match(source, /混柜完成后按入仓码查询库存/) + assert.match(source, /String\(row.customerId\) === String\(item.order.tenantId\)/) + assert.match(source, /String\(p.warehousePalletId\) === String\(row.palletId\)/) +}) + +test('schedule interleaves three modes and uses FBA cumulative count rather than delta', () => { + const steps = mixedReceivingSchedule(fixtures()) + assert.deepEqual(steps.map(s => s.path), ['bulk', 'pallet', 'carton', 'carton', 'carton', 'carton', 'bulk', 'pallet']) + assert.deepEqual(steps.filter(s => s.path === 'bulk').map(s => [s.count, s.body.packageCount]), [[1, 1], [3, 4]]) + assert.equal(steps.reduce((sum, s) => sum + s.count, 0), 12) + assert.throws(() => mixedReceivingSchedule(fixtures().slice(0, 2))) +}) + +function driver({ earlyComplete = false, wrongModeAccepted = false, drift = false, repeatIncrement = false, + changedPayloadAccepted = false } = {}) { + const group = fixtures() + const expected = new Map(group.map(r => [r.source.inboundCode, 0])) + const schedule = mixedReceivingSchedule(group) + const seen = new Map() + let index = 0 + let complete = false + const snapshot = () => ({ status: complete ? 'COMPLETED' : 'SIGNED', totalPackages: 12, + receivedPackages: [...expected.values()].reduce((a, b) => a + b, 0), + pendingPackages: 12 - [...expected.values()].reduce((a, b) => a + b, 0), + inbounds: [...expected].map(([inboundCode, receivedPackages]) => ({ inboundCode, totalPackages: 4, + receivedPackages, pendingPackages: 4 - receivedPackages })) }) + return { group, marker: 'MIXED-TEST', check: (name, _response, passed) => assert.ok(passed, name), + request: async (_method, path, body) => { + if (body.idempotencyKey.endsWith('-duplicate')) { + return { body: path.endsWith('/carton') ? { code: 'A0409' } + : { code: '200', data: { result: 'ALREADY_RECEIVED', acceptedPackages: repeatIncrement ? 1 : 0 } } } + } + if (seen.has(body.idempotencyKey)) return { body: { code: changedPayloadAccepted ? '200' : 'A0409' } } + return { body: { code: (path.endsWith('/complete') ? earlyComplete : wrongModeAccepted) ? '200' : 'A0409' } } + }, + call: async (_name, method, path, body) => { + if (method === 'GET') return snapshot() + if (seen.has(body.idempotencyKey)) return structuredClone(seen.get(body.idempotencyKey)) + if (path.endsWith('/complete')) { + complete = true + const result = snapshot(); seen.set(body.idempotencyKey, result); return result + } + const step = schedule[index++] + assert.ok(path.endsWith(`/receipts/${step.path}`)) + const inbound = drift ? 'FBA' : step.record.source.inboundCode + expected.set(inbound, expected.get(inbound) + step.count) + const result = { acceptedPackages: step.count } + seen.set(body.idempotencyKey, result) + return result + } } +} + +test('mixed receiving executes the whole schedule and completes only after all three rows balance', async () => { + const result = await receiveMixedContainer(driver()) + assert.equal(result.status, 'COMPLETED') + assert.equal(result.receivedPackages, 12) +}) +test('mixed receiving fails on early-completion acceptance', async () => { + await assert.rejects(receiveMixedContainer(driver({ earlyComplete: true }))) +}) +test('mixed receiving fails if overseas or dropship can bypass pallet/carton receipt rules', async () => { + await assert.rejects(receiveMixedContainer(driver({ wrongModeAccepted: true }))) +}) +test('mixed receiving detects correct aggregate count allocated to the wrong inbound', async () => { + await assert.rejects(receiveMixedContainer(driver({ drift: true }))) +}) +test('mixed receiving rejects a new-key rescan that accepts another box', async () => { + await assert.rejects(receiveMixedContainer(driver({ repeatIncrement: true }))) +}) +test('mixed receiving rejects same-key different-payload success', async () => { + await assert.rejects(receiveMixedContainer(driver({ changedPayloadAccepted: true }))) +}) diff --git a/scripts/admin-test-env-modules/mixed-container-receiving.mjs b/scripts/admin-test-env-modules/mixed-container-receiving.mjs new file mode 100644 index 000000000..f204aba7e --- /dev/null +++ b/scripts/admin-test-env-modules/mixed-container-receiving.mjs @@ -0,0 +1,81 @@ +import { receivingProgressMatches } from './dropship-outbound-edge-cases.mjs' + +export async function run(context) { + const { runMixedContainer } = await import('./dropship-pallet-matrix.mjs') + return runMixedContainer(context) +} + +export function mixedReceivingSchedule(group) { + const fba = group.filter(r => r.order.fulfillmentType === 'STANDARD' && r.order.destinationType === 'FBA') + const overseas = group.filter(r => r.order.fulfillmentType === 'STANDARD' && r.order.destinationType === 'OVERSEAS') + const dropship = group.filter(r => r.order.fulfillmentType === 'ONE_CLICK_DROPSHIP') + if (group.length !== 3 || fba.length !== 1 || overseas.length !== 1 || dropship.length !== 1 + || dropship[0].task?.pallets?.length !== 2) throw new Error('混柜必须包含普通FBA、普通海外仓及一件代发双托三票') + const [f, o, d] = [fba[0], overseas[0], dropship[0]] + const fbaPallet = f.overseas.plan.pallets?.[0]?.palletNo + const overseasPallet = o.overseas.plan.pallets?.[0]?.palletNo + if (!fbaPallet || !overseasPallet || f.expectedCartonMarks.length < 2) throw new Error('混柜普通订单缺少真实托盘或分批箱数') + const palletStep = pallet => ({ record: d, path: 'pallet', count: pallet.cartons.filter(c => + String(c.inboundId) === String(d.source.inboundId)).length, body: { sourcePalletNo: pallet.palletNo } }) + return [ + { record: f, path: 'bulk', count: 1, body: { inboundCode: f.source.inboundCode, palletCode: fbaPallet, packageCount: 1 } }, + palletStep(d.task.pallets[0]), + ...o.expectedCartonMarks.map(cartonMark => ({ record: o, path: 'carton', count: 1, body: { cartonMark, palletCode: overseasPallet } })), + { record: f, path: 'bulk', count: f.expectedCartonMarks.length - 1, + body: { inboundCode: f.source.inboundCode, palletCode: fbaPallet, packageCount: f.expectedCartonMarks.length } }, + palletStep(d.task.pallets[1]), + ] +} + +export async function receiveMixedContainer({ group, call, check, request, marker }) { + const base = '/api/pda/v1/last-leg/receiving-containers/current' + const steps = mixedReceivingSchedule(group) + const expected = group.map(r => ({ inboundCode: r.source.inboundCode, total: r.expectedCartonMarks.length, received: 0 })) + for (const record of group.filter(r => r.order.fulfillmentType === 'ONE_CLICK_DROPSHIP' || r.order.destinationType === 'OVERSEAS')) { + const before = await call('混柜错误收货模式前回查', 'GET', base) + const invalid = await request('POST', `${base}/receipts/bulk`, { + inboundCode: record.source.inboundCode, palletCode: record.overseas.plan.pallets[0].palletNo, + packageCount: 1, idempotencyKey: `${marker}-forbid-bulk-${record.label}`, + }) + check('普通海外仓和一件代发均不能绕过各自规则使用FBA批量收货', invalid, invalid.body?.code === 'A0409') + const after = await call('混柜错误收货模式拒绝后回查', 'GET', base) + check('混柜错误收货模式拒绝后零漂移', null, JSON.stringify(before) === JSON.stringify(after)) + } + for (const [index, step] of steps.entries()) { + const before = await call('混柜交替收货前进度', 'GET', base) + check('混柜每步前逐票和柜级进度准确', null, receivingProgressMatches(before, expected)) + const premature = await request('POST', `${base}/complete`, + { allowDifference: false, idempotencyKey: `${marker}-early-${index}` }) + check('混柜任一票未齐不得正常完成', premature, premature.body?.code === 'A0409') + const afterRejected = await call('混柜提前完成拒绝后回查', 'GET', base) + check('混柜提前完成拒绝后零漂移', null, JSON.stringify(afterRejected) === JSON.stringify(before)) + const path = `${base}/receipts/${step.path}` + const body = { ...step.body, idempotencyKey: `${marker}-mixed-receive-${index}` } + const received = await call(`${step.record.label}交替收货`, 'POST', path, body) + check('混柜本次只收当前操作对应箱数', null, Number(received.acceptedPackages) === step.count) + const replay = await call('混柜收货同键重放', 'POST', path, body) + check('混柜同键重放结果稳定', null, JSON.stringify(replay) === JSON.stringify(received)) + expected.find(row => row.inboundCode === step.record.source.inboundCode).received += step.count + const after = await call('混柜每步后逐票回查', 'GET', base) + check('混柜交替收货不串票不重计', null, receivingProgressMatches(after, expected)) + const changedBody = step.path === 'pallet' ? { ...body, sourcePalletNo: `${body.sourcePalletNo}-CHANGED` } + : step.path === 'bulk' ? { ...body, packageCount: body.packageCount + 1 } + : { ...body, palletCode: `${body.palletCode}-CHANGED` } + const changed = await request('POST', path, changedBody) + check('混柜收货同键异载荷必须冲突', changed, changed.body?.code === 'A0409') + const duplicate = await request('POST', path, { ...body, idempotencyKey: `${body.idempotencyKey}-duplicate` }) + check('混柜同货物新键重复操作不重复收货', duplicate, step.path === 'carton' + ? duplicate.body?.code === 'A0409' + : duplicate.body?.code === '200' && duplicate.body?.data?.result === 'ALREADY_RECEIVED' + && Number(duplicate.body?.data?.acceptedPackages) === 0) + const afterRetries = await call('混柜重复和冲突请求后回查全部票', 'GET', base) + check('混柜冲突及新键重复后全柜事实不变', null, JSON.stringify(afterRetries) === JSON.stringify(after)) + } + const body = { allowDifference: false, idempotencyKey: `${marker}-mixed-complete` } + const completed = await call('混柜全部核平后正常完成入库', 'POST', `${base}/complete`, body) + check('混柜三票全收且普通完成', null, + completed.status === 'COMPLETED' && receivingProgressMatches(completed, expected)) + const replay = await call('混柜入库完成重放', 'POST', `${base}/complete`, body) + check('混柜完成重放不重复入库', null, JSON.stringify(replay) === JSON.stringify(completed)) + return completed +} diff --git a/scripts/admin-test-env-modules/ocean-booking-fixture-contract.test.mjs b/scripts/admin-test-env-modules/ocean-booking-fixture-contract.test.mjs index f3f4830b4..1492d7fcd 100644 --- a/scripts/admin-test-env-modules/ocean-booking-fixture-contract.test.mjs +++ b/scripts/admin-test-env-modules/ocean-booking-fixture-contract.test.mjs @@ -5,7 +5,7 @@ import test from 'node:test' import { oceanBookingDraft, oceanBookingDraftWithoutCustomer, publishPlatformLogisticsChannels, resolveOceanBookingAgent, resolveOceanBookingMaster } from './ocean-booking-fixture.mjs' const read = (name) => readFileSync(new URL(name, import.meta.url), 'utf8') -const master = { customerId: '21', customerName: 'IT 测试客户', channelCode: 'SEA-US', channelName: '美西海运', warehouseId: '41', warehouseName: '深圳仓' } +const master = { customerId: '21', customerName: 'IT 测试客户', channelCode: 'MATSON_CLX_TRUCK', channelName: '美森正班海卡', warehouseId: '41', warehouseName: '深圳仓' } test('fixture resolves current-marker customer channel and warehouse without inventing data', async () => { const steps = [] @@ -22,12 +22,13 @@ test('fixture resolves current-marker customer channel and warehouse without inv test('shared DRAFT uses requirements and never registers a real container number', () => { const draft = oceanBookingDraft({ marker: 'ADMIN-RUN-3' }, '3', master) assert.equal(draft.channelName, undefined) + assert.equal(draft.channelCode, 'MATSON_CLX') assert.equal(draft.warehouseName, undefined) - assert.deepEqual(draft.containerRequirements, [{ containerType: '40HC', containerKind: 'FCL', quantity: 1, capacityCbm: 76, maxWeightKg: 28000 }]) + assert.deepEqual(draft.containerRequirements, [{ containerType: '40HQ', containerKind: 'FCL', quantity: 1, capacityCbm: 68, maxWeightKg: 19500 }]) assert.equal(Object.hasOwn(draft, 'containers'), false) assert.equal(JSON.stringify(draft).includes('containerNo'), false) - assert.equal(draft.serviceTerm, 'CY-CY') - assert.equal(draft.freightTerm, 'NINGBO') + assert.equal('serviceTerm' in draft, false) + assert.equal('freightTerm' in draft, false) assert.equal(draft.carrierCode, 'OTHER') for (const field of ['billOfLadingNo', 'vesselName', 'voyageNo', 'estimatedSailingAt', 'estimatedArrivalAt']) assert.ok(draft[field]) }) @@ -41,8 +42,8 @@ test('shared DRAFT permits an omitted customer without inventing placeholders', test('explicit requirement rows preserve quantity and fixed catalog facts', () => { const requirements = [ - { containerType: '40HC', containerKind: 'FCL', quantity: 2, capacityCbm: 76, maxWeightKg: 28000 }, - { containerType: '20GP', containerKind: 'FCL', quantity: 1, capacityCbm: 33, maxWeightKg: 28000 }, + { containerType: '40HQ', containerKind: 'FCL', quantity: 2, capacityCbm: 68, maxWeightKg: 19500 }, + { containerType: '40GP', containerKind: 'FCL', quantity: 1, capacityCbm: 58, maxWeightKg: 19500 }, ] const draft = oceanBookingDraft({ marker: 'ADMIN-RUN-MULTI' }, 'multi', master, { containerRequirements: requirements }) assert.deepEqual(draft.containerRequirements, requirements) diff --git a/scripts/admin-test-env-modules/ocean-booking-fixture.mjs b/scripts/admin-test-env-modules/ocean-booking-fixture.mjs index e2a26d8f1..642275274 100644 --- a/scripts/admin-test-env-modules/ocean-booking-fixture.mjs +++ b/scripts/admin-test-env-modules/ocean-booking-fixture.mjs @@ -1,3 +1,5 @@ +import { channelGroup } from './smart-loading-channel-group.mjs' + function text(value) { return value == null ? '' : String(value).trim() } const PLATFORM_CHANNELS = '/api/platform/v1/ocean-bookings/channels' @@ -69,10 +71,11 @@ export function oceanBookingDraft(context, suffix, master, labels = {}) { const { channelName: _readOnlyChannelName, warehouseName: _readOnlyWarehouseName, ...writableMaster } = master const normalizedSuffix = text(suffix).replace(/[^A-Za-z0-9]/g, '').slice(-32) || 'BOOKING' const containerRequirements = labels.containerRequirements || [{ - containerType: '40HC', containerKind: 'FCL', quantity: 1, capacityCbm: 76, maxWeightKg: 28000, + containerType: '40HQ', containerKind: 'FCL', quantity: 1, capacityCbm: 68, maxWeightKg: 19500, }] return { ...writableMaster, + channelCode: channelGroup(writableMaster.channelCode), billOfLadingNo: labels.billOfLadingNo || `BL-${suffix}`, carrierCode: labels.carrierCode || 'OTHER', shippingCompanyId: labels.shippingCompanyId ?? null, @@ -81,7 +84,7 @@ export function oceanBookingDraft(context, suffix, master, labels = {}) { bookingAgentName: labels.bookingAgentId ? labels.bookingAgentName ?? null : null, vesselName: labels.vesselName || `验收船-${suffix}`, voyageNo: labels.voyageNo || `V-${suffix}`, departurePortCode: 'CNSZX', departurePortName: '深圳', destinationPortCode: 'USLAX', - destinationPortName: '洛杉矶', placeOfDelivery: 'LAX', serviceTerm: 'CY-CY', freightTerm: 'NINGBO', + destinationPortName: '洛杉矶', placeOfDelivery: 'LAX', estimatedSailingAt: labels.estimatedSailingAt || '2026-09-01T08:00:00', estimatedArrivalAt: labels.estimatedArrivalAt || '2026-09-20T08:00:00', remark: context.marker, diff --git a/scripts/admin-test-env-modules/ocean-bookings.mjs b/scripts/admin-test-env-modules/ocean-bookings.mjs index a4797d0d7..e96c81c03 100644 --- a/scripts/admin-test-env-modules/ocean-bookings.mjs +++ b/scripts/admin-test-env-modules/ocean-bookings.mjs @@ -27,8 +27,8 @@ function fullDraft(context, suffix, master, labels) { return oceanBookingDraft(context, suffix, master, { ...labels, containerRequirements: labels.containerRequirements || [ - { containerType: '40HC', containerKind: 'FCL', quantity: 1, capacityCbm: 76, maxWeightKg: 28000 }, - { containerType: '20GP', containerKind: 'FCL', quantity: 1, capacityCbm: 33, maxWeightKg: 28000 }, + { containerType: '40HQ', containerKind: 'FCL', quantity: 1, capacityCbm: 68, maxWeightKg: 19500 }, + { containerType: '40GP', containerKind: 'FCL', quantity: 1, capacityCbm: 58, maxWeightKg: 19500 }, ], }) } diff --git a/scripts/admin-test-env-modules/orders-lifecycle-idempotency-contract.test.mjs b/scripts/admin-test-env-modules/orders-lifecycle-idempotency-contract.test.mjs index 892b6d86d..6e893fad5 100644 --- a/scripts/admin-test-env-modules/orders-lifecycle-idempotency-contract.test.mjs +++ b/scripts/admin-test-env-modules/orders-lifecycle-idempotency-contract.test.mjs @@ -2,10 +2,24 @@ import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import test from 'node:test' -import { orderPersistenceFacts, requestedOrderDestinationType } from './orders.mjs' +import { fixtureInboundCode, orderPersistenceFacts, requestedOrderDestinationType } from './orders.mjs' const source = readFileSync(new URL('orders.mjs', import.meta.url), 'utf8') +test('fresh fixture obeys the authoritative inbound-code policy without changing global settings', () => { + assert.deepEqual(fixtureInboundCode({ mode: 'AUTO', manualEntryRequired: false }, 'batch-A'), {}) + const policy = { mode: 'LEGACY_MANUAL', manualEntryRequired: true } + const first = fixtureInboundCode(policy, 'batch-A') + assert.deepEqual(first, fixtureInboundCode(policy, 'batch-A')) + assert.notEqual(first.inboundCode, fixtureInboundCode(policy, 'batch-B').inboundCode) + assert.match(first.inboundCode, /^AT[A-F0-9]{20}$/) + assert.ok(`${first.inboundCode}#9999-9999`.length <= 34) + for (const invalid of [null, {}, { mode: 'AUTO', manualEntryRequired: true }, { mode: 'UNKNOWN', manualEntryRequired: false }]) { + assert.throws(() => fixtureInboundCode(invalid, 'batch-A'), /入仓码策略/) + } + assert.match(source, /GET', `\$\{createPath\}\/inbound-code-policy`/) +}) + test('order demo payload covers multi-box multi-SKU images and both destination families', () => { for (const boxType of ['SMALL', 'MEDIUM', 'LARGE', 'EXTRA_LARGE']) { assert.match(source, new RegExp(`boxType: '${boxType}'`)) diff --git a/scripts/admin-test-env-modules/orders.mjs b/scripts/admin-test-env-modules/orders.mjs index a86d46759..b91b70c02 100644 --- a/scripts/admin-test-env-modules/orders.mjs +++ b/scripts/admin-test-env-modules/orders.mjs @@ -1,3 +1,5 @@ +import { createHash } from 'node:crypto' + const MODULE = 'orders' const CONTRACT_OPTIONS = { scenario: 'isolated-order-chain', deploymentBlocker: true } const MERCHANT_USERNAME = process.env.MERCHANT_TEST_USERNAME || '' @@ -25,6 +27,14 @@ function rotate(values, offset) { return [...values.slice(shift), ...values.slice(0, shift)] } function key(context, operation) { return `${context.marker}-${operation}`.slice(0, 128) } +export function fixtureInboundCode(policy, marker) { + if (policy?.mode === 'AUTO' && policy.manualEntryRequired === false) return {} + if (policy?.mode !== 'LEGACY_MANUAL' || policy.manualEntryRequired !== true || !marker) { + throw new Error('入仓码策略缺失或不一致,停止创建测试订单') + } + // Short enough for the 34-character carton QR limit; stable within this fresh fixture. + return { inboundCode: `AT${createHash('sha256').update(marker).digest('hex').slice(0, 20).toUpperCase()}` } +} function declarationCargo(source, index) { const number = (value) => Number.isFinite(Number(value)) ? Number(value) : 0 const optionalNumber = (value) => value === null || value === undefined || value === '' ? null : number(value) @@ -449,7 +459,8 @@ function buildPayload(context, profile, master, productImageFileId, customerAddr const carton = dropship ? selectedBoxes[0] : box const cartonVolumeCbm = carton.lengthCm * carton.widthCm * carton.heightCm / 1_000_000 return { - skuNo: `SKU-${box.boxType}-${context.marker}`.slice(0, 64), + skuNo: context.context?.waitSplitVariant?.skuCodes?.[index] + || `SKU-${box.boxType}-${context.marker}`.slice(0, 64), cargoName: `${box.boxType} 测试产品`, cargoNameEn: `${box.boxType} test product`, hsCode: '8518210000', customsHsCode: '8518210000', packageType: 'CARTON', packingGroupNo: dropship ? mixedPackingGroupNo : null, @@ -573,8 +584,11 @@ export async function run(context) { privateDestinationAddressId: master.destinationType === 'PRIVATE' ? await ensureCustomerAddress(context, merchantToken, profile, 'PRIVATE_DESTINATION') : null, } - const payload = buildPayload(context, profile, master, productImageFileId, customerAddresses) const createPath = '/api/merchant/v1/orders/first-leg' + const policyResponse = await request(context, 'GET', `${createPath}/inbound-code-policy`, undefined, merchantToken) + const policy = context.apiSuccess(MODULE, 'Client 读取入仓码录入策略', policyResponse, '', CONTRACT_OPTIONS) + const payload = { ...buildPayload(context, profile, master, productImageFileId, customerAddresses), + ...fixtureInboundCode(policy, context.marker) } const createResponse = await request(context, 'POST', createPath, payload, merchantToken, { 'Idempotency-Key': key(context, 'client-create') }) const orderId = id(context.apiSuccess(MODULE, 'Client 创建四箱型隔离订单', createResponse, diff --git a/scripts/admin-test-env-modules/phase3-container-loading.mjs b/scripts/admin-test-env-modules/phase3-container-loading.mjs index 174eb2f69..6d4a8eb62 100644 --- a/scripts/admin-test-env-modules/phase3-container-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-container-loading.mjs @@ -173,7 +173,7 @@ export async function run(context) { OPTIONS) if (!selectedChannel) return const channelName = selectedChannel.channelName - const primaryRequirement = { containerType: '40HC', containerKind: 'FCL', capacityCbm: 76, maxWeightKg: 28000 } + const primaryRequirement = { containerType: '40HQ', containerKind: 'FCL', capacityCbm: 68, maxWeightKg: 19500 } const realContainerNo = iso6346ContainerNumber(`${context.marker}-initial`) const wrongContainerNo = `TST-${suffix}-02`.slice(0, 64) const bookingAgent = await resolveOceanBookingAgent(context, MODULE, 'MATSON', OPTIONS) @@ -188,14 +188,14 @@ export async function run(context) { if (!shippingCarrier?.id) return const bookingDraft = { customerId: tenantId, customerName: inbound?.summary?.customerName || `验收客户-${tenantId}`, - channelCode, warehouseId, + channelCode: channelGroup(channelCode), warehouseId, billOfLadingNo: `BL-${suffix}`.slice(0, 128), shippingCompanyId: id(shippingCarrier.id), shippingCompanyName: shippingCarrier.name, carrierCode: 'MATSON', bookingAgentId: id(bookingAgent.id), bookingAgentName: bookingAgent.name, vesselName: `验收船-${suffix}`.slice(0, 128), voyageNo: `V-${suffix}`.slice(0, 64), departurePortCode: 'CNSZX', departurePortName: '深圳', destinationPortCode: 'USLAX', destinationPortName: '洛杉矶', placeOfDelivery: 'LAX', - serviceTerm: 'CY-CY', freightTerm: 'NINGBO', estimatedSailingAt: '2026-09-01T08:00:00', + estimatedSailingAt: '2026-09-01T08:00:00', estimatedArrivalAt: '2026-09-20T08:00:00', remark: context.marker, parties: [{ partyType: 'SHIPPER', partyName: `发货方-${suffix}` }, { partyType: 'CONSIGNEE', partyName: `收货方-${suffix}` }, @@ -203,7 +203,7 @@ export async function run(context) { containerRequirements: [ { containerType: primaryRequirement.containerType, containerKind: primaryRequirement.containerKind, quantity: 1, capacityCbm: primaryRequirement.capacityCbm, maxWeightKg: primaryRequirement.maxWeightKg }, - { containerType: '20GP', containerKind: 'FCL', quantity: 1, capacityCbm: 33, maxWeightKg: 28000 }, + { containerType: '40GP', containerKind: 'FCL', quantity: 1, capacityCbm: 58, maxWeightKg: 19500 }, ], } context.assert(MODULE, '订舱渠道与仓库快照完整', inboundResponse, Boolean(channelCode && channelName && warehouseName), 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-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs index 985840bbb..c25df145c 100644 --- a/scripts/admin-test-env-modules/phase3-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-loading.mjs @@ -197,24 +197,24 @@ export async function run(context) { const bookingDraft = { customerId: tenantId, customerName: initial?.summary?.customerName, warehouseId, - channelCode: initial?.summary?.channelCode, + channelCode: channelGroup(initial?.summary?.channelCode), billOfLadingNo: `P3BL-${suffix}`.slice(0, 128), shippingCompanyId: id(shippingCarrier.id), shippingCompanyName: shippingCarrier.name, carrierCode: 'MATSON', bookingAgentId: id(supplier.id), bookingAgentName: supplier.name, vesselName: `P3VESSEL-${suffix}`.slice(0, 128), voyageNo: `P3V-${suffix}`.slice(0, 64), departurePortCode: 'CNSZX', departurePortName: '深圳', destinationPortCode: 'USLAX', - destinationPortName: '洛杉矶', placeOfDelivery: 'LAX', serviceTerm: 'CY-CY', freightTerm: 'NINGBO', + destinationPortName: '洛杉矶', placeOfDelivery: 'LAX', estimatedSailingAt: '2026-09-01T08:00:00', estimatedArrivalAt: '2026-09-20T08:00:00', remark: context.marker, parties: [{ partyType: 'SHIPPER', partyName: `P3-SHIPPER-${suffix}` }, { partyType: 'CONSIGNEE', partyName: `P3-CONSIGNEE-${suffix}` }, { partyType: 'NOTIFY_PARTY', partyName: `P3-NOTIFY-${suffix}` }], - containerRequirements: [{ containerType: '40HC', containerKind: 'FCL', quantity: 1, capacityCbm: 76, - maxWeightKg: 28000 }], + containerRequirements: [{ containerType: '40HQ', containerKind: 'FCL', quantity: 1, capacityCbm: 68, + maxWeightKg: 19500 }], } const simulationResponse = await request(context, 'POST', '/api/platform/v1/containers', { - channelGroupCode: channelGroup(initial?.summary?.channelCode), warehouseId, containerType: '40HC', - capacityCbm: 76, maxWeightKg: 28000, + channelGroupCode: channelGroup(initial?.summary?.channelCode), warehouseId, containerType: '40HQ', + capacityCbm: 68, maxWeightKg: 19500, }) const simulation = context.apiSuccess(MODULE, '新增三期内部模拟柜', simulationResponse, '', OPTIONS) const containerBookingId = id(simulation?.id) @@ -618,7 +618,7 @@ export async function run(context) { const frozenTransportValid = transportFields.every((field) => frozenTransport[field] != null && String(frozenTransport[field]).trim() !== '') const frozenTransportMatches = frozenTransportValid && transportFields.every((field) => String(frozenTransport[field]) === String(bookingDraft[field])) - const requirement = bookingConfirmed?.containerRequirements?.find?.((item) => item.containerType === '40HC') + const requirement = bookingConfirmed?.containerRequirements?.find?.((item) => item.containerType === '40HQ') context.assert(MODULE, 'RELEASED 订舱回显完整冻结运输快照且无真实柜号', releaseResponse, Boolean(bookingId) && bookingConfirmed?.oceanBooking?.status === 'RELEASED' && frozenTransportMatches && Boolean(requirement?.id) && (!bookingConfirmed?.containers || bookingConfirmed.containers.length === 0), diff --git a/scripts/admin-test-env-modules/phase3-order-splitting-contract.test.mjs b/scripts/admin-test-env-modules/phase3-order-splitting-contract.test.mjs index 41ee5ace2..7858d015b 100644 --- a/scripts/admin-test-env-modules/phase3-order-splitting-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-order-splitting-contract.test.mjs @@ -102,8 +102,8 @@ test('booking freezes the source customer channel and warehouse scope', () => { assert.match(source, /customerId: source\.tenantId/) assert.match(source, /channelCode: source\.channelCode/) assert.match(source, /warehouseId: source\.warehouseId/) - assert.match(source, /containerType === '40HC'/) - assert.match(source, /containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000/) + assert.match(source, /containerType === '40HQ'/) + assert.match(source, /containerType: '40HQ', capacityCbm: 68, maxWeightKg: 19500/) }) test('split chain starts from the partial inbound and derives quantities from the real tree', () => { diff --git a/scripts/admin-test-env-modules/phase3-order-splitting.mjs b/scripts/admin-test-env-modules/phase3-order-splitting.mjs index a7a624b68..29cb09fc0 100644 --- a/scripts/admin-test-env-modules/phase3-order-splitting.mjs +++ b/scripts/admin-test-env-modules/phase3-order-splitting.mjs @@ -188,7 +188,7 @@ async function loadOneFragment(context, state) { if (!carrier?.id) return null const simulationResponse = await request(context, 'POST', CONTAINERS, { channelGroupCode: channelGroup(state.channelCode), warehouseId, - containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000, + containerType: '40HQ', capacityCbm: 68, maxWeightKg: 19500, }) const simulation = context.apiSuccess(MODULE, `为 ${sourceOrderNo} 新增内部模拟柜`, simulationResponse, '', OPTIONS) const containerId = id(simulation?.id) @@ -221,7 +221,7 @@ async function loadOneFragment(context, state) { bookingConfirmed?.oceanBooking?.status === 'RELEASED' && (!bookingConfirmed?.containers || bookingConfirmed.containers.length === 0), `status=${bookingConfirmed?.oceanBooking?.status || 'missing'}, containers=${bookingConfirmed?.containers?.length ?? 0}`, OPTIONS) - const requirement = bookingConfirmed?.containerRequirements?.find?.((item) => item.containerType === '40HC') + const requirement = bookingConfirmed?.containerRequirements?.find?.((item) => item.containerType === '40HQ') const realContainerNo = iso6346ContainerNumber(`${context.marker}-split-${sequence}`) if (!requirement?.id) return null const linkResponse = await request(context, 'POST', `${CONTAINERS}/${containerId}/ocean-booking-link`, { diff --git a/scripts/admin-test-env-modules/phase3-port-schedules.mjs b/scripts/admin-test-env-modules/phase3-port-schedules.mjs index e6c5ccd86..ff109fa1f 100644 --- a/scripts/admin-test-env-modules/phase3-port-schedules.mjs +++ b/scripts/admin-test-env-modules/phase3-port-schedules.mjs @@ -46,7 +46,7 @@ function oceanDraft(context, suffix, master) { ...oceanBookingDraft(context, suffix, master), departurePortCode: 'CNSZX', departurePortName: '深圳', destinationPortCode: 'USLAX', destinationPortName: '洛杉矶', - placeOfDelivery: 'LAX', serviceTerm: 'CY-CY', freightTerm: 'NINGBO', + placeOfDelivery: 'LAX', remark: context.marker, parties: [ { partyType: 'SHIPPER', partyName: `发货方-${suffix}` }, 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-overseas-business.mjs b/scripts/admin-test-env-modules/phase4-overseas-business.mjs index a4cf2fbfc..d0a361cdf 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-business.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-business.mjs @@ -316,7 +316,7 @@ function looseboardPayload(context) { return { transportMode: 'SHARED_TRUCK', palletRequired: 'YES', transportType: 'WAREHOUSE_TO_WAREHOUSE', origin: `散板起运-${suffix}`, originAddress: 'Los Angeles', destination: `散板目的-${suffix}`, - destinationAddress: 'Ontario', containerType: '20GP', packageCount: 3, totalWeightKg: 80, + destinationAddress: 'Ontario', containerType: '40GP', packageCount: 3, totalWeightKg: 80, totalVolumeCbm: 1.8, budgetAmount: 66, currency: 'USD', contactName: '散板验收联系人', contactInfo: '13800000001', requestedDate: new Date(Date.now() + 8 * 86400000).toISOString().slice(0, 10), remark: context.marker, idempotencyKey: `${context.marker}-phase4-looseboard`.slice(0, 255), diff --git a/scripts/admin-test-env-modules/phase4-overseas-dispatch-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-dispatch-contract.test.mjs index 76995bfb6..5c7a348f2 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-dispatch-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-dispatch-contract.test.mjs @@ -28,6 +28,14 @@ test('module uses marker-owned completed PDA pallets and never borrows historica assert.doesNotMatch(source, /candidates\[0\]/) }) +test('ordinary dispatch selects one current-run pallet per destination for multi-FBA delivery proof', () => { + assert.match(source, /const candidatesByDestination = new Map\(\)/) + assert.match(source, /destinationWarehouseCode \|\| value\?\.fbaWarehouseCode/) + assert.match(source, /selectedCandidates\.length >= 2/) + assert.match(source, /planPalletIds, route: `\$\{context\.marker\}-ROUTE`/) + assert.match(source, /Number\(arranged\.data\?\.palletCount\) === planPalletIds\.length/) +}) + test('module proves draft idempotency, lock isolation, editable pre-arrange replay and readback', () => { assert.match(source, /相同草稿快照重复保存/) assert.match(source, /同草稿重复锁定托盘幂等成功/) diff --git a/scripts/admin-test-env-modules/phase4-overseas-dispatch.mjs b/scripts/admin-test-env-modules/phase4-overseas-dispatch.mjs index 59e07045c..5f5337eb9 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-dispatch.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-dispatch.mjs @@ -95,7 +95,7 @@ async function runRelabelDispatch(context, relabel) { export async function run(context) { context.createdResources.phase4OverseasDispatch = { marker: context.marker, dispatchNo: null, dispatchId: null, draftToken: null, - planPalletId: null, finalStatus: null, + planPalletId: null, planPalletIds: [], destinationWarehouseCodes: [], finalStatus: null, cleanup: 'PENDING_DEPARTURE dispatch is retained as marker-owned acceptance evidence; outbound, PDA and delivery APIs are excluded', } @@ -122,16 +122,26 @@ export async function run(context) { const candidateResult = await api(context, 'GET', '查询本轮已收货托盘智能预排候选', query(`${BASE}/candidates`, { sourceStage: 'RECEIVED' })) const candidates = Array.isArray(candidateResult.data) ? candidateResult.data : [] - const candidate = candidates.find((value) => fixture.palletIds.has(id(value?.warehousePalletId)) + const markerCandidates = candidates.filter((value) => fixture.palletIds.has(id(value?.warehousePalletId)) && id(value?.overseasWarehouseId) === id(fixture.overseasWarehouseId) - && id(value?.planId) && id(value?.planPalletId)) - context.assert(MODULE, '候选严格命中本轮PDA库存托盘与实际海外仓', candidateResult.response, - Boolean(candidate) && candidate.sourceStage === 'RECEIVED' && Number(candidate.packageCount) > 0, - `candidate=${Boolean(candidate)}, pdaPallets=${[...fixture.palletIds].join(',')}, warehouseId=${id(fixture.overseasWarehouseId)}; ${trace(candidateResult.response)}`, OPTIONS) - if (!candidate) return + && id(value?.planId) && id(value?.planPalletId) + && value?.sourceStage === 'RECEIVED' && Number(value?.packageCount) > 0) + const candidatesByDestination = new Map() + for (const value of markerCandidates) { + const destination = String(value?.destinationWarehouseCode || value?.fbaWarehouseCode || '').trim().toUpperCase() + if (destination && !candidatesByDestination.has(destination)) candidatesByDestination.set(destination, value) + } + const selectedCandidates = [...candidatesByDestination.values()] + const candidate = selectedCandidates[0] + context.assert(MODULE, '候选严格命中本轮PDA库存托盘且覆盖多个FBA仓', candidateResult.response, + selectedCandidates.length >= 2, + `candidates=${selectedCandidates.length}, destinations=${[...candidatesByDestination.keys()].join(',')}, pdaPallets=${[...fixture.palletIds].join(',')}, warehouseId=${id(fixture.overseasWarehouseId)}; ${trace(candidateResult.response)}`, OPTIONS) + if (selectedCandidates.length < 2) return const planId = id(candidate.planId) const planPalletId = id(candidate.planPalletId) + const planIds = [...new Set(selectedCandidates.map(value => id(value.planId)))] + const planPalletIds = selectedCandidates.map(value => id(value.planPalletId)) // Exercise removal on a disposable dispatch, then reuse the released current-run pallet. const removable = await api(context, 'POST', '创建本轮托盘移除对照排车', `${BASE}/pre-arrange`, { planPalletIds: [planPalletId], route: `${context.marker}-REMOVE`, @@ -158,9 +168,11 @@ export async function run(context) { context.createdResources.phase4OverseasDispatch.removalDispatchNo = removableNo const draftToken = `${context.marker}-DISPATCH-DRAFT`.slice(0, 64) const containersJson = JSON.stringify([{ key: 'C1', capacity: Math.max(1, Number(candidate.packageCount)) }]) - const snapshotBody = { draftToken, planIds: [planId], containersJson } + const snapshotBody = { draftToken, planIds, containersJson } context.createdResources.phase4OverseasDispatch.draftToken = draftToken context.createdResources.phase4OverseasDispatch.planPalletId = planPalletId + context.createdResources.phase4OverseasDispatch.planPalletIds = planPalletIds + context.createdResources.phase4OverseasDispatch.destinationWarehouseCodes = [...candidatesByDestination.keys()] const snapshot = await api(context, 'POST', '保存智能预排草稿快照', `${BASE}/drafts/snapshot`, snapshotBody) context.assert(MODULE, 'Void草稿快照允许data为空且请求成功', snapshot.response, @@ -173,11 +185,11 @@ export async function run(context) { const draftRows = Array.isArray(draftsResult.data) ? draftsResult.data : [] const matchingDrafts = draftRows.filter((value) => value?.draftToken === draftToken) context.assert(MODULE, '同token同载荷快照只保留一份活动草稿', draftsResult.response, - matchingDrafts.length === 1 && String(matchingDrafts[0].planIdsJson || '').split(',').includes(planId) + matchingDrafts.length === 1 && planIds.every(value => String(matchingDrafts[0].planIdsJson || '').split(',').includes(value)) && matchingDrafts[0].containersJson === containersJson, `matches=${matchingDrafts.length}, planIds=${matchingDrafts[0]?.planIdsJson || 'missing'}; ${trace(draftsResult.response)}`, OPTIONS) - const disposableContainer = await api(context, 'POST', '创建真实智能预排草稿柜', `${BASE}/drafts/containers`, { draftToken, capacity: 500 }) + const disposableContainer = await api(context, 'POST', '创建真实智能预排草稿柜', `${BASE}/drafts/containers`, { draftToken, capacity: 15 }) const containerId = id(disposableContainer.data?.id) if (!success(disposableContainer.response) || !containerId) return const boundBody = { planPalletId, draftToken, draftContainerId: containerId } @@ -196,26 +208,31 @@ export async function run(context) { const deletedDrafts = await api(context, 'GET', '删除后回查草稿柜消失', `${BASE}/drafts`) context.assert(MODULE, '删除草稿柜不再出现在结构响应', deletedDrafts.response, Array.isArray(deletedDrafts.data) && !deletedDrafts.data.find(draft => draft.draftToken === draftToken)?.containers?.some(container => id(container.id) === containerId), trace(deletedDrafts.response), OPTIONS) - const finalContainer = await api(context, 'POST', '创建正式预排使用的草稿柜', `${BASE}/drafts/containers`, { draftToken, capacity: 500 }) + const finalContainer = await api(context, 'POST', '创建正式预排使用的草稿柜', `${BASE}/drafts/containers`, { draftToken, capacity: 15 }) if (!success(finalContainer.response) || !finalContainer.data?.id) return - const lockBody = { planPalletId, draftToken, draftContainerId: id(finalContainer.data.id) } - const locked = await api(context, 'POST', '锁定本轮预排托盘', `${BASE}/draft-locks/lock`, lockBody) - if (!success(locked.response)) return + const lockBodies = planPalletIds.map(value => ({ planPalletId: value, draftToken, draftContainerId: id(finalContainer.data.id) })) + for (const [index, body] of lockBodies.entries()) { + const locked = await api(context, 'POST', `锁定本轮预排托盘${index + 1}`, `${BASE}/draft-locks/lock`, body) + if (!success(locked.response)) return + } + const lockBody = lockBodies[0] const lockReplay = await api(context, 'POST', '同草稿重复锁定托盘幂等成功', `${BASE}/draft-locks/lock`, lockBody) context.assert(MODULE, '重复锁定不创建冲突且Void响应可为空', lockReplay.response, success(lockReplay.response) && lockReplay.response.body?.data == null, trace(lockReplay.response), OPTIONS) const visibleInDraft = await api(context, 'GET', '当前草稿仍可查看自身锁定候选', query(`${BASE}/candidates`, { sourceStage: 'RECEIVED', draftToken })) - const visible = Array.isArray(visibleInDraft.data) && visibleInDraft.data.some((value) => id(value?.planPalletId) === planPalletId) + const visibleIds = new Set((Array.isArray(visibleInDraft.data) ? visibleInDraft.data : []).map(value => id(value?.planPalletId))) + const visible = planPalletIds.every(value => visibleIds.has(value)) context.assert(MODULE, 'draftToken仅放行当前草稿自己的锁', visibleInDraft.response, visible, - `planPalletId=${planPalletId}; ${trace(visibleInDraft.response)}`, OPTIONS) + `planPalletIds=${planPalletIds.join(',')}; ${trace(visibleInDraft.response)}`, OPTIONS) const hiddenResult = await api(context, 'GET', '无草稿标识时隐藏已锁托盘', query(`${BASE}/candidates`, { sourceStage: 'RECEIVED' })) - const hidden = Array.isArray(hiddenResult.data) && !hiddenResult.data.some((value) => id(value?.planPalletId) === planPalletId) + const hiddenIds = new Set((Array.isArray(hiddenResult.data) ? hiddenResult.data : []).map(value => id(value?.planPalletId))) + const hidden = planPalletIds.every(value => !hiddenIds.has(value)) context.assert(MODULE, '活动草稿锁阻止其他预排重复选择', hiddenResult.response, hidden, - `planPalletId=${planPalletId}; ${trace(hiddenResult.response)}`, OPTIONS) + `planPalletIds=${planPalletIds.join(',')}; ${trace(hiddenResult.response)}`, OPTIONS) const foreignToken = `${context.marker}-DISPATCH-FOREIGN`.slice(0, 64) const foreignLock = await context.requestOverseas('POST', `${BASE}/draft-locks/lock`, { @@ -224,7 +241,7 @@ export async function run(context) { context.addStep(MODULE, '其他草稿锁定同一托盘返回A0409', foreignLock, conflict(foreignLock), trace(foreignLock), OPTIONS) const preArrangeBody = { - planPalletIds: [planPalletId], route: `${context.marker}-ROUTE`, fbaAppointmentNo: null, + planPalletIds, route: `${context.marker}-ROUTE`, fbaAppointmentNo: null, fbaAppointmentStatus: null, vehicleAppointmentStatus: null, draftToken, } const arranged = await api(context, 'POST', '将草稿锁原子转换为待出库预排', `${BASE}/pre-arrange`, preArrangeBody) @@ -232,9 +249,9 @@ export async function run(context) { context.assert(MODULE, '智能预排仅创建PENDING_DEPARTURE排车并冻结托盘', arranged.response, Boolean(dispatchNo) && arranged.data?.status === 'PENDING_DEPARTURE' && Array.isArray(arranged.data?.items) - && arranged.data.items.some((item) => id(item?.planPalletId) === planPalletId) - && Number(arranged.data?.palletCount) === 1, - `dispatchNo=${dispatchNo || 'missing'}, status=${arranged.data?.status || 'missing'}; ${trace(arranged.response)}`, OPTIONS) + && planPalletIds.every(value => arranged.data.items.some(item => id(item?.planPalletId) === value)) + && Number(arranged.data?.palletCount) === planPalletIds.length, + `dispatchNo=${dispatchNo || 'missing'}, status=${arranged.data?.status || 'missing'}, pallets=${arranged.data?.palletCount ?? 'missing'}, destinations=${[...candidatesByDestination.keys()].join(',')}; ${trace(arranged.response)}`, OPTIONS) if (!dispatchNo) return context.createdResources.phase4OverseasDispatch.dispatchNo = dispatchNo context.createdResources.phase4OverseasDispatch.dispatchId = id(arranged.data?.id) diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs index fee612bc2..79db67ffe 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs @@ -64,8 +64,10 @@ test('current loading event binds each pallet carton group to an explicit source const producer = fs.readFileSync(path.join(here, '../../firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java'), 'utf8') assert.match(model, /record SkuPackageSnapshot\(String sourceInboundDetailId, String skuCode/) assert.match(model, /sourceInboundDetailId == null \|\| sourceInboundDetailId\.isBlank\(\)/) - assert.match(producer, /SkuPackageSnapshot\(detail\.detailId\(\)\.toString\(\), detail\.skuCode\(\)/) assert.match(producer, /SkuPackageSnapshot\(detail\.getSourceInboundDetailId\(\)\.toString\(\), detail\.getSkuCode\(\)/) + assert.match(producer, /groupingBy\(PalletRow::getSourceInboundDetailId/) + assert.match(producer, /map\(PalletRow::getActualSkuQuantity\)/) + assert.match(producer, /cartonQuantities\(detailRows\.getValue\(\), marks\)/) const measurement = fs.readFileSync(path.join(here, 'inbound-measurement.mjs'), 'utf8') const loading = fs.readFileSync(path.join(here, 'phase3-loading.mjs'), 'utf8') const finalize = fs.readFileSync(path.join(here, 'phase3-loading-finalize.mjs'), 'utf8') @@ -137,9 +139,10 @@ test('PDA module covers the container-number receiving contract and excludes out assert.match(source, /rememberGeneratedPlans\(context, plans\)/) assert.match(source, /resolveGeneratedPallet\(context, inbound\)/) assert.match(source, /generatedPalletsByInbound/) - assert.match(source, /standardFba = matrix\?\.byDestinationType\?\.FBA\?\.find/) + assert.match(source, /standardFbaProducers = \(matrix\?\.byDestinationType\?\.FBA \|\| \[\]\)/) assert.match(source, /item\?\.fulfillmentType !== 'ONE_CLICK_DROPSHIP'/) - assert.match(source, /const producers = \[dropship[\s\S]*?standardFba,/) + assert.match(source, /\.slice\(0, 2\)/) + assert.match(source, /const producers = \[dropship, \.\.\.standardFbaProducers,/) assert.doesNotMatch(source, /创建当前柜实收托盘|创建当前柜实收库位|async function createPallet/) assert.match(source, /retained as acceptance evidence/) assert.match(source, /preferredProducer\?\.cartonMarks\?\.length/) diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs index 00e5cbf0f..7cf858e55 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs @@ -898,10 +898,13 @@ export async function run(context) { } const matrix = context.createdResources?.phase4InboundProducerFixtures const dropship = matrix?.containers?.find((item) => item?.fulfillmentType === 'ONE_CLICK_DROPSHIP') - const standardFba = matrix?.byDestinationType?.FBA?.find((item) => item?.fulfillmentType !== 'ONE_CLICK_DROPSHIP') + const standardFbaProducers = (matrix?.byDestinationType?.FBA || []) + .filter((item) => item?.fulfillmentType !== 'ONE_CLICK_DROPSHIP') + .slice(0, 2) // The sealed dropship pallet proves the indivisible handover. A separate standard FBA - // fixture supplies multiple cartons for reversible split/merge/unbind and relabel checks. - const producers = [dropship, standardFba, + // fixture supplies multiple cartons for reversible operations. The second standard FBA + // fixture remains available for the multi-destination dispatch and delivery split proof. + const producers = [dropship, ...standardFbaProducers, matrix?.byDestinationType?.OVERSEAS?.[0]] .filter(uniqueContainerProducer) const allProducers = Array.isArray(matrix?.containers) ? matrix.containers : producers 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/admin-test-env-modules/phase5-overseas-fulfillment-contract.test.mjs b/scripts/admin-test-env-modules/phase5-overseas-fulfillment-contract.test.mjs index 40a0dbe67..369f3757c 100644 --- a/scripts/admin-test-env-modules/phase5-overseas-fulfillment-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase5-overseas-fulfillment-contract.test.mjs @@ -25,7 +25,7 @@ test('phase-five module covers dispatch edit, transfer, PDA pick/depart and deli "const PDA_DISPATCH_BASE = '/api/pda/v1/last-leg/dispatches'", 'query(MERCHANT_DELIVERY_BASE, { dispatchNo })', '/items/${encodeURIComponent(id(item.dispatchItemId))}/pick', '/complete`', '/depart`', '/outbound-orders/${encodeURIComponent(outboundNo)}', - '/delivery-orders/${encodeURIComponent(deliveryNo)}/status', + '/delivery-orders/${encodeURIComponent(currentNo)}/status', ]) assert.ok(source.includes(token), token) assert.match(source, /WAIT_OUTBOUND/) assert.match(source, /WAIT_DELIVERY/) @@ -33,7 +33,9 @@ test('phase-five module covers dispatch edit, transfer, PDA pick/depart and deli assert.match(source, /OUTBOUNDED/) assert.match(source, /DELIVERING/) assert.match(source, /expectedVersion: deliveryVersion/) - assert.match(source, /status: 'DELIVERED', expectedVersion: completeVersion/) + assert.match(source, /transferDeliveries\(transferred\.data\)/) + assert.match(source, /new Set\(destinationKeys\)\.size === deliveries\.length/) + assert.match(source, /deliveries\.reduce\(\(sum, value\) => sum \+ Number\(value\?\.packageCount \|\| 0\), 0\)/) }) test('phase-five module proves the client delivery create, list and nested detail contract', () => { @@ -53,9 +55,10 @@ test('phase-five module proves the client delivery create, list and nested detai assert.match(source, /merchantDetail\.data\?\.operations/) }) -test('phase-five module proves completion, customer visit, manual exception and delivery return', () => { +test('phase-five module proves receipt-save completion, cross-region convergence, customer visit, exception and return', () => { for (const token of [ - '/delivery-orders/${encodeURIComponent(deliveryNo)}/complete', + '/documents/RECEIPT/files', 'requestOverseasMultipart', "category: 'RECEIPT'", + 'SAVE-RECEIPT', 'RECEIPT_AUTO_COMPLETE', '/customer-visits/${encodeURIComponent(visitId)}', "visitResult: 'SATISFIED'", '/exception-orders', '/return-inbound-options', '/mark-return', "returnDirection: 'OVERSEAS_WAREHOUSE'", @@ -64,6 +67,15 @@ test('phase-five module proves completion, customer visit, manual exception and "idempotencyKey: key(context, 'DELIVERY-RETURN')", '派送退回同键同载荷重放', 'RETURN_PENDING_WITH_COMPLETED_VISIT', ]) assert.ok(source.includes(token), token) + assert.match(source, /saved\.data\?\.categories\?\.some/) + assert.match(source, /completed\.data\?\.order\?\.status === 'DELIVERED'/) + assert.match(source, /人工确认派送完成接口已移除/) + assert.match(source, /query\('\/api\/platform\/inbounds'/) + assert.match(source, /inboundCode: inbound\.inboundCode/) + assert.match(source, /inboundSummary\?\.processStatus === 'DELIVERED'/) + assert.match(source, /\/api\/platform\/v1\/orders\/\$\{encodeURIComponent\(sourceOrderId\)\}/) + assert.match(source, /mainStatus === 'COMPLETED'/) + assert.doesNotMatch(source, /platform\(context, 'POST', '确认派送完成/) assert.match(source, /manualExceptionNo/) assert.match(source, /returnExceptionNo/) }) diff --git a/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs b/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs index 4f03048f4..2285bebff 100644 --- a/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs +++ b/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs @@ -48,6 +48,73 @@ async function merchant(context, token, method, name, path, body = undefined) { return { response, data } } +function transferDeliveries(data) { + return Array.isArray(data?.deliveries) ? data.deliveries.filter((value) => nonBlank(value?.deliveryNo)) : [] +} + +function deliveryNumbers(data) { + return transferDeliveries(data).map((value) => id(value.deliveryNo)).sort() +} + +async function poll(action, predicate, attempts = 45, intervalMs = 1_000) { + let result = null + for (let attempt = 1; attempt <= attempts; attempt += 1) { + result = await action() + if (predicate(result)) return { result, attempts: attempt } + if (attempt < attempts) await new Promise((resolve) => setTimeout(resolve, intervalMs)) + } + return { result, attempts } +} + +export async function saveReceiptAndComplete(context, delivery, customerId, index) { + const deliveryNo = id(delivery.deliveryNo) + const documentsPath = query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}/documents`, { customerId }) + const before = await platform(context, 'GET', `读取第${index + 1}张派送单资料版本`, documentsPath) + const version = Number(before.data?.version) + context.assert(MODULE, `第${index + 1}张派送单资料提供乐观锁版本`, before.response, + Number.isInteger(version) && version >= 0, `version=${before.data?.version ?? 'missing'}; ${trace(before.response)}`, OPTIONS) + if (!success(before.response) || !Number.isInteger(version) || version < 0) return null + + const uploadResponse = await context.requestOverseasMultipart('POST', + query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}/documents/RECEIPT/files`, { customerId }), { + name: `${context.marker}-${deliveryNo}-receipt.txt`, type: 'text/plain', + content: new TextEncoder().encode(`${context.marker} receipt acceptance for ${deliveryNo}`), + }) + const uploaded = context.apiSuccess(MODULE, `上传第${index + 1}张派送单签收文件`, uploadResponse, + trace(uploadResponse), OPTIONS) + const fileId = id(uploaded?.fileId) + context.assert(MODULE, `第${index + 1}张派送单签收文件返回业务绑定ID`, uploadResponse, + /^\d+$/.test(fileId), `fileIdPresent=${/^\d+$/.test(fileId)}; ${trace(uploadResponse)}`, OPTIONS) + if (!success(uploadResponse) || !fileId) return null + + const existing = (before.data?.categories || []).map((category) => ({ + category: category.category, + fileIds: (category.files || []).map((file) => id(file.fileId)).filter((value) => /^\d+$/.test(value)), + })).filter((binding) => binding.category && binding.category !== 'RECEIPT' && binding.fileIds.length > 0) + const saveBody = { + expectedVersion: version, idempotencyKey: key(context, 'SAVE-RECEIPT', String(index)), + bolNo: before.data?.bolNo || null, + documents: [...existing, { category: 'RECEIPT', fileIds: [fileId] }], + } + const saved = await platform(context, 'PUT', `保存第${index + 1}张派送单签收资料并自动完成`, documentsPath, saveBody) + context.assert(MODULE, `第${index + 1}张派送单签收资料保存后已上传`, saved.response, + saved.data?.categories?.some((category) => category?.category === 'RECEIPT' + && category?.status === 'UPLOADED' && Number(category?.fileCount) === 1), trace(saved.response), OPTIONS) + if (!success(saved.response)) return null + + const savedReplay = await platform(context, 'PUT', `第${index + 1}张派送单签收资料同键重放`, documentsPath, saveBody) + context.assert(MODULE, `第${index + 1}张派送单资料重放版本稳定`, savedReplay.response, + savedReplay.data?.replayed === true && Number(savedReplay.data?.version) === Number(saved.data?.version), + trace(savedReplay.response), OPTIONS) + const completed = await platform(context, 'GET', `回查第${index + 1}张派送单自动完成`, + query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}`, { customerId })) + context.assert(MODULE, `第${index + 1}张派送单签收保存即进入DELIVERED`, completed.response, + completed.data?.order?.status === 'DELIVERED' + && completed.data?.operations?.some((operation) => operation?.operationNode === 'RECEIPT_AUTO_COMPLETE'), + trace(completed.response), OPTIONS) + return success(completed.response) && completed.data?.order?.status === 'DELIVERED' ? completed.data : null +} + export function relabelOutboundMarksMatch(detail, relabel) { const marks = (detail?.pallets || []).flatMap(item => String(item.cartonMarks || '') .split(',').map(mark => mark.trim()).filter(Boolean)) @@ -74,7 +141,7 @@ async function verifyRelabelDispatchOutbound(context, relabel, transferBody) { success(detail.response) && detail.data?.outbound?.outboundNo === outboundNo && detail.data?.outbound?.dispatchNo === relabel.dispatchNo && relabelOutboundMarksMatch(detail.data, relabel), trace(detail.response), OPTIONS) - Object.assign(relabel, { outboundNo, deliveryNo: transferred.data?.deliveryNo }) + Object.assign(relabel, { outboundNo, deliveryNos: deliveryNumbers(transferred.data) }) } async function loginMerchant(context, customerId) { @@ -113,7 +180,7 @@ function fixture(context) { return { dispatch, receiving } } -async function pickAll(context, token, dispatchNo) { +export async function pickAll(context, token, dispatchNo) { let detail = await pda(context, token, 'GET', 'PDA读取本轮排车拣货任务', `${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`) const items = Array.isArray(detail.data?.items) ? detail.data.items : [] @@ -170,7 +237,7 @@ async function pickAll(context, token, dispatchNo) { export async function run(context) { context.createdResources.phase5OverseasFulfillment = { - marker: context.marker, dispatchNo: null, outboundNo: null, deliveryNo: null, + marker: context.marker, dispatchNo: null, outboundNo: null, deliveryNo: null, deliveryNos: [], visitId: null, manualExceptionNo: null, returnExceptionNo: null, finalStatus: null, cleanup: 'marker-owned completed delivery, customer visit and return exception are retained as acceptance evidence', } @@ -210,7 +277,7 @@ export async function run(context) { idempotencyKey: key(context, 'TRANSFER'), plannedDeliveryAt: new Date(Date.now() + 172_800_000).toISOString(), deliveryMethod: 'TRUCK_PALLET', waybillNo: `${context.marker}-WAYBILL`.slice(0, 64), carrierName: updateBody.carrierName, pickupMethod: 'WAREHOUSE_PICKUP', loadingType: 'PALLET', - vehicleType: 'TRUCK', vehicleSize: 'MEDIUM', driverName: updateBody.driverName, + vehicleType: 'TRUCK', vehicleSize: '15托', driverName: updateBody.driverName, driverPhone: updateBody.driverPhone, vehiclePlate: updateBody.vehiclePlate, appointmentAccount: 'SELF', sellerName: null, remark: `${context.marker}-phase5-transfer`, fees: [{ feeType: 'DELIVERY_FEE', feeName: '派送费', direction: 'RECEIVABLE', @@ -220,18 +287,25 @@ export async function run(context) { const transferred = await merchant(context, merchantToken, 'POST', '客户创建派送并原子转出库单', query(MERCHANT_DELIVERY_BASE, { dispatchNo }), transferBody) const outboundNo = id(transferred.data?.outboundNo) - const deliveryNo = id(transferred.data?.deliveryNo) - context.assert(MODULE, '转派送同时生成WAIT_OUTBOUND与WAIT_DELIVERY单据', transferred.response, - nonBlank(outboundNo) && nonBlank(deliveryNo) && transferred.data?.outboundStatus === 'WAIT_OUTBOUND' - && transferred.data?.deliveryStatus === 'WAIT_DELIVERY' && Number(transferred.data?.packageCount) > 0, - `outboundNo=${outboundNo || 'missing'}, deliveryNo=${deliveryNo || 'missing'}; ${trace(transferred.response)}`, OPTIONS) - if (!success(transferred.response) || !outboundNo || !deliveryNo) return - Object.assign(context.createdResources.phase5OverseasFulfillment, { outboundNo, deliveryNo }) + const deliveries = transferDeliveries(transferred.data) + const deliveryNos = deliveryNumbers(transferred.data) + const destinationKeys = deliveries.map((value) => id(value.destinationWarehouseCode || value.destinationWarehouseId)) + const deliveryNo = deliveryNos[0] || '' + context.assert(MODULE, '转派送按目的FBA仓生成WAIT_DELIVERY单据数组', transferred.response, + nonBlank(outboundNo) && deliveries.length > 0 && new Set(deliveryNos).size === deliveries.length + && new Set(destinationKeys).size === deliveries.length && destinationKeys.every(nonBlank) + && transferred.data?.outboundStatus === 'WAIT_OUTBOUND' + && deliveries.every((value) => value?.deliveryStatus === 'WAIT_DELIVERY') + && deliveries.reduce((sum, value) => sum + Number(value?.packageCount || 0), 0) === Number(transferred.data?.packageCount) + && Number(transferred.data?.packageCount) > 0, + `outboundNo=${outboundNo || 'missing'}, deliveries=${deliveryNos.join(',') || 'missing'}, destinations=${destinationKeys.join(',') || 'missing'}; ${trace(transferred.response)}`, OPTIONS) + if (!success(transferred.response) || !outboundNo || deliveries.length === 0) return + Object.assign(context.createdResources.phase5OverseasFulfillment, { outboundNo, deliveryNo, deliveryNos }) const adminReplay = await platform(context, 'POST', '后台转派送复用客户已创建的同一出库派送单', `${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/transfer-delivery`, transferBody) context.assert(MODULE, '后台与客户转派送共享同一业务事实', adminReplay.response, adminReplay.data?.replayed === true && id(adminReplay.data?.outboundNo) === outboundNo - && id(adminReplay.data?.deliveryNo) === deliveryNo, trace(adminReplay.response), OPTIONS) + && JSON.stringify(deliveryNumbers(adminReplay.data)) === JSON.stringify(deliveryNos), trace(adminReplay.response), OPTIONS) const lockedItem = updated.data?.items?.[0] if (lockedItem?.id) { const deniedRemove = await context.requestOverseas('DELETE', @@ -246,24 +320,28 @@ export async function run(context) { query(MERCHANT_DELIVERY_BASE, { dispatchNo }), transferBody) context.assert(MODULE, '转派送重放复用同一业务单据', transferReplay.response, transferReplay.data?.replayed === true && id(transferReplay.data?.outboundNo) === outboundNo - && id(transferReplay.data?.deliveryNo) === deliveryNo, trace(transferReplay.response), OPTIONS) + && JSON.stringify(deliveryNumbers(transferReplay.data)) === JSON.stringify(deliveryNos), trace(transferReplay.response), OPTIONS) const transferConflict = await context.requestOverseas('POST', query(MERCHANT_DELIVERY_BASE, { dispatchNo }), { ...transferBody, idempotencyKey: key(context, 'TRANSFER-DIFFERENT') }, {}, merchantToken) context.addStep(MODULE, '同一排车使用另一幂等键仍返回既有单据', transferConflict, success(transferConflict) && id(transferConflict.body?.data?.outboundNo) === outboundNo - && transferConflict.body?.data?.replayed === true, trace(transferConflict), OPTIONS) + && transferConflict.body?.data?.replayed === true + && JSON.stringify(deliveryNumbers(transferConflict.body?.data)) === JSON.stringify(deliveryNos), trace(transferConflict), OPTIONS) const merchantPage = await merchant(context, merchantToken, 'GET', '客户分页回查自身派送单', query(MERCHANT_DELIVERY_BASE, { page: 1, size: 20, dispatchNo })) const merchantRows = Array.isArray(merchantPage.data?.records) ? merchantPage.data.records : [] - context.assert(MODULE, '客户派送列表包含本轮单据且不依赖customerId参数', merchantPage.response, - merchantRows.some((value) => id(value?.deliveryNo) === deliveryNo && id(value?.dispatchNo) === dispatchNo), + context.assert(MODULE, '客户派送列表包含本轮全部FBA派送单且不依赖customerId参数', merchantPage.response, + deliveryNos.every((number) => merchantRows.some((value) => id(value?.deliveryNo) === number + && id(value?.dispatchNo) === dispatchNo)), `records=${merchantRows.length}; ${trace(merchantPage.response)}`, OPTIONS) const merchantDetail = await merchant(context, merchantToken, 'GET', '客户读取自身派送详情', `${MERCHANT_DELIVERY_BASE}/${encodeURIComponent(deliveryNo)}`) context.assert(MODULE, '客户派送详情返回订单、货物、费用与操作四段正式契约', merchantDetail.response, id(merchantDetail.data?.order?.deliveryNo) === deliveryNo && id(merchantDetail.data?.order?.dispatchNo) === dispatchNo + && nonBlank(id(merchantDetail.data?.order?.destinationWarehouseCode + || merchantDetail.data?.order?.destinationWarehouseId)) && Array.isArray(merchantDetail.data?.skus) && merchantDetail.data.skus.length > 0 && Array.isArray(merchantDetail.data?.fees) && Array.isArray(merchantDetail.data?.operations), `skus=${merchantDetail.data?.skus?.length ?? 'missing'}, fees=${merchantDetail.data?.fees?.length ?? 'missing'}, operations=${merchantDetail.data?.operations?.length ?? 'missing'}; ${trace(merchantDetail.response)}`, @@ -301,42 +379,52 @@ export async function run(context) { outbound.data?.outbound?.outboundNo === outboundNo && outbound.data?.outbound?.status === 'OUTBOUNDED', trace(outbound.response), OPTIONS) - const deliveryBeforeStatus = await platform(context, 'GET', '读取派送状态更新即时版本', - query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}`, { customerId })) - const deliveryVersion = Number(deliveryBeforeStatus.data?.order?.version) - context.assert(MODULE, '派送详情提供状态乐观锁版本', deliveryBeforeStatus.response, - Number.isInteger(deliveryVersion) && deliveryVersion >= 0, - `version=${deliveryBeforeStatus.data?.order?.version ?? 'missing'}; ${trace(deliveryBeforeStatus.response)}`, OPTIONS) - if (!Number.isInteger(deliveryVersion) || deliveryVersion < 0) return - const delivering = await platform(context, 'PUT', '派送单推进配送中', - query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}/status`, { customerId }), - { status: 'DELIVERING', expectedVersion: deliveryVersion, remark: `${context.marker}-delivering` }) - context.assert(MODULE, '派送状态进入DELIVERING', delivering.response, - delivering.data?.deliveryStatus === 'DELIVERING', trace(delivering.response), OPTIONS) - if (!success(delivering.response)) return - const deliveryBeforeComplete = await platform(context, 'GET', '读取派送完成即时版本', - query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}`, { customerId })) - const completeVersion = Number(deliveryBeforeComplete.data?.order?.version) - context.assert(MODULE, '配送中详情版本已递增', deliveryBeforeComplete.response, - Number.isInteger(completeVersion) && completeVersion > deliveryVersion, - `before=${deliveryVersion}, after=${deliveryBeforeComplete.data?.order?.version ?? 'missing'}; ${trace(deliveryBeforeComplete.response)}`, - OPTIONS) - if (!Number.isInteger(completeVersion) || completeVersion <= deliveryVersion) return - const completed = await platform(context, 'POST', '确认派送完成并生成客户回访', + for (const [index, delivery] of deliveries.entries()) { + const currentNo = id(delivery.deliveryNo) + const beforeStatus = await platform(context, 'GET', `读取第${index + 1}张派送单状态版本`, + query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(currentNo)}`, { customerId })) + const deliveryVersion = Number(beforeStatus.data?.order?.version) + context.assert(MODULE, `第${index + 1}张派送单提供状态乐观锁版本`, beforeStatus.response, + Number.isInteger(deliveryVersion) && deliveryVersion >= 0, + `version=${beforeStatus.data?.order?.version ?? 'missing'}; ${trace(beforeStatus.response)}`, OPTIONS) + if (!Number.isInteger(deliveryVersion) || deliveryVersion < 0) return + const delivering = await platform(context, 'PUT', `第${index + 1}张派送单推进配送中`, + query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(currentNo)}/status`, { customerId }), + { status: 'DELIVERING', expectedVersion: deliveryVersion, remark: `${context.marker}-delivering-${index + 1}` }) + context.assert(MODULE, `第${index + 1}张派送状态进入DELIVERING`, delivering.response, + delivering.data?.deliveryStatus === 'DELIVERING', trace(delivering.response), OPTIONS) + if (!success(delivering.response)) return + } + + const completedDeliveries = [] + for (const [index, delivery] of deliveries.entries()) { + const completed = await saveReceiptAndComplete(context, delivery, customerId, index) + if (!completed) return + completedDeliveries.push(completed) + } + context.assert(MODULE, '每个目的FBA仓派送单均由各自签收文件自动完成', null, + completedDeliveries.length === deliveries.length + && completedDeliveries.every((value) => value?.order?.status === 'DELIVERED'), + `deliveries=${deliveryNos.join(',')}, completed=${completedDeliveries.length}`, OPTIONS) + + const removedComplete = await context.requestOverseas('POST', query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}/complete`, { customerId }), - { status: 'DELIVERED', expectedVersion: completeVersion, remark: `${context.marker}-delivered` }) - const visitId = id(completed.data?.visitId) - context.assert(MODULE, '派送完成生成PENDING回访记录', completed.response, - completed.data?.deliveryStatus === 'DELIVERED' && completed.data?.visitStatus === 'PENDING' - && /^\d+$/.test(visitId), `visitIdPresent=${/^\d+$/.test(visitId)}; ${trace(completed.response)}`, OPTIONS) - if (!success(completed.response) || !visitId) return + { status: 'DELIVERED', expectedVersion: 0, remark: `${context.marker}-removed-complete-probe` }) + context.addStep(MODULE, '人工确认派送完成接口已移除', removedComplete, + removedComplete?.http === 404 || removedComplete?.http === 405 + || (removedComplete?.http === 200 && ['A0404', 'A0405'].includes(code(removedComplete))), + trace(removedComplete), OPTIONS) + + const visits = await platform(context, 'GET', '按派送单回查签收自动生成的客户回访', + query(`${LASTLEG_BASE}/customer-visits`, { page: 1, size: 200, customerId, keyword: deliveryNo })) + const visitRow = Array.isArray(visits.data?.records) + ? visits.data.records.find((value) => id(value?.deliveryNo) === deliveryNo) : null + const visitId = id(visitRow?.id) + context.assert(MODULE, '签收自动完成生成PENDING回访记录', visits.response, + /^\d+$/.test(visitId) && visitRow?.status === 'PENDING', + `visitIdPresent=${/^\d+$/.test(visitId)}; ${trace(visits.response)}`, OPTIONS) + if (!visitId) return context.createdResources.phase5OverseasFulfillment.visitId = visitId - const completeReplay = await platform(context, 'POST', '确认派送完成重复调用', - query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}/complete`, { customerId }), - { status: 'DELIVERED', expectedVersion: completeVersion, remark: `${context.marker}-delivered` }) - context.assert(MODULE, '派送完成重放复用同一回访', completeReplay.response, - completeReplay.data?.replayed === true && id(completeReplay.data?.visitId) === visitId, - trace(completeReplay.response), OPTIONS) const visitDetail = await platform(context, 'GET', '读取本轮客户回访详情', query(`${LASTLEG_BASE}/customer-visits/${encodeURIComponent(visitId)}`, { customerId })) @@ -345,6 +433,29 @@ export async function run(context) { context.assert(MODULE, '回访详情包含派送关联的真实入仓单与尾程单', visitDetail.response, Boolean(inbound), `inboundOption=${Boolean(inbound)}; ${trace(visitDetail.response)}`, OPTIONS) if (!inbound) return + + const domesticInbound = await poll(async () => context.request('GET', query('/api/platform/inbounds', { + tenantId: customerId, inboundCode: inbound.inboundCode, page: 1, size: 20, + })), (response) => success(response) && Array.isArray(response.body?.data?.records) + && response.body.data.records.some((value) => value?.inboundCode === inbound.inboundCode + && value?.processStatus === 'DELIVERED')) + const inboundSummary = domesticInbound.result?.body?.data?.records?.find( + (value) => value?.inboundCode === inbound.inboundCode) + context.addStep(MODULE, '海外签收完成事件回推头程入仓单DELIVERED', domesticInbound.result, + success(domesticInbound.result) && inboundSummary?.processStatus === 'DELIVERED', + `inboundCode=${inbound.inboundCode}, pollRequests=${domesticInbound.attempts}, processStatus=${inboundSummary?.processStatus || 'missing'}; ${trace(domesticInbound.result)}`, OPTIONS) + const sourceOrderId = id(inboundSummary?.orderId) + if (!sourceOrderId) { + blocker(context, '取得签收对应头程订单', '头程入仓详情缺少来源订单ID', domesticInbound.result) + return + } + const domesticOrder = await poll(async () => context.request('GET', + query(`/api/platform/v1/orders/${encodeURIComponent(sourceOrderId)}`, { tenantId: customerId })), + (response) => success(response) && response.body?.data?.summary?.mainStatus === 'COMPLETED') + context.addStep(MODULE, '同订单全部入仓签收后统一订单进入COMPLETED', domesticOrder.result, + success(domesticOrder.result) && domesticOrder.result?.body?.data?.summary?.mainStatus === 'COMPLETED', + `orderId=${sourceOrderId}, pollRequests=${domesticOrder.attempts}, mainStatus=${domesticOrder.result?.body?.data?.summary?.mainStatus || 'missing'}; ${trace(domesticOrder.result)}`, OPTIONS) + const savedVisit = await platform(context, 'PUT', '保存无附件客户回访结果', query(`${LASTLEG_BASE}/customer-visits/${encodeURIComponent(visitId)}`, { customerId }), { selectedInboundOrderId: id(inbound.inboundOrderId), visitResult: 'SATISFIED', diff --git a/scripts/admin-test-env-modules/phase5-terminal-drayage-contract.test.mjs b/scripts/admin-test-env-modules/phase5-terminal-drayage-contract.test.mjs index c39c9eb87..b4a04ba53 100644 --- a/scripts/admin-test-env-modules/phase5-terminal-drayage-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase5-terminal-drayage-contract.test.mjs @@ -7,12 +7,12 @@ import { prepareTerminalDrayage, run } from './phase5-terminal-drayage.mjs' const source = await readFile(fileURLToPath(new URL('./phase5-terminal-drayage.mjs', import.meta.url)), 'utf8') -test('four real command payloads mutate contract fields and preserve remarks and state on conflict', async () => { +test('three real command payloads mutate contract fields and preserve remarks and state on conflict', async () => { const marker = 'offline-terminal' const containerBookingId = '9223372036854775806' const containerNo = 'TEST0000001' - const actions = ['SELECT_FLEET', 'SCHEDULE_PICKUP', 'RECORD_PICKUP', 'RECORD_EMPTY_RETURN'] - const paths = ['fleet', 'pickup-appointment', 'pickup', 'empty-return'] + const actions = ['SELECT_FLEET', 'SCHEDULE_PICKUP', 'RECORD_EMPTY_RETURN'] + const paths = ['fleet', 'pickup-appointment', 'empty-return'] let state = { containerBookingId, containerNo, version: 0, allowedActions: ['SELECT_FLEET'] } const history = [] const commands = new Map() @@ -59,20 +59,19 @@ test('four real command payloads mutate contract fields and preserve remarks and state = { ...state, version: state.version + 1 } if (index === 0) Object.assign(state, { fleetSupplierId: body.fleetSupplierId, fleetSupplierName: body.fleetSupplierName, allowedActions: ['SCHEDULE_PICKUP'] }) if (index === 1) state.pickupAppointmentAt = body.pickupAppointmentAt - if (index === 2) Object.assign(state, { status: 'PICKED_UP', actualPickupAt: body.actualAt, allowedActions: [] }) - if (index === 3) Object.assign(state, { status: 'EMPTY_RETURNED', actualEmptyReturnAt: body.actualAt, allowedActions: [], emptyReturnWarning: { status: 'RESOLVED' } }) + if (index === 2) Object.assign(state, { status: 'EMPTY_RETURNED', actualEmptyReturnAt: body.actualAt, allowedActions: [], emptyReturnWarning: { status: 'RESOLVED' } }) history.push({ action: actions[index], fromVersion: index, toVersion: index + 1, reason: body[field] }) commands.set(key, { body: structuredClone(body), state: structuredClone(state) }) return response(state) }, } await prepareTerminalDrayage(context, producer) - state = { ...state, status: 'UNLOADED', unloadingCompletedAt: '2026-09-09T04:00:00', allowedActions: ['RECORD_EMPTY_RETURN'] } + state = { ...state, status: 'UNLOADED', unloadingCompletedAt: '2026-09-09T04:00:00', actualPickupAt: '2026-09-09T04:00:00', allowedActions: ['RECORD_EMPTY_RETURN'] } await run(context) - assert.equal(commands.size, 4) - assert.equal(replays, 4) - assert.equal(conflicts, 4) - assert.equal(state.version, 4) + assert.equal(commands.size, 3) + assert.equal(replays, 3) + assert.equal(conflicts, 3) + assert.equal(state.version, 3) assert.equal(context.createdResources.phase5TerminalDrayage.finalStatus, 'EMPTY_RETURNED') }) @@ -90,7 +89,7 @@ test('terminal drayage prepares pickup before unloading and finalizes return bef test('module uses only public business endpoints and reuses public trucking master data', () => { for (const path of [ - '/api/platform/v1/terminal-drayages', "'fleet'", "'pickup-appointment'", "'pickup'", "'empty-return'", '/history', + '/api/platform/v1/terminal-drayages', "'fleet'", "'pickup-appointment'", "'empty-return'", '/history', 'category=TRUCKING', ]) assert.ok(source.includes(path), `missing ${path}`) assert.doesNotMatch(source, /\/internal\//) @@ -98,11 +97,11 @@ test('module uses only public business endpoints and reuses public trucking mast assert.match(source, /脚本不临时创建主数据/) }) -test('all four commands cover idempotent replay hash conflict and lifecycle semantics', () => { +test('all three commands cover idempotent replay hash conflict and lifecycle semantics', () => { for (const token of [ - '选择车队', '预约提柜', '确认实际提柜', '确认实际还柜', - '同键重放', '同键异载荷返回A0409', 'PICKED_UP', 'UNLOADED', 'EMPTY_RETURNED', - 'SELECT_FLEET', 'SCHEDULE_PICKUP', 'RECORD_PICKUP', 'RECORD_EMPTY_RETURN', 'RESOLVED', + '选择车队', '预约提柜', '确认实际还柜', + '同键重放', '同键异载荷返回A0409', 'UNLOADED', 'EMPTY_RETURNED', + 'SELECT_FLEET', 'SCHEDULE_PICKUP', 'RECORD_EMPTY_RETURN', 'RESOLVED', ]) assert.ok(source.includes(token), `missing ${token}`) assert.match(source, /JSON\.stringify\(replay\.data\) === JSON\.stringify\(first\.data\)/) assert.match(source, /terminal-\$\{path\}.*replace\(\/\[\^A-Za-z0-9:_-\]\//) diff --git a/scripts/admin-test-env-modules/phase5-terminal-drayage.mjs b/scripts/admin-test-env-modules/phase5-terminal-drayage.mjs index 656c3a5f2..36cbf266e 100644 --- a/scripts/admin-test-env-modules/phase5-terminal-drayage.mjs +++ b/scripts/admin-test-env-modules/phase5-terminal-drayage.mjs @@ -39,7 +39,7 @@ async function command(context, target, action, path, body) { `${BASE}/${target.containerBookingId}/${path}`, body, { 'Idempotency-Key': key }) context.assert(MODULE, `${action}重放状态和版本零漂移`, replay.response, JSON.stringify(replay.data) === JSON.stringify(first.data), trace(replay.response), OPTIONS) - const changed = ['pickup', 'empty-return'].includes(path) ? { ...body, remark: `${body.remark}-DIFFERENT` } + const changed = path === 'empty-return' ? { ...body, remark: `${body.remark}-DIFFERENT` } : { ...body, reason: `${body.reason}-DIFFERENT` } const hashConflict = await context.request('POST', `${BASE}/${target.containerBookingId}/${path}`, changed, context.token, { 'Idempotency-Key': key }) @@ -103,15 +103,7 @@ export async function prepareTerminalDrayage(context, producer) { trace(scheduled.response), OPTIONS) if (!success(scheduled.response)) return null - const pickupAt = localIso(new Date(Date.parse(`${appointmentAt}Z`) + 60 * 60_000)) - const picked = await command(context, target, '确认实际提柜', 'pickup', { - expectedVersion: Number(scheduled.data.version), actualAt: pickupAt, remark: context.marker, - }) - context.assert(MODULE, '实际提柜同步PICKED_UP并等待海外仓卸柜', picked.response, - picked.data?.status === 'PICKED_UP' && picked.data?.actualPickupAt === pickupAt, - trace(picked.response), OPTIONS) - if (!success(picked.response) || picked.data?.status !== 'PICKED_UP') return null - resource.prepared = { ...target, appointmentAt, pickupAt, pickedVersion: Number(picked.data.version) } + resource.prepared = { ...target, appointmentAt, scheduledVersion: Number(scheduled.data.version) } resource.containerBookingId = target.containerBookingId resource.containerNo = target.containerNo return resource.prepared @@ -149,15 +141,15 @@ export async function run(context) { context.assert(MODULE, '卸柜聚合保留提柜事实并开放还柜', initial.response, id(initial.data?.containerBookingId) === target.containerBookingId && id(initial.data?.containerNo) === id(target.completed.containerNo) - && Number(initial.data?.version) === Number(target.pickedVersion) + && Number(initial.data?.version) === Number(target.scheduledVersion) && initial.data?.status === 'UNLOADED' - && initial.data?.actualPickupAt === target.pickupAt + && initial.data?.actualPickupAt === initial.data?.unloadingCompletedAt && initial.data?.allowedActions?.includes('RECORD_EMPTY_RETURN'), `pollRequests=${pollRequests}, status=${initial.data?.status || 'missing'}, actions=${initial.data?.allowedActions?.join(',') || 'missing'}; ${trace(initial.response)}`, OPTIONS) if (!success(initial.response)) return const unloadingAt = Date.parse(`${initial.data?.unloadingCompletedAt}Z`) - const returnBase = Math.max(Date.parse(`${target.pickupAt}Z`), Number.isNaN(unloadingAt) ? 0 : unloadingAt) + const returnBase = Number.isNaN(unloadingAt) ? Date.now() : unloadingAt const returnedAt = localIso(new Date(returnBase + 60 * 60_000)) const returned = await command(context, target, '确认实际还柜', 'empty-return', { expectedVersion: Number(initial.data.version), actualAt: returnedAt, remark: context.marker, @@ -176,13 +168,12 @@ export async function run(context) { && list.data[0]?.status === 'EMPTY_RETURNED', trace(list.response), OPTIONS) const history = await api(context, 'GET', '读取目的港码头拖车不可变历史', `${path}/history`) const actions = new Set((Array.isArray(history.data) ? history.data : []).map((item) => item?.action)) - context.assert(MODULE, '历史完整记录四个业务动作和连续版本', history.response, - ['SELECT_FLEET', 'SCHEDULE_PICKUP', 'RECORD_PICKUP', 'RECORD_EMPTY_RETURN'].every((action) => actions.has(action)) + context.assert(MODULE, '历史完整记录三个业务动作和连续版本', history.response, + ['SELECT_FLEET', 'SCHEDULE_PICKUP', 'RECORD_EMPTY_RETURN'].every((action) => actions.has(action)) && history.data.every((item) => Number(item?.toVersion) === Number(item?.fromVersion) + 1), `actions=${[...actions].join(',')}; ${trace(history.response)}`, OPTIONS) - context.assert(MODULE, '提柜和还柜备注写入不可变历史', history.response, - ['RECORD_PICKUP', 'RECORD_EMPTY_RETURN'].every((action) => - history.data?.some((item) => item.action === action && item.reason === context.marker)), + context.assert(MODULE, '还柜备注写入不可变历史', history.response, + history.data?.some((item) => item.action === 'RECORD_EMPTY_RETURN' && item.reason === context.marker), trace(history.response), OPTIONS) context.createdResources.phase5TerminalDrayage = { ...context.createdResources.phase5TerminalDrayage, diff --git a/scripts/admin-test-env-modules/smart-loading-channel-group.mjs b/scripts/admin-test-env-modules/smart-loading-channel-group.mjs index dacbfbdef..1da739ef1 100644 --- a/scripts/admin-test-env-modules/smart-loading-channel-group.mjs +++ b/scripts/admin-test-env-modules/smart-loading-channel-group.mjs @@ -1,5 +1,7 @@ export function channelGroup(channelCode) { const value = String(channelCode || '').trim().toUpperCase() + if (['MATSON_CLX', 'MATSON_MAX', 'EXX', 'HEDE', 'OA_LA', 'OA_CHICAGO', + 'OA_HOUSTON', 'OA_SAVANNAH', 'OA_NEW_YORK_TRUCK'].includes(value)) return value if (value.startsWith('EXX_') || ['EXXHK', 'EXXHWK', 'NYEXX'].includes(value)) return 'EXX' if (value.startsWith('MATSON_CLX_') || ['ZBYHK', 'ZBYHWK', 'ZBYHP'].includes(value)) return 'MATSON_CLX' if (value.startsWith('MATSON_MAX_') || ['BYHK', 'BYHWK', 'BYHP'].includes(value)) return 'MATSON_MAX' diff --git a/scripts/admin-test-env-modules/truck-appointments.mjs b/scripts/admin-test-env-modules/truck-appointments.mjs index 836f4424d..da7403740 100644 --- a/scripts/admin-test-env-modules/truck-appointments.mjs +++ b/scripts/admin-test-env-modules/truck-appointments.mjs @@ -144,7 +144,7 @@ async function fixture(context) { const draft = oceanBookingDraft(context, suffix, master, { bookingAgentId: bookingAgent.supplierId, bookingAgentName: bookingAgent.supplierName, shippingCompanyId: id(carrier.id), shippingCompanyName: carrier.name, carrierCode: 'MATSON', - containerRequirements: [{ containerType: '40HC', containerKind: 'FCL', quantity: 2, capacityCbm: 76, maxWeightKg: 28000 }], + containerRequirements: [{ containerType: '40HQ', containerKind: 'FCL', quantity: 2, capacityCbm: 68, maxWeightKg: 19500 }], }) const createdResponse = await request(context, 'POST', BOOKINGS, { expectedVersion: 0, data: draft }, { 'Idempotency-Key': `${context.marker}-truck-booking-create`.slice(0, 128) }) 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', diff --git a/scripts/java-quality-baseline.json b/scripts/java-quality-baseline.json index 9811044e9..2e8d955f3 100644 --- a/scripts/java-quality-baseline.json +++ b/scripts/java-quality-baseline.json @@ -11,7 +11,7 @@ "services": 15 }, "openapi": { - "documents": 48, + "documents": 49, "coveredServices": 10, "codeFirstServices": 5 }, diff --git a/scripts/java-quality-summary.test.mjs b/scripts/java-quality-summary.test.mjs index a8b56435d..841efff6e 100644 --- a/scripts/java-quality-summary.test.mjs +++ b/scripts/java-quality-summary.test.mjs @@ -65,7 +65,8 @@ test('repository freezes the current topology and non-blocking aggregate summary const summary = await buildQualitySummary(repositoryRoot) const baseline = JSON.parse(await readFile(path.join(repositoryRoot, 'scripts', 'java-quality-baseline.json'), 'utf8')) assert.deepEqual(summary.topology, { modules: 22, codeModules: 21, services: 15 }) - assert.equal(summary.openapi.documents, 48) + assert.equal(summary.openapi.documents, 49) + assert.ok(summary.openapi.files.includes('file/src/main/resources/openapi/loading-evidence-v1.yaml')) assert.ok(summary.openapi.files.includes('file/src/main/resources/openapi/overseas-relabel-document-v1.yaml')) assert.ok(summary.openapi.files.includes('finance/src/main/resources/openapi/finance-container-settlement-v1.yaml')) assert.ok(summary.openapi.files.includes('warehouse-overseas-service/src/main/resources/openapi/warehouse-pallet-batch-internal-v1.yaml')) diff --git a/scripts/mixed-container-outbound-test-env.mjs b/scripts/mixed-container-outbound-test-env.mjs new file mode 100644 index 000000000..9f5a48cf4 --- /dev/null +++ b/scripts/mixed-container-outbound-test-env.mjs @@ -0,0 +1,168 @@ +import { readFileSync, writeFileSync, existsSync } from 'node:fs' +import { resolve } from 'node:path' +import { AcceptanceRun, parseArguments, validateExecutionOptions } from './admin-test-env-acceptance.mjs' +import { createPicker, disablePicker, resolvePickerAccess } from './admin-test-env-modules/phase4-overseas-pda.mjs' +import { pickAll, saveReceiptAndComplete } from './admin-test-env-modules/phase5-overseas-fulfillment.mjs' + +// Explicit retained fixture continuation, not a new fixture generator. +const [sourcePath, containerNo, label, outputPath] = process.argv.slice(2) +if (!sourcePath || !containerNo || !['MIXED-FBA', 'MIXED-DROPSHIP'].includes(label) || !outputPath) { + throw new Error('Usage: source-report containerNo MIXED-FBA|MIXED-DROPSHIP output-report; explicit test authorization required') +} +const options = parseArguments([]) +validateExecutionOptions(options) +const context = new AcceptanceRun(options) +const source = JSON.parse(readFileSync(resolve(sourcePath), 'utf8')) +const record = source.createdResources?.dropshipPalletMatrix?.records?.find(row => row.label === label) +if (!record || record.overseas?.plan?.containerNo !== containerNo || !record.overseas?.receiving?.receivingVerified) { + throw new Error('Fixture must be the explicitly selected, completed mixed-container receipt') +} +const inboundCode = record.source.inboundCode +const customerId = record.overseas.plan.customerId +const output = resolve(outputPath) +const result = existsSync(output) ? JSON.parse(readFileSync(output, 'utf8')) : { + marker: context.marker, containerNo, inboundCode, label, source: resolve(sourcePath), events: [], +} +if (result.inboundCode !== inboundCode || result.containerNo !== containerNo || result.label !== label) throw new Error('Checkpoint scope mismatch') +context.marker = result.marker +const save = () => writeFileSync(output, JSON.stringify({ ...result, updatedAt: new Date().toISOString() }, null, 2)) +const base = '/api/platform/v1/last-leg' +async function api(method, path, body, token) { + const response = await context.requestOverseas(method, path, body, {}, token || context.overseasToken) + result.events.push({ method, path, http: response.http, code: response.body?.code, + message: response.body?.message, requestId: response.requestId, traceId: response.traceId }) + save() + if (response.http !== 200 || String(response.body?.code) !== '200') throw new Error(`${method} ${path}: ${response.body?.message}`) + return response.body.data +} +let picker +try { + const login = await context.request('POST', '/api/auth/login', { loginId: options.username, password: options.password }, '') + if (!login.body?.data?.overseasAccessToken) throw new Error('Platform login failed') + context.token = login.body.data.accessToken + context.overseasToken = login.body.data.overseasAccessToken + if (label === 'MIXED-DROPSHIP') { + const candidatePath = `${base}/outbound-orders/one-click-dropship/candidates?customerId=${customerId}&warehouseId=${record.overseas.warehouseId}` + if (!result.task) { + const candidates = await api('GET', candidatePath) + const selected = candidates.filter(row => row.inboundCode === inboundCode) + const inputs = selected.map(row => { + const pallet = record.task.pallets.find(p => p.palletNo === row.palletNo) + const cartonMarks = (pallet?.cartons || []).filter(c => String(c.inboundId) === String(record.source.inboundId)).map(c => c.boxCode) + if (cartonMarks.length !== Number(row.availablePackages) || row.customerId !== customerId) throw new Error('Dropship pallet scope mismatch') + return { inventoryPositionId: row.inventoryPositionId, packages: cartonMarks.length, sku: row.skuCode, cartonMarks } + }) + if (inputs.length !== 2 || inputs.reduce((n, i) => n + i.packages, 0) !== 4) throw new Error('Expected exactly two pallets and four cartons') + result.selected = selected + result.inputs = inputs + result.createBody = { recipient: `${context.marker} TEST ONLY`, items: inputs, remark: `${context.marker}-test-only`, idempotencyKey: `${context.marker}-OUTBOUND` } + save() + result.task = await api('POST', `${base}/outbound-orders/one-click-dropship?customerId=${customerId}`, result.createBody) + save() + } + const before = await api('GET', `${base}/outbound-orders/${result.task.outboundNo}`) + if (before.outbound.status !== 'OUTBOUNDED') { + const access = await resolvePickerAccess(context) + picker = access ? await createPicker(context, 9, record.overseas.warehouseId, access) : null + if (!picker?.token) throw new Error('PDA operator unavailable') + result.pickerMembershipId = picker.membershipId + save() + const path = `/api/pda/v1/last-leg/outbound-tasks/${result.task.taskNo}` + const detail = await api('GET', path, undefined, picker.token) + for (const [index, candidate] of result.selected.entries()) { + const item = detail.items.find(i => i.palletNo === candidate.palletNo && i.inboundCode === inboundCode && i.skuCode === candidate.skuCode) + if (!item || !item.positions.some(p => String(p.inventoryPositionId) === String(candidate.inventoryPositionId))) throw new Error('PDA source position mismatch') + const body = { outboundPalletItemId: item.outboundPalletItemId, inventoryPositionId: candidate.inventoryPositionId, + packages: result.inputs[index].packages, cartonMarks: result.inputs[index].cartonMarks, idempotencyKey: `${context.marker}-PICK-${index}` } + await api('POST', `${path}/pick`, body, picker.token) + await api('POST', `${path}/pick`, body, picker.token) + } + result.completedTask = await api('POST', `${path}/complete`, { idempotencyKey: `${context.marker}-COMPLETE` }, picker.token) + } + result.outbound = await api('GET', `${base}/outbound-orders/${result.task.outboundNo}`) + result.remainingCandidates = (await api('GET', candidatePath)).filter(row => row.inboundCode === inboundCode) + result.finalStatus = result.outbound.outbound.status === 'OUTBOUNDED' && result.remainingCandidates.length === 0 + ? 'OUTBOUND_PASSED_DELIVERY_NOT_SUPPORTED' : 'OUTBOUND_FAILED' + } else { + const candidates = await api('GET', `${base}/dispatches/candidates?sourceStage=RECEIVED&orderNo=${encodeURIComponent(record.overseas.summary.orderNo)}`) + const selected = candidates.filter(row => row.inboundCode === inboundCode && row.containerNo === containerNo) + if (!result.dispatchNo) { + if (selected.reduce((sum, row) => sum + Number(row.packageCount), 0) !== 4 || selected.some(row => row.fbaWarehouseCode !== 'ABE4')) throw new Error('Live FBA candidate quantity or destination changed') + // The caller must retain the checkpoint after every mutation; do not retry creation without checking live candidates. + const dispatch = await api('POST', `${base}/dispatches/pre-arrange`, { planPalletIds: selected.map(row => row.planPalletId) }) + result.dispatchNo = dispatch.dispatchNo + save() + } + const dispatchPath = `${base}/dispatches/${encodeURIComponent(result.dispatchNo)}` + let dispatch = await api('GET', dispatchPath) + if (!result.transfer) { + await api('PUT', dispatchPath, { vehiclePlate: `TEST-${context.marker.slice(-8)}`, driverName: 'HTTP测试司机', + driverPhone: '19900000000', carrierName: 'HTTP测试车队', estimatedArrivalAt: new Date(Date.now() + 86400000).toISOString() }) + result.transfer = await api('POST', `${dispatchPath}/transfer-delivery`, { + idempotencyKey: `${context.marker}-TRANSFER`, plannedDeliveryAt: new Date(Date.now() + 172800000).toISOString(), + deliveryMethod: 'TRUCK_PALLET', carrierName: 'HTTP测试车队', pickupMethod: 'WAREHOUSE_PICKUP', loadingType: 'PALLET', + vehicleType: 'TRUCK', vehicleSize: '15托', driverName: 'HTTP测试司机', driverPhone: '19900000000', + vehiclePlate: `TEST-${context.marker.slice(-8)}`, appointmentAccount: 'SELF', remark: `${context.marker}-test-only`, + fees: [{ feeType: 'DELIVERY_FEE', feeName: '测试派送费', direction: 'RECEIVABLE', currency: 'USD', amount: 1, remark: `${context.marker}-test-only` }], + }) + save() + } + dispatch = await api('GET', dispatchPath) + if (dispatch.status !== 'DEPARTED') { + const access = await resolvePickerAccess(context) + picker = access ? await createPicker(context, 8, record.overseas.warehouseId, access) : null + if (!picker?.token) throw new Error('PDA operator unavailable') + result.pickerMembershipId = picker.membershipId + save() + if (dispatch.status !== 'READY_TO_DEPART' && !await pickAll(context, picker.token, result.dispatchNo)) throw new Error('PDA picking failed') + result.departure = await api('POST', `/api/pda/v1/last-leg/dispatches/${result.dispatchNo}/depart`, + { idempotencyKey: `${context.marker}-DEPART`, remark: `${context.marker}-test-only` }, picker.token) + save() + } + const deliveries = result.transfer.deliveries + if (!Array.isArray(deliveries) || deliveries.length !== 1 || Number(result.transfer.packageCount) !== 4) throw new Error('Unexpected transfer quantities') + result.deliveries = [] + for (const [index, delivery] of deliveries.entries()) { + const path = `${base}/delivery-orders/${delivery.deliveryNo}?customerId=${customerId}` + const detail = await api('GET', path) + if (detail.order.status !== 'DELIVERED') { + if (detail.order.status !== 'DELIVERING') await api('PUT', `${base}/delivery-orders/${delivery.deliveryNo}/status?customerId=${customerId}`, + { status: 'DELIVERING', expectedVersion: detail.order.version, remark: `${context.marker}-test-only` }) + if (!await saveReceiptAndComplete(context, delivery, customerId, index)) throw new Error('Receipt signature failed') + } + result.deliveries.push(await api('GET', path)) + save() + } + result.outbound = await api('GET', `${base}/outbound-orders/${result.transfer.outboundNo}`) + for (let attempt = 0; attempt < 30; attempt++) { + const response = await context.request('GET', `/api/platform/inbounds?tenantId=${customerId}&inboundCode=${inboundCode}&page=1&size=20`) + if (String(response.body?.code) !== '200') throw new Error('Domestic inbound query failed') + result.domesticInbound = response.body.data.records.find(row => row.inboundCode === inboundCode) + if (result.domesticInbound?.processStatus === 'DELIVERED') break + await new Promise(resolve => setTimeout(resolve, 1000)) + } + if (!result.domesticInbound?.orderId) throw new Error('Missing domestic source order') + const order = await context.request('GET', `/api/platform/v1/orders/${result.domesticInbound.orderId}?tenantId=${customerId}`) + result.domesticOrder = order.body?.data?.summary + result.finalStatus = result.domesticInbound?.processStatus === 'DELIVERED' && result.domesticOrder?.mainStatus === 'COMPLETED' + && result.outbound?.outbound?.status === 'OUTBOUNDED' ? 'PASSED' : 'STATE_CALLBACK_FAILED' + } +} catch (error) { + result.error = error.message + result.finalStatus = 'BLOCKED' + process.exitCode = 1 +} finally { + try { + await disablePicker(context, picker, label === 'MIXED-DROPSHIP' ? 9 : 8) + } catch (error) { + result.cleanupError = error.message + result.finalStatus = 'CLEANUP_FAILED' + } + result.checks = context.steps + if (context.steps.some(step => step.passed === false)) result.finalStatus = 'CHECK_FAILED' + if (result.finalStatus !== 'PASSED') process.exitCode = 1 + save() + console.log(JSON.stringify({ containerNo, inboundCode, finalStatus: result.finalStatus, error: result.error, + dispatchNo: result.dispatchNo, outboundNo: result.transfer?.outboundNo || result.task?.outboundNo, + deliveryNos: result.transfer?.deliveries?.map(row => row.deliveryNo), report: output })) +} diff --git a/scripts/overseas-deployment-contract.test.mjs b/scripts/overseas-deployment-contract.test.mjs index 8d981d746..229328bc9 100644 --- a/scripts/overseas-deployment-contract.test.mjs +++ b/scripts/overseas-deployment-contract.test.mjs @@ -155,6 +155,18 @@ test('domestic event bridge SQL converges legacy tenant columns to the customer assert.match(incremental, /bridge_outbox_customer_column = 0 AND @bridge_outbox_tenant_column = 1/) }) +test('both overseas PDF consumers explicitly connect to the existing CN Print service', async () => { + const compose = await read('deploy/docker-compose.overseas.yml') + for (const service of ['lastleg-service', 'warehouse-overseas-service']) { + const block = compose.match(new RegExp(`^ ${service}:\\n([\\s\\S]*?)(?=^ [a-z][\\w-]*:|^networks:)`, 'm'))?.[1] + assert.ok(block, `${service} must have a deployment definition`) + assert.match(block, /PRINT_SERVICE_URL: \$\{PRINT_SERVICE_URL:-http:\/\/print:9207\}/, + `${service} cannot discover the CN Print service from its overseas Nacos namespace`) + const config = await read(`${service}/src/main/resources/application.yml`) + assert.match(config, /print:\s*\n\s+url: \$\{PRINT_SERVICE_URL:\}/) + } +}) + test('domestic unified SQL creates the overseas hold version before queuing its comment', async () => { const incremental = await read('deploy/sql/头程数据库统一增量-MySQL8.sql') const addColumn = incremental.indexOf('CALL add_inbound_overseas_hold_version();') diff --git a/scripts/overseas-relabel-fulfillment-acceptance.mjs b/scripts/overseas-relabel-fulfillment-acceptance.mjs index 390b7c6ca..d2117ed5b 100644 --- a/scripts/overseas-relabel-fulfillment-acceptance.mjs +++ b/scripts/overseas-relabel-fulfillment-acceptance.mjs @@ -57,7 +57,7 @@ const transfer = await api(overseas, merchantToken, 'POST', `/api/merchant/v1/last-leg/delivery-orders?dispatchNo=${encodeURIComponent(dispatchNo)}`, { idempotencyKey: key('TRANSFER'), plannedDeliveryAt: new Date(Date.now() + 172800000).toISOString(), deliveryMethod: 'TRUCK_PALLET', waybillNo: `${marker}-WAYBILL`, carrierName: '尾程验收车队', - pickupMethod: 'WAREHOUSE_PICKUP', loadingType: 'PALLET', vehicleType: 'TRUCK', vehicleSize: 'MEDIUM', + pickupMethod: 'WAREHOUSE_PICKUP', loadingType: 'PALLET', vehicleType: 'TRUCK', vehicleSize: '15托', driverName: '尾程验收司机', driverPhone: '19900000000', vehiclePlate: `UAT-${marker.slice(-8)}`, appointmentAccount: 'SELF', remark: marker, fees: [{ feeType: 'DELIVERY_FEE', feeName: '派送费', direction: 'RECEIVABLE', currency: 'CNY', amount: 10, remark: marker }], diff --git a/scripts/synchronous-dependency-baseline.mjs b/scripts/synchronous-dependency-baseline.mjs index 309907610..1c7845f0f 100644 --- a/scripts/synchronous-dependency-baseline.mjs +++ b/scripts/synchronous-dependency-baseline.mjs @@ -76,6 +76,8 @@ export const SYNCHRONOUS_CLIENTS = Object.freeze([ '在线测量/箱号/排柜读取;Order 审核 Outbox 创建 Firstleg 入库及箱号时也会后台回读订单货物'), feign('firstleg/src/main/java/com/baiyue/firstleg/client/OrderFulfillmentSplitClient.java', 'firstleg-service', 'order-service', ['REQUEST'], 'WRITE', 'IDEMPOTENCY_KEY', 'PDA 结算拆单携带 idempotencyKey/operationId'), + feign('firstleg/src/main/java/com/baiyue/firstleg/client/OrderFulfillmentCompletionClient.java', + 'firstleg-service', 'order-service', ['BACKGROUND'], 'WRITE', 'EVENT_ID', '海外派送完成事件按 eventId/payloadHash 幂等确认来源订单完成'), feign('firstleg/src/main/java/com/baiyue/firstleg/client/PendingReviewOrderClient.java', 'firstleg-service', 'order-service', ['REQUEST'], 'READ', 'SAFE_READ', '智能排柜分页读取待审核订单投影'), feign('firstleg/src/main/java/com/baiyue/firstleg/client/PriceBookFileClient.java', @@ -117,6 +119,8 @@ export const SYNCHRONOUS_CLIENTS = Object.freeze([ 'lastleg-service', 'warehouse-service', ['REQUEST'], 'READ', 'SAFE_READ', '读取国内共享仓库权威目录'), feign('lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/client/DomesticPartnerWarehouseClient.java', 'lastleg-service', 'warehouse-service', ['REQUEST'], 'READ', 'SAFE_READ', '报价创建时读取国内合作仓快照'), + feign('warehouse-overseas-service/src/main/java/com/baiyue/warehouse/client/PrintPdfClient.java', + 'warehouse-overseas-service', 'print-service', ['REQUEST'], 'READ', 'SAFE_READ', '按已授权托盘生成标签PDF,内部令牌保护,无业务状态写入'), feign('warehouse-overseas-service/src/main/java/com/baiyue/warehouse/client/DomesticWarehouseDirectoryClient.java', 'warehouse-overseas-service', 'warehouse-service', ['REQUEST'], 'READ', 'SAFE_READ', '海外库位、托盘和资源锁写入前读取国内共享收货仓权威目录'), @@ -167,6 +171,8 @@ export const SYNCHRONOUS_CLIENTS = Object.freeze([ 'lastleg-service', 'file-service', ['REQUEST'], 'READ_WRITE', 'MIXED', '异常处理证据上传无幂等键,归属校验可重复'), http('lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpUnloadPhotoFileGateway.java', 'lastleg-service', 'file-service', ['REQUEST'], 'READ_WRITE', 'MIXED', '上传无幂等键,元数据校验可重复'), + http('lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpDomesticLoadingEvidenceGateway.java', + 'lastleg-service', 'file-service', ['REQUEST'], 'READ', 'SAFE_READ', '当前PDA会话校验来源柜后,通过内部服务令牌只读下载国内装柜照片'), http('lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java', 'lastleg-service', 'warehouse-overseas-service', ['REQUEST'], 'READ_WRITE', 'MIXED', '查询可重复;写操作按 business/reservation key 或状态收敛'), http('lastleg-service/src/main/java/com/baiyue/lastleg/palletops/gateway/HttpPalletPhysicalGateway.java', diff --git a/scripts/synchronous-dependency-contract.test.mjs b/scripts/synchronous-dependency-contract.test.mjs index c2613a429..bb70439d8 100644 --- a/scripts/synchronous-dependency-contract.test.mjs +++ b/scripts/synchronous-dependency-contract.test.mjs @@ -42,6 +42,7 @@ const expectedOnlineEdges = [ 'order-service->firstleg-service', 'order-service->iam-service', 'order-service->warehouse-service', + 'warehouse-overseas-service->print-service', 'warehouse-overseas-service->warehouse-service' ] @@ -100,15 +101,15 @@ async function discoverFeignEdges() { return clients.sort((left, right) => left.path.localeCompare(right.path)) } -test('all 58 Feign clients have reviewed context, access and idempotency metadata', async () => { +test('all 60 Feign clients have reviewed context, access and idempotency metadata', async () => { const reviewed = SYNCHRONOUS_CLIENTS.filter(client => client.transport === 'FEIGN') .map(({ path, source, target }) => ({ path, source, target })) .sort((left, right) => left.path.localeCompare(right.path)) - assert.equal(reviewed.length, 58) + assert.equal(reviewed.length, 60) assert.deepEqual(await discoverFeignEdges(), reviewed) }) -test('all 22 raw HTTP transports have reviewed context, access and idempotency metadata', async () => { +test('all 23 raw HTTP transports have reviewed context, access and idempotency metadata', async () => { const discovered = [] for (const module of ['common-audit', 'event-bridge-service', 'gateway-overseas', 'lastleg-service', 'integration-service']) { for (const file of await javaFiles(path.join(root, module, 'src', 'main', 'java'))) { @@ -120,7 +121,7 @@ test('all 22 raw HTTP transports have reviewed context, access and idempotency m } const reviewed = SYNCHRONOUS_CLIENTS.filter(client => client.transport === 'HTTP') .map(client => client.path).sort() - assert.equal(reviewed.length, 22) + assert.equal(reviewed.length, 23) assert.deepEqual(discovered.sort(), reviewed) }) @@ -271,6 +272,7 @@ test('known user entries expose every longest simple REQUEST path over the two-h ] }, 'warehouse-service': { hops: 0, paths: ['warehouse-service'] }, 'warehouse-overseas-service': { hops: 1, paths: [ + 'warehouse-overseas-service->print-service', 'warehouse-overseas-service->warehouse-service' ] } }) diff --git a/warehouse-overseas-service/AGENTS.md b/warehouse-overseas-service/AGENTS.md index 9da3839df..72157a55e 100644 --- a/warehouse-overseas-service/AGENTS.md +++ b/warehouse-overseas-service/AGENTS.md @@ -17,6 +17,7 @@ | 托盘解析与业务绑定 | `controller/InternalWarehousePalletController.java`、`dto/WarehousePalletResolveResponse.java`、`entity/WarehousePalletBindingEntity.java` | | 柜级入库计划批量托盘 | `controller/InternalWarehousePalletBatchController.java`、`service/WarehousePalletBatchService.java`、`warehouse-pallet-batch-internal-v1.yaml`;以稳定批次键幂等执行 `ensure-reserved → confirm`,权威托盘码已存在且资源可预留时必须复用,未提供或不存在时才创建;Lastleg 本地计划事务失败时仅补偿仍为 `RESERVED` 的未确认资源,已 `CONFIRMED` 批次不得回退,同柜重放不得产生第二批资源 | | 仓储资源服务 | `service/WarehouseResourceService.java`、`WarehouseResourceLockService.java` | +| 单张托盘标签 PDF | `controller/PlatformWarehouseResourceController.java` 的 `/pallets/{id}/label`、`service/PalletLabelPrintService.java`、`client/PrintPdfClient.java`;契约 `src/main/resources/openapi/pallet-label-v1.yaml`。使用公共托盘真实编码并复用 Print `INBOUND_MARK_V1`,模板仍只在 Print 维护;沿用托盘列表权限与海外 Gateway 的 `warehouse-admin/**` 路由。`baiyue.clients.print.url` 对应 `PRINT_SERVICE_URL`,海外 Compose 必须与 BOL 一样显式注入国内 Print 地址,不能依赖海外 Nacos 发现国内服务;Feign 及熔断包装异常统一返回 `S0503` | | 内部服务令牌 | `security/InternalServiceTokenGuard.java` | | 数据库基线与中文注释契约 | `src/main/resources/db/warehouse.sql`、`src/test/java/com/baiyue/warehouse/schema/SchemaChineseCommentContractTest.java` | | 测试 | `src/test/java/com/baiyue/warehouse/` | diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/client/PrintPdfClient.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/client/PrintPdfClient.java new file mode 100644 index 000000000..4d8179fbb --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/client/PrintPdfClient.java @@ -0,0 +1,24 @@ +package com.baiyue.warehouse.client; + +import java.math.BigDecimal; +import java.util.List; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; + +/** Reuses the Print service's existing label renderer; no local PDF template. */ +@FeignClient(name = "print-service", contextId = "overseasWarehousePrintPdfClient", + url = "${baiyue.clients.print.url:}", path = "/internal/print/v1/pdfs") +public interface PrintPdfClient { + @PostMapping(value = "/inbound-marks", consumes = "application/json") + ResponseEntity generateInboundMarks( + @RequestHeader("X-Internal-Service-Token") String token, + @RequestHeader(value = "X-Request-Id", required = false) String requestId, + @RequestBody Request request); + + record Request(String templateCode, PageSpec pageSpec, String downloadFileName, List