# Conflicts:
#	config/nacos/cn/event-bridge-service.yml
#	config/nacos/overseas/lastleg-service.yml
#	deploy/sql/头程数据库统一增量-MySQL8.sql
#	event-bridge-service/src/main/resources/application.yml
#	iam/src/main/resources/db/lastleg-permissions.sql
#	lastleg-service/AGENTS.md
#	lastleg-service/src/main/java/com/baiyue/lastleg/BaiyueLastlegApplication.java
#	lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java
#	lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java
#	lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/controller/InventoryCommandController.java
#	lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryController.java
#	lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/PlatformOverseasProcurementController.java
#	lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/OverseasProcurementService.java
#	lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationController.java
#	lastleg-service/src/main/java/com/baiyue/lastleg/trucking/controller/PlatformUsTruckOrderController.java
#	lastleg-service/src/main/java/com/baiyue/lastleg/trucking/service/UsTruckOrderService.java
#	lastleg-service/src/main/resources/application.yml
#	lastleg-service/src/main/resources/db/lastleg.sql
#	scripts/db/incremental.sql
#	warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseController.java
#	warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseResourceController.java
This commit is contained in:
2026-08-20 18:14:55 +08:00
27 changed files with 415 additions and 19 deletions
@@ -46,7 +46,9 @@ public record FirstLegLoadingPlanCompletedV2(
String destinationWarehouseName, String fbaTrackingNo, String destinationWarehouseName, String fbaTrackingNo,
String shipmentNo, String shippingMark, String shipmentNo, String shippingMark,
String customerRemark, String internalRemark, String warehouseRemark, String customerRemark, String internalRemark, String warehouseRemark,
String palletNo, List<String> cartonMarks) { String palletNo,
/** 头程箱唛二维码中的箱号;这些箱号均对应本明细的 skuCode。 */
List<String> cartonMarks) {
public Item { public Item {
cartonMarks = cartonMarks == null ? List.of() : cartonMarks; cartonMarks = cartonMarks == null ? List.of() : cartonMarks;
} }
@@ -72,7 +74,7 @@ public record FirstLegLoadingPlanCompletedV2(
/** 一键代发入仓单的托盘冻结快照。 */ /** 一键代发入仓单的托盘冻结快照。 */
public record PalletSnapshot(String palletNo, List<SkuPackageSnapshot> skuPackages) {} public record PalletSnapshot(String palletNo, List<SkuPackageSnapshot> skuPackages) {}
/** 托盘下 SKU、箱数、数量及箱唛冻结快照。 */ /** 托盘下 SKU、箱数、数量及箱号冻结快照;cartonMarks 中每个箱号都对应 skuCode。 */
public record SkuPackageSnapshot(String skuCode, String productName, public record SkuPackageSnapshot(String skuCode, String productName,
int packageCount, int productQuantity, int packageCount, int productQuantity,
List<String> cartonMarks) {} List<String> cartonMarks) {}
@@ -39,4 +39,19 @@ class FirstLegLoadingPlanCompletedV2Test {
assertThat(json.at("/orders/0/orderType").asText()).isEqualTo("ONE_CLICK_DROPSHIP"); assertThat(json.at("/orders/0/orderType").asText()).isEqualTo("ONE_CLICK_DROPSHIP");
assertThat(json.at("/orders/0/pallets/0/skuPackages/0/cartonMarks")).hasSize(2); assertThat(json.at("/orders/0/pallets/0/skuPackages/0/cartonMarks")).hasSize(2);
} }
@Test
void mapsEachOrdinaryCartonNumberToItsSku() {
var item = new FirstLegLoadingPlanCompletedV2.Item("1", "SKU-PSC2", "产品", "Product", 2,
20, null, null, null, null, null, null, null, null, null, null, null,
null, null, null, null, List.of("HDHK260711126-01", "HDHK260711126-02"));
var order = new FirstLegLoadingPlanCompletedV2.Order("2", "2", "3", "ORD", "3", "RK",
null, null, null, 2, 20, null, null, null, null, List.of(item));
var json = new ObjectMapper().valueToTree(new FirstLegLoadingPlanCompletedV2(
"4", "P", "5", "CONT", "CAB", "6", "WH", List.of(order)));
assertThat(json.at("/orders/0/items/0/skuCode").asText()).isEqualTo("SKU-PSC2");
assertThat(json.at("/orders/0/items/0/cartonMarks")).hasSize(2);
}
} }
+1
View File
@@ -10,3 +10,4 @@ baiyue:
data-residency: ${DATA_RESIDENCY:CN} data-residency: ${DATA_RESIDENCY:CN}
event-dispatcher: event-dispatcher:
consumer-url: ${EVENT_CONSUMER_URL:http://firstleg:9202/internal/firstleg/v1/container-events} consumer-url: ${EVENT_CONSUMER_URL:http://firstleg:9202/internal/firstleg/v1/container-events}
firstleg-consumer-url: ${FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL:http://firstleg:9202/internal/firstleg/v1/container-events}
@@ -9,6 +9,10 @@ baiyue:
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:
url: ${FILE_SERVICE_URL}
event-bridge:
url: ${CN_EVENT_BRIDGE_URL:${FIRSTLEG_EVENT_BRIDGE_URL}}
warehouse: warehouse:
base-url: ${WAREHOUSE_OVERSEAS_SERVICE_URL:http://warehouse-overseas-service:9305} base-url: ${WAREHOUSE_OVERSEAS_SERVICE_URL:http://warehouse-overseas-service:9305}
firstleg: firstleg:
+1
View File
@@ -60,6 +60,7 @@ services:
LASTLEG_DB_PASSWORD: ${LASTLEG_DB_PASSWORD} LASTLEG_DB_PASSWORD: ${LASTLEG_DB_PASSWORD}
FIRSTLEG_EVENT_BRIDGE_URL: ${FIRSTLEG_EVENT_BRIDGE_URL} FIRSTLEG_EVENT_BRIDGE_URL: ${FIRSTLEG_EVENT_BRIDGE_URL}
DOMESTIC_WAREHOUSE_URL: ${DOMESTIC_WAREHOUSE_URL} DOMESTIC_WAREHOUSE_URL: ${DOMESTIC_WAREHOUSE_URL}
FILE_SERVICE_URL: ${FILE_SERVICE_URL}
WAREHOUSE_OVERSEAS_SERVICE_URL: ${WAREHOUSE_OVERSEAS_SERVICE_URL:-http://warehouse-overseas-service:9305} WAREHOUSE_OVERSEAS_SERVICE_URL: ${WAREHOUSE_OVERSEAS_SERVICE_URL:-http://warehouse-overseas-service:9305}
warehouse-overseas-service: warehouse-overseas-service:
+1
View File
@@ -195,6 +195,7 @@ services:
EVENT_BRIDGE_DB_USERNAME: ${EVENT_BRIDGE_DB_USERNAME} EVENT_BRIDGE_DB_USERNAME: ${EVENT_BRIDGE_DB_USERNAME}
EVENT_BRIDGE_DB_PASSWORD: ${EVENT_BRIDGE_DB_PASSWORD} EVENT_BRIDGE_DB_PASSWORD: ${EVENT_BRIDGE_DB_PASSWORD}
EVENT_CONSUMER_URL: ${EVENT_CONSUMER_URL:-http://firstleg:9202/internal/firstleg/v1/container-events} EVENT_CONSUMER_URL: ${EVENT_CONSUMER_URL:-http://firstleg:9202/internal/firstleg/v1/container-events}
FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL: ${FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL:-http://firstleg:9202/internal/firstleg/v1/container-events}
SPRING_SQL_INIT_MODE: never SPRING_SQL_INIT_MODE: never
audit-service: audit-service:
@@ -16,8 +16,7 @@ import java.util.List;
public interface BridgeInboxMapper extends BaseMapper<BridgeInbox> { public interface BridgeInboxMapper extends BaseMapper<BridgeInbox> {
@Select(""" @Select("""
SELECT event_id FROM bridge_inbox SELECT event_id FROM bridge_inbox
WHERE target_region = 'OVERSEAS' WHERE ((status IN ('RECEIVED','RETRY') AND (next_retry_at IS NULL OR next_retry_at <= CURRENT_TIMESTAMP(3)))
AND ((status IN ('RECEIVED','RETRY') AND (next_retry_at IS NULL OR next_retry_at <= CURRENT_TIMESTAMP(3)))
OR (status = 'PROCESSING' AND lease_until < CURRENT_TIMESTAMP(3))) OR (status = 'PROCESSING' AND lease_until < CURRENT_TIMESTAMP(3)))
ORDER BY received_at LIMIT #{limit} ORDER BY received_at LIMIT #{limit}
""") """)
@@ -26,7 +25,7 @@ public interface BridgeInboxMapper extends BaseMapper<BridgeInbox> {
@Update(""" @Update("""
UPDATE bridge_inbox SET status='PROCESSING', lease_owner=#{owner}, UPDATE bridge_inbox SET status='PROCESSING', lease_owner=#{owner},
lease_until=DATE_ADD(CURRENT_TIMESTAMP(3), INTERVAL #{leaseSeconds} SECOND) lease_until=DATE_ADD(CURRENT_TIMESTAMP(3), INTERVAL #{leaseSeconds} SECOND)
WHERE event_id=#{eventId} AND target_region='OVERSEAS' WHERE event_id=#{eventId}
AND ((status IN ('RECEIVED','RETRY') AND (next_retry_at IS NULL OR next_retry_at <= CURRENT_TIMESTAMP(3))) AND ((status IN ('RECEIVED','RETRY') AND (next_retry_at IS NULL OR next_retry_at <= CURRENT_TIMESTAMP(3)))
OR (status='PROCESSING' AND lease_until < CURRENT_TIMESTAMP(3))) OR (status='PROCESSING' AND lease_until < CURRENT_TIMESTAMP(3)))
""") """)
@@ -0,0 +1,7 @@
package com.baiyue.eventbridge.service;
import com.baiyue.eventbridge.domain.BridgeInbox;
public interface FirstlegEventClient {
String deliver(BridgeInbox event) throws Exception;
}
@@ -0,0 +1,52 @@
package com.baiyue.eventbridge.service;
import com.baiyue.common.event.EventEnvelope;
import com.baiyue.common.event.RegionCode;
import com.baiyue.eventbridge.config.EventDispatcherProperties;
import com.baiyue.eventbridge.domain.BridgeInbox;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
@Component
public class HttpFirstlegEventClient implements FirstlegEventClient {
@Resource private ObjectMapper objectMapper;
@Resource private EventDispatcherProperties properties;
@Value("${baiyue.event-dispatcher.firstleg-consumer-url:http://127.0.0.1:9203/internal/firstleg/v1/container-events}")
private URI consumerUrl;
@Value("${baiyue.security.internal-service-token:}") private String serviceToken;
private HttpClient httpClient;
@PostConstruct
void initializeHttpClient() {
httpClient = HttpClient.newBuilder().connectTimeout(properties.connectTimeout()).build();
}
@Override
public String deliver(BridgeInbox row) throws Exception {
EventEnvelope envelope = new EventEnvelope(row.getEventId(), row.getEventType(), row.getEventVersion(),
row.getOccurredAt(), RegionCode.valueOf(row.getSourceRegion()), RegionCode.valueOf(row.getTargetRegion()),
row.getCustomerId(), row.getAggregateType(), row.getAggregateId(), row.getAggregateVersion(),
row.getHandoverVersion(), row.getTraceId(), row.getCorrelationId(), row.getCausationId(),
objectMapper.readTree(row.getPayloadJson()), row.getPayloadHash());
HttpRequest request = HttpRequest.newBuilder(consumerUrl).timeout(properties.requestTimeout())
.header("Content-Type", "application/json")
.header("X-Internal-Service-Token", serviceToken)
.POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(envelope))).build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
JsonNode responseBody = objectMapper.readTree(response.body());
if (response.statusCode() / 100 != 2 || responseBody == null
|| !"200".equals(responseBody.path("code").asText())) {
throw new IllegalStateException("头程消费失败: HTTP " + response.statusCode());
}
return String.valueOf(response.statusCode());
}
}
@@ -20,6 +20,7 @@ public class OverseasEventDispatcher {
@Resource private BridgeInboxMapper inboxMapper; @Resource private BridgeInboxMapper inboxMapper;
@Resource private BridgeDeliveryAttemptMapper attemptMapper; @Resource private BridgeDeliveryAttemptMapper attemptMapper;
@Resource private LastlegEventClient eventClient; @Resource private LastlegEventClient eventClient;
@Resource private FirstlegEventClient firstlegEventClient;
@Resource private EventDispatcherProperties properties; @Resource private EventDispatcherProperties properties;
private final String leaseOwner = ManagementFactory.getRuntimeMXBean().getName() + ":" + UUID.randomUUID(); private final String leaseOwner = ManagementFactory.getRuntimeMXBean().getName() + ":" + UUID.randomUUID();
@@ -40,7 +41,7 @@ public class OverseasEventDispatcher {
int deliveryAttemptNo = inboxMapper.nextAttemptNo(eventId); int deliveryAttemptNo = inboxMapper.nextAttemptNo(eventId);
BridgeDeliveryAttempt attempt = startAttempt(event, deliveryAttemptNo); BridgeDeliveryAttempt attempt = startAttempt(event, deliveryAttemptNo);
try { try {
String responseCode = eventClient.deliver(event); String responseCode = deliver(event);
if (inboxMapper.markProcessed(eventId, leaseOwner) != 1) { if (inboxMapper.markProcessed(eventId, leaseOwner) != 1) {
throw new IllegalStateException("事件租约已失效,无法确认投递结果"); throw new IllegalStateException("事件租约已失效,无法确认投递结果");
} }
@@ -49,11 +50,16 @@ public class OverseasEventDispatcher {
String error = abbreviate(exception.getMessage(), 1000); String error = abbreviate(exception.getMessage(), 1000);
inboxMapper.markFailed(eventId, leaseOwner, properties.maxAttempts(), backoffSeconds(retryAttempt), error); inboxMapper.markFailed(eventId, leaseOwner, properties.maxAttempts(), backoffSeconds(retryAttempt), error);
finishAttempt(attempt, retryAttempt >= properties.maxAttempts() ? "DEAD" : "RETRY", null, error); finishAttempt(attempt, retryAttempt >= properties.maxAttempts() ? "DEAD" : "RETRY", null, error);
log.warn("尾程事件投递失败 eventId={}, retryAttempt={}, deliveryAttempt={}, reason={}", log.warn("跨区事件投递失败 eventId={}, retryAttempt={}, deliveryAttempt={}, reason={}",
eventId, retryAttempt, deliveryAttemptNo, error); eventId, retryAttempt, deliveryAttemptNo, error);
} }
} }
private String deliver(BridgeInbox event) throws Exception {
return "CN".equals(event.getTargetRegion())
? firstlegEventClient.deliver(event) : eventClient.deliver(event);
}
private BridgeDeliveryAttempt startAttempt(BridgeInbox event, int attemptNo) { private BridgeDeliveryAttempt startAttempt(BridgeInbox event, int attemptNo) {
BridgeDeliveryAttempt attempt = new BridgeDeliveryAttempt(); BridgeDeliveryAttempt attempt = new BridgeDeliveryAttempt();
attempt.setEventId(event.getEventId()); attempt.setEventId(event.getEventId());
@@ -32,6 +32,7 @@ baiyue:
region: ${REGION_CODE:OVERSEAS} region: ${REGION_CODE:OVERSEAS}
event-dispatcher: event-dispatcher:
consumer-url: ${EVENT_CONSUMER_URL:${LASTLEG_CONSUMER_URL:http://127.0.0.1:9303/internal/lastleg/events}} consumer-url: ${EVENT_CONSUMER_URL:${LASTLEG_CONSUMER_URL:http://127.0.0.1:9303/internal/lastleg/events}}
firstleg-consumer-url: ${FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL:http://127.0.0.1:9202/internal/firstleg/v1/container-events}
fixed-delay: ${EVENT_DISPATCH_FIXED_DELAY:3000} fixed-delay: ${EVENT_DISPATCH_FIXED_DELAY:3000}
batch-size: ${EVENT_DISPATCH_BATCH_SIZE:50} batch-size: ${EVENT_DISPATCH_BATCH_SIZE:50}
max-attempts: ${EVENT_DISPATCH_MAX_ATTEMPTS:10} max-attempts: ${EVENT_DISPATCH_MAX_ATTEMPTS:10}
@@ -21,6 +21,7 @@ class OverseasEventDispatcherTest {
private final BridgeInboxMapper inboxMapper = mock(BridgeInboxMapper.class); private final BridgeInboxMapper inboxMapper = mock(BridgeInboxMapper.class);
private final BridgeDeliveryAttemptMapper attemptMapper = mock(BridgeDeliveryAttemptMapper.class); private final BridgeDeliveryAttemptMapper attemptMapper = mock(BridgeDeliveryAttemptMapper.class);
private final LastlegEventClient eventClient = mock(LastlegEventClient.class); private final LastlegEventClient eventClient = mock(LastlegEventClient.class);
private final FirstlegEventClient firstlegEventClient = mock(FirstlegEventClient.class);
private OverseasEventDispatcher dispatcher; private OverseasEventDispatcher dispatcher;
private BridgeInbox event; private BridgeInbox event;
@@ -34,6 +35,7 @@ class OverseasEventDispatcherTest {
ReflectionTestUtils.setField(dispatcher, "inboxMapper", inboxMapper); ReflectionTestUtils.setField(dispatcher, "inboxMapper", inboxMapper);
ReflectionTestUtils.setField(dispatcher, "attemptMapper", attemptMapper); ReflectionTestUtils.setField(dispatcher, "attemptMapper", attemptMapper);
ReflectionTestUtils.setField(dispatcher, "eventClient", eventClient); ReflectionTestUtils.setField(dispatcher, "eventClient", eventClient);
ReflectionTestUtils.setField(dispatcher, "firstlegEventClient", firstlegEventClient);
ReflectionTestUtils.setField(dispatcher, "properties", properties); ReflectionTestUtils.setField(dispatcher, "properties", properties);
event = new BridgeInbox(); event = new BridgeInbox();
event.setEventId("event-1"); event.setEventId("event-1");
@@ -83,9 +85,24 @@ class OverseasEventDispatcherTest {
dispatcher.dispatchOne("event-1"); dispatcher.dispatchOne("event-1");
verifyNoInteractions(eventClient); verifyNoInteractions(eventClient);
verifyNoInteractions(firstlegEventClient);
verify(attemptMapper, never()).insert(any(BridgeDeliveryAttempt.class)); verify(attemptMapper, never()).insert(any(BridgeDeliveryAttempt.class));
} }
@Test
void cnTargetIsDeliveredToFirstleg() throws Exception {
event.setSourceRegion("OVERSEAS");
event.setTargetRegion("CN");
when(firstlegEventClient.deliver(event)).thenReturn("200");
when(inboxMapper.markProcessed(eq("event-1"), anyString())).thenReturn(1);
dispatcher.dispatchOne("event-1");
verify(firstlegEventClient).deliver(event);
verifyNoInteractions(eventClient);
assertThat(updatedAttempt().getStatus()).isEqualTo("DELIVERED");
}
private BridgeDeliveryAttempt updatedAttempt() { private BridgeDeliveryAttempt updatedAttempt() {
ArgumentCaptor<BridgeDeliveryAttempt> captor = ArgumentCaptor.forClass(BridgeDeliveryAttempt.class); ArgumentCaptor<BridgeDeliveryAttempt> captor = ArgumentCaptor.forClass(BridgeDeliveryAttempt.class);
verify(attemptMapper).updateById(captor.capture()); verify(attemptMapper).updateById(captor.capture());
@@ -22,7 +22,8 @@ public class InternalBusinessFileService {
"CONTAINER_CLEARANCE_DOCUMENT", "CONTAINER_CLEARANCE_DOCUMENT",
"LOADING_CUSTOMS_DOCUMENT", "LOADING_CUSTOMS_DOCUMENT",
"CONTAINER_DELIVERY_ORDER", "CONTAINER_DELIVERY_ORDER",
"CONTAINER_PICKUP_ORDER"); "CONTAINER_PICKUP_ORDER",
"OVERSEAS_UNLOAD_PHOTO");
@Resource private FileObjectMapper files; @Resource private FileObjectMapper files;
@Resource private BusinessFileStorage storage; @Resource private BusinessFileStorage storage;
@@ -40,6 +40,8 @@ class InternalBusinessFileServiceTest {
when(storage.storeLoading(eq(0L), anyLong(), same(file))).thenAnswer(invocation -> when(storage.storeLoading(eq(0L), anyLong(), same(file))).thenAnswer(invocation ->
new BusinessFileStorage.Stored("business-files/0/" + invocation.getArgument(1) + "/customs.pdf", new BusinessFileStorage.Stored("business-files/0/" + invocation.getArgument(1) + "/customs.pdf",
"application/pdf", file.getSize(), "b".repeat(64), "customs.pdf")); "application/pdf", file.getSize(), "b".repeat(64), "customs.pdf"));
when(storage.storeLoading(11L, 51L, file)).thenReturn(new BusinessFileStorage.Stored(
"business-files/11/51/unload.jpg", "image/jpeg", file.getSize(), "c".repeat(64), "unload.jpg"));
when(files.insert(any(FileObjectEntity.class))).thenAnswer(invocation -> { when(files.insert(any(FileObjectEntity.class))).thenAnswer(invocation -> {
((FileObjectEntity) invocation.getArgument(0)).setId(81L); return 1; ((FileObjectEntity) invocation.getArgument(0)).setId(81L); return 1;
}); });
@@ -48,6 +50,7 @@ class InternalBusinessFileServiceTest {
var customs = service.upload(0L, "LOADING_CUSTOMS_DOCUMENT", "51", 7L, file); var customs = service.upload(0L, "LOADING_CUSTOMS_DOCUMENT", "51", 7L, file);
var deliveryOrder = service.upload(0L, "CONTAINER_DELIVERY_ORDER", "41", 7L, file); var deliveryOrder = service.upload(0L, "CONTAINER_DELIVERY_ORDER", "41", 7L, file);
var pickupOrder = service.upload(0L, "CONTAINER_PICKUP_ORDER", "41", 7L, file); var pickupOrder = service.upload(0L, "CONTAINER_PICKUP_ORDER", "41", 7L, file);
var unloadPhoto = service.upload(11L, "OVERSEAS_UNLOAD_PHOTO", "51", 7L, file);
assertThat(clearance).extracting(InternalBusinessFileService.Metadata::tenantId, assertThat(clearance).extracting(InternalBusinessFileService.Metadata::tenantId,
InternalBusinessFileService.Metadata::businessType, InternalBusinessFileService.Metadata::businessType,
@@ -57,8 +60,13 @@ class InternalBusinessFileServiceTest {
InternalBusinessFileService.Metadata::businessId).containsExactly(0L, "LOADING_CUSTOMS_DOCUMENT", "51"); InternalBusinessFileService.Metadata::businessId).containsExactly(0L, "LOADING_CUSTOMS_DOCUMENT", "51");
assertThat(deliveryOrder.businessType()).isEqualTo("CONTAINER_DELIVERY_ORDER"); assertThat(deliveryOrder.businessType()).isEqualTo("CONTAINER_DELIVERY_ORDER");
assertThat(pickupOrder.businessType()).isEqualTo("CONTAINER_PICKUP_ORDER"); assertThat(pickupOrder.businessType()).isEqualTo("CONTAINER_PICKUP_ORDER");
assertThat(unloadPhoto).extracting(InternalBusinessFileService.Metadata::tenantId,
InternalBusinessFileService.Metadata::businessType,
InternalBusinessFileService.Metadata::businessId)
.containsExactly(11L, "OVERSEAS_UNLOAD_PHOTO", "51");
verify(storage, times(3)).storeLoading(0L, 41L, file); verify(storage, times(3)).storeLoading(0L, 41L, file);
verify(storage).storeLoading(0L, 51L, file); verify(storage).storeLoading(0L, 51L, file);
verify(storage).storeLoading(11L, 51L, file);
} }
@Test void rejectsNegativeTenantAndInvalidOwnershipInputs() { @Test void rejectsNegativeTenantAndInvalidOwnershipInputs() {
@@ -109,9 +109,19 @@ public class LoadingCompletedOutboxService {
Map<Long, List<String>> result = new java.util.LinkedHashMap<>(); Map<Long, List<String>> result = new java.util.LinkedHashMap<>();
for (Row row : rows) { for (Row row : rows) {
if (dropship(row.getBusinessType())) continue; if (dropship(row.getBusinessType())) continue;
if (row.getSkuCode() == null || row.getSkuCode().isBlank()) {
throw new BizException(ResultCode.CONFLICT, "装柜明细缺少 SKU,不能建立箱唛与 SKU 的对应关系");
}
List<String> labels = Objects.requireNonNullElse(cartonManifestMapper.labelPool(row.getAccountId(), List<String> labels = Objects.requireNonNullElse(cartonManifestMapper.labelPool(row.getAccountId(),
row.getSourceInboundId(), row.getSourceInboundDetailId()), List.of()); row.getSourceInboundId(), row.getSourceInboundDetailId()), List.of());
if (labels.isEmpty()) throw new BizException(ResultCode.CONFLICT, "装柜明细没有已打印的可用箱唛池,不能生成海外交接事件"); if (labels.isEmpty()) throw new BizException(ResultCode.CONFLICT, "装柜明细没有已打印的可用箱唛池,不能生成海外交接事件");
if (labels.stream().anyMatch(label -> label == null || label.isBlank())
|| labels.stream().distinct().count() != labels.size()) {
throw new BizException(ResultCode.CONFLICT, "装柜明细箱唛池包含空值或重复箱号");
}
if (labels.size() < Objects.requireNonNullElse(row.getPackageCount(), 0)) {
throw new BizException(ResultCode.CONFLICT, "装柜明细箱唛数量少于实装箱数,不能生成海外交接事件");
}
result.put(row.getLoadingPlanItemId(), labels); result.put(row.getLoadingPlanItemId(), labels);
} }
return result; return result;
@@ -19,6 +19,7 @@ import java.math.BigDecimal;
import java.util.List; import java.util.List;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
@@ -52,10 +53,21 @@ class LoadingCompletedOutboxServiceTest {
assertThat(first.payload().get("orders")).hasSize(2); assertThat(first.payload().get("orders")).hasSize(2);
assertThat(first.payload().at("/orders/0/sourceInboundId").asText()).isEqualTo("100"); assertThat(first.payload().at("/orders/0/sourceInboundId").asText()).isEqualTo("100");
assertThat(first.payload().at("/orders/0/items/0/sourceInboundDetailId").asText()).isEqualTo("101"); assertThat(first.payload().at("/orders/0/items/0/sourceInboundDetailId").asText()).isEqualTo("101");
assertThat(first.payload().at("/orders/0/items/0/skuCode").asText()).isEqualTo("SKU101");
assertThat(first.payload().at("/orders/0/items/0/cartonMarks/0").asText()).isEqualTo("BOX-POOL-1");
assertThat(rows.get(0).getEventKey()).isEqualTo("FIRSTLEG_LOADING_COMPLETED:90:11:2"); assertThat(rows.get(0).getEventKey()).isEqualTo("FIRSTLEG_LOADING_COMPLETED:90:11:2");
assertThat(first.eventVersion()).isEqualTo(2); assertThat(first.eventVersion()).isEqualTo(2);
} }
@Test void rejectsOrdinaryItemWhenSkuToCartonMappingCannotBeBuilt() {
Row value = row(11, 100, 101);
value.setSkuCode(null);
when(snapshots.rows(90L)).thenReturn(List.of(value));
assertThatThrownBy(() -> service.publish(90L))
.hasMessageContaining("缺少 SKU");
}
@Test void snapshotOnlyPublishesFinalizedLoadedInbounds() throws Exception { @Test void snapshotOnlyPublishesFinalizedLoadedInbounds() throws Exception {
String sql = String.join(" ", LoadingCompletedEventMapper.class.getMethod("rows", long.class) String sql = String.join(" ", LoadingCompletedEventMapper.class.getMethod("rows", long.class)
.getAnnotation(Select.class).value()).replaceAll("\\s+", " "); .getAnnotation(Select.class).value()).replaceAll("\\s+", " ");
@@ -13,15 +13,15 @@ import java.util.Map;
* 尾程预留服务状态接口,用于验证尾程服务边界及基础可用性。 * 尾程预留服务状态接口,用于验证尾程服务边界及基础可用性。
*/ */
@Tag( @Tag(
name = "Last-leg service status / 尾程服务状态", name = "尾程服务状态",
description = "Last-leg reserved service availability API. / 尾程预留服务的可用性接口。" description = "尾程服务可用性接口"
) )
@RestController @RestController
@RequestMapping("/api/platform/v1/last-leg") @RequestMapping("/api/platform/v1/last-leg")
public class LastlegHealthController { public class LastlegHealthController {
@Operation( @Operation(
summary = "Get service status / 查询服务状态", summary = "查询服务状态",
description = "Returns the logical status of the reserved lastleg-service boundary. / 返回预留 lastleg-service 边界的逻辑运行状态。" description = "返回尾程服务边界的逻辑运行状态"
) )
@io.swagger.v3.oas.annotations.responses.ApiResponse( @io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200", responseCode = "200",
@@ -5,7 +5,7 @@ import io.swagger.v3.oas.annotations.media.Schema;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDateTime; import java.time.LocalDateTime;
@Schema(description = "Tail-leg order row at inbound-code granularity") @Schema(description = "按入仓单编码粒度展示的尾程订单列表行")
public record LastlegOrderSummary( public record LastlegOrderSummary(
Long id, Long id,
String orderNo, String orderNo,
@@ -16,6 +16,9 @@ import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.http.MediaType;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
import java.util.List; import java.util.List;
@@ -114,6 +117,15 @@ public class InboundPdaController {
return ApiResponse.success(service.uploadUnloadPhotos(scope(user, customerId), planNo, request)); return ApiResponse.success(service.uploadUnloadPhotos(scope(user, customerId), planNo, request));
} }
@PostMapping(value = "/unload-photos/files", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ApiResponse<PdaResponses.UnloadPhotoFile> uploadUnloadPhotoFile(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String planNo,
@RequestParam(required = false) String customerId, @RequestPart("file") MultipartFile file) {
requireOperate(user);
return ApiResponse.success(service.uploadUnloadPhotoFile(
scope(user, customerId), planNo, user.userId(), file));
}
private String scope(AuthenticatedUser user, String requestedCustomer) { private String scope(AuthenticatedUser user, String requestedCustomer) {
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) { if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) {
@@ -12,6 +12,8 @@ public final class PdaResponses {
List<Long> unloadPhotoFileIds, LocalDateTime unloadPhotoUploadedAt, List<Long> unloadPhotoFileIds, LocalDateTime unloadPhotoUploadedAt,
LocalDateTime estimatedEmptyReturnAt) {} LocalDateTime estimatedEmptyReturnAt) {}
public record UnloadPhotoFile(long fileId, String fileName, String contentType, long sizeBytes) {}
public record Operation(String result, long allocationId, int requiredPackageCount, public record Operation(String result, long allocationId, int requiredPackageCount,
int countedPackageCount, int pickedPackageCount, int countedPackageCount, int pickedPackageCount,
BigDecimal actualVolumeCbm, Integer remainingPackageCount, BigDecimal actualVolumeCbm, Integer remainingPackageCount,
@@ -0,0 +1,79 @@
package com.baiyue.lastleg.inboundplan.pda.gateway;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.MediaType;
import org.springframework.http.client.MultipartBodyBuilder;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
@Component
public class HttpUnloadPhotoFileGateway implements UnloadPhotoFileGateway {
private static final String BUSINESS_TYPE = "OVERSEAS_UNLOAD_PHOTO";
@Resource private RestClient.Builder restClientBuilder;
@Value("${baiyue.clients.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 = restClientBuilder.baseUrl(baseUrl).build(); }
@Override
public UploadedFile upload(String customerId, long taskId, long operatorId, MultipartFile file) {
long tenantId = tenantId(customerId);
MultipartBodyBuilder body = new MultipartBodyBuilder();
try {
body.part("file", new ByteArrayResource(file.getBytes()) {
@Override public String getFilename() { return file.getOriginalFilename(); }
}).contentType(file.getContentType() == null ? MediaType.APPLICATION_OCTET_STREAM
: MediaType.parseMediaType(file.getContentType()));
} catch (IOException exception) {
throw new IllegalArgumentException("Cannot read unload photo", exception);
}
MetadataEnvelope response = client.post()
.uri("/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}",
tenantId, BUSINESS_TYPE, taskId)
.header("X-Internal-Service-Token", internalToken).header("X-Operator-Id", String.valueOf(operatorId))
.contentType(MediaType.MULTIPART_FORM_DATA).body(body.build()).retrieve()
.onStatus(HttpStatusCode::isError, (request, result) -> {
throw new IllegalArgumentException("Unload photo upload failed");
}).body(MetadataEnvelope.class);
Metadata data = response == null ? null : response.data();
if (data == null || data.fileId() == null) throw new IllegalArgumentException("File service returned no photo metadata");
return new UploadedFile(data.fileId(), data.fileName(), data.contentType(),
data.sizeBytes() == null ? 0 : data.sizeBytes());
}
@Override
public void validate(String customerId, long taskId, long fileId) {
long tenantId = tenantId(customerId);
MetadataEnvelope response = client.get()
.uri("/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}/{fileId}",
tenantId, BUSINESS_TYPE, taskId, fileId)
.header("X-Internal-Service-Token", internalToken)
.retrieve().onStatus(HttpStatusCode::isError, (request, result) -> {
throw new IllegalArgumentException("Unload photo does not exist or its ownership is invalid");
}).body(MetadataEnvelope.class);
Metadata data = response == null ? null : response.data();
if (data == null || data.fileId() == null || data.fileId() != fileId
|| data.tenantId() == null || data.tenantId() != tenantId
|| !BUSINESS_TYPE.equals(data.businessType()) || !String.valueOf(taskId).equals(data.businessId())) {
throw new IllegalArgumentException("Unload photo metadata does not match the PDA task");
}
}
private long tenantId(String customerId) {
try { return Long.parseLong(customerId); }
catch (NumberFormatException exception) { throw new IllegalArgumentException("Customer ID must be numeric", exception); }
}
private record MetadataEnvelope(Metadata data) {}
private record Metadata(Long fileId, Long tenantId, String businessType, String businessId,
String fileName, String contentType, Long sizeBytes) {}
}
@@ -0,0 +1,10 @@
package com.baiyue.lastleg.inboundplan.pda.gateway;
import org.springframework.web.multipart.MultipartFile;
public interface UnloadPhotoFileGateway {
UploadedFile upload(String customerId, long taskId, long operatorId, MultipartFile file);
void validate(String customerId, long taskId, long fileId);
record UploadedFile(long fileId, String fileName, String contentType, long sizeBytes) {}
}
@@ -12,6 +12,7 @@ import com.baiyue.lastleg.inboundplan.pda.entity.InboundPalletMerge;
import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaOperation; import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaOperation;
import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask; import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask;
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
import com.baiyue.lastleg.inboundplan.pda.gateway.UnloadPhotoFileGateway;
import com.baiyue.lastleg.inboundplan.pda.repository.PdaWorkflowRepository; import com.baiyue.lastleg.inboundplan.pda.repository.PdaWorkflowRepository;
import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.MapperFeature; import com.fasterxml.jackson.databind.MapperFeature;
@@ -21,6 +22,7 @@ import com.fasterxml.jackson.core.type.TypeReference;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
import jakarta.annotation.PostConstruct; import jakarta.annotation.PostConstruct;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
@@ -40,12 +42,14 @@ import java.util.Objects;
@Service @Service
public class InboundPdaWorkflowService { public class InboundPdaWorkflowService {
private static final long MAX_UNLOAD_PHOTO_SIZE = 10L * 1024 * 1024;
private static final BigDecimal ZERO_VOLUME = new BigDecimal("0.0000"); private static final BigDecimal ZERO_VOLUME = new BigDecimal("0.0000");
@Resource private PdaWorkflowRepository repository; @Resource private PdaWorkflowRepository repository;
@Resource private WarehouseResourceGateway warehouse; @Resource private WarehouseResourceGateway warehouse;
@Resource private InboundStockReceiptService stockReceipts; @Resource private InboundStockReceiptService stockReceipts;
@Resource private InboundUnloadCompletedOutboxService unloadCompletedOutbox; @Resource private InboundUnloadCompletedOutboxService unloadCompletedOutbox;
@Resource private UnloadPhotoFileGateway unloadPhotoFiles;
@Resource private ObjectMapper sourceObjectMapper; @Resource private ObjectMapper sourceObjectMapper;
private ObjectMapper objectMapper; private ObjectMapper objectMapper;
@@ -421,6 +425,7 @@ public class InboundPdaWorkflowService {
InboundPdaTask task = requireActiveTask(customerId, plan); InboundPdaTask task = requireActiveTask(customerId, plan);
List<Long> fileIds = request.fileIds().stream().distinct().sorted().toList(); List<Long> fileIds = request.fileIds().stream().distinct().sorted().toList();
if (fileIds.size() != request.fileIds().size()) throw conflict("Unload photo file IDs cannot be duplicated"); if (fileIds.size() != request.fileIds().size()) throw conflict("Unload photo file IDs cannot be duplicated");
fileIds.forEach(fileId -> validateUnloadPhoto(customerId, task.getId(), fileId));
LocalDateTime uploadedAt = LocalDateTime.now(); LocalDateTime uploadedAt = LocalDateTime.now();
task.setUnloadPhotoFileIds(write(fileIds)); task.setUnloadPhotoFileIds(write(fileIds));
task.setUnloadPhotoUploadedAt(uploadedAt); task.setUnloadPhotoUploadedAt(uploadedAt);
@@ -433,6 +438,21 @@ public class InboundPdaWorkflowService {
return result; return result;
} }
public PdaResponses.UnloadPhotoFile uploadUnloadPhotoFile(String customerId, String planNo,
long operatorId, MultipartFile file) {
validateUnloadPhotoFile(file);
InboundPlan plan = requirePlan(customerId, planNo);
InboundPdaTask task = requireActiveTask(customerId, plan);
final UnloadPhotoFileGateway.UploadedFile uploaded;
try {
uploaded = unloadPhotoFiles.upload(customerId, task.getId(), operatorId, file);
} catch (IllegalArgumentException exception) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, exception.getMessage(), exception);
}
return new PdaResponses.UnloadPhotoFile(uploaded.fileId(), uploaded.fileName(),
uploaded.contentType(), uploaded.sizeBytes());
}
private void moveSourceAllocation(Long planId, InboundPlanPalletAllocation source, Long targetPalletId, private void moveSourceAllocation(Long planId, InboundPlanPalletAllocation source, Long targetPalletId,
int packageCount, BigDecimal movedVolume) { int packageCount, BigDecimal movedVolume) {
source.setPickedPackageCount(value(source.getPickedPackageCount()) - packageCount); source.setPickedPackageCount(value(source.getPickedPackageCount()) - packageCount);
@@ -623,6 +643,27 @@ public class InboundPdaWorkflowService {
} }
} }
private void validateUnloadPhoto(String customerId, long taskId, long fileId) {
try {
unloadPhotoFiles.validate(customerId, taskId, fileId);
} catch (IllegalArgumentException exception) {
throw conflict("Unload photo does not exist or does not belong to this PDA task");
}
}
private static void validateUnloadPhotoFile(MultipartFile file) {
if (file == null || file.isEmpty()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Unload photo cannot be empty");
}
if (file.getSize() > MAX_UNLOAD_PHOTO_SIZE) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Unload photo cannot exceed 10 MB");
}
String contentType = file.getContentType();
if (!"image/jpeg".equalsIgnoreCase(contentType) && !"image/png".equalsIgnoreCase(contentType)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Unload photo must be JPG or PNG");
}
}
private BigDecimal perPackageVolume(InboundPlanLine line) { private BigDecimal perPackageVolume(InboundPlanLine line) {
int required = value(line.getRequiredPackageCount()); int required = value(line.getRequiredPackageCount());
if (required <= 0) throw conflict("Inbound line required package count must be positive"); if (required <= 0) throw conflict("Inbound line required package count must be positive");
@@ -19,7 +19,9 @@ import java.math.BigDecimal;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.Objects; import java.util.Objects;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Set;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
@Service @Service
@@ -48,6 +50,7 @@ public class LoadingCompletedConsumer {
if(payload.containerNo()==null||payload.containerNo().isBlank()) if(payload.containerNo()==null||payload.containerNo().isBlank())
throw new IllegalArgumentException("事件没有实际集装箱号 containerNo"); throw new IllegalArgumentException("事件没有实际集装箱号 containerNo");
if(payload.orders()==null||payload.orders().isEmpty()) throw new IllegalArgumentException("事件没有入仓单"); if(payload.orders()==null||payload.orders().isEmpty()) throw new IllegalArgumentException("事件没有入仓单");
if (envelope.eventVersion() == 2) validateCartonSkuMappings(payload);
LastlegEventInbox existing=inboxMapper.selectById(envelope.eventId()); LastlegEventInbox existing=inboxMapper.selectById(envelope.eventId());
if(existing!=null) { if(existing!=null) {
if(!existing.getPayloadHash().equalsIgnoreCase(envelope.payloadHash())) throw new IllegalArgumentException("事件摘要冲突"); if(!existing.getPayloadHash().equalsIgnoreCase(envelope.payloadHash())) throw new IllegalArgumentException("事件摘要冲突");
@@ -88,6 +91,39 @@ public class LoadingCompletedConsumer {
return Math.toIntExact(orderMapper.countContainer(envelope.tenantId(),containerNo)); return Math.toIntExact(orderMapper.countContainer(envelope.tenantId(),containerNo));
} }
private void validateCartonSkuMappings(FirstLegLoadingPlanCompletedV1 payload) {
for (FirstLegLoadingPlanCompletedV1.Order order : payload.orders()) {
Set<String> orderCartonMarks = new HashSet<>();
if ("ONE_CLICK_DROPSHIP".equals(order.orderType())) {
for (FirstLegLoadingPlanCompletedV1.PalletSnapshot pallet : order.pallets()) {
for (FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot sku : pallet.skuPackages()) {
requireSkuCartonMapping(sku.skuCode(), sku.packageCount(), sku.cartonMarks(), orderCartonMarks);
}
}
continue;
}
for (FirstLegLoadingPlanCompletedV1.Item item : order.items()) {
requireSkuCartonMapping(item.skuCode(), item.packageCount(), item.cartonMarks(), orderCartonMarks);
}
}
}
private void requireSkuCartonMapping(String skuCode, int loadedPackages, List<String> cartonMarks,
Set<String> orderCartonMarks) {
if (skuCode == null || skuCode.isBlank()) {
throw new IllegalArgumentException("V2 尾程交接事件的箱唛映射缺少 SKU");
}
List<String> marks = cartonMarks == null ? List.of() : cartonMarks;
if (marks.size() < loadedPackages) {
throw new IllegalArgumentException("V2 尾程交接事件的 SKU 箱唛数量少于实装箱数: " + skuCode);
}
for (String mark : marks) {
if (mark == null || mark.isBlank() || !orderCartonMarks.add(mark)) {
throw new IllegalArgumentException("V2 尾程交接事件包含空值或重复箱唛: " + skuCode);
}
}
}
private void createOrder(EventEnvelope event,FirstLegLoadingPlanCompletedV1 payload, private void createOrder(EventEnvelope event,FirstLegLoadingPlanCompletedV1 payload,
FirstLegLoadingPlanCompletedV1.Order source,String orderNo, JsonNode rawSource) { FirstLegLoadingPlanCompletedV1.Order source,String orderNo, JsonNode rawSource) {
String orderHash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(rawSource)); String orderHash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(rawSource));
@@ -120,7 +156,8 @@ public class LoadingCompletedConsumer {
order.setVolumeWeightKg(source.items().stream().map(FirstLegLoadingPlanCompletedV1.Item::dimensionalWeightKg) order.setVolumeWeightKg(source.items().stream().map(FirstLegLoadingPlanCompletedV1.Item::dimensionalWeightKg)
.filter(Objects::nonNull).reduce(BigDecimal.ZERO,BigDecimal::add)); .filter(Objects::nonNull).reduce(BigDecimal.ZERO,BigDecimal::add));
order.setCurrentStatus("PENDING_ASSIGNMENT"); order.setSourceEventId(event.eventId()); order.setCurrentStatus("PENDING_ASSIGNMENT"); order.setSourceEventId(event.eventId());
order.setSourceLoadingPlanId(payload.loadingPlanId()); order.setSourceContainerBookingId(payload.containerBookingId()); order.setSourceLoadingPlanId(payload.loadingPlanId());
order.setSourceContainerBookingId(payload.containerBookingId());
order.setSourceLoadingPlanVersion(event.aggregateVersion()); order.setSourceLoadingPlanVersion(event.aggregateVersion());
order.setSourceHandoverVersion(event.handoverVersion()); order.setSourcePayloadHash(orderHash); order.setSourceHandoverVersion(event.handoverVersion()); order.setSourcePayloadHash(orderHash);
order.setCreatedAt(LocalDateTime.now()); order.setUpdatedAt(LocalDateTime.now()); order.setCreatedAt(LocalDateTime.now()); order.setUpdatedAt(LocalDateTime.now());
@@ -39,6 +39,10 @@ baiyue:
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:
url: ${FILE_SERVICE_URL:http://127.0.0.1:9205}
event-bridge:
url: ${CN_EVENT_BRIDGE_URL:${FIRSTLEG_EVENT_BRIDGE_URL:}}
firstleg: firstleg:
event-bridge-url: ${FIRSTLEG_EVENT_BRIDGE_URL:} event-bridge-url: ${FIRSTLEG_EVENT_BRIDGE_URL:}
outbox-relay: outbox-relay:
@@ -10,6 +10,7 @@ import com.baiyue.lastleg.inboundplan.pda.entity.InboundPalletMerge;
import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaOperation; import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaOperation;
import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask; import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask;
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
import com.baiyue.lastleg.inboundplan.pda.gateway.UnloadPhotoFileGateway;
import com.baiyue.lastleg.inboundplan.pda.repository.PdaWorkflowRepository; import com.baiyue.lastleg.inboundplan.pda.repository.PdaWorkflowRepository;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
@@ -20,6 +21,7 @@ import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.mock.web.MockMultipartFile;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDateTime; import java.time.LocalDateTime;
@@ -33,6 +35,7 @@ import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.atLeastOnce; import static org.mockito.Mockito.atLeastOnce;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.never; import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times; import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
@@ -44,6 +47,7 @@ class InboundPdaWorkflowServiceTest {
@Mock WarehouseResourceGateway warehouse; @Mock WarehouseResourceGateway warehouse;
@Mock InboundStockReceiptService stockReceipts; @Mock InboundStockReceiptService stockReceipts;
@Mock InboundUnloadCompletedOutboxService unloadCompletedOutbox; @Mock InboundUnloadCompletedOutboxService unloadCompletedOutbox;
@Mock UnloadPhotoFileGateway unloadPhotoFiles;
private InboundPdaWorkflowService service; private InboundPdaWorkflowService service;
private InboundPlan plan; private InboundPlan plan;
@@ -64,6 +68,7 @@ class InboundPdaWorkflowServiceTest {
ReflectionTestUtils.setField(service, "warehouse", warehouse); ReflectionTestUtils.setField(service, "warehouse", warehouse);
ReflectionTestUtils.setField(service, "stockReceipts", stockReceipts); ReflectionTestUtils.setField(service, "stockReceipts", stockReceipts);
ReflectionTestUtils.setField(service, "unloadCompletedOutbox", unloadCompletedOutbox); ReflectionTestUtils.setField(service, "unloadCompletedOutbox", unloadCompletedOutbox);
ReflectionTestUtils.setField(service, "unloadPhotoFiles", unloadPhotoFiles);
ReflectionTestUtils.setField(service, "sourceObjectMapper", new ObjectMapper().findAndRegisterModules()); ReflectionTestUtils.setField(service, "sourceObjectMapper", new ObjectMapper().findAndRegisterModules());
service.initializeObjectMapper(); service.initializeObjectMapper();
plan = plan(); plan = plan();
@@ -76,7 +81,7 @@ class InboundPdaWorkflowServiceTest {
target = allocation(31L, targetLine.getId(), targetPallet.getId(), 3, 3, 0); target = allocation(31L, targetLine.getId(), targetPallet.getId(), 3, 3, 0);
source = allocation(32L, sourceLine.getId(), sourcePallet.getId(), 2, 2, 2); source = allocation(32L, sourceLine.getId(), sourcePallet.getId(), 2, 2, 2);
otherWarehouse = allocation(33L, sourceLine.getId(), otherWarehousePallet.getId(), 2, 2, 2); otherWarehouse = allocation(33L, sourceLine.getId(), otherWarehousePallet.getId(), 2, 2, 2);
when(repository.lockPlan("100", "LIP1")).thenReturn(plan); lenient().when(repository.lockPlan("100", "LIP1")).thenReturn(plan);
} }
@Test @Test
@@ -326,6 +331,8 @@ class InboundPdaWorkflowServiceTest {
assertEquals(List.of(101L, 103L), result.unloadPhotoFileIds()); assertEquals(List.of(101L, 103L), result.unloadPhotoFileIds());
assertEquals(result.unloadPhotoUploadedAt().plusDays(2), result.estimatedEmptyReturnAt()); assertEquals(result.unloadPhotoUploadedAt().plusDays(2), result.estimatedEmptyReturnAt());
verify(unloadPhotoFiles).validate("100", task.getId(), 101L);
verify(unloadPhotoFiles).validate("100", task.getId(), 103L);
verify(repository).update(task); verify(repository).update(task);
ArgumentCaptor<InboundPdaOperation> operation = ArgumentCaptor.forClass(InboundPdaOperation.class); ArgumentCaptor<InboundPdaOperation> operation = ArgumentCaptor.forClass(InboundPdaOperation.class);
verify(repository).insert(operation.capture()); verify(repository).insert(operation.capture());
@@ -344,6 +351,54 @@ class InboundPdaWorkflowServiceTest {
verify(repository, never()).update(task); verify(repository, never()).update(task);
} }
@Test
void uploadUnloadPhotosRejectsAFileOwnedByAnotherBusinessTask() {
activeTask();
org.mockito.Mockito.doThrow(new IllegalArgumentException("ownership mismatch"))
.when(unloadPhotoFiles).validate("100", task.getId(), 101L);
assertThrows(ResponseStatusException.class, () -> service.uploadUnloadPhotos("100", "LIP1",
new PdaRequests.UnloadPhotos(List.of(101L), "unload-photo-wrong-owner", "7", "worker")));
verify(repository, never()).update(task);
}
@Test
void uploadUnloadPhotoFileBindsTheFileToTheActivePdaTask() {
activeTask();
var file = new MockMultipartFile("file", "unload.jpg", "image/jpeg", new byte[] {1, 2, 3});
when(unloadPhotoFiles.upload("100", task.getId(), 7L, file))
.thenReturn(new UnloadPhotoFileGateway.UploadedFile(101L, "unload.jpg", "image/jpeg", 3L));
var result = service.uploadUnloadPhotoFile("100", "LIP1", 7L, file);
assertEquals(101L, result.fileId());
assertEquals("unload.jpg", result.fileName());
assertEquals("image/jpeg", result.contentType());
assertEquals(3L, result.sizeBytes());
verify(unloadPhotoFiles).upload("100", task.getId(), 7L, file);
}
@Test
void uploadUnloadPhotoFileRejectsEmptyFileBeforeCallingFileService() {
var file = new MockMultipartFile("file", "unload.jpg", "image/jpeg", new byte[0]);
assertThrows(ResponseStatusException.class,
() -> service.uploadUnloadPhotoFile("100", "LIP1", 7L, file));
verify(unloadPhotoFiles, never()).upload(any(), anyLong(), anyLong(), any());
}
@Test
void uploadUnloadPhotoFileRejectsNonImageBeforeCallingFileService() {
var file = new MockMultipartFile("file", "unload.pdf", "application/pdf", new byte[] {1});
assertThrows(ResponseStatusException.class,
() -> service.uploadUnloadPhotoFile("100", "LIP1", 7L, file));
verify(unloadPhotoFiles, never()).upload(any(), anyLong(), anyLong(), any());
}
@Test @Test
void completesWhenMergedAllocationsPreserveExactLineTotals() { void completesWhenMergedAllocationsPreserveExactLineTotals() {
activeTask(); activeTask();
@@ -1,24 +1,43 @@
package com.baiyue.lastleg.service; package com.baiyue.lastleg.service;
import com.baiyue.common.event.*; import com.baiyue.lastleg.entity.*; import com.baiyue.lastleg.mapper.*; import com.baiyue.lastleg.dropship.dto.DropshipRequests; import com.baiyue.lastleg.dropship.service.DropshipOrderService; import com.baiyue.lastleg.inboundplan.service.InboundPlanService; import com.fasterxml.jackson.databind.*; import org.junit.jupiter.api.*; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.*; import org.springframework.test.util.ReflectionTestUtils; import java.math.BigDecimal; import java.time.Instant; import java.util.*; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; import com.baiyue.common.event.*; import com.baiyue.lastleg.entity.*; import com.baiyue.lastleg.mapper.*; import com.baiyue.lastleg.dropship.dto.DropshipRequests; import com.baiyue.lastleg.dropship.service.DropshipOrderService; import com.baiyue.lastleg.inboundplan.mapper.InboundPlanCartonMapper; import com.baiyue.lastleg.inboundplan.service.InboundPlanService; import com.fasterxml.jackson.databind.*; import org.junit.jupiter.api.*; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.*; import org.springframework.test.util.ReflectionTestUtils; import java.math.BigDecimal; import java.time.Instant; import java.util.*; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*;
@ExtendWith(org.mockito.junit.jupiter.MockitoExtension.class) @ExtendWith(org.mockito.junit.jupiter.MockitoExtension.class)
class LoadingCompletedConsumerTest { class LoadingCompletedConsumerTest {
@Mock LastlegEventInboxMapper inbox; @Mock LastlegOrderMapper orders; @Mock LastlegOrderDetailMapper details; @Mock LastlegOrderStatusHistoryMapper histories; @Mock DropshipOrderService dropshipOrders; @Mock InboundPlanService inboundPlans; ObjectMapper json=new ObjectMapper().findAndRegisterModules(); LoadingCompletedConsumer service; @Mock LastlegEventInboxMapper inbox; @Mock LastlegOrderMapper orders; @Mock LastlegOrderDetailMapper details; @Mock LastlegOrderStatusHistoryMapper histories; @Mock InboundPlanCartonMapper cartons; @Mock DropshipOrderService dropshipOrders; @Mock InboundPlanService inboundPlans; 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,"detailMapper",details); ReflectionTestUtils.setField(service,"historyMapper",histories); ReflectionTestUtils.setField(service,"dropshipOrders",dropshipOrders); ReflectionTestUtils.setField(service,"inboundPlans",inboundPlans); ReflectionTestUtils.setField(service,"objectMapper",json); ReflectionTestUtils.setField(service,"orderNoGenerator",new LastlegOrderNoGenerator()); lenient().doAnswer(i->{ LastlegOrder o=i.getArgument(0); o.setId(Math.abs(o.getSourceInboundId().hashCode())+1L); return 1; }).when(orders).insert(any(LastlegOrder.class)); } @BeforeEach void setup(){ service=new LoadingCompletedConsumer(); ReflectionTestUtils.setField(service,"inboxMapper",inbox); ReflectionTestUtils.setField(service,"orderMapper",orders); ReflectionTestUtils.setField(service,"detailMapper",details); ReflectionTestUtils.setField(service,"historyMapper",histories); ReflectionTestUtils.setField(service,"cartonMapper",cartons); ReflectionTestUtils.setField(service,"dropshipOrders",dropshipOrders); ReflectionTestUtils.setField(service,"inboundPlans",inboundPlans); ReflectionTestUtils.setField(service,"objectMapper",json); ReflectionTestUtils.setField(service,"orderNoGenerator",new LastlegOrderNoGenerator()); lenient().doAnswer(i->{ LastlegOrder o=i.getArgument(0); o.setId(Math.abs(o.getSourceInboundId().hashCode())+1L); return 1; }).when(orders).insert(any(LastlegOrder.class)); }
@Test void createsOneOrderAndOneInboundPlanPerInbound(){ EventEnvelope event=event(); assertEquals(2,service.consume(event)); verify(orders,times(2)).insert(any(LastlegOrder.class)); verify(inboundPlans,times(2)).convert(eq("100"),anyLong()); 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.getId()!=null)); assertTrue(c.getAllValues().stream().allMatch(o->"MSCU1".equals(o.getContainerNo()))); assertEquals(1,c.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertEquals(new LastlegOrderNoGenerator().forContainerNo("MSCU1"),c.getAllValues().get(0).getOrderNo()); } @Test void createsOneOrderAndOneInboundPlanPerInbound(){ EventEnvelope event=event(); assertEquals(2,service.consume(event)); verify(orders,times(2)).insert(any(LastlegOrder.class)); verify(inboundPlans,times(2)).convert(eq("100"),anyLong()); 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.getId()!=null)); assertTrue(c.getAllValues().stream().allMatch(o->"MSCU1".equals(o.getContainerNo()))); assertEquals(1,c.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertEquals(new LastlegOrderNoGenerator().forContainerNo("MSCU1"),c.getAllValues().get(0).getOrderNo()); }
@Test void repeatedEventReturnsExistingEffect(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash(event.payloadHash()); when(inbox.selectById(event.eventId())).thenReturn(row); when(orders.countContainer("100","MSCU1")).thenReturn(2L); assertEquals(2,service.consume(event)); verify(orders).countContainer("100","MSCU1"); verify(orders,never()).insert(any(LastlegOrder.class)); verify(details,never()).insert(any(LastlegOrderDetail.class)); } @Test void repeatedEventReturnsExistingEffect(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash(event.payloadHash()); when(inbox.selectById(event.eventId())).thenReturn(row); when(orders.countContainer("100","MSCU1")).thenReturn(2L); assertEquals(2,service.consume(event)); verify(orders).countContainer("100","MSCU1"); verify(orders,never()).insert(any(LastlegOrder.class)); verify(details,never()).insert(any(LastlegOrderDetail.class)); }
@Test void sameEventWithDifferentHashIsRejected(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash("sha256:"+"0".repeat(64)); when(inbox.selectById(event.eventId())).thenReturn(row); assertThrows(IllegalArgumentException.class,()->service.consume(event)); } @Test void sameEventWithDifferentHashIsRejected(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash("sha256:"+"0".repeat(64)); when(inbox.selectById(event.eventId())).thenReturn(row); assertThrows(IllegalArgumentException.class,()->service.consume(event)); }
@Test void rejectsEventFromWrongRegion(){ EventEnvelope source=event(); EventEnvelope invalid=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),RegionCode.OVERSEAS,RegionCode.CN,source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),source.payload(),source.payloadHash()); assertThrows(IllegalArgumentException.class,()->service.consume(invalid)); } @Test void rejectsEventFromWrongRegion(){ EventEnvelope source=event(); EventEnvelope invalid=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),RegionCode.OVERSEAS,RegionCode.CN,source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),source.payload(),source.payloadHash()); assertThrows(IllegalArgumentException.class,()->service.consume(invalid)); }
@Test void consumesFormalDropshipPalletSnapshots(){ var sku=new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot("SKU-A","Product A",2,20,List.of("BOX-1","BOX-2")); var pallet=new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-1",List.of(sku)); var item=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-A","Product A","Product A",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,null,"MARK",null,List.of()); var order=new FirstLegLoadingPlanCompletedV1.Order("in-d","root-d","ord-d","FL-D","root-od","RK-D","wh-1","ONT8","ONT8",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,List.of(item),"100","ONE_CLICK_DROPSHIP",List.of(pallet)); var payload=new FirstLegLoadingPlanCompletedV1("plan-d","LP-D","book-d","CONT-D","CAB-D","92","Warehouse",List.of(order)); JsonNode node=json.valueToTree(payload); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); var event=new EventEnvelope("123e4567-e89b-12d3-a456-426614174010",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN","plan-d",2,1L,"123e4567-e89b-12d3-a456-426614174011","FIRSTLEG_LOADING_COMPLETED:plan-d:100:1",null,node,hash); assertEquals(1,service.consume(event)); ArgumentCaptor<DropshipRequests.Receive> captor=ArgumentCaptor.forClass(DropshipRequests.Receive.class); verify(dropshipOrders).receive(captor.capture()); assertEquals("100",captor.getValue().customerId()); assertEquals(1,captor.getValue().pallets().size()); assertEquals("PLT-1",captor.getValue().pallets().get(0).palletNo()); assertEquals(List.of("BOX-1","BOX-2"),captor.getValue().pallets().get(0).skuPackages().get(0).cartonMarks()); } @Test void consumesFormalDropshipPalletSnapshots(){ var sku=new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot("SKU-A","Product A",2,20,List.of("BOX-1","BOX-2")); var pallet=new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-1",List.of(sku)); var item=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-A","Product A","Product A",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,null,"MARK",null,List.of()); var order=new FirstLegLoadingPlanCompletedV1.Order("in-d","root-d","ord-d","FL-D","root-od","RK-D","wh-1","ONT8","ONT8",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,List.of(item),"100","ONE_CLICK_DROPSHIP",List.of(pallet)); var payload=new FirstLegLoadingPlanCompletedV1("plan-d","LP-D","book-d","CONT-D","CAB-D","92","Warehouse",List.of(order)); JsonNode node=json.valueToTree(payload); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); var event=new EventEnvelope("123e4567-e89b-12d3-a456-426614174010",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN","plan-d",2,1L,"123e4567-e89b-12d3-a456-426614174011","FIRSTLEG_LOADING_COMPLETED:plan-d:100:1",null,node,hash); assertEquals(1,service.consume(event)); ArgumentCaptor<DropshipRequests.Receive> captor=ArgumentCaptor.forClass(DropshipRequests.Receive.class); verify(dropshipOrders).receive(captor.capture()); assertEquals("100",captor.getValue().customerId()); assertEquals(1,captor.getValue().pallets().size()); assertEquals("PLT-1",captor.getValue().pallets().get(0).palletNo()); assertEquals(List.of("BOX-1","BOX-2"),captor.getValue().pallets().get(0).skuPackages().get(0).cartonMarks()); }
@Test void consumesV2LoadingEventAndKeepsDropshipSnapshot(){ @Test void consumesV2LoadingEventAndKeepsDropshipSnapshot(){
EventEnvelope source=event(); EventEnvelope source=event();
JsonNode node=source.payload().deepCopy(); JsonNode node=source.payload().deepCopy();
((com.fasterxml.jackson.databind.node.ObjectNode) node.path("orders").get(0).path("items").get(0)).put("customerRemark","fragile"); ((com.fasterxml.jackson.databind.node.ObjectNode) node.path("orders").get(0).path("items").get(0)).put("customerRemark","fragile");
((com.fasterxml.jackson.databind.node.ObjectNode) node.path("orders").get(0).path("items").get(0))
.set("cartonMarks", json.valueToTree(List.of("BOX-1","BOX-2","BOX-3","BOX-4","BOX-5")));
((com.fasterxml.jackson.databind.node.ObjectNode) node.path("orders").get(1).path("items").get(0))
.set("cartonMarks", json.valueToTree(List.of("BOX-6","BOX-7","BOX-8")));
String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node));
EventEnvelope v2=new EventEnvelope(source.eventId(),source.eventType(),2,source.occurredAt(),source.sourceRegion(), EventEnvelope v2=new EventEnvelope(source.eventId(),source.eventType(),2,source.occurredAt(),source.sourceRegion(),
source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(), source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),
2L,source.traceId(),source.correlationId(),source.causationId(),node,hash); 2L,source.traceId(),source.correlationId(),source.causationId(),node,hash);
assertEquals(2,service.consume(v2)); assertEquals(2,service.consume(v2));
verify(orders,times(2)).insert(any(LastlegOrder.class)); verify(orders,times(2)).insert(any(LastlegOrder.class));
ArgumentCaptor<com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton> cartonCaptor=
ArgumentCaptor.forClass(com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton.class);
verify(cartons,times(8)).insert(cartonCaptor.capture());
assertEquals("SKU-1",cartonCaptor.getAllValues().get(0).getSkuCode());
assertEquals("BOX-1",cartonCaptor.getAllValues().get(0).getCartonMark());
}
@Test void rejectsV2EventWithoutSkuCartonMapping(){
EventEnvelope source=event(); JsonNode node=source.payload().deepCopy();
String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node));
EventEnvelope v2=new EventEnvelope(source.eventId(),source.eventType(),2,source.occurredAt(),source.sourceRegion(),
source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),
2L,source.traceId(),source.correlationId(),source.causationId(),node,hash);
IllegalArgumentException error=assertThrows(IllegalArgumentException.class,()->service.consume(v2));
assertTrue(error.getMessage().contains("箱唛数量少于实装箱数"));
verify(orders,never()).insert(any(LastlegOrder.class));
} }
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","MSCU1","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)),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)))); 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","MSCU1","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)),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)))); 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); }
} }