This commit is contained in:
2026-08-20 15:25:40 +08:00
parent b55b049e77
commit cd34cd426e
14 changed files with 170 additions and 49 deletions
@@ -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;
@@ -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() {
@@ -55,7 +55,7 @@ class OverseasContainerEventConsumerTest {
eq(LocalDateTime.parse("2026-08-22T02:30:00")), isNull(), eq(7L));
ArgumentCaptor<ContainerEventInboxEntity> captor = ArgumentCaptor.forClass(ContainerEventInboxEntity.class);
verify(inbox).insert(captor.capture());
assertThat(captor.getValue().getAggregateId()).isEqualTo("1001");
assertThat(captor.getValue().getAggregateId()).isEqualTo("MSCU1234567");
assertThat(captor.getValue().getStatus()).isEqualTo("PROCESSED");
verify(inbox).updateById(captor.getValue());
}
@@ -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",
@@ -32,14 +32,15 @@ import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets;
@RestController
@Tag(name = "Tail-leg orders")
@Tag(name = "尾程订单")
public class LastlegOrderController {
@Resource private LoadingCompletedConsumer consumer;
@Resource private LastlegOrderService orderService;
@Resource private InternalServiceTokenGuard tokenGuard;
@PostMapping("/internal/lastleg/events")
@Operation(summary = "Receive a first-leg loading-completed event")
/** 接收头程装柜完成事件并创建尾程订单。 */
@Operation(summary = "接收头程装柜完成事件")
public ApiResponse<Integer> receive(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@RequestBody EventEnvelope event) {
@@ -48,7 +49,8 @@ public class LastlegOrderController {
}
@GetMapping("/api/platform/v1/last-leg/orders")
@Operation(summary = "Page tail-leg order rows")
/** 分页查询尾程订单列表。 */
@Operation(summary = "分页查询尾程订单")
public ApiResponse<PageResult<LastlegOrderSummary>> list(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@@ -57,7 +59,8 @@ public class LastlegOrderController {
}
@GetMapping("/api/platform/v1/last-leg/orders/{id}")
@Operation(summary = "Get one inbound-code-level tail-leg order")
/** 查询指定入仓单维度的尾程订单详情。 */
@Operation(summary = "查询尾程订单详情")
public ApiResponse<LastlegOrderDetailResponse> detail(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@@ -66,7 +69,8 @@ public class LastlegOrderController {
}
@GetMapping(value = "/api/platform/v1/last-leg/orders/export", produces = "text/csv")
@Operation(summary = "Export filtered tail-leg orders as UTF-8 CSV")
/** 按筛选条件导出尾程订单 CSV 文件。 */
@Operation(summary = "导出尾程订单")
public ResponseEntity<byte[]> export(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@@ -79,7 +83,8 @@ public class LastlegOrderController {
}
@PutMapping("/api/platform/v1/last-leg/orders/{id}/destination")
@Operation(summary = "Transfer one order to a destination warehouse")
/** 将单个尾程订单转移到目标仓库。 */
@Operation(summary = "单个订单转移仓库")
public ApiResponse<Integer> transferOne(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@@ -89,7 +94,8 @@ public class LastlegOrderController {
}
@PutMapping("/api/platform/v1/last-leg/orders/destination")
@Operation(summary = "Transfer multiple orders to a destination warehouse")
/** 将多个尾程订单批量转移到目标仓库。 */
@Operation(summary = "批量订单转移仓库")
public ApiResponse<Integer> transferBatch(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@@ -17,12 +17,13 @@ import org.springframework.web.server.ResponseStatusException;
@RestController
@RequestMapping("/api/platform/v1/last-leg")
@Tag(name = "Outbound and delivery")
@Tag(name = "出库与派送")
public class DeliveryTransferController {
@Resource private DeliveryTransferService service;
@PostMapping("/dispatches/{dispatchNo}/transfer-delivery")
@Operation(summary = "Create one pending outbound order and one pending delivery order")
/** 创建一个待处理出库单和一个待派送单。 */
@Operation(summary = "创建出库与派送单")
public ApiResponse<DeliveryResponses.TransferResult> transfer(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String dispatchNo,
@RequestParam(required = false) String customerId,
@@ -30,28 +31,36 @@ public class DeliveryTransferController {
return ApiResponse.success(service.transfer(scope(user, customerId), dispatchNo, operator(user), request));
}
/** 分页查询出库单。 */
@GetMapping("/outbound-orders")
@Operation(summary = "分页查询出库单")
public ApiResponse<PageResult<DeliveryResponses.OutboundRow>> outboundPage(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId,
@Valid DeliveryRequests.Query query) {
return ApiResponse.success(service.outboundPage(scope(user, customerId), query));
}
/** 查询出库单详情及托盘信息。 */
@GetMapping("/outbound-orders/{outboundNo}")
@Operation(summary = "查询出库单详情")
public ApiResponse<DeliveryResponses.OutboundDetail> outboundDetail(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId,
@PathVariable String outboundNo, @Valid DeliveryRequests.PalletQuery query) {
return ApiResponse.success(service.outboundDetail(scope(user, customerId), outboundNo, query));
}
/** 分页查询派送单。 */
@GetMapping("/delivery-orders")
@Operation(summary = "分页查询派送单")
public ApiResponse<PageResult<DeliveryResponses.DeliveryRow>> deliveryPage(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId,
@Valid DeliveryRequests.Query query) {
return ApiResponse.success(service.deliveryPage(scope(user, customerId), query));
}
/** 查询派送单详情及托盘信息。 */
@GetMapping("/delivery-orders/{deliveryNo}")
@Operation(summary = "查询派送单详情")
public ApiResponse<DeliveryResponses.OutboundDetail> deliveryDetail(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId,
@PathVariable String deliveryNo, @Valid DeliveryRequests.PalletQuery query) {
@@ -32,13 +32,14 @@ import java.util.List;
@RestController
@RequestMapping("/api/platform/v1/last-leg/dispatches")
@Tag(name = "Tail-leg vehicle dispatch")
@Tag(name = "尾程派车")
public class DispatchController {
@Resource private DispatchService service;
@Resource private DispatchPdaWorkflowService pdaWorkflow;
@GetMapping("/candidates")
@Operation(summary = "List unlocked planned or received pallets for intelligent pre-dispatch")
/** 查询可用于智能预派车的未锁定托盘。 */
@Operation(summary = "查询可派车托盘")
public ApiResponse<List<DispatchPalletCandidate>> candidates(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@@ -49,7 +50,8 @@ public class DispatchController {
}
@PostMapping("/pre-arrange")
@Operation(summary = "Create a pending-departure dispatch and lock all selected pallets")
/** 创建待出发派车单并锁定选中的托盘。 */
@Operation(summary = "创建预派车单")
public ApiResponse<DispatchResponse> preArrange(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@@ -60,7 +62,8 @@ public class DispatchController {
}
@GetMapping
@Operation(summary = "Page dispatch orders")
/** 分页查询派车单。 */
@Operation(summary = "分页查询派车单")
public ApiResponse<PageResult<DispatchResponse>> page(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@@ -69,7 +72,8 @@ public class DispatchController {
}
@GetMapping("/{dispatchNo}")
@Operation(summary = "Get dispatch details and all pallet destinations")
/** 查询派车单详情及托盘目的地。 */
@Operation(summary = "查询派车单详情")
public ApiResponse<DispatchResponse> detail(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@@ -78,7 +82,8 @@ public class DispatchController {
}
@PutMapping("/{dispatchNo}")
@Operation(summary = "Edit vehicle, driver, appointment, route and departure data")
/** 编辑车辆、司机、预约、路线和出发信息。 */
@Operation(summary = "编辑派车单")
public ApiResponse<DispatchResponse> update(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@@ -88,7 +93,8 @@ public class DispatchController {
}
@DeleteMapping("/{dispatchNo}")
@Operation(summary = "Cancel a pending dispatch and release its pallet locks")
/** 取消待处理派车单并释放托盘锁定。 */
@Operation(summary = "取消派车单")
public ApiResponse<Void> cancel(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@@ -98,7 +104,8 @@ public class DispatchController {
}
@PostMapping("/{dispatchNo}/depart")
@Operation(summary = "Ship all PDA-picked pallets and mark the dispatch departed")
/** 出库 PDA 已拣货托盘并将派车单标记为已出发。 */
@Operation(summary = "PDA 出库并完成派车")
public ApiResponse<DispatchPdaResponses.Departed> depart(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@@ -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,
@@ -20,20 +20,22 @@ import org.springframework.web.server.ResponseStatusException;
@RestController
@RequestMapping("/api/platform/v1/last-leg/inbound-plans")
@Tag(name = "Tail-leg inbound plans")
@Tag(name = "尾程入仓计划")
public class InboundPlanController {
@Resource private InboundPlanService service;
@Resource private InboundFbaAppointmentService appointmentService;
@PostMapping("/convert")
@Operation(summary = "Convert one inbound order to one inbound plan")
/** 将入仓单转换为入仓计划。 */
@Operation(summary = "入仓单转入仓计划")
public ApiResponse<InboundPlanResponse> convert(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody ConvertInboundPlanRequest request) {
return ApiResponse.success(service.convert(scope(user, request.customerId()), request.orderId()));
}
@GetMapping("/{planNo}")
@Operation(summary = "Get an inbound plan with lines, pallets and allocations")
/** 查询入仓计划及其明细、托盘和分配信息。 */
@Operation(summary = "查询入仓计划详情")
public ApiResponse<InboundPlanResponse> detail(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String planNo,
@RequestParam(required = false) String customerId) {
@@ -41,7 +43,8 @@ public class InboundPlanController {
}
@PostMapping("/{planNo}/pallets/supplement")
@Operation(summary = "Add one printable supplemental pallet for a plan destination warehouse")
/** 为计划目标仓新增一个可打印的补充托盘。 */
@Operation(summary = "新增补充托盘")
public ApiResponse<InboundPlanResponse.Pallet> supplementPallet(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String planNo,
@Valid @RequestBody SupplementPalletRequest request) {
@@ -49,7 +52,8 @@ public class InboundPlanController {
}
@PostMapping("/fba-appointments/batch")
@Operation(summary = "Create one FBA appointment for selected inbound lines")
/** 为选中的入仓明细创建 FBA 预约。 */
@Operation(summary = "创建 FBA 预约")
public ApiResponse<FbaAppointmentResponse> batchFbaAppointment(
@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody BatchFbaAppointmentRequest request) {
@@ -59,7 +63,8 @@ public class InboundPlanController {
}
@GetMapping("/fba-appointments/{appointmentNo}")
@Operation(summary = "Get an FBA appointment and its selected inbound lines")
/** 查询 FBA 预约及其选中的入仓明细。 */
@Operation(summary = "查询 FBA 预约详情")
public ApiResponse<FbaAppointmentResponse> fbaAppointmentDetail(
@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String appointmentNo,
@@ -39,10 +39,11 @@ public class InboundUnloadCompletedOutboxService {
throw new IllegalStateException("Inbound plan has no source container booking ID");
}
Instant photoUploadedAt = task.getUnloadPhotoUploadedAt().toInstant(ZoneOffset.UTC);
var payload = new OverseasContainerUnloadedV1(sourceOrder.getSourceContainerBookingId(),
plan.getContainerNo(), photoUploadedAt, photoUploadedAt, null);
var payload = new OverseasContainerUnloadedV1(plan.getContainerNo(), photoUploadedAt,
photoUploadedAt, null);
JsonNode payloadNode = objectMapper.valueToTree(payload);
long aggregateVersion = Math.max(1, task.getVersion() == null ? 1 : task.getVersion());
// Multiple inbound plans can share one container; upload epoch keeps their container events ordered.
long aggregateVersion = photoUploadedAt.toEpochMilli();
String eventKey = EventTypes.OVERSEAS_CONTAINER_UNLOADED + ':' + task.getId();
String traceId = EventIdentifiers.newUuid();
EventEnvelope envelope = new EventEnvelope(EventIdentifiers.newUuid(), EventTypes.OVERSEAS_CONTAINER_UNLOADED,
@@ -19,14 +19,15 @@ import org.springframework.web.server.ResponseStatusException;
@RestController
@RequestMapping("/api/platform/v1/last-leg/inventory")
@Tag(name = "Tail-leg inbound-order package inventory commands")
@Tag(name = "尾程入仓单库存操作")
public class InventoryCommandController {
public static final String MANAGE_PERMISSION = "lastleg:inventory:manage";
public static final String ADJUST_PERMISSION = "lastleg:inventory:adjust";
@Resource private InventoryCommandService service;
@PostMapping("/hold")
@Operation(summary = "Move available inbound-order packages to hold")
/** 将可用入仓单箱数转为冻结。 */
@Operation(summary = "冻结入仓单库存")
public ApiResponse<InventoryCommandResponse> hold(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody InventoryCommandRequests.Move request) {
requirePermission(user, MANAGE_PERMISSION);
@@ -34,7 +35,8 @@ public class InventoryCommandController {
}
@PostMapping("/release")
@Operation(summary = "Release held inbound-order packages")
/** 释放已冻结的入仓单箱数。 */
@Operation(summary = "释放入仓单库存")
public ApiResponse<InventoryCommandResponse> release(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody InventoryCommandRequests.Move request) {
requirePermission(user, MANAGE_PERMISSION);
@@ -42,7 +44,8 @@ public class InventoryCommandController {
}
@PostMapping("/allocate")
@Operation(summary = "Allocate available inbound-order packages to an outbound order")
/** 将可用入仓单箱数分配给出库单。 */
@Operation(summary = "分配出库库存")
public ApiResponse<InventoryCommandResponse> allocate(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody InventoryCommandRequests.Move request) {
requirePermission(user, MANAGE_PERMISSION);
@@ -50,7 +53,8 @@ public class InventoryCommandController {
}
@PostMapping("/unallocate")
@Operation(summary = "Cancel an outbound allocation")
/** 取消出库库存分配。 */
@Operation(summary = "取消出库分配")
public ApiResponse<InventoryCommandResponse> unallocate(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody InventoryCommandRequests.Move request) {
requirePermission(user, MANAGE_PERMISSION);
@@ -58,7 +62,8 @@ public class InventoryCommandController {
}
@PostMapping("/pick")
@Operation(summary = "Confirm picked inbound-order packages")
/** 确认入仓单箱数已拣货。 */
@Operation(summary = "确认拣货")
public ApiResponse<InventoryCommandResponse> pick(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody InventoryCommandRequests.Move request) {
requirePermission(user, MANAGE_PERMISSION);
@@ -66,7 +71,8 @@ public class InventoryCommandController {
}
@PostMapping("/ship")
@Operation(summary = "Ship picked inbound-order packages and report whether the pallet is empty")
/** 出库已拣货箱数并返回托盘是否为空。 */
@Operation(summary = "出库拣货库存")
public ApiResponse<InventoryCommandResponse> ship(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody InventoryCommandRequests.Move request) {
requirePermission(user, MANAGE_PERMISSION);
@@ -74,7 +80,8 @@ public class InventoryCommandController {
}
@PostMapping("/adjust")
@Operation(summary = "Adjust inbound-order package inventory after a physical count")
/** 根据实物盘点结果调整入仓单库存。 */
@Operation(summary = "盘点调整库存")
public ApiResponse<InventoryCommandResponse> adjust(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody InventoryCommandRequests.Adjustment request) {
requirePermission(user, ADJUST_PERMISSION);
@@ -28,14 +28,15 @@ import java.nio.charset.StandardCharsets;
import java.util.List;
@RestController
@Tag(name = "Inventory queries")
@Tag(name = "库存查询")
public class InventoryQueryController {
private static final String BASE_PATH = "/api/platform/v1/last-leg/inventory";
@Resource private InventoryQueryService service;
@GetMapping(BASE_PATH + "/summary")
@Operation(summary = "Get inbound-order package inventory totals")
/** 查询入仓单库存汇总。 */
@Operation(summary = "查询库存汇总")
public ApiResponse<InventoryDashboardResponse> summary(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@@ -44,7 +45,8 @@ public class InventoryQueryController {
}
@GetMapping(BASE_PATH + "/balances")
@Operation(summary = "Page inventory by customer, warehouse and inbound order")
/** 按客户、仓库和入仓单分页查询库存。 */
@Operation(summary = "分页查询库存余额")
public ApiResponse<PageResult<InventoryBalanceView>> balances(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@@ -53,7 +55,8 @@ public class InventoryQueryController {
}
@GetMapping(BASE_PATH + "/balances/{balanceId}/positions")
@Operation(summary = "List pallet and batch positions for one inbound-order balance")
/** 查询库存余额对应的托盘和批次位置。 */
@Operation(summary = "查询库存位置")
public ApiResponse<List<InventoryPositionView>> positions(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@@ -63,7 +66,8 @@ public class InventoryQueryController {
}
@GetMapping(BASE_PATH + "/ledger")
@Operation(summary = "Page append-only inventory movements")
/** 分页查询只增不改的库存流水。 */
@Operation(summary = "分页查询库存流水")
public ApiResponse<PageResult<InventoryLedgerView>> ledgers(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@@ -72,7 +76,8 @@ public class InventoryQueryController {
}
@GetMapping(value = BASE_PATH + "/balances/export", produces = "text/csv")
@Operation(summary = "Export filtered inbound-order inventory as UTF-8 CSV")
/** 导出筛选后的入仓单库存 CSV 文件。 */
@Operation(summary = "导出库存余额")
public ResponseEntity<byte[]> exportBalances(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@@ -81,7 +86,8 @@ public class InventoryQueryController {
}
@GetMapping(value = BASE_PATH + "/ledger/export", produces = "text/csv")
@Operation(summary = "Export filtered inventory movements as UTF-8 CSV")
/** 导出筛选后的库存流水 CSV 文件。 */
@Operation(summary = "导出库存流水")
public ResponseEntity<byte[]> exportLedgers(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@@ -0,0 +1,71 @@
package com.baiyue.lastleg.inboundplan.pda.service;
import com.baiyue.common.event.EventEnvelope;
import com.baiyue.common.event.EventTypes;
import com.baiyue.common.event.RegionCode;
import com.baiyue.lastleg.entity.LastlegEventOutbox;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.inboundplan.entity.InboundPlan;
import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask;
import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.LocalDateTime;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class InboundUnloadCompletedOutboxServiceTest {
@Mock LastlegEventOutboxMapper outbox;
@Mock LastlegOrderMapper orders;
private final ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules();
private InboundUnloadCompletedOutboxService service;
@BeforeEach
void setUp() {
service = new InboundUnloadCompletedOutboxService();
ReflectionTestUtils.setField(service, "mapper", outbox);
ReflectionTestUtils.setField(service, "orders", orders);
ReflectionTestUtils.setField(service, "objectMapper", objectMapper);
}
@Test
void writesAValidOverseasToCnEnvelopeUsingTheSourceBookingId() throws Exception {
InboundPlan plan = new InboundPlan(); plan.setSourceOrderId(11L); plan.setContainerNo("MSCU1");
InboundPdaTask task = new InboundPdaTask(); task.setId(22L); task.setCustomerId("100");
task.setUnloadPhotoUploadedAt(LocalDateTime.of(2026, 8, 20, 10, 0)); task.setVersion(3);
LastlegOrder order = new LastlegOrder(); order.setSourceContainerBookingId("9001");
when(orders.selectById(11L)).thenReturn(order);
service.publish(plan, task, List.of(101L));
ArgumentCaptor<LastlegEventOutbox> saved = ArgumentCaptor.forClass(LastlegEventOutbox.class);
verify(outbox).insert(saved.capture());
EventEnvelope envelope = objectMapper.readValue(saved.getValue().getPayloadJson(), EventEnvelope.class);
assertEquals(EventTypes.OVERSEAS_CONTAINER_UNLOADED, envelope.eventType());
assertEquals(RegionCode.OVERSEAS, envelope.sourceRegion());
assertEquals(RegionCode.CN, envelope.targetRegion());
assertEquals("9001", envelope.aggregateId());
assertEquals("MSCU1", envelope.payload().path("containerNo").asText());
}
@Test
void rejectsPlansWhoseSourceOrderLostTheContainerBookingId() {
InboundPlan plan = new InboundPlan(); plan.setSourceOrderId(11L);
InboundPdaTask task = new InboundPdaTask(); task.setId(22L); task.setCustomerId("100");
when(orders.selectById(11L)).thenReturn(new LastlegOrder());
assertThrows(IllegalStateException.class, () -> service.publish(plan, task, List.of(101L)));
}
}
@@ -4,7 +4,7 @@ import com.baiyue.common.event.*; import com.baiyue.lastleg.entity.*; import com
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)); }
@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()))); assertTrue(c.getAllValues().stream().allMatch(o->"book-1".equals(o.getSourceContainerBookingId()))); 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)); }