diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2.java b/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2.java index 097bf69e0..07e473d43 100644 --- a/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2.java +++ b/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2.java @@ -46,7 +46,9 @@ public record FirstLegLoadingPlanCompletedV2( String destinationWarehouseName, String fbaTrackingNo, String shipmentNo, String shippingMark, String customerRemark, String internalRemark, String warehouseRemark, - String palletNo, List cartonMarks) { + String palletNo, + /** 头程箱唛二维码中的箱号;这些箱号均对应本明细的 skuCode。 */ + List cartonMarks) { public Item { cartonMarks = cartonMarks == null ? List.of() : cartonMarks; } @@ -72,7 +74,7 @@ public record FirstLegLoadingPlanCompletedV2( /** 一键代发入仓单的托盘冻结快照。 */ public record PalletSnapshot(String palletNo, List skuPackages) {} - /** 托盘下 SKU、箱数、数量及箱唛冻结快照。 */ + /** 托盘下 SKU、箱数、数量及箱号冻结快照;cartonMarks 中每个箱号都对应 skuCode。 */ public record SkuPackageSnapshot(String skuCode, String productName, int packageCount, int productQuantity, List cartonMarks) {} diff --git a/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2Test.java b/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2Test.java index 0856e4da7..b5bb212f4 100644 --- a/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2Test.java +++ b/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2Test.java @@ -39,4 +39,19 @@ class FirstLegLoadingPlanCompletedV2Test { assertThat(json.at("/orders/0/orderType").asText()).isEqualTo("ONE_CLICK_DROPSHIP"); 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); + } } diff --git a/config/nacos/cn/event-bridge-service.yml b/config/nacos/cn/event-bridge-service.yml index baa031c2d..5b81ac297 100644 --- a/config/nacos/cn/event-bridge-service.yml +++ b/config/nacos/cn/event-bridge-service.yml @@ -10,3 +10,4 @@ baiyue: data-residency: ${DATA_RESIDENCY:CN} event-dispatcher: 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} diff --git a/config/nacos/overseas/lastleg-service.yml b/config/nacos/overseas/lastleg-service.yml index 5cdbb6d01..e22b8ec59 100644 --- a/config/nacos/overseas/lastleg-service.yml +++ b/config/nacos/overseas/lastleg-service.yml @@ -9,6 +9,10 @@ baiyue: domestic-warehouse: url: ${DOMESTIC_WAREHOUSE_URL} cache-ttl: ${DOMESTIC_WAREHOUSE_CACHE_TTL:30m} + file: + url: ${FILE_SERVICE_URL} + event-bridge: + url: ${CN_EVENT_BRIDGE_URL:${FIRSTLEG_EVENT_BRIDGE_URL}} warehouse: base-url: ${WAREHOUSE_OVERSEAS_SERVICE_URL:http://warehouse-overseas-service:9305} firstleg: diff --git a/deploy/docker-compose.overseas.yml b/deploy/docker-compose.overseas.yml index c7cc50e2f..e807b86e2 100644 --- a/deploy/docker-compose.overseas.yml +++ b/deploy/docker-compose.overseas.yml @@ -60,6 +60,7 @@ services: LASTLEG_DB_PASSWORD: ${LASTLEG_DB_PASSWORD} FIRSTLEG_EVENT_BRIDGE_URL: ${FIRSTLEG_EVENT_BRIDGE_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: diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 60790fe27..a435826b8 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -195,6 +195,7 @@ services: EVENT_BRIDGE_DB_USERNAME: ${EVENT_BRIDGE_DB_USERNAME} EVENT_BRIDGE_DB_PASSWORD: ${EVENT_BRIDGE_DB_PASSWORD} 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 audit-service: diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/mapper/BridgeInboxMapper.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/mapper/BridgeInboxMapper.java index f93a34ff2..568192832 100644 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/mapper/BridgeInboxMapper.java +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/mapper/BridgeInboxMapper.java @@ -16,8 +16,7 @@ import java.util.List; public interface BridgeInboxMapper extends BaseMapper { @Select(""" SELECT event_id FROM bridge_inbox - WHERE target_region = 'OVERSEAS' - AND ((status IN ('RECEIVED','RETRY') AND (next_retry_at IS NULL OR next_retry_at <= CURRENT_TIMESTAMP(3))) + WHERE ((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))) ORDER BY received_at LIMIT #{limit} """) @@ -26,7 +25,7 @@ public interface BridgeInboxMapper extends BaseMapper { @Update(""" UPDATE bridge_inbox SET status='PROCESSING', lease_owner=#{owner}, 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))) OR (status='PROCESSING' AND lease_until < CURRENT_TIMESTAMP(3))) """) diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/FirstlegEventClient.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/FirstlegEventClient.java new file mode 100644 index 000000000..a0bb0e33e --- /dev/null +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/FirstlegEventClient.java @@ -0,0 +1,7 @@ +package com.baiyue.eventbridge.service; + +import com.baiyue.eventbridge.domain.BridgeInbox; + +public interface FirstlegEventClient { + String deliver(BridgeInbox event) throws Exception; +} diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpFirstlegEventClient.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpFirstlegEventClient.java new file mode 100644 index 000000000..0aacdc3f3 --- /dev/null +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpFirstlegEventClient.java @@ -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 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()); + } +} diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/OverseasEventDispatcher.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/OverseasEventDispatcher.java index 76c1bf82f..b795a7019 100644 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/OverseasEventDispatcher.java +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/OverseasEventDispatcher.java @@ -20,6 +20,7 @@ public class OverseasEventDispatcher { @Resource private BridgeInboxMapper inboxMapper; @Resource private BridgeDeliveryAttemptMapper attemptMapper; @Resource private LastlegEventClient eventClient; + @Resource private FirstlegEventClient firstlegEventClient; @Resource private EventDispatcherProperties properties; private final String leaseOwner = ManagementFactory.getRuntimeMXBean().getName() + ":" + UUID.randomUUID(); @@ -40,7 +41,7 @@ public class OverseasEventDispatcher { int deliveryAttemptNo = inboxMapper.nextAttemptNo(eventId); BridgeDeliveryAttempt attempt = startAttempt(event, deliveryAttemptNo); try { - String responseCode = eventClient.deliver(event); + String responseCode = deliver(event); if (inboxMapper.markProcessed(eventId, leaseOwner) != 1) { throw new IllegalStateException("事件租约已失效,无法确认投递结果"); } @@ -49,11 +50,16 @@ public class OverseasEventDispatcher { String error = abbreviate(exception.getMessage(), 1000); inboxMapper.markFailed(eventId, leaseOwner, properties.maxAttempts(), backoffSeconds(retryAttempt), 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); } } + 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) { BridgeDeliveryAttempt attempt = new BridgeDeliveryAttempt(); attempt.setEventId(event.getEventId()); diff --git a/event-bridge-service/src/main/resources/application.yml b/event-bridge-service/src/main/resources/application.yml index 5d83c9689..e80450011 100644 --- a/event-bridge-service/src/main/resources/application.yml +++ b/event-bridge-service/src/main/resources/application.yml @@ -32,6 +32,7 @@ baiyue: region: ${REGION_CODE:OVERSEAS} event-dispatcher: 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} batch-size: ${EVENT_DISPATCH_BATCH_SIZE:50} max-attempts: ${EVENT_DISPATCH_MAX_ATTEMPTS:10} diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/OverseasEventDispatcherTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/OverseasEventDispatcherTest.java index f3f727fb0..67cb7b3d1 100644 --- a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/OverseasEventDispatcherTest.java +++ b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/OverseasEventDispatcherTest.java @@ -21,6 +21,7 @@ class OverseasEventDispatcherTest { private final BridgeInboxMapper inboxMapper = mock(BridgeInboxMapper.class); private final BridgeDeliveryAttemptMapper attemptMapper = mock(BridgeDeliveryAttemptMapper.class); private final LastlegEventClient eventClient = mock(LastlegEventClient.class); + private final FirstlegEventClient firstlegEventClient = mock(FirstlegEventClient.class); private OverseasEventDispatcher dispatcher; private BridgeInbox event; @@ -34,6 +35,7 @@ class OverseasEventDispatcherTest { ReflectionTestUtils.setField(dispatcher, "inboxMapper", inboxMapper); ReflectionTestUtils.setField(dispatcher, "attemptMapper", attemptMapper); ReflectionTestUtils.setField(dispatcher, "eventClient", eventClient); + ReflectionTestUtils.setField(dispatcher, "firstlegEventClient", firstlegEventClient); ReflectionTestUtils.setField(dispatcher, "properties", properties); event = new BridgeInbox(); event.setEventId("event-1"); @@ -83,9 +85,24 @@ class OverseasEventDispatcherTest { dispatcher.dispatchOne("event-1"); verifyNoInteractions(eventClient); + verifyNoInteractions(firstlegEventClient); 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() { ArgumentCaptor captor = ArgumentCaptor.forClass(BridgeDeliveryAttempt.class); verify(attemptMapper).updateById(captor.capture()); diff --git a/file/src/main/java/com/baiyue/file/service/InternalBusinessFileService.java b/file/src/main/java/com/baiyue/file/service/InternalBusinessFileService.java index 96faadfc4..d320f4aaa 100644 --- a/file/src/main/java/com/baiyue/file/service/InternalBusinessFileService.java +++ b/file/src/main/java/com/baiyue/file/service/InternalBusinessFileService.java @@ -22,7 +22,8 @@ public class InternalBusinessFileService { "CONTAINER_CLEARANCE_DOCUMENT", "LOADING_CUSTOMS_DOCUMENT", "CONTAINER_DELIVERY_ORDER", - "CONTAINER_PICKUP_ORDER"); + "CONTAINER_PICKUP_ORDER", + "OVERSEAS_UNLOAD_PHOTO"); @Resource private FileObjectMapper files; @Resource private BusinessFileStorage storage; diff --git a/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java b/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java index 44fd69118..7b8c7bcd6 100644 --- a/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java +++ b/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java @@ -40,6 +40,8 @@ class InternalBusinessFileServiceTest { when(storage.storeLoading(eq(0L), anyLong(), same(file))).thenAnswer(invocation -> new BusinessFileStorage.Stored("business-files/0/" + invocation.getArgument(1) + "/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 -> { ((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 deliveryOrder = service.upload(0L, "CONTAINER_DELIVERY_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, InternalBusinessFileService.Metadata::businessType, @@ -57,8 +60,13 @@ class InternalBusinessFileServiceTest { InternalBusinessFileService.Metadata::businessId).containsExactly(0L, "LOADING_CUSTOMS_DOCUMENT", "51"); assertThat(deliveryOrder.businessType()).isEqualTo("CONTAINER_DELIVERY_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).storeLoading(0L, 51L, file); + verify(storage).storeLoading(11L, 51L, file); } @Test void rejectsNegativeTenantAndInvalidOwnershipInputs() { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java index 922cd7cca..c0abb9c50 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java @@ -109,9 +109,19 @@ public class LoadingCompletedOutboxService { Map> result = new java.util.LinkedHashMap<>(); for (Row row : rows) { if (dropship(row.getBusinessType())) continue; + if (row.getSkuCode() == null || row.getSkuCode().isBlank()) { + throw new BizException(ResultCode.CONFLICT, "装柜明细缺少 SKU,不能建立箱唛与 SKU 的对应关系"); + } List labels = Objects.requireNonNullElse(cartonManifestMapper.labelPool(row.getAccountId(), row.getSourceInboundId(), row.getSourceInboundDetailId()), List.of()); 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); } return result; diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java index d7c085e81..46bf81b01 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java @@ -19,6 +19,7 @@ import java.math.BigDecimal; import java.util.List; 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.ArgumentMatchers.anyLong; import static org.mockito.Mockito.verify; @@ -52,10 +53,21 @@ class LoadingCompletedOutboxServiceTest { assertThat(first.payload().get("orders")).hasSize(2); 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/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(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 { String sql = String.join(" ", LoadingCompletedEventMapper.class.getMethod("rows", long.class) .getAnnotation(Select.class).value()).replaceAll("\\s+", " "); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegHealthController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegHealthController.java index abb4a10cb..0e8e98834 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegHealthController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegHealthController.java @@ -13,15 +13,15 @@ import java.util.Map; * 尾程预留服务状态接口,用于验证尾程服务边界及基础可用性。 */ @Tag( - name = "Last-leg service status / 尾程服务状态", - description = "Last-leg reserved service availability API. / 尾程预留服务的可用性接口。" + name = "尾程服务状态", + description = "尾程服务可用性接口" ) @RestController @RequestMapping("/api/platform/v1/last-leg") public class LastlegHealthController { @Operation( - summary = "Get service status / 查询服务状态", - description = "Returns the logical status of the reserved lastleg-service boundary. / 返回预留 lastleg-service 边界的逻辑运行状态。" + summary = "查询服务状态", + description = "返回尾程服务边界的逻辑运行状态" ) @io.swagger.v3.oas.annotations.responses.ApiResponse( responseCode = "200", diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java index 8cc4eeaaf..4b7f04c07 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java @@ -5,7 +5,7 @@ import io.swagger.v3.oas.annotations.media.Schema; import java.math.BigDecimal; import java.time.LocalDateTime; -@Schema(description = "Tail-leg order row at inbound-code granularity") +@Schema(description = "按入仓单编码粒度展示的尾程订单列表行") public record LastlegOrderSummary( Long id, String orderNo, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPdaController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPdaController.java index 0c11e1f3b..29056ca92 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPdaController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPdaController.java @@ -16,6 +16,9 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; 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 java.util.List; @@ -114,6 +117,15 @@ public class InboundPdaController { return ApiResponse.success(service.uploadUnloadPhotos(scope(user, customerId), planNo, request)); } + @PostMapping(value = "/unload-photos/files", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ApiResponse 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) { if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaResponses.java index 0c6a2bb40..fa261c613 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaResponses.java @@ -12,6 +12,8 @@ public final class PdaResponses { List unloadPhotoFileIds, LocalDateTime unloadPhotoUploadedAt, LocalDateTime estimatedEmptyReturnAt) {} + public record UnloadPhotoFile(long fileId, String fileName, String contentType, long sizeBytes) {} + public record Operation(String result, long allocationId, int requiredPackageCount, int countedPackageCount, int pickedPackageCount, BigDecimal actualVolumeCbm, Integer remainingPackageCount, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpUnloadPhotoFileGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpUnloadPhotoFileGateway.java new file mode 100644 index 000000000..debcb721b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpUnloadPhotoFileGateway.java @@ -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) {} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/UnloadPhotoFileGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/UnloadPhotoFileGateway.java new file mode 100644 index 000000000..b1abf3e64 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/UnloadPhotoFileGateway.java @@ -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) {} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java index d77ec080a..4c8b09a2a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java @@ -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.InboundPdaTask; 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.fasterxml.jackson.core.JsonProcessingException; 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.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; import org.springframework.web.server.ResponseStatusException; import jakarta.annotation.PostConstruct; import jakarta.annotation.Resource; @@ -40,12 +42,14 @@ import java.util.Objects; @Service public class InboundPdaWorkflowService { + private static final long MAX_UNLOAD_PHOTO_SIZE = 10L * 1024 * 1024; private static final BigDecimal ZERO_VOLUME = new BigDecimal("0.0000"); @Resource private PdaWorkflowRepository repository; @Resource private WarehouseResourceGateway warehouse; @Resource private InboundStockReceiptService stockReceipts; @Resource private InboundUnloadCompletedOutboxService unloadCompletedOutbox; + @Resource private UnloadPhotoFileGateway unloadPhotoFiles; @Resource private ObjectMapper sourceObjectMapper; private ObjectMapper objectMapper; @@ -421,6 +425,7 @@ public class InboundPdaWorkflowService { InboundPdaTask task = requireActiveTask(customerId, plan); List fileIds = request.fileIds().stream().distinct().sorted().toList(); 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(); task.setUnloadPhotoFileIds(write(fileIds)); task.setUnloadPhotoUploadedAt(uploadedAt); @@ -433,6 +438,21 @@ public class InboundPdaWorkflowService { 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, int packageCount, BigDecimal movedVolume) { 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) { int required = value(line.getRequiredPackageCount()); if (required <= 0) throw conflict("Inbound line required package count must be positive"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java index cf841602a..4c07fbe51 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java @@ -19,7 +19,9 @@ import java.math.BigDecimal; import java.time.LocalDateTime; import java.util.Objects; import java.util.ArrayList; +import java.util.HashSet; import java.util.List; +import java.util.Set; import com.fasterxml.jackson.databind.JsonNode; @Service @@ -48,6 +50,7 @@ public class LoadingCompletedConsumer { if(payload.containerNo()==null||payload.containerNo().isBlank()) throw new IllegalArgumentException("事件没有实际集装箱号 containerNo"); if(payload.orders()==null||payload.orders().isEmpty()) throw new IllegalArgumentException("事件没有入仓单"); + if (envelope.eventVersion() == 2) validateCartonSkuMappings(payload); LastlegEventInbox existing=inboxMapper.selectById(envelope.eventId()); if(existing!=null) { if(!existing.getPayloadHash().equalsIgnoreCase(envelope.payloadHash())) throw new IllegalArgumentException("事件摘要冲突"); @@ -88,6 +91,39 @@ public class LoadingCompletedConsumer { return Math.toIntExact(orderMapper.countContainer(envelope.tenantId(),containerNo)); } + private void validateCartonSkuMappings(FirstLegLoadingPlanCompletedV1 payload) { + for (FirstLegLoadingPlanCompletedV1.Order order : payload.orders()) { + Set 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 cartonMarks, + Set orderCartonMarks) { + if (skuCode == null || skuCode.isBlank()) { + throw new IllegalArgumentException("V2 尾程交接事件的箱唛映射缺少 SKU"); + } + List 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, FirstLegLoadingPlanCompletedV1.Order source,String orderNo, JsonNode rawSource) { String orderHash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(rawSource)); @@ -120,7 +156,8 @@ public class LoadingCompletedConsumer { order.setVolumeWeightKg(source.items().stream().map(FirstLegLoadingPlanCompletedV1.Item::dimensionalWeightKg) .filter(Objects::nonNull).reduce(BigDecimal.ZERO,BigDecimal::add)); 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.setSourceHandoverVersion(event.handoverVersion()); order.setSourcePayloadHash(orderHash); order.setCreatedAt(LocalDateTime.now()); order.setUpdatedAt(LocalDateTime.now()); diff --git a/lastleg-service/src/main/resources/application.yml b/lastleg-service/src/main/resources/application.yml index dc6c7d504..1ea1a0cb0 100644 --- a/lastleg-service/src/main/resources/application.yml +++ b/lastleg-service/src/main/resources/application.yml @@ -39,6 +39,10 @@ baiyue: domestic-warehouse: url: ${DOMESTIC_WAREHOUSE_URL:} 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: event-bridge-url: ${FIRSTLEG_EVENT_BRIDGE_URL:} outbox-relay: diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowServiceTest.java index 6caff0876..e1353af91 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowServiceTest.java @@ -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.InboundPdaTask; 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.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.BeforeEach; @@ -20,6 +21,7 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.web.server.ResponseStatusException; import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.mock.web.MockMultipartFile; import java.math.BigDecimal; 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.eq; import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -44,6 +47,7 @@ class InboundPdaWorkflowServiceTest { @Mock WarehouseResourceGateway warehouse; @Mock InboundStockReceiptService stockReceipts; @Mock InboundUnloadCompletedOutboxService unloadCompletedOutbox; + @Mock UnloadPhotoFileGateway unloadPhotoFiles; private InboundPdaWorkflowService service; private InboundPlan plan; @@ -64,6 +68,7 @@ class InboundPdaWorkflowServiceTest { ReflectionTestUtils.setField(service, "warehouse", warehouse); ReflectionTestUtils.setField(service, "stockReceipts", stockReceipts); ReflectionTestUtils.setField(service, "unloadCompletedOutbox", unloadCompletedOutbox); + ReflectionTestUtils.setField(service, "unloadPhotoFiles", unloadPhotoFiles); ReflectionTestUtils.setField(service, "sourceObjectMapper", new ObjectMapper().findAndRegisterModules()); service.initializeObjectMapper(); plan = plan(); @@ -76,7 +81,7 @@ class InboundPdaWorkflowServiceTest { target = allocation(31L, targetLine.getId(), targetPallet.getId(), 3, 3, 0); source = allocation(32L, sourceLine.getId(), sourcePallet.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 @@ -326,6 +331,8 @@ class InboundPdaWorkflowServiceTest { assertEquals(List.of(101L, 103L), result.unloadPhotoFileIds()); 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); ArgumentCaptor operation = ArgumentCaptor.forClass(InboundPdaOperation.class); verify(repository).insert(operation.capture()); @@ -344,6 +351,54 @@ class InboundPdaWorkflowServiceTest { 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 void completesWhenMergedAllocationsPreserveExactLineTotals() { activeTask(); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java index 4f3142a49..3ee2c40f6 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java @@ -1,24 +1,43 @@ 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) 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; - @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)); } + @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,"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 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 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 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 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(){ - EventEnvelope source=event(); + EventEnvelope source=event(); 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)) + .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)); 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); assertEquals(2,service.consume(v2)); verify(orders,times(2)).insert(any(LastlegOrder.class)); + ArgumentCaptor 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); } }