This commit is contained in:
2026-09-15 13:36:58 +08:00
parent 1bb69698de
commit f9649c44e9
44 changed files with 647 additions and 63 deletions
+2 -2
View File
@@ -14,7 +14,7 @@
| 事件 ID 与业务键 | `EventIdentifiers.java` | | 事件 ID 与业务键 | `EventIdentifiers.java` |
| Payload 规范化与哈希 | `EventPayloadCanonicalizer.java`、`EventPayloadHash.java` | | Payload 规范化与哈希 | `EventPayloadCanonicalizer.java`、`EventPayloadHash.java` |
| 事件类型与区域 | `EventTypes.java`、`RegionCode.java` | | 事件类型与区域 | `EventTypes.java`、`RegionCode.java` |
| 头程装柜完成 V2(当前生产) | `FirstLegLoadingPlanCompletedV2.java`;订单级冻结 `destinationType=FBA|OVERSEAS`,FBA 可按箱数接收,OVERSEAS 逐明细冻结与实装箱数一致的 SKU/箱号;同时冻结来源/根订单与入仓标识、客户、业务类型、目的仓/备注/托盘及一键代发托盘 SKU 快照;国内生产者见 `../firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java`,海外消费者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java` | | 头程装柜完成 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 解析模型 | `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 | `OverseasContainerUnloadedV1.java`;按来源柜预订 ID 做柜级聚合/幂等,同一柜只发布一次,信封固定使用 `tenantId=0` 平台技术范围;`unloadingCompletedAt` 必须是全柜 complete 时刻,不得用签收拍照时刻代替;海外生产者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`,国内消费者见 `../firstleg/src/main/java/com/baiyue/firstleg/service/OverseasContainerEventConsumer.java` |
| 海外入仓单暂扣变更 V1 | `OverseasInboundOrderDetainedChangedV1.java`;聚合键固定为来源头程入仓单 ID,暂扣与解除暂扣均由尾程 Outbox 回传,国内按入仓单绑定、客户归属及聚合版本单调消费 | | 海外入仓单暂扣变更 V1 | `OverseasInboundOrderDetainedChangedV1.java`;聚合键固定为来源头程入仓单 ID,暂扣与解除暂扣均由尾程 Outbox 回传,国内按入仓单绑定、客户归属及聚合版本单调消费 |
@@ -24,7 +24,7 @@
| 海外业务资金指令与结果 V1 | `WalletCommandRequestedV1.java`、`WalletCommandCompletedV1.java`;海外业务通过 Outbox 请求国内 Finance 冻结、结算或释放,Finance 执行后再经 Outbox 回传结果,双方以事件与业务幂等键防重,不跨区域同步调用钱包 | | 海外业务资金指令与结果 V1 | `WalletCommandRequestedV1.java`、`WalletCommandCompletedV1.java`;海外业务通过 Outbox 请求国内 Finance 冻结、结算或释放,Finance 执行后再经 Outbox 回传结果,双方以事件与业务幂等键防重,不跨区域同步调用钱包 |
| IAM 安全事件 V1 | `IamSecurityEventsV1.java`;账号、授权快照等安全事件的冻结模型 | | IAM 安全事件 V1 | `IamSecurityEventsV1.java`;账号、授权快照等安全事件的冻结模型 |
| 契约说明 | `package-info.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` | | 真实生产/消费验收 | `../firstleg/src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java`、`../lastleg-service/src/test/java/com/baiyue/lastleg/service/TaillegRealConsumerAcceptanceTest.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<FirstLegLoadingPlanCompletedV2.Order> orders, List<LoadingEvidence> 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();
}
}
}
@@ -5,6 +5,25 @@ info:
paths: {} paths: {}
components: components:
schemas: 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: FirstLegLoadingPlanCompletedV2:
type: object type: object
required: [loadingPlanId, sourceContainerBookingId, containerNo, estimatedPortArrivalAt, actualPortArrivalAt, estimatedWarehouseArrivalAt, orders] required: [loadingPlanId, sourceContainerBookingId, containerNo, estimatedPortArrivalAt, actualPortArrivalAt, estimatedWarehouseArrivalAt, orders]
@@ -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<FirstLegLoadingPlanCompletedV3.LoadingEvidence> 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<FirstLegLoadingPlanCompletedV3.LoadingEvidence> 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");
}
}
@@ -34,12 +34,17 @@ baiyue:
pallet-physical: pallet-physical:
connect-timeout: ${LASTLEG_PALLET_PHYSICAL_CONNECT_TIMEOUT:3s} connect-timeout: ${LASTLEG_PALLET_PHYSICAL_CONNECT_TIMEOUT:3s}
read-timeout: ${LASTLEG_PALLET_PHYSICAL_READ_TIMEOUT:5s} 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: clients:
domestic-warehouse: domestic-warehouse:
url: ${DOMESTIC_WAREHOUSE_URL} url: ${DOMESTIC_WAREHOUSE_URL}
cache-ttl: ${DOMESTIC_WAREHOUSE_CACHE_TTL:30m} cache-ttl: ${DOMESTIC_WAREHOUSE_CACHE_TTL:30m}
file: file:
url: ${FILE_SERVICE_URL} url: ${FILE_SERVICE_URL}
domestic-file:
url: ${DOMESTIC_FILE_SERVICE_URL}
event-bridge: event-bridge:
url: ${CN_EVENT_BRIDGE_URL:${FIRSTLEG_EVENT_BRIDGE_URL}} url: ${CN_EVENT_BRIDGE_URL:${FIRSTLEG_EVENT_BRIDGE_URL}}
warehouse: warehouse:
+13 -1
View File
@@ -5058,4 +5058,16 @@ DELIMITER ;
CALL assert_relabel_document_columns_ready(); CALL assert_relabel_document_columns_ready();
DROP PROCEDURE IF EXISTS 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;
+1 -1
View File
@@ -41,7 +41,7 @@
| 货柜与全局智能排柜 | `controller/PlatformContainerController.java`、`PlatformSmartLoadingController.java`、`service/SmartLoadingService.java`、`repository/SmartLoadingRepository.java`、`client/PendingReviewOrderClient.java`、`loading/`;全局候选合并本服务履约投影与 Order `/internal/order/v1/pending-review-orders` 权威待审核页,工作台柜次从 `/inbound-candidates`、`/containers` 进入;普通箱数、待审核占位、同柜组和全部未拣移除分别走 `/inbound-assignments`、`/pending-review-assignments`、`/group-assignments`、`/unpicked-items`,明细规格拆分留在服务端 | | 货柜与全局智能排柜 | `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/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,相同键不同内容返回冲突 | | 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` | | 海外卸柜完成事件回写 | 内部入口 `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/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` | | 海外派送完成回写 | 同一内部事件入口、消费者 `service/OverseasDeliveryCompletedConsumer.java`;按来源入仓单、订单、入仓码和 FBA 仓校验后推进当前入仓单 `LOADED→DELIVERED`,同一 Order 全部有效入仓单完成后才经 `OrderFulfillmentCompletionClient` 推进 Order `COMPLETED` |
@@ -10,6 +10,7 @@ import com.baiyue.common.event.EventPayloadCanonicalizer;
import com.baiyue.common.event.EventPayloadHash; import com.baiyue.common.event.EventPayloadHash;
import com.baiyue.common.event.EventTypes; import com.baiyue.common.event.EventTypes;
import com.baiyue.common.event.FirstLegLoadingPlanCompletedV2; import com.baiyue.common.event.FirstLegLoadingPlanCompletedV2;
import com.baiyue.common.event.FirstLegLoadingPlanCompletedV3;
import com.baiyue.common.event.RegionCode; import com.baiyue.common.event.RegionCode;
import com.baiyue.firstleg.entity.InboundOutboxEntity; import com.baiyue.firstleg.entity.InboundOutboxEntity;
import com.baiyue.firstleg.mapper.InboundOutboxMapper; 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.LoadingCartonManifestMapper;
import com.baiyue.firstleg.mapper.LoadingPalletLabelMapper; import com.baiyue.firstleg.mapper.LoadingPalletLabelMapper;
import com.baiyue.firstleg.mapper.LoadingCartonManifestMapper.LabelRow; import com.baiyue.firstleg.mapper.LoadingCartonManifestMapper.LabelRow;
import com.baiyue.firstleg.loading.mapper.CabinetLoadingEvidenceMapper;
import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
@@ -42,11 +44,12 @@ import java.util.Set;
/** 在装柜完成事务内按客户及可信客户名称快照写跨区业务 Outbox。 */ /** 在装柜完成事务内按客户及可信客户名称快照写跨区业务 Outbox。 */
@Service @Service
public class LoadingCompletedOutboxService { public class LoadingCompletedOutboxService {
private static final long HANDOVER_VERSION = 2L; private static final long HANDOVER_VERSION = 3L;
@Resource private LoadingCompletedEventMapper snapshotMapper; @Resource private LoadingCompletedEventMapper snapshotMapper;
@Resource private LoadingCartonManifestMapper cartonManifestMapper; @Resource private LoadingCartonManifestMapper cartonManifestMapper;
@Resource private LoadingPalletLabelMapper palletLabelMapper; @Resource private LoadingPalletLabelMapper palletLabelMapper;
@Resource private LoadingPalletLabelService palletLabelService; @Resource private LoadingPalletLabelService palletLabelService;
@Resource private CabinetLoadingEvidenceMapper cabinetEvidenceMapper;
@Resource private InboundOutboxMapper outboxMapper; @Resource private InboundOutboxMapper outboxMapper;
@Resource private ObjectMapper objectMapper; @Resource private ObjectMapper objectMapper;
@@ -110,21 +113,42 @@ public class LoadingCompletedOutboxService {
.filter(item -> Objects.equals(item.getSourceInboundId(), value.get(0).getSourceInboundId())).toList(), .filter(item -> Objects.equals(item.getSourceInboundId(), value.get(0).getSourceInboundId())).toList(),
standardLabels.stream().filter(item -> Objects.equals(item.getInboundId(), value.get(0).getSourceInboundId())).toList(), standardLabels.stream().filter(item -> Objects.equals(item.getInboundId(), value.get(0).getSourceInboundId())).toList(),
cartonMarks, palletManifests)).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()), first.getPlanNo(), string(first.getContainerBookingId()),
ContainerNumbers.requireCanonical(first.getContainerNo()), first.getCabinetNo(), ContainerNumbers.requireCanonical(first.getContainerNo()), first.getCabinetNo(),
string(first.getWarehouseId()), first.getWarehouseName(), iso(first.getEstimatedPortArrivalAt()), 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); JsonNode payloadNode = objectMapper.valueToTree(payload);
String hash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payloadNode)); String hash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payloadNode));
String eventKey = "FIRSTLEG_LOADING_COMPLETED:" + first.getLoadingPlanId() + ':' + accountId + ':' + HANDOVER_VERSION; String eventKey = "FIRSTLEG_LOADING_COMPLETED:" + first.getLoadingPlanId() + ':' + accountId + ':' + HANDOVER_VERSION;
String traceId = EventIdentifiers.newUuid(); 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", Instant.now(), RegionCode.CN, RegionCode.OVERSEAS, String.valueOf(accountId), "LOADING_PLAN",
String.valueOf(first.getLoadingPlanId()), first.getAggregateVersion(), HANDOVER_VERSION, String.valueOf(first.getLoadingPlanId()), first.getAggregateVersion(), HANDOVER_VERSION,
traceId, eventKey, null, payloadNode, hash).requireValid(); traceId, eventKey, null, payloadNode, hash).requireValid();
} }
private List<FirstLegLoadingPlanCompletedV3.LoadingEvidence> loadingEvidence(Long containerBookingId) {
if (containerBookingId == null || containerBookingId <= 0) {
throw new BizException(ResultCode.CONFLICT, "装柜计划缺少柜次,不能冻结装柜照片");
}
List<FirstLegLoadingPlanCompletedV3.LoadingEvidence> 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<String> 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<Row> values, List<PalletRow> palletRows, private FirstLegLoadingPlanCompletedV2.Order order(List<Row> values, List<PalletRow> palletRows,
List<com.baiyue.firstleg.mapper.LoadingPalletLabelMapper.LabelRow> standardLabels, List<com.baiyue.firstleg.mapper.LoadingPalletLabelMapper.LabelRow> standardLabels,
Map<Long, List<String>> cartonMarks, Map<Long, List<String>> cartonMarks,
@@ -10,6 +10,8 @@ import com.baiyue.firstleg.mapper.LoadingCompletedEventMapper.PalletRow;
import com.baiyue.firstleg.mapper.LoadingCartonManifestMapper; import com.baiyue.firstleg.mapper.LoadingCartonManifestMapper;
import com.baiyue.firstleg.mapper.LoadingCartonManifestMapper.LabelRow; import com.baiyue.firstleg.mapper.LoadingCartonManifestMapper.LabelRow;
import com.baiyue.firstleg.mapper.LoadingPalletLabelMapper; 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 com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -33,6 +35,7 @@ class LoadingCompletedOutboxServiceTest {
private final LoadingCartonManifestMapper manifests = mock(LoadingCartonManifestMapper.class); private final LoadingCartonManifestMapper manifests = mock(LoadingCartonManifestMapper.class);
private final LoadingPalletLabelMapper palletLabels = mock(LoadingPalletLabelMapper.class); private final LoadingPalletLabelMapper palletLabels = mock(LoadingPalletLabelMapper.class);
private final LoadingPalletLabelService palletLabelService = mock(LoadingPalletLabelService.class); private final LoadingPalletLabelService palletLabelService = mock(LoadingPalletLabelService.class);
private final CabinetLoadingEvidenceMapper cabinetEvidence = mock(CabinetLoadingEvidenceMapper.class);
private final ObjectMapper mapper = new ObjectMapper().findAndRegisterModules(); private final ObjectMapper mapper = new ObjectMapper().findAndRegisterModules();
private final LoadingCompletedOutboxService service = new LoadingCompletedOutboxService(); private final LoadingCompletedOutboxService service = new LoadingCompletedOutboxService();
@@ -41,14 +44,25 @@ class LoadingCompletedOutboxServiceTest {
ReflectionTestUtils.setField(service,"cartonManifestMapper",manifests); ReflectionTestUtils.setField(service,"cartonManifestMapper",manifests);
ReflectionTestUtils.setField(service,"palletLabelMapper",palletLabels); ReflectionTestUtils.setField(service,"palletLabelMapper",palletLabels);
ReflectionTestUtils.setField(service,"palletLabelService",palletLabelService); ReflectionTestUtils.setField(service,"palletLabelService",palletLabelService);
ReflectionTestUtils.setField(service,"cabinetEvidenceMapper",cabinetEvidence);
ReflectionTestUtils.setField(service,"outboxMapper",outbox); ReflectionTestUtils.setField(service,"outboxMapper",outbox);
ReflectionTestUtils.setField(service,"objectMapper",mapper); ReflectionTestUtils.setField(service,"objectMapper",mapper);
when(palletLabels.labels(anyLong())).thenReturn(List.of()); 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( when(manifests.labelPool(anyLong(), anyLong())).thenAnswer(invocation -> List.of(
label(101, "BOX-101"), label(102, "BOX-102"), label(101, "BOX-101"), label(102, "BOX-102"),
label(202, "BOX-202"), label(302, "BOX-302"))); 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 { @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.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( 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("/actualPortArrivalAt").asText()).isEqualTo("2026-10-01T08:30");
assertThat(first.payload().at("/estimatedWarehouseArrivalAt").asText()).isEqualTo("2026-10-03T09:00"); assertThat(first.payload().at("/estimatedWarehouseArrivalAt").asText()).isEqualTo("2026-10-03T09:00");
assertThat(first.payload().at("/sourceContainerBookingId").asText()).isEqualTo("91"); 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(first.payload().has("containerBookingId")).isFalse();
assertThat(rows.get(0).getEventKey()).isEqualTo("FIRSTLEG_LOADING_COMPLETED:90:11:2"); assertThat(rows.get(0).getEventKey()).isEqualTo("FIRSTLEG_LOADING_COMPLETED:90:11:3");
assertThat(first.eventVersion()).isEqualTo(2); assertThat(first.eventVersion()).isEqualTo(3);
} }
@Test void requiresFrozenPhysicalContainerNumberBeforePublishing() { @Test void requiresFrozenPhysicalContainerNumberBeforePublishing() {
@@ -39,7 +39,7 @@ class TaillegRealProducerAcceptanceTest {
@Test @Test
void completedLoadingPlanPublishesFirstLegAndDropshipOrders() { 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 existing = jdbc.queryForObject("SELECT COUNT(*) FROM fl_inbound_outbox WHERE event_key=?",
Integer.class, eventKey); Integer.class, eventKey);
if (existing == 0) { if (existing == 0) {
@@ -51,6 +51,8 @@ class TaillegRealProducerAcceptanceTest {
assertEquals(2, jdbc.queryForObject("SELECT JSON_LENGTH(JSON_EXTRACT(payload_json,'$.payload.orders')) " + assertEquals(2, jdbc.queryForObject("SELECT JSON_LENGTH(JSON_EXTRACT(payload_json,'$.payload.orders')) " +
"FROM fl_inbound_outbox WHERE event_key=?", Integer.class, "FROM fl_inbound_outbox WHERE event_key=?", Integer.class,
eventKey)); 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( assertEquals("真实验收客户", jdbc.queryForObject(
"SELECT JSON_UNQUOTE(JSON_EXTRACT(payload_json,'$.payload.orders[0].customerName')) " + "SELECT JSON_UNQUOTE(JSON_EXTRACT(payload_json,'$.payload.orders[0].customerName')) " +
"FROM fl_inbound_outbox WHERE event_key=?", String.class, eventKey)); "FROM fl_inbound_outbox WHERE event_key=?", String.class, eventKey));
@@ -91,6 +93,15 @@ class TaillegRealProducerAcceptanceTest {
990022901L, PLAN, BOOKING, MARKER + "-CAB", MARKER + "-CNTR"); 990022901L, PLAN, BOOKING, MARKER + "-CAB", MARKER + "-CNTR");
seedPickItem(990022911L, 990022321L, FIRST_INBOUND, 990022421L, 3); seedPickItem(990022911L, 990022321L, FIRST_INBOUND, 990022421L, 3);
seedPickItem(990022912L, 990022322L, DROPSHIP_INBOUND, 990022422L, 2); 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); jdbc.update("UPDATE fl_loading_plan SET plan_status='COMPLETED',confirmed_at=NOW(3),version=version+1 WHERE id=?", PLAN);
} }
+3 -2
View File
@@ -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`;只约束本服务内部包依赖,不改变部署单元或数据库边界 | | 内部模块化基线与依赖分析 | `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/编码/名称一致 | | 事件接收、平台订单列表/详情/导出、柜级进度、目的仓调整 | `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` | | 客户尾程订单列表与详情 | `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/LastlegOrderNoGenerator.java` |
| 订单查询与更新 | `service/LastlegOrderService.java` | | 订单查询与更新 | `service/LastlegOrderService.java` |
| 业务 Inbox | `entity/LastlegEventInbox.java`、`mapper/LastlegEventInboxMapper.java` | | 业务 Inbox | `entity/LastlegEventInbox.java`、`mapper/LastlegEventInboxMapper.java` |
@@ -42,6 +42,7 @@
| FBA 预约创建、详情与编辑 | `InboundPlanController.java` 的 `/fba-appointments/**`、`inboundplan/service/InboundFbaAppointmentService.java`、`BatchFbaAppointmentRequest.java`、`UpdateFbaAppointmentRequest.java` | | 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 入仓、实收、上架与完成 | `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/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` | | 卸柜照片与海外卸柜完成 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` 应收费用来源,不直接改国内钱包 | | 拆柜打托成本与客户应收分摊 | `inboundplan/service/DestuffingPalletCostJob.java`、`DestuffingPalletCostService.java`;仅全柜所有 PDA 入仓任务完成后执行,按实物托盘单价汇总并按客户实收 CBM 分摊,尾差落最后一位客户;每份分摊发布 `OVERSEAS_CONTAINER_FEE/DESTUFF_PALLET` 应收费用来源,不直接改国内钱包 |
| 尾程暂扣状态回传 | `service/InboundOrderDetainedOutboxService.java`;暂扣与解除暂扣在订单更新事务内按 `sourceInboundId` 写入海外到国内 Outbox,国内按来源入仓单绑定更新 | | 尾程暂扣状态回传 | `service/InboundOrderDetainedOutboxService.java`;暂扣与解除暂扣在订单更新事务内按 `sourceInboundId` 写入海外到国内 Outbox,国内按来源入仓单绑定更新 |
@@ -14,6 +14,7 @@ public class LastlegRestClientProperties {
private ClientTimeouts procurementFile = new ClientTimeouts(Duration.ofSeconds(3), Duration.ofSeconds(30)); private ClientTimeouts procurementFile = new ClientTimeouts(Duration.ofSeconds(3), Duration.ofSeconds(30));
private ClientTimeouts warehouseResource = new ClientTimeouts(Duration.ofSeconds(3), Duration.ofSeconds(5)); private ClientTimeouts warehouseResource = new ClientTimeouts(Duration.ofSeconds(3), Duration.ofSeconds(5));
private ClientTimeouts palletPhysical = 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 ClientTimeouts getUnloadPhotoFile() { return unloadPhotoFile; }
public void setUnloadPhotoFile(ClientTimeouts value) { unloadPhotoFile = required(value, "unload-photo-file"); } 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 void setWarehouseResource(ClientTimeouts value) { warehouseResource = required(value, "warehouse-resource"); }
public ClientTimeouts getPalletPhysical() { return palletPhysical; } public ClientTimeouts getPalletPhysical() { return palletPhysical; }
public void setPalletPhysical(ClientTimeouts value) { palletPhysical = required(value, "pallet-physical"); } 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) { private static ClientTimeouts required(ClientTimeouts value, String client) {
if (value == null) throw new IllegalArgumentException(client + " HTTP timeouts are required"); if (value == null) throw new IllegalArgumentException(client + " HTTP timeouts are required");
@@ -5,13 +5,11 @@ import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size; import jakarta.validation.constraints.Size;
import java.time.LocalDate;
public record ManualInboundPlanRequest( public record ManualInboundPlanRequest(
@NotBlank @Size(max = 128) String customerId, @NotBlank @Size(max = 128) String customerId,
@NotNull Long orderId, @NotNull Long orderId,
@NotNull @Min(0) Integer expectedOrderVersion, @NotNull @Min(0) Integer expectedOrderVersion,
@NotNull LocalDate plannedArrivalDate,
@NotNull @Min(1) @Max(999) Integer palletCount, @NotNull @Min(1) @Max(999) Integer palletCount,
@NotBlank @Size(max = 32) String palletType, @NotBlank @Size(max = 32) String palletType,
@NotNull Boolean assigned, @NotNull Boolean assigned,
@@ -75,6 +75,11 @@ public interface InboundPlanOrderMapper {
String findSourceContainerBookingId(@Param("customerId") String customerId, String findSourceContainerBookingId(@Param("customerId") String customerId,
@Param("orderId") Long orderId); @Param("orderId") Long orderId);
@Select("SELECT estimated_warehouse_at FROM ll_order " +
"WHERE source_container_booking_id=#{sourceContainerBookingId} ORDER BY customer_id,id")
List<LocalDateTime> containerEstimatedWarehouseTimes(
@Param("sourceContainerBookingId") String sourceContainerBookingId);
@Select("SELECT order_type FROM ll_order WHERE customer_id=#{customerId} AND id=#{orderId} LIMIT 1") @Select("SELECT order_type FROM ll_order WHERE customer_id=#{customerId} AND id=#{orderId} LIMIT 1")
String findOrderType(@Param("customerId") String customerId, String findOrderType(@Param("customerId") String customerId,
@Param("orderId") Long orderId); @Param("orderId") Long orderId);
@@ -91,6 +91,18 @@ public class ReceivingContainerController {
.contentLength(download.content().length).body(download.content()); .contentLength(download.content().length).body(download.content());
} }
@GetMapping("/current/domestic-loading-photos/{evidenceType}/content")
public ResponseEntity<byte[]> 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") @PostMapping("/current/sign")
public ApiResponse<ReceivingContainerResponses.Session> sign(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<ReceivingContainerResponses.Session> sign(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody ReceivingContainerRequests.Sign request) { @Valid @RequestBody ReceivingContainerRequests.Sign request) {
@@ -32,7 +32,8 @@ public final class ReceivingContainerResponses {
public record Session(String containerNo, Long overseasWarehouseId, String overseasWarehouseCode, public record Session(String containerNo, Long overseasWarehouseId, String overseasWarehouseCode,
String overseasWarehouseName, String overseasWarehouseName,
String status, String operatorName, int version, String status, String operatorName, int version,
List<SignedPhoto> photos, LocalDateTime preparedAt, LocalDateTime signedAt, List<SignedPhoto> photos, List<DomesticLoadingPhoto> domesticLoadingPhotos,
LocalDateTime preparedAt, LocalDateTime signedAt,
LocalDateTime receivingAt, LocalDateTime completedAt, LocalDateTime receivingAt, LocalDateTime completedAt,
int totalPackages, int receivedPackages, int pendingPackages, int totalPackages, int receivedPackages, int pendingPackages,
boolean hasDifference, List<Inbound> inbounds) { boolean hasDifference, List<Inbound> inbounds) {
@@ -42,7 +43,7 @@ public final class ReceivingContainerResponses {
LocalDateTime completedAt, int totalPackages, int receivedPackages, LocalDateTime completedAt, int totalPackages, int receivedPackages,
int pendingPackages, List<Inbound> inbounds) { int pendingPackages, List<Inbound> inbounds) {
this(containerNo, overseasWarehouseId, null, overseasWarehouseName, status, operatorName, 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); receivedPackages, pendingPackages, false, inbounds);
} }
@@ -52,13 +53,15 @@ public final class ReceivingContainerResponses {
LocalDateTime receivingAt, LocalDateTime completedAt, int totalPackages, LocalDateTime receivingAt, LocalDateTime completedAt, int totalPackages,
int receivedPackages, int pendingPackages, List<Inbound> inbounds) { int receivedPackages, int pendingPackages, List<Inbound> inbounds) {
this(containerNo, overseasWarehouseId, overseasWarehouseCode, overseasWarehouseName, 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); completedAt, totalPackages, receivedPackages, pendingPackages, false, inbounds);
} }
} }
public record SignedPhoto(ReceivingPhotoRole role, String fileId, String downloadPath) { } 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, public record Inbound(String inboundCode, String destinationType,
String overseasWarehouseId, String overseasWarehouseName, String overseasWarehouseId, String overseasWarehouseName,
String destinationWarehouseId, String destinationWarehouseId,
@@ -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;
}
@@ -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) { }
}
@@ -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<byte[]> 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);
}
}
@@ -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<ContainerLoadingEvidence> {
@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<ContainerLoadingEvidence> 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);
}
@@ -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.ReceivingFbaPalletCount;
import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingSkuReceipt; import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingSkuReceipt;
import com.baiyue.lastleg.inboundplan.pda.gateway.UnloadPhotoFileGateway; 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.gateway.WarehouseResourceGateway;
import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingContainerOperationMapper; import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingContainerOperationMapper;
import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingContainerSessionMapper; import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingContainerSessionMapper;
import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingFbaPalletCountMapper; import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingFbaPalletCountMapper;
import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingSkuReceiptMapper; 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.LastlegOrderDetailMapper;
import com.baiyue.lastleg.mapper.LastlegOrderMapper; import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper; 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 PLATFORM_SCOPE = "0";
private static final String PHOTO_DOWNLOAD_PATH = private static final String PHOTO_DOWNLOAD_PATH =
"/api/pda/v1/last-leg/receiving-containers/current/unload-photos/files/{fileId}/download"; "/api/pda/v1/last-leg/receiving-containers/current/unload-photos/files/{fileId}/download";
private static final String DOMESTIC_PHOTO_DOWNLOAD_PATH =
"/api/pda/v1/last-leg/receiving-containers/current/domestic-loading-photos/{type}/content";
private static final String PALLET_MERGE_HINT = private static final String PALLET_MERGE_HINT =
"该入库计划可选择合托:合托时扫描同目的仓已使用的托盘码,独立收货时扫描新托盘码;不强制合托"; "该入库计划可选择合托:合托时扫描同目的仓已使用的托盘码,独立收货时扫描新托盘码;不强制合托";
@@ -86,6 +90,8 @@ public class ReceivingContainerService {
@Resource private DropshipOrderProfileMapper dropshipProfiles; @Resource private DropshipOrderProfileMapper dropshipProfiles;
@Resource private WarehouseResourceGateway warehouse; @Resource private WarehouseResourceGateway warehouse;
@Resource private UnloadPhotoFileGateway unloadPhotos; @Resource private UnloadPhotoFileGateway unloadPhotos;
@Resource private DomesticLoadingEvidenceGateway domesticEvidenceFiles;
@Resource private ContainerLoadingEvidenceMapper domesticEvidence;
@Resource private InboundPdaWorkflowService workflow; @Resource private InboundPdaWorkflowService workflow;
@Resource private ObjectMapper sourceObjectMapper; @Resource private ObjectMapper sourceObjectMapper;
@@ -247,6 +253,19 @@ public class ReceivingContainerService {
return unloadPhotos.download(PLATFORM_SCOPE, session.getId(), parsedFileId); 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) { public void requireCurrentWarehouse(String operatorKey, long overseasWarehouseId) {
requireSessionWarehouse(sessions.findActiveByOperator(activeOperatorKey(operatorKey)), overseasWarehouseId); requireSessionWarehouse(sessions.findActiveByOperator(activeOperatorKey(operatorKey)), overseasWarehouseId);
} }
@@ -983,11 +1002,41 @@ public class ReceivingContainerService {
int received = inbounds.stream().mapToInt(ReceivingContainerResponses.Inbound::receivedPackages).sum(); int received = inbounds.stream().mapToInt(ReceivingContainerResponses.Inbound::receivedPackages).sum();
return new ReceivingContainerResponses.Session(session.getContainerNo(), session.getOverseasWarehouseId(), return new ReceivingContainerResponses.Session(session.getContainerNo(), session.getOverseasWarehouseId(),
session.getOverseasWarehouseCode(), session.getOverseasWarehouseName(), session.getStatus(), session.getOperatorName(), 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), session.getReceivingAt(), session.getCompletedAt(), total, received, Math.max(0, total - received),
hasFbaOverReceipt(session, containerPlans), inbounds); hasFbaOverReceipt(session, containerPlans), inbounds);
} }
private List<ReceivingContainerResponses.DomesticLoadingPhoto> 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, private void savePhysicalFbaCount(long sessionId, String groupKey, String palletCode, int actualPackages,
ReceivingFbaPalletCount existing) { ReceivingFbaPalletCount existing) {
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
@@ -38,6 +38,7 @@ import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.security.MessageDigest; import java.security.MessageDigest;
@@ -103,6 +104,8 @@ public class InboundPlanService {
} }
String containerWarehouseId = requireSingleWarehouseId(containerFacts.stream() String containerWarehouseId = requireSingleWarehouseId(containerFacts.stream()
.map(InboundPlanOrderMapper.ContainerWarehouseRef::overseasWarehouseId).toList()); .map(InboundPlanOrderMapper.ContainerWarehouseRef::overseasWarehouseId).toList());
LocalDate plannedArrivalDate = requiredContainerPlannedArrivalDate(
containerFacts.get(0).sourceContainerBookingId());
List<SourceOrderSnapshot> snapshots = orderMapper.lockOrder(customerId, request.orderId()); List<SourceOrderSnapshot> snapshots = orderMapper.lockOrder(customerId, request.orderId());
if (snapshots == null || snapshots.size() != 1) { if (snapshots == null || snapshots.size() != 1) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "尾程订单不存在"); throw new ResponseStatusException(HttpStatus.NOT_FOUND, "尾程订单不存在");
@@ -152,7 +155,7 @@ public class InboundPlanService {
"人工计划托数不能少于柜级预览建议托数 " + minimumPalletCount); "人工计划托数不能少于柜级预览建议托数 " + minimumPalletCount);
} }
InboundPlanResponse response = createPlan(customerId, request.orderId(), snapshots, List.of(), null, InboundPlanResponse response = createPlan(customerId, request.orderId(), snapshots, List.of(), null,
request, receivingWarehouse); request, receivingWarehouse, plannedArrivalDate);
LastlegOrderCommandRecord command = new LastlegOrderCommandRecord(); LastlegOrderCommandRecord command = new LastlegOrderCommandRecord();
command.setId(IdWorker.getId()); command.setCustomerId(customerId); command.setOrderId(request.orderId()); command.setId(IdWorker.getId()); command.setCustomerId(customerId); command.setOrderId(request.orderId());
command.setAction("MANUAL_PLAN"); command.setOperatorId(operatorId); command.setIdempotencyKey(key); command.setAction("MANUAL_PLAN"); command.setOperatorId(operatorId); command.setIdempotencyKey(key);
@@ -426,21 +429,25 @@ public class InboundPlanService {
return detail(customerId, existing); return detail(customerId, existing);
} }
validateOrders(orders); validateOrders(orders);
return createPlan(customerId, selectedOrderId, orders, sourcePalletNos, wholePallets, null, null); 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<SourceOrderSnapshot> orders, List<String> sourcePalletNos,
List<WholePalletInput> wholePallets, ManualInboundPlanRequest manual,
WarehouseResourceGateway.WarehouseReference receivingWarehouse) {
return createPlan(customerId, selectedOrderId, orders, sourcePalletNos, wholePallets, manual,
receivingWarehouse, null, Map.of());
} }
private InboundPlanResponse createPlan(String customerId, Long selectedOrderId, private InboundPlanResponse createPlan(String customerId, Long selectedOrderId,
List<SourceOrderSnapshot> orders, List<String> sourcePalletNos, List<SourceOrderSnapshot> orders, List<String> sourcePalletNos,
List<WholePalletInput> wholePallets, ManualInboundPlanRequest manual, List<WholePalletInput> wholePallets, ManualInboundPlanRequest manual,
WarehouseResourceGateway.WarehouseReference receivingWarehouse, 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<SourceOrderSnapshot> orders, List<String> sourcePalletNos,
List<WholePalletInput> wholePallets, ManualInboundPlanRequest manual,
WarehouseResourceGateway.WarehouseReference receivingWarehouse,
LocalDate plannedArrivalDate,
PreparedPlan preparedPlan, PreparedPlan preparedPlan,
Map<Integer, WarehouseResourceGateway.PalletBatchItem> reservedPallets) { Map<Integer, WarehouseResourceGateway.PalletBatchItem> reservedPallets) {
String containerNo = requiredContainerNo(orders.get(0).containerNo()); String containerNo = requiredContainerNo(orders.get(0).containerNo());
@@ -463,7 +470,7 @@ public class InboundPlanService {
throw new ResponseStatusException(HttpStatus.CONFLICT, "接柜仓ID格式无效", exception); throw new ResponseStatusException(HttpStatus.CONFLICT, "接柜仓ID格式无效", exception);
} }
plan.setStatus("PENDING_ASSIGNMENT"); plan.setStatus("PENDING_ASSIGNMENT");
plan.setPlannedArrivalDate(manual == null ? null : manual.plannedArrivalDate()); plan.setPlannedArrivalDate(plannedArrivalDate);
plan.setPalletType(manual == null ? null : manual.palletType().trim().toUpperCase()); plan.setPalletType(manual == null ? null : manual.palletType().trim().toUpperCase());
plan.setAssignedFlag(manual != null && manual.assigned()); plan.setAssignedFlag(manual != null && manual.assigned());
plan.setMergeRequired(manual != null && manual.mergeRequired()); plan.setMergeRequired(manual != null && manual.mergeRequired());
@@ -550,6 +557,7 @@ public class InboundPlanService {
if (bookingId == null) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "来源柜预订 ID 不能为空"); if (bookingId == null) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "来源柜预订 ID 不能为空");
List<InboundPlanOrderMapper.ContainerOrderRef> refs = orderMapper.lockContainerOrders(bookingId); List<InboundPlanOrderMapper.ContainerOrderRef> refs = orderMapper.lockContainerOrders(bookingId);
if (refs.isEmpty()) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "来源柜没有尾程订单"); if (refs.isEmpty()) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "来源柜没有尾程订单");
LocalDate plannedArrivalDate = requiredContainerPlannedArrivalDate(bookingId);
if (snapshotVersion != null) { if (snapshotVersion != null) {
ContainerInboundPlanPreviewResponse current = previewContainer(bookingId); ContainerInboundPlanPreviewResponse current = previewContainer(bookingId);
if (current.snapshotVersion() != snapshotVersion || !Objects.equals(current.snapshotHash(), snapshotHash) if (current.snapshotVersion() != snapshotVersion || !Objects.equals(current.snapshotHash(), snapshotHash)
@@ -639,7 +647,7 @@ public class InboundPlanService {
.map(value -> value.reservation().palletCode()).toList(); .map(value -> value.reservation().palletCode()).toList();
} }
created.put(refIndex, createPlan(ref.customerId(), ref.orderId(), snapshots.get(pendingIndex), effectivePalletNos, 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++) { for (int index = 0; index < refs.size(); index++) {
InboundPlan existing = existingPlans.get(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<LocalDateTime> 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) { private void requirePlanContainer(InboundPlan plan, String containerNo) {
String frozen = requiredContainerNo(plan.getContainerNo()); String frozen = requiredContainerNo(plan.getContainerNo());
if (!Objects.equals(containerNo, frozen)) { if (!Objects.equals(containerNo, frozen)) {
@@ -7,6 +7,8 @@ import com.baiyue.lastleg.dropship.dto.DropshipRequests;
import com.baiyue.lastleg.dropship.service.DropshipOrderService; import com.baiyue.lastleg.dropship.service.DropshipOrderService;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton; import com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanCartonMapper; 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.dto.InventoryCommandRequests;
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService; import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
import com.baiyue.lastleg.shipping.entity.ContainerShippingGate; import com.baiyue.lastleg.shipping.entity.ContainerShippingGate;
@@ -46,12 +48,13 @@ public class LoadingCompletedConsumer {
@Resource private InventoryCommandService inventory; @Resource private InventoryCommandService inventory;
@Resource private ContainerShippingGateMapper shippingGates; @Resource private ContainerShippingGateMapper shippingGates;
@Resource private LastlegSourcePalletMapper sourcePallets; @Resource private LastlegSourcePalletMapper sourcePallets;
@Resource private ContainerLoadingEvidenceMapper loadingEvidence;
@Transactional(isolation = Isolation.READ_COMMITTED) @Transactional(isolation = Isolation.READ_COMMITTED)
public int consume(EventEnvelope envelope) { public int consume(EventEnvelope envelope) {
envelope.requireValid(); envelope.requireValid();
if (!EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED.equals(envelope.eventType()) 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("不支持的尾程交接事件"); throw new IllegalArgumentException("不支持的尾程交接事件");
if (envelope.sourceRegion() != RegionCode.CN || envelope.targetRegion() != RegionCode.OVERSEAS) if (envelope.sourceRegion() != RegionCode.CN || envelope.targetRegion() != RegionCode.OVERSEAS)
throw new IllegalArgumentException("尾程交接事件区域无效"); throw new IllegalArgumentException("尾程交接事件区域无效");
@@ -64,7 +67,7 @@ public class LoadingCompletedConsumer {
String sourceContainerBookingId = sourceContainerBookingId(envelope, payload); String sourceContainerBookingId = sourceContainerBookingId(envelope, payload);
if(payload.orders()==null||payload.orders().isEmpty()) throw new IllegalArgumentException("事件没有入仓单"); if(payload.orders()==null||payload.orders().isEmpty()) throw new IllegalArgumentException("事件没有入仓单");
OwnerSnapshot owner = requireOwnerSnapshot(payload.orders(), envelope.tenantId()); 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); freezeContainerIdentity(envelope, sourceContainerBookingId, containerNo);
LastlegEventInbox existing=inboxMapper.selectById(envelope.eventId()); LastlegEventInbox existing=inboxMapper.selectById(envelope.eventId());
if(existing!=null) { if(existing!=null) {
@@ -85,6 +88,8 @@ public class LoadingCompletedConsumer {
return countOrders(envelope,containerNo); return countOrders(envelope,containerNo);
} }
projectLoadingEvidence(envelope, sourceContainerBookingId);
// The loading-plan and container-shipping events are independent aggregate streams. // 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 // 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. // (aggregateVersion=0) look stale whenever the loading plan has a higher version.
@@ -100,6 +105,45 @@ public class LoadingCompletedConsumer {
return payload.orders().size(); 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<String> expected = Set.of("PHOTO_STEP_1", "PHOTO_STEP_2", "PHOTO_STEP_3", "PHOTO_STEP_4", "SEAL");
Set<String> 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) { private FirstLegLoadingPlanCompletedV1 parsePayload(EventEnvelope envelope) {
try { try {
ObjectMapper compatibleMapper = objectMapper.copy() ObjectMapper compatibleMapper = objectMapper.copy()
@@ -134,7 +178,7 @@ public class LoadingCompletedConsumer {
} }
private String sourceContainerBookingId(EventEnvelope envelope, FirstLegLoadingPlanCompletedV1 payload) { private String sourceContainerBookingId(EventEnvelope envelope, FirstLegLoadingPlanCompletedV1 payload) {
String value = envelope.eventVersion() == 2 String value = envelope.eventVersion() >= 2
? envelope.payload().path("sourceContainerBookingId").asText(null) ? envelope.payload().path("sourceContainerBookingId").asText(null)
: payload.containerBookingId(); : payload.containerBookingId();
if (value == null || value.isBlank() || value.trim().length() > 128) { if (value == null || value.isBlank() || value.trim().length() > 128) {
@@ -66,6 +66,9 @@ baiyue:
pallet-physical: pallet-physical:
connect-timeout: ${LASTLEG_PALLET_PHYSICAL_CONNECT_TIMEOUT:3s} connect-timeout: ${LASTLEG_PALLET_PHYSICAL_CONNECT_TIMEOUT:3s}
read-timeout: ${LASTLEG_PALLET_PHYSICAL_READ_TIMEOUT:5s} 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: clients:
print: print:
url: ${PRINT_SERVICE_URL:} url: ${PRINT_SERVICE_URL:}
@@ -74,6 +77,8 @@ baiyue:
cache-ttl: ${DOMESTIC_WAREHOUSE_CACHE_TTL:30m} cache-ttl: ${DOMESTIC_WAREHOUSE_CACHE_TTL:30m}
file: file:
url: ${FILE_SERVICE_URL:http://127.0.0.1:9205} 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: event-bridge:
url: ${CN_EVENT_BRIDGE_URL:${FIRSTLEG_EVENT_BRIDGE_URL:}} url: ${CN_EVENT_BRIDGE_URL:${FIRSTLEG_EVENT_BRIDGE_URL:}}
firstleg: firstleg:
@@ -2542,3 +2542,15 @@ CREATE TABLE IF NOT EXISTS ll_return_inventory_operation (
id BIGINT NOT NULL COMMENT '退货库存PDA操作ID', operator_id VARCHAR(128) NOT NULL COMMENT 'PDA操作人ID', idempotency_key VARCHAR(255) NOT NULL COMMENT '操作幂等键', request_hash CHAR(64) NOT NULL COMMENT '请求内容SHA-256', operation_type VARCHAR(32) NOT NULL COMMENT '操作类型,当前MERGE', result_carton_id BIGINT NULL COMMENT '合箱结果退货库存箱ID', created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间', 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) 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合箱幂等操作审计'; ) 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='海外拆柜只读国内装柜照片引用';
@@ -80,6 +80,22 @@ paths:
application/octet-stream: { schema: { type: string, format: binary } } application/octet-stream: { schema: { type: string, format: binary } }
'403': { description: fileId 未绑定到当前操作员的当前柜会话三类签收照片 } '403': { description: fileId 未绑定到当前操作员的当前柜会话三类签收照片 }
'404': { description: 当前柜会话或照片不存在 } '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: /api/pda/v1/last-leg/receiving-containers/current/sign:
post: post:
summary: 签收当前柜 summary: 签收当前柜
@@ -434,7 +450,7 @@ paths:
/api/platform/v1/last-leg/inbound-plans/manual: /api/platform/v1/last-leg/inbound-plans/manual:
post: post:
summary: 平台按单票手工生成入库计划 summary: 平台按单票手工生成入库计划
description: 权限 platform:overseas-warehouse:inbound-order:create;按 customerId 与 orderId 锁定单票,校验订单版本、状态、暂扣状态、真实入仓码、客户归属及有效公共接柜仓。一次只生成当前票的独立计划,不改变同柜其他订单;命令原子且幂等。 description: 权限 platform:overseas-warehouse:inbound-order:create;按 customerId 与 orderId 锁定单票,校验订单版本、状态、暂扣状态、真实入仓码、客户归属及有效公共接柜仓。预计到仓日期由服务端锁定读取来源柜全部订单的 estimatedWarehouseAt,缺失或同柜不一致时拒绝生成,不接受调用方提交。一次只生成当前票的独立计划,不改变同柜其他订单;命令原子且幂等。
security: [{ bearerAuth: [] }] security: [{ bearerAuth: [] }]
parameters: parameters:
- { name: Idempotency-Key, in: header, required: true, schema: { type: string, minLength: 1, maxLength: 128 } } - { name: Idempotency-Key, in: header, required: true, schema: { type: string, minLength: 1, maxLength: 128 } }
@@ -446,7 +462,7 @@ paths:
responses: responses:
'200': { description: 统一 ApiResponse 包装的入库计划详情 } '200': { description: 统一 ApiResponse 包装的入库计划详情 }
'400': { description: 客户、入仓码、仓库主数据或请求字段无效 } '400': { description: 客户、入仓码、仓库主数据或请求字段无效 }
'409': { description: 订单版本、状态、暂扣状态、重复计划或幂等键冲突 } '409': { description: 订单版本、状态、暂扣状态、柜级预计到仓时间、重复计划或幂等键冲突 }
/api/platform/v1/last-leg/inbound-plans/convert-container: /api/platform/v1/last-leg/inbound-plans/convert-container:
post: post:
summary: 按整柜批量生成剩余入库计划 summary: 按整柜批量生成剩余入库计划
@@ -1941,7 +1957,7 @@ components:
currentAccount: { type: boolean } currentAccount: { type: boolean }
ReceivingSession: ReceivingSession:
type: object 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: properties:
containerNo: { type: string } containerNo: { type: string }
overseasWarehouseId: { type: string, pattern: '^\d+$', description: 平台预分配并与PDA Token匹配的海外接柜仓ID } overseasWarehouseId: { type: string, pattern: '^\d+$', description: 平台预分配并与PDA Token匹配的海外接柜仓ID }
@@ -1951,6 +1967,7 @@ components:
operatorName: { type: string, nullable: true } operatorName: { type: string, nullable: true }
version: { type: integer } version: { type: integer }
photos: { type: array, maxItems: 3, items: { $ref: '#/components/schemas/ReceivingSignedSessionPhoto' } } 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 } preparedAt: { type: string, format: date-time, nullable: true }
signedAt: { type: string, format: date-time, nullable: true } signedAt: { type: string, format: date-time, nullable: true }
receivingAt: { type: string, format: date-time, nullable: true } receivingAt: { type: string, format: date-time, nullable: true }
@@ -1968,6 +1985,13 @@ components:
fileName: { type: string } fileName: { type: string }
contentType: { type: string } contentType: { type: string }
sizeBytes: { type: integer, format: int64 } 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: ReceivingCartonResolved:
allOf: allOf:
- { $ref: '#/components/schemas/ReceivingInbound' } - { $ref: '#/components/schemas/ReceivingInbound' }
@@ -2606,13 +2630,12 @@ components:
items: { $ref: '#/components/schemas/LastlegOrderManualNode' } items: { $ref: '#/components/schemas/LastlegOrderManualNode' }
ManualInboundPlanRequest: ManualInboundPlanRequest:
type: object type: object
description: 单票人工计划只能使用所属柜已冻结的接柜仓;接柜仓变更必须通过柜级接口完成。 description: 单票人工计划只能使用所属柜已冻结的接柜仓;接柜仓变更必须通过柜级接口完成。预计到仓日期由服务端读取来源柜冻结的 estimatedWarehouseArrivalAt,同柜所有计划保持一致,不接受人工提交。
required: [customerId, orderId, expectedOrderVersion, plannedArrivalDate, palletCount, palletType, assigned, mergeRequired, urgent] required: [customerId, orderId, expectedOrderVersion, palletCount, palletType, assigned, mergeRequired, urgent]
properties: properties:
customerId: { type: string, pattern: '^[1-9][0-9]*$' } customerId: { type: string, pattern: '^[1-9][0-9]*$' }
orderId: { type: integer, format: int64, minimum: 1 } orderId: { type: integer, format: int64, minimum: 1 }
expectedOrderVersion: { type: integer, minimum: 0 } expectedOrderVersion: { type: integer, minimum: 0 }
plannedArrivalDate: { type: string, format: date }
palletCount: { type: integer, minimum: 1, maximum: 999 } palletCount: { type: integer, minimum: 1, maximum: 999 }
palletType: { type: string, minLength: 1, maxLength: 64 } palletType: { type: string, minLength: 1, maxLength: 64 }
assigned: { type: boolean } assigned: { type: boolean }
@@ -24,8 +24,10 @@ class TailPlanCommandOpenApiContractTest {
int manualSchemaEnd = api.indexOf("\n PageMetadata:", manualSchemaStart); int manualSchemaEnd = api.indexOf("\n PageMetadata:", manualSchemaStart);
assertTrue(manualSchemaStart >= 0 && manualSchemaEnd > manualSchemaStart); assertTrue(manualSchemaStart >= 0 && manualSchemaEnd > manualSchemaStart);
assertFalse(api.substring(manualSchemaStart, manualSchemaEnd).contains("inboundCode")); assertFalse(api.substring(manualSchemaStart, manualSchemaEnd).contains("inboundCode"));
assertFalse(api.substring(manualSchemaStart, manualSchemaEnd).contains("plannedArrivalDate"));
assertFalse(java.util.Arrays.stream(ManualInboundPlanRequest.class.getRecordComponents()) 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:")); assertTrue(api.contains("/api/platform/v1/last-leg/orders/{id}/hold:"));
assertFalse(api.contains("/api/platform/v1/last-leg/orders/{id}/detained:")); assertFalse(api.contains("/api/platform/v1/last-leg/orders/{id}/detained:"));
assertTrue(api.contains("/api/platform/v1/last-leg/orders/{id}/manual-nodes:")); assertTrue(api.contains("/api/platform/v1/last-leg/orders/{id}/manual-nodes:"));
@@ -20,7 +20,6 @@ import org.springframework.web.server.ResponseStatusException;
import java.util.List; import java.util.List;
import java.util.Set; import java.util.Set;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.LocalDate;
import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
@@ -106,6 +105,6 @@ class InboundPlanControllerTest {
private static ManualInboundPlanRequest manualRequest() { private static ManualInboundPlanRequest manualRequest() {
return new ManualInboundPlanRequest("193000000000200003", 9223372036854775807L, 0, return new ManualInboundPlanRequest("193000000000200003", 9223372036854775807L, 0,
LocalDate.of(2026, 9, 8), 2, "STANDARD", false, true, true, "加急入仓"); 2, "STANDARD", false, true, true, "加急入仓");
} }
} }
@@ -3,6 +3,7 @@ package com.baiyue.lastleg.inboundplan.pda.controller;
import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerRequests; import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerRequests;
import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerResponses; 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.gateway.UnloadPhotoFileGateway;
import com.baiyue.lastleg.inboundplan.pda.service.ReceivingContainerService; import com.baiyue.lastleg.inboundplan.pda.service.ReceivingContainerService;
import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity; import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity;
@@ -90,6 +91,21 @@ class ReceivingContainerControllerTest {
verify(service).downloadCurrentPhoto("42", "9999"); 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<byte[]> 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 @Test
void pickerAuthorityWithoutWarehouseBindingIsForbidden() { void pickerAuthorityWithoutWarehouseBindingIsForbidden() {
AuthenticatedUser unbound = new AuthenticatedUser(42L, 0L, 0L, 8L, "receiver", "PLATFORM", "PDA", AuthenticatedUser unbound = new AuthenticatedUser(42L, 0L, 0L, 8L, "receiver", "PLATFORM", "PDA",
@@ -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.ReceivingContainerSession;
import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingFbaPalletCount; import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingFbaPalletCount;
import com.baiyue.lastleg.inboundplan.pda.gateway.UnloadPhotoFileGateway; 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.gateway.WarehouseResourceGateway;
import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingContainerOperationMapper; import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingContainerOperationMapper;
import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingContainerSessionMapper; import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingContainerSessionMapper;
import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingFbaPalletCountMapper; import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingFbaPalletCountMapper;
import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingSkuReceiptMapper; 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.DropshipCartonMapper;
import com.baiyue.lastleg.dropship.mapper.DropshipOrderProfileMapper; import com.baiyue.lastleg.dropship.mapper.DropshipOrderProfileMapper;
import com.baiyue.lastleg.dropship.entity.DropshipCarton; import com.baiyue.lastleg.dropship.entity.DropshipCarton;
@@ -71,6 +73,8 @@ class ReceivingContainerServiceBehaviorTest {
@Mock DropshipOrderProfileMapper dropshipProfiles; @Mock DropshipOrderProfileMapper dropshipProfiles;
@Mock WarehouseResourceGateway warehouse; @Mock WarehouseResourceGateway warehouse;
@Mock UnloadPhotoFileGateway unloadPhotos; @Mock UnloadPhotoFileGateway unloadPhotos;
@Mock DomesticLoadingEvidenceGateway domesticEvidenceFiles;
@Mock ContainerLoadingEvidenceMapper domesticEvidence;
@Mock InboundPdaWorkflowService workflow; @Mock InboundPdaWorkflowService workflow;
private ReceivingContainerService service; private ReceivingContainerService service;
@@ -93,11 +97,14 @@ class ReceivingContainerServiceBehaviorTest {
ReflectionTestUtils.setField(service, "dropshipProfiles", dropshipProfiles); ReflectionTestUtils.setField(service, "dropshipProfiles", dropshipProfiles);
ReflectionTestUtils.setField(service, "warehouse", warehouse); ReflectionTestUtils.setField(service, "warehouse", warehouse);
ReflectionTestUtils.setField(service, "unloadPhotos", unloadPhotos); ReflectionTestUtils.setField(service, "unloadPhotos", unloadPhotos);
ReflectionTestUtils.setField(service, "domesticEvidenceFiles", domesticEvidenceFiles);
ReflectionTestUtils.setField(service, "domesticEvidence", domesticEvidence);
ReflectionTestUtils.setField(service, "workflow", workflow); ReflectionTestUtils.setField(service, "workflow", workflow);
ReflectionTestUtils.setField(service, "sourceObjectMapper", new ObjectMapper().findAndRegisterModules()); ReflectionTestUtils.setField(service, "sourceObjectMapper", new ObjectMapper().findAndRegisterModules());
service.initializeObjectMapper(); service.initializeObjectMapper();
org.mockito.Mockito.lenient().when(shippingGates.receivingAvailable(any(), any())).thenReturn(1); 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(pallets.selectCount(any())).thenReturn(1L);
org.mockito.Mockito.lenient().when(domesticEvidence.listByContainer(any())).thenReturn(List.of());
} }
@Test @Test
@@ -904,6 +911,25 @@ class ReceivingContainerServiceBehaviorTest {
verifyNoInteractions(warehouse); 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) { private void stubCurrent(String destinationType, String containerNo) {
InboundPlan plan = plan(containerNo); InboundPlan plan = plan(containerNo);
InboundPlanLine line = line(destinationType); InboundPlanLine line = line(destinationType);
@@ -409,7 +409,7 @@ class ContainerInboundPlanConversionCoordinatorTest {
} }
private ManualInboundPlanRequest manualRequest() { 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, "自动生成"); 1, "WOOD", false, false, false, "自动生成");
} }
@@ -93,6 +93,10 @@ class InboundPlanServiceTest {
org.mockito.Mockito.lenient().when(orderMapper.lockContainerWarehouseFacts(any(), any())).thenReturn(List.of( org.mockito.Mockito.lenient().when(orderMapper.lockContainerWarehouseFacts(any(), any())).thenReturn(List.of(
new InboundPlanOrderMapper.ContainerWarehouseRef( new InboundPlanOrderMapper.ContainerWarehouseRef(
"100", 2L, "BOOKING-1", "193000000000200003"))); "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 @Test
@@ -108,6 +112,7 @@ class InboundPlanServiceTest {
ArgumentCaptor<InboundPlan> plan = ArgumentCaptor.forClass(InboundPlan.class); ArgumentCaptor<InboundPlan> plan = ArgumentCaptor.forClass(InboundPlan.class);
verify(planMapper).insert(plan.capture()); verify(planMapper).insert(plan.capture());
assertEquals(2L, plan.getValue().getSourceOrderId()); assertEquals(2L, plan.getValue().getSourceOrderId());
assertEquals(LocalDate.of(2026, 9, 15), plan.getValue().getPlannedArrivalDate());
assertEquals(1, plan.getValue().getLineCount()); assertEquals(1, plan.getValue().getLineCount());
assertEquals("PENDING_ASSIGNMENT", plan.getValue().getStatus()); assertEquals("PENDING_ASSIGNMENT", plan.getValue().getStatus());
assertEquals(1, plan.getValue().getPalletCount()); 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())) when(orderMapper.markPlannedManual(eq("100"), eq(2L), any(), eq(2), eq(true), eq(0), any()))
.thenReturn(1); .thenReturn(1);
ManualInboundPlanRequest request = new ManualInboundPlanRequest("100", 2L, 0, 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"); var result = service.convertManual(request, "manual-2", "900");
@@ -167,6 +172,27 @@ class InboundPlanServiceTest {
verify(orderMapper).lockContainerWarehouseFacts("100", 2L); 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 @Test
void manualPlanMayExceedButCannotUndercutPreviewPalletCount() { void manualPlanMayExceedButCannotUndercutPreviewPalletCount() {
SourceOrderSnapshot source = new SourceOrderSnapshot(2L, "100", "MSCU6639870", "RK-2", SourceOrderSnapshot source = new SourceOrderSnapshot(2L, "100", "MSCU6639870", "RK-2",
@@ -175,7 +201,7 @@ class InboundPlanServiceTest {
null, null, "Amazon ACY2", 0, 0, false, 0); null, null, "Amazon ACY2", 0, 0, false, 0);
when(orderMapper.lockOrder("100", 2L)).thenReturn(List.of(source)); when(orderMapper.lockOrder("100", 2L)).thenReturn(List.of(source));
ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> service.convertManual( 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")); 2, "WOOD", false, false, false, null), "manual-below-preview", "900"));
assertEquals(400, error.getStatusCode().value()); assertEquals(400, error.getStatusCode().value());
@@ -192,7 +218,7 @@ class InboundPlanServiceTest {
new DropshipPalletSnapshot("DP-260914-0001", 10L, List.of()))); new DropshipPalletSnapshot("DP-260914-0001", 10L, List.of())));
ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> service.convertManual( 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")); 2, "WOOD", false, false, false, null), "dropship-pallet-mismatch", "900"));
assertEquals(400, error.getStatusCode().value()); assertEquals(400, error.getStatusCode().value());
@@ -212,7 +238,7 @@ class InboundPlanServiceTest {
.thenReturn(1); .thenReturn(1);
var result = service.convertManual(new ManualInboundPlanRequest("100", 2L, 0, 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"); "dropship-exact-pallet-count", "900");
assertEquals(1, result.palletCount()); assertEquals(1, result.palletCount());
@@ -266,7 +292,7 @@ class InboundPlanServiceTest {
.thenReturn(1); .thenReturn(1);
var result = service.convertManual(new ManualInboundPlanRequest("100", 2L, 0, 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"); "manual-id-only", "900");
assertEquals("US-LAX-01", result.overseasWarehouseCode()); assertEquals("US-LAX-01", result.overseasWarehouseCode());
@@ -282,7 +308,7 @@ class InboundPlanServiceTest {
new InboundPlanOrderMapper.ContainerWarehouseRef("200", 3L, "BOOKING-1", "193000000000200004"))); new InboundPlanOrderMapper.ContainerWarehouseRef("200", 3L, "BOOKING-1", "193000000000200004")));
ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> service.convertManual( 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")); 1, "STANDARD", false, false, false, null), "mixed-warehouse", "900"));
assertEquals(409, error.getStatusCode().value()); assertEquals(409, error.getStatusCode().value());
@@ -300,7 +326,7 @@ class InboundPlanServiceTest {
when(orderMapper.lockOrder("100", 2L)).thenReturn(List.of(held)); when(orderMapper.lockOrder("100", 2L)).thenReturn(List.of(held));
ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> service.convertManual( 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), 1, "STANDARD", false, false, false, null),
"manual-held", "900")); "manual-held", "900"));
@@ -3,8 +3,8 @@ import com.baiyue.common.event.*; import com.baiyue.lastleg.entity.*; import com
@ExtendWith(org.mockito.junit.jupiter.MockitoExtension.class) @ExtendWith(org.mockito.junit.jupiter.MockitoExtension.class)
class LoadingCompletedConsumerTest { class LoadingCompletedConsumerTest {
private final Map<String,String> frozenContainerNos=new HashMap<>(); private final Map<String,String> frozenContainerNos=new HashMap<>();
@Mock LastlegEventInboxMapper inbox; @Mock LastlegOrderMapper orders; @Mock LastlegInboundOrderMapper inboundOrders; @Mock LastlegOrderDetailMapper details; @Mock LastlegOrderStatusHistoryMapper histories; @Mock InboundPlanCartonMapper cartons; @Mock DropshipOrderService dropshipOrders; @Mock InventoryCommandService inventory; @Mock com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper shippingGates; @Mock LastlegOrderNoDailySequenceMapper sequences; ObjectMapper json=new ObjectMapper().findAndRegisterModules(); LoadingCompletedConsumer service; @Mock LastlegEventInboxMapper inbox; @Mock LastlegOrderMapper orders; @Mock LastlegInboundOrderMapper inboundOrders; @Mock LastlegOrderDetailMapper details; @Mock LastlegOrderStatusHistoryMapper histories; @Mock InboundPlanCartonMapper cartons; @Mock DropshipOrderService dropshipOrders; @Mock InventoryCommandService inventory; @Mock com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper shippingGates; @Mock 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,"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; }); } @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<InventoryCommandRequests.InTransit> inTransit=ArgumentCaptor.forClass(InventoryCommandRequests.InTransit.class); verify(inventory,times(2)).registerInTransit(eq("100"),eq("SYSTEM"),inTransit.capture()); assertEquals(Set.of("RK1","RK2"),inTransit.getAllValues().stream().map(InventoryCommandRequests.InTransit::inboundOrderNo).collect(java.util.stream.Collectors.toSet())); verify(details,times(2)).insert(any(LastlegOrderDetail.class)); verify(histories,times(2)).insert(any(LastlegOrderStatusHistory.class)); ArgumentCaptor<LastlegOrder> c=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(c.capture()); assertEquals(Set.of("in-1","in-2"),c.getAllValues().stream().map(LastlegOrder::getSourceInboundId).collect(java.util.stream.Collectors.toSet())); assertTrue(c.getAllValues().stream().allMatch(o->"PENDING_ASSIGNMENT".equals(o.getCurrentStatus()))); assertTrue(c.getAllValues().stream().allMatch(o->o.getInboundOrderNo()==null)); assertTrue(c.getAllValues().stream().allMatch(o->o.getId()!=null)); assertTrue(c.getAllValues().stream().allMatch(o->"MSCU6639870".equals(o.getContainerNo()))); assertTrue(c.getAllValues().stream().allMatch(o->"FBA".equals(o.getDestinationType()))); assertTrue(c.getAllValues().stream().allMatch(o->"Customer 100".equals(o.getCustomerName()))); assertTrue(c.getAllValues().stream().allMatch(o->"ow-1".equals(o.getSourceWarehouseId())&&"193000000000200003".equals(o.getOverseasWarehouseId())&&"US-LAX-01".equals(o.getOverseasWarehouseCode())&&"洛杉矶仓".equals(o.getOverseasWarehouseName())&&Integer.valueOf(0).equals(o.getReceivingWarehouseVersion()))); ArgumentCaptor<LastlegInboundOrder> snapshots=ArgumentCaptor.forClass(LastlegInboundOrder.class); verify(inboundOrders,times(2)).insert(snapshots.capture()); assertTrue(snapshots.getAllValues().stream().allMatch(o->"100".equals(o.getCustomerId())&&"FBA".equals(o.getDestinationType()))); assertEquals(1,c.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertTrue(c.getAllValues().get(0).getOrderNo().matches("FL-WH-[0-9]{8}-[0-9]{6}")); } @Test void createsPendingOrdersAndInTransitSnapshotsWithoutInboundPlans(){ EventEnvelope event=event(); assertEquals(2,service.consume(event)); assertThrows(NoSuchFieldException.class,()->LoadingCompletedConsumer.class.getDeclaredField("inboundPlans")); verify(orders,times(2)).insert(any(LastlegOrder.class)); ArgumentCaptor<InventoryCommandRequests.InTransit> inTransit=ArgumentCaptor.forClass(InventoryCommandRequests.InTransit.class); verify(inventory,times(2)).registerInTransit(eq("100"),eq("SYSTEM"),inTransit.capture()); assertEquals(Set.of("RK1","RK2"),inTransit.getAllValues().stream().map(InventoryCommandRequests.InTransit::inboundOrderNo).collect(java.util.stream.Collectors.toSet())); verify(details,times(2)).insert(any(LastlegOrderDetail.class)); verify(histories,times(2)).insert(any(LastlegOrderStatusHistory.class)); ArgumentCaptor<LastlegOrder> c=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(c.capture()); assertEquals(Set.of("in-1","in-2"),c.getAllValues().stream().map(LastlegOrder::getSourceInboundId).collect(java.util.stream.Collectors.toSet())); assertTrue(c.getAllValues().stream().allMatch(o->"PENDING_ASSIGNMENT".equals(o.getCurrentStatus()))); assertTrue(c.getAllValues().stream().allMatch(o->o.getInboundOrderNo()==null)); assertTrue(c.getAllValues().stream().allMatch(o->o.getId()!=null)); assertTrue(c.getAllValues().stream().allMatch(o->"MSCU6639870".equals(o.getContainerNo()))); assertTrue(c.getAllValues().stream().allMatch(o->"FBA".equals(o.getDestinationType()))); assertTrue(c.getAllValues().stream().allMatch(o->"Customer 100".equals(o.getCustomerName()))); assertTrue(c.getAllValues().stream().allMatch(o->"ow-1".equals(o.getSourceWarehouseId())&&"193000000000200003".equals(o.getOverseasWarehouseId())&&"US-LAX-01".equals(o.getOverseasWarehouseCode())&&"洛杉矶仓".equals(o.getOverseasWarehouseName())&&Integer.valueOf(0).equals(o.getReceivingWarehouseVersion()))); ArgumentCaptor<LastlegInboundOrder> snapshots=ArgumentCaptor.forClass(LastlegInboundOrder.class); verify(inboundOrders,times(2)).insert(snapshots.capture()); assertTrue(snapshots.getAllValues().stream().allMatch(o->"100".equals(o.getCustomerId())&&"FBA".equals(o.getDestinationType()))); assertEquals(1,c.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertTrue(c.getAllValues().get(0).getOrderNo().matches("FL-WH-[0-9]{8}-[0-9]{6}")); }
@Test void oneContainerWith15InboundRowsSharesOneStableContainerOrderNumber(){ EventEnvelope source=event(); com.fasterxml.jackson.databind.node.ObjectNode node=source.payload().deepCopy(); com.fasterxml.jackson.databind.node.ArrayNode rows=json.createArrayNode(); for(int index=1;index<=15;index++){ com.fasterxml.jackson.databind.node.ObjectNode row=node.path("orders").get(0).deepCopy(); row.put("sourceInboundId","in-"+index).put("rootInboundId","root-"+index).put("sourceOrderId","order-"+index).put("sourceOrderNo","FL-"+index).put("rootOrderId","root-order-"+index).put("inboundCode","RK-"+index); rows.add(row); } node.set("orders",rows); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope fifteen=new EventEnvelope("123e4567-e89b-12d3-a456-426614174015",source.eventType(),source.eventVersion(),source.occurredAt(),source.sourceRegion(),source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); assertEquals(15,service.consume(fifteen)); ArgumentCaptor<LastlegOrder> captured=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(15)).insert(captured.capture()); assertEquals(1,captured.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertTrue(captured.getAllValues().get(0).getOrderNo().matches("^FL-WH-\\d{8}-\\d{6}$")); verify(sequences,never()).nextValue(any(java.time.LocalDate.class)); } @Test void oneContainerWith15InboundRowsSharesOneStableContainerOrderNumber(){ EventEnvelope source=event(); com.fasterxml.jackson.databind.node.ObjectNode node=source.payload().deepCopy(); com.fasterxml.jackson.databind.node.ArrayNode rows=json.createArrayNode(); for(int index=1;index<=15;index++){ com.fasterxml.jackson.databind.node.ObjectNode row=node.path("orders").get(0).deepCopy(); row.put("sourceInboundId","in-"+index).put("rootInboundId","root-"+index).put("sourceOrderId","order-"+index).put("sourceOrderNo","FL-"+index).put("rootOrderId","root-order-"+index).put("inboundCode","RK-"+index); rows.add(row); } node.set("orders",rows); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope fifteen=new EventEnvelope("123e4567-e89b-12d3-a456-426614174015",source.eventType(),source.eventVersion(),source.occurredAt(),source.sourceRegion(),source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); assertEquals(15,service.consume(fifteen)); ArgumentCaptor<LastlegOrder> captured=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(15)).insert(captured.capture()); assertEquals(1,captured.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertTrue(captured.getAllValues().get(0).getOrderNo().matches("^FL-WH-\\d{8}-\\d{6}$")); verify(sequences,never()).nextValue(any(java.time.LocalDate.class)); }
@Test void rejectsMissingOwnerSnapshotBeforeAnyLastlegWrite(){ EventEnvelope source=event(); com.fasterxml.jackson.databind.node.ObjectNode node=source.payload().deepCopy(); ((com.fasterxml.jackson.databind.node.ObjectNode)node.path("orders").get(0)).put("customerName"," "); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope missing=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),source.sourceRegion(),source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); IllegalArgumentException error=assertThrows(IllegalArgumentException.class,()->service.consume(missing)); assertTrue(error.getMessage().contains("尾程事件负载无效")||error.getMessage().contains("货主快照")); verify(inbox,never()).insert(any(LastlegEventInbox.class)); verify(orders,never()).insert(any(LastlegOrder.class)); } @Test void rejectsMissingOwnerSnapshotBeforeAnyLastlegWrite(){ EventEnvelope source=event(); com.fasterxml.jackson.databind.node.ObjectNode node=source.payload().deepCopy(); ((com.fasterxml.jackson.databind.node.ObjectNode)node.path("orders").get(0)).put("customerName"," "); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope missing=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),source.sourceRegion(),source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); IllegalArgumentException error=assertThrows(IllegalArgumentException.class,()->service.consume(missing)); assertTrue(error.getMessage().contains("尾程事件负载无效")||error.getMessage().contains("货主快照")); verify(inbox,never()).insert(any(LastlegEventInbox.class)); verify(orders,never()).insert(any(LastlegOrder.class)); }
@@ -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); 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); } 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<com.baiyue.lastleg.inboundplan.pda.entity.ContainerLoadingEvidence> 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 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 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){ private void addManifest(JsonNode pallet,int packageCount){
@@ -43,7 +43,7 @@ import static org.junit.jupiter.api.Assertions.assertNotNull;
class TaillegRealConsumerAcceptanceTest { class TaillegRealConsumerAcceptanceTest {
static final String CUSTOMER = "990022"; static final String CUSTOMER = "990022";
static final String MARKER = "REAL-TAIL-20260822-R3"; 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 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 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); 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, InboundPlanService.class, InventoryCommandService.class, RelabelApplicationService.class,
WarehouseTransferService.class, LocalLooseboardService.class}) WarehouseTransferService.class, LocalLooseboardService.class})
@MapperScan({"com.baiyue.lastleg.mapper", "com.baiyue.lastleg.dropship.mapper", @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.inventory.command.mapper", "com.baiyue.lastleg.relabel.mapper",
"com.baiyue.lastleg.transfer.mapper", "com.baiyue.lastleg.looseboard.mapper"}) "com.baiyue.lastleg.transfer.mapper", "com.baiyue.lastleg.looseboard.mapper"})
static class AcceptanceApplication { static class AcceptanceApplication {
+2 -1
View File
@@ -11,7 +11,7 @@ const OPTIONAL_MODULES = ['manual-order-splitting-fixture', 'manual-order-splitt
'manual-pda-ready-fixture', 'manual-pda-ready-verify', 'manual-pda-ready-fixture', 'manual-pda-ready-verify',
'manual-pda-batch-fixture', 'manual-pda-batch-verify', 'manual-pda-batch-fixture', 'manual-pda-batch-verify',
'manual-loading-complete-fixture', 'manual-loading-complete-verify', 'manual-loading-complete-fixture', 'manual-loading-complete-verify',
'coload-groups', 'dropship-pallet-matrix'] 'coload-groups', 'dropship-pallet-matrix', 'dropship-browser-fixture']
const AVAILABLE_MODULES = new Set([...CONFIGURED_MODULES, ...OPTIONAL_MODULES]) const AVAILABLE_MODULES = new Set([...CONFIGURED_MODULES, ...OPTIONAL_MODULES])
const A0_LOCATION_PAGE_SIZE = 200 const A0_LOCATION_PAGE_SIZE = 200
const A0_MAX_LOCATION_MUTATIONS = 8 const A0_MAX_LOCATION_MUTATIONS = 8
@@ -43,6 +43,7 @@ export function planA0LocationIsolation(locations, requiredSlots = 1,
const MODULE_DEPENDENCIES = new Map([ const MODULE_DEPENDENCIES = new Map([
['dropship-pallet-matrix', ['merchant-qualification-fixture']], ['dropship-pallet-matrix', ['merchant-qualification-fixture']],
['dropship-browser-fixture', ['merchant-qualification-fixture']],
['suppliers', ['merchant-qualification-fixture']], ['suppliers', ['merchant-qualification-fixture']],
['supplemental', ['merchant-qualification-fixture']], ['supplemental', ['merchant-qualification-fixture']],
['orders', ['merchant-qualification-fixture']], ['orders', ['merchant-qualification-fixture']],
+2
View File
@@ -37,6 +37,8 @@ export async function run(context) {
## 可用上下文 ## 可用上下文
`dropship-browser-fixture.mjs` 通过总入口 `--modules dropship-browser-fixture` 执行,自动依赖客户资质准备;复用矩阵生产者创建一票四箱两托,完成国内装柜、船节点和按柜生成海外入库计划,明确停在 `READY_FOR_BROWSER_RECEIVING`。只准备新测试数据,不调用海外 PDA 收货或出库、不创建海外操作员;后续由浏览器页面验收。报告仍保存于 `createdResources.dropshipPalletMatrix`,准备通过不代表全流程通过,不自动删除数据。
`dropship-pallet-matrix.mjs` 验证下述四场景整托手工加入、整托移除、智能推荐、独立订舱与文件放舱、真实柜号关联、预装确认、PDA 整托拣货和五类证据上传;异步装柜完成必须等到任务 `COMPLETED` 并逐票回查 `LOADED`。智能装柜的托盘汇总与装柜详情逐票组成分别核对,不能把共托汇总行的首票编号当作全托归属。随后经正式报关、国内离仓及人工船节点接口推进至 `PICKUP_READY`,再按柜生成逐票海外入库计划,检查来源托盘完整绑定、共享托盘身份一致和同键重放。海外收货使用本轮创建并绑定接柜仓的 PDA 操作员,上传三类照片、签收、逐实物托盘收货、逐票箱数核平和完成入库,同键重放及同托新键扫码均不得重复入账。随后从本轮逐票库存候选创建一件代发专用出库,携带原始箱唛由 PDA 拣货、完成,核对终态、幂等和可用库存扣减;finally 停用本轮操作员。出库重放按业务身份和数据库毫秒时间精度核对。离线合同为 `dropship-pallet-matrix-contract.test.mjs`;脚本覆盖不等于真实 HTTP 已通过,须检查当轮报告和库存审计。 `dropship-pallet-matrix.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`,不能代替海外收货及一件代发专用出库的完整验收。 - `dropship-pallet-matrix` 通过总入口 `--modules dropship-pallet-matrix` 执行,自动依赖客户资质准备。通过正式 HTTP 创建五票订单,覆盖单票多托、单票单托、单票移箱合托、多票共享开放托盘;会创建并保留测试数据,不自动清理。报告 `createdResources.dropshipPalletMatrix` 保存逐票箱唛、托盘及各阶段证据,失败即停止并保留阻断;重跑创建新批次,不借用历史资源。`fullFlowVerified=false`,不能代替海外收货及一件代发专用出库的完整验收。
@@ -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'
@@ -5,6 +5,16 @@ import { normalizeModuleOrder } from '../admin-test-env-acceptance.mjs'
import { sameOutboundTask } from './dropship-pallet-matrix.mjs' import { sameOutboundTask } from './dropship-pallet-matrix.mjs'
import { probePartialPick } from './dropship-outbound-edge-cases.mjs' import { probePartialPick } from './dropship-outbound-edge-cases.mjs'
test('browser fixture is explicit and stops before overseas receipt or outbound', () => {
assert.deepEqual(normalizeModuleOrder(['dropship-browser-fixture']), ['merchant-qualification-fixture', 'dropship-browser-fixture'])
const source = readFileSync(new URL('dropship-pallet-matrix.mjs', import.meta.url), 'utf8')
const branch = source.slice(source.indexOf('if (stopBeforeOverseasPda)'), source.indexOf("await verify(await prepare('SINGLE-MULTI'"))
assert.match(branch, /prepare\('BROWSER-MULTI', 2\)/)
assert.match(branch, /convertOverseas\(\[record\]\)/)
assert.match(branch, /READY_FOR_BROWSER_RECEIVING'[\s\S]*return/)
assert.doesNotMatch(branch, /receiveOverseas|outboundOverseas|createPicker/)
})
test('partial-pick rejection stays failed; a changed snapshot aborts further picking', async () => { test('partial-pick rejection stays failed; a changed snapshot aborts further picking', async () => {
const failures = [] const failures = []
const snapshot = { pallets: [{ pickedPackageCount: 0 }] } const snapshot = { pallets: [{ pickedPackageCount: 0 }] }
@@ -30,6 +30,14 @@ export function sameOutboundTask(left, right) {
// Domestic producer stage: these retained loaded fixtures must subsequently be verified // Domestic producer stage: these retained loaded fixtures must subsequently be verified
// through overseas receipt and dedicated dropship outbound, not generic dispatch. // through overseas receipt and dedicated dropship outbound, not generic dispatch.
export async function run(context) { export async function run(context) {
return runWorkflow(context, false)
}
export async function prepareBrowserFixture(context) {
return runWorkflow(context, true)
}
async function runWorkflow(context, stopBeforeOverseasPda) {
const marker = context.marker const marker = context.marker
const savedOptions = context.options.prepareDropshipManualCount const savedOptions = context.options.prepareDropshipManualCount
const savedContext = { ...context.context } const savedContext = { ...context.context }
@@ -472,6 +480,15 @@ export async function run(context) {
for (const record of group) record.outbound = { task: completed, selected, detail: outbound, completed: true } for (const record of group) record.outbound = { task: completed, selected, detail: outbound, completed: true }
} }
try { try {
if (stopBeforeOverseasPda) {
const record = await prepare('BROWSER-MULTI', 2)
await verify(record, 2)
await allocate([record])
await progressShipping([record])
await convertOverseas([record])
context.createdResources.dropshipPalletMatrix.stage = 'READY_FOR_BROWSER_RECEIVING'
return
}
await verify(await prepare('SINGLE-MULTI', 2), 2) await verify(await prepare('SINGLE-MULTI', 2), 2)
await verify(await prepare('SINGLE-SINGLE', 1), 1) await verify(await prepare('SINGLE-SINGLE', 1), 1)
await verify(await prepare('SINGLE-MERGE', 2, { mergeToFirstPallet: true }), 1) await verify(await prepare('SINGLE-MERGE', 2, { mergeToFirstPallet: true }), 1)
@@ -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 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 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 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') 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', () => { 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/) 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, /FirstLegLoadingPlanCompletedV2/)
assert.match(outbox, /FirstLegLoadingPlanCompletedV3/)
assert.match(eventV3, /List<FirstLegLoadingPlanCompletedV2\.Order> orders/)
for (const field of ['customerRemark', 'internalRemark', 'warehouseRemark']) assert.match(event, new RegExp(field)) for (const field of ['customerRemark', 'internalRemark', 'warehouseRemark']) assert.match(event, new RegExp(field))
assert.match(outbox, /row\.getCustomerRemark\(\)/) assert.match(outbox, /row\.getCustomerRemark\(\)/)
assert.match(outbox, /row\.getInternalRemark\(\)/) assert.match(outbox, /row\.getInternalRemark\(\)/)
assert.match(outbox, /row\.getWarehouseRemark\(\)/) 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, /EventPayloadCanonicalizer\.canonicalize\(payloadNode\)/)
assert.match(outbox, /FIRSTLEG_LOADING_COMPLETED:[^\n]*HANDOVER_VERSION/) 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', () => { test('Admin exposes only warehouse remark editing and keeps completed rows read-only', () => {
@@ -558,8 +558,8 @@ export async function run(context) {
const producer = { const producer = {
marker: context.context?.phase4ProducerMatrixMarker || context.marker, marker: context.context?.phase4ProducerMatrixMarker || context.marker,
eventType: 'FIRST_LEG_LOADING_PLAN_COMPLETED', eventType: 'FIRST_LEG_LOADING_PLAN_COMPLETED',
eventVersion: 2, eventVersion: 3,
eventKey: `FIRSTLEG_LOADING_COMPLETED:${loadingPlanId}:${tenantId}:2`, eventKey: `FIRSTLEG_LOADING_COMPLETED:${loadingPlanId}:${tenantId}:3`,
resourceKey: `${context.marker}:${fixture.destinationType || 'UNKNOWN'}:${fixture.containerNo || containerBookingId}`, resourceKey: `${context.marker}:${fixture.destinationType || 'UNKNOWN'}:${fixture.containerNo || containerBookingId}`,
tenantId, tenantId,
aggregateType: 'LOADING_PLAN', aggregateType: 'LOADING_PLAN',
@@ -813,7 +813,7 @@ test('finalize publishes a current-marker producer correlation for cross-region
assert.match(finalize, /createdResources\.phase3LoadingCompletedProducer = producer/) assert.match(finalize, /createdResources\.phase3LoadingCompletedProducer = producer/)
assert.match(finalize, /marker: context\.context\?\.phase4ProducerMatrixMarker \|\| context\.marker/) assert.match(finalize, /marker: context\.context\?\.phase4ProducerMatrixMarker \|\| context\.marker/)
assert.match(finalize, /eventType: 'FIRST_LEG_LOADING_PLAN_COMPLETED'/) 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, /sourceStatus: 'LOADED'/)
assert.match(finalize, /PRODUCED_IN_COMPLETE_TRANSACTION_PENDING_BRIDGE_CONSUMER_HTTP/) assert.match(finalize, /PRODUCED_IN_COMPLETE_TRANSACTION_PENDING_BRIDGE_CONSUMER_HTTP/)
}) })
@@ -223,7 +223,6 @@ async function verifyDestinationMergeFeeAndExport(context, details, producer) {
function manualPlanBody(order, version, marker) { function manualPlanBody(order, version, marker) {
return { return {
customerId: id(order.customerId), orderId: id(order.id), expectedOrderVersion: Number(version), 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, inboundCode: order.inboundCode,
palletCount: Math.min(999, Math.max(1, Number(order.palletCount) || 1)), palletCount: Math.min(999, Math.max(1, Number(order.palletCount) || 1)),
palletType: 'STANDARD', assigned: false, mergeRequired: false, urgent: true, palletType: 'STANDARD', assigned: false, mergeRequired: false, urgent: true,
+1
View File
@@ -1102,6 +1102,7 @@ $criticalPaths = @(
'baiyue-firstleg/gateway-overseas/src/test/java/com/baiyue/gateway/config/OverseasLastlegRouteContractTest.java', '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/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/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/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/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', 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java',