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", "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() {
@@ -55,7 +55,7 @@ class OverseasContainerEventConsumerTest {
eq(LocalDateTime.parse("2026-08-22T02:30:00")), isNull(), eq(7L)); eq(LocalDateTime.parse("2026-08-22T02:30:00")), isNull(), eq(7L));
ArgumentCaptor<ContainerEventInboxEntity> captor = ArgumentCaptor.forClass(ContainerEventInboxEntity.class); ArgumentCaptor<ContainerEventInboxEntity> captor = ArgumentCaptor.forClass(ContainerEventInboxEntity.class);
verify(inbox).insert(captor.capture()); verify(inbox).insert(captor.capture());
assertThat(captor.getValue().getAggregateId()).isEqualTo("1001"); assertThat(captor.getValue().getAggregateId()).isEqualTo("MSCU1234567");
assertThat(captor.getValue().getStatus()).isEqualTo("PROCESSED"); assertThat(captor.getValue().getStatus()).isEqualTo("PROCESSED");
verify(inbox).updateById(captor.getValue()); verify(inbox).updateById(captor.getValue());
} }
@@ -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",
@@ -32,14 +32,15 @@ import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
@RestController @RestController
@Tag(name = "Tail-leg orders") @Tag(name = "尾程订单")
public class LastlegOrderController { public class LastlegOrderController {
@Resource private LoadingCompletedConsumer consumer; @Resource private LoadingCompletedConsumer consumer;
@Resource private LastlegOrderService orderService; @Resource private LastlegOrderService orderService;
@Resource private InternalServiceTokenGuard tokenGuard; @Resource private InternalServiceTokenGuard tokenGuard;
@PostMapping("/internal/lastleg/events") @PostMapping("/internal/lastleg/events")
@Operation(summary = "Receive a first-leg loading-completed event") /** 接收头程装柜完成事件并创建尾程订单。 */
@Operation(summary = "接收头程装柜完成事件")
public ApiResponse<Integer> receive( public ApiResponse<Integer> receive(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token, @RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@RequestBody EventEnvelope event) { @RequestBody EventEnvelope event) {
@@ -48,7 +49,8 @@ public class LastlegOrderController {
} }
@GetMapping("/api/platform/v1/last-leg/orders") @GetMapping("/api/platform/v1/last-leg/orders")
@Operation(summary = "Page tail-leg order rows") /** 分页查询尾程订单列表。 */
@Operation(summary = "分页查询尾程订单")
public ApiResponse<PageResult<LastlegOrderSummary>> list( public ApiResponse<PageResult<LastlegOrderSummary>> list(
@AuthenticationPrincipal AuthenticatedUser user, @AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId, @RequestParam(required = false) String customerId,
@@ -57,7 +59,8 @@ public class LastlegOrderController {
} }
@GetMapping("/api/platform/v1/last-leg/orders/{id}") @GetMapping("/api/platform/v1/last-leg/orders/{id}")
@Operation(summary = "Get one inbound-code-level tail-leg order") /** 查询指定入仓单维度的尾程订单详情。 */
@Operation(summary = "查询尾程订单详情")
public ApiResponse<LastlegOrderDetailResponse> detail( public ApiResponse<LastlegOrderDetailResponse> detail(
@AuthenticationPrincipal AuthenticatedUser user, @AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId, @RequestParam(required = false) String customerId,
@@ -66,7 +69,8 @@ public class LastlegOrderController {
} }
@GetMapping(value = "/api/platform/v1/last-leg/orders/export", produces = "text/csv") @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( public ResponseEntity<byte[]> export(
@AuthenticationPrincipal AuthenticatedUser user, @AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId, @RequestParam(required = false) String customerId,
@@ -79,7 +83,8 @@ public class LastlegOrderController {
} }
@PutMapping("/api/platform/v1/last-leg/orders/{id}/destination") @PutMapping("/api/platform/v1/last-leg/orders/{id}/destination")
@Operation(summary = "Transfer one order to a destination warehouse") /** 将单个尾程订单转移到目标仓库。 */
@Operation(summary = "单个订单转移仓库")
public ApiResponse<Integer> transferOne( public ApiResponse<Integer> transferOne(
@AuthenticationPrincipal AuthenticatedUser user, @AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId, @RequestParam(required = false) String customerId,
@@ -89,7 +94,8 @@ public class LastlegOrderController {
} }
@PutMapping("/api/platform/v1/last-leg/orders/destination") @PutMapping("/api/platform/v1/last-leg/orders/destination")
@Operation(summary = "Transfer multiple orders to a destination warehouse") /** 将多个尾程订单批量转移到目标仓库。 */
@Operation(summary = "批量订单转移仓库")
public ApiResponse<Integer> transferBatch( public ApiResponse<Integer> transferBatch(
@AuthenticationPrincipal AuthenticatedUser user, @AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId, @RequestParam(required = false) String customerId,
@@ -17,12 +17,13 @@ import org.springframework.web.server.ResponseStatusException;
@RestController @RestController
@RequestMapping("/api/platform/v1/last-leg") @RequestMapping("/api/platform/v1/last-leg")
@Tag(name = "Outbound and delivery") @Tag(name = "出库与派送")
public class DeliveryTransferController { public class DeliveryTransferController {
@Resource private DeliveryTransferService service; @Resource private DeliveryTransferService service;
@PostMapping("/dispatches/{dispatchNo}/transfer-delivery") @PostMapping("/dispatches/{dispatchNo}/transfer-delivery")
@Operation(summary = "Create one pending outbound order and one pending delivery order") /** 创建一个待处理出库单和一个待派送单。 */
@Operation(summary = "创建出库与派送单")
public ApiResponse<DeliveryResponses.TransferResult> transfer( public ApiResponse<DeliveryResponses.TransferResult> transfer(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String dispatchNo, @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String dispatchNo,
@RequestParam(required = false) String customerId, @RequestParam(required = false) String customerId,
@@ -30,28 +31,36 @@ public class DeliveryTransferController {
return ApiResponse.success(service.transfer(scope(user, customerId), dispatchNo, operator(user), request)); return ApiResponse.success(service.transfer(scope(user, customerId), dispatchNo, operator(user), request));
} }
/** 分页查询出库单。 */
@GetMapping("/outbound-orders") @GetMapping("/outbound-orders")
@Operation(summary = "分页查询出库单")
public ApiResponse<PageResult<DeliveryResponses.OutboundRow>> outboundPage( public ApiResponse<PageResult<DeliveryResponses.OutboundRow>> outboundPage(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId,
@Valid DeliveryRequests.Query query) { @Valid DeliveryRequests.Query query) {
return ApiResponse.success(service.outboundPage(scope(user, customerId), query)); return ApiResponse.success(service.outboundPage(scope(user, customerId), query));
} }
/** 查询出库单详情及托盘信息。 */
@GetMapping("/outbound-orders/{outboundNo}") @GetMapping("/outbound-orders/{outboundNo}")
@Operation(summary = "查询出库单详情")
public ApiResponse<DeliveryResponses.OutboundDetail> outboundDetail( public ApiResponse<DeliveryResponses.OutboundDetail> outboundDetail(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId,
@PathVariable String outboundNo, @Valid DeliveryRequests.PalletQuery query) { @PathVariable String outboundNo, @Valid DeliveryRequests.PalletQuery query) {
return ApiResponse.success(service.outboundDetail(scope(user, customerId), outboundNo, query)); return ApiResponse.success(service.outboundDetail(scope(user, customerId), outboundNo, query));
} }
/** 分页查询派送单。 */
@GetMapping("/delivery-orders") @GetMapping("/delivery-orders")
@Operation(summary = "分页查询派送单")
public ApiResponse<PageResult<DeliveryResponses.DeliveryRow>> deliveryPage( public ApiResponse<PageResult<DeliveryResponses.DeliveryRow>> deliveryPage(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId,
@Valid DeliveryRequests.Query query) { @Valid DeliveryRequests.Query query) {
return ApiResponse.success(service.deliveryPage(scope(user, customerId), query)); return ApiResponse.success(service.deliveryPage(scope(user, customerId), query));
} }
/** 查询派送单详情及托盘信息。 */
@GetMapping("/delivery-orders/{deliveryNo}") @GetMapping("/delivery-orders/{deliveryNo}")
@Operation(summary = "查询派送单详情")
public ApiResponse<DeliveryResponses.OutboundDetail> deliveryDetail( public ApiResponse<DeliveryResponses.OutboundDetail> deliveryDetail(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId,
@PathVariable String deliveryNo, @Valid DeliveryRequests.PalletQuery query) { @PathVariable String deliveryNo, @Valid DeliveryRequests.PalletQuery query) {
@@ -32,13 +32,14 @@ import java.util.List;
@RestController @RestController
@RequestMapping("/api/platform/v1/last-leg/dispatches") @RequestMapping("/api/platform/v1/last-leg/dispatches")
@Tag(name = "Tail-leg vehicle dispatch") @Tag(name = "尾程派车")
public class DispatchController { public class DispatchController {
@Resource private DispatchService service; @Resource private DispatchService service;
@Resource private DispatchPdaWorkflowService pdaWorkflow; @Resource private DispatchPdaWorkflowService pdaWorkflow;
@GetMapping("/candidates") @GetMapping("/candidates")
@Operation(summary = "List unlocked planned or received pallets for intelligent pre-dispatch") /** 查询可用于智能预派车的未锁定托盘。 */
@Operation(summary = "查询可派车托盘")
public ApiResponse<List<DispatchPalletCandidate>> candidates( public ApiResponse<List<DispatchPalletCandidate>> candidates(
@AuthenticationPrincipal AuthenticatedUser user, @AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId, @RequestParam(required = false) String customerId,
@@ -49,7 +50,8 @@ public class DispatchController {
} }
@PostMapping("/pre-arrange") @PostMapping("/pre-arrange")
@Operation(summary = "Create a pending-departure dispatch and lock all selected pallets") /** 创建待出发派车单并锁定选中的托盘。 */
@Operation(summary = "创建预派车单")
public ApiResponse<DispatchResponse> preArrange( public ApiResponse<DispatchResponse> preArrange(
@AuthenticationPrincipal AuthenticatedUser user, @AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId, @RequestParam(required = false) String customerId,
@@ -60,7 +62,8 @@ public class DispatchController {
} }
@GetMapping @GetMapping
@Operation(summary = "Page dispatch orders") /** 分页查询派车单。 */
@Operation(summary = "分页查询派车单")
public ApiResponse<PageResult<DispatchResponse>> page( public ApiResponse<PageResult<DispatchResponse>> page(
@AuthenticationPrincipal AuthenticatedUser user, @AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId, @RequestParam(required = false) String customerId,
@@ -69,7 +72,8 @@ public class DispatchController {
} }
@GetMapping("/{dispatchNo}") @GetMapping("/{dispatchNo}")
@Operation(summary = "Get dispatch details and all pallet destinations") /** 查询派车单详情及托盘目的地。 */
@Operation(summary = "查询派车单详情")
public ApiResponse<DispatchResponse> detail( public ApiResponse<DispatchResponse> detail(
@AuthenticationPrincipal AuthenticatedUser user, @AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId, @RequestParam(required = false) String customerId,
@@ -78,7 +82,8 @@ public class DispatchController {
} }
@PutMapping("/{dispatchNo}") @PutMapping("/{dispatchNo}")
@Operation(summary = "Edit vehicle, driver, appointment, route and departure data") /** 编辑车辆、司机、预约、路线和出发信息。 */
@Operation(summary = "编辑派车单")
public ApiResponse<DispatchResponse> update( public ApiResponse<DispatchResponse> update(
@AuthenticationPrincipal AuthenticatedUser user, @AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId, @RequestParam(required = false) String customerId,
@@ -88,7 +93,8 @@ public class DispatchController {
} }
@DeleteMapping("/{dispatchNo}") @DeleteMapping("/{dispatchNo}")
@Operation(summary = "Cancel a pending dispatch and release its pallet locks") /** 取消待处理派车单并释放托盘锁定。 */
@Operation(summary = "取消派车单")
public ApiResponse<Void> cancel( public ApiResponse<Void> cancel(
@AuthenticationPrincipal AuthenticatedUser user, @AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId, @RequestParam(required = false) String customerId,
@@ -98,7 +104,8 @@ public class DispatchController {
} }
@PostMapping("/{dispatchNo}/depart") @PostMapping("/{dispatchNo}/depart")
@Operation(summary = "Ship all PDA-picked pallets and mark the dispatch departed") /** 出库 PDA 已拣货托盘并将派车单标记为已出发。 */
@Operation(summary = "PDA 出库并完成派车")
public ApiResponse<DispatchPdaResponses.Departed> depart( public ApiResponse<DispatchPdaResponses.Departed> depart(
@AuthenticationPrincipal AuthenticatedUser user, @AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId, @RequestParam(required = false) String customerId,
@@ -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,
@@ -20,20 +20,22 @@ import org.springframework.web.server.ResponseStatusException;
@RestController @RestController
@RequestMapping("/api/platform/v1/last-leg/inbound-plans") @RequestMapping("/api/platform/v1/last-leg/inbound-plans")
@Tag(name = "Tail-leg inbound plans") @Tag(name = "尾程入仓计划")
public class InboundPlanController { public class InboundPlanController {
@Resource private InboundPlanService service; @Resource private InboundPlanService service;
@Resource private InboundFbaAppointmentService appointmentService; @Resource private InboundFbaAppointmentService appointmentService;
@PostMapping("/convert") @PostMapping("/convert")
@Operation(summary = "Convert one inbound order to one inbound plan") /** 将入仓单转换为入仓计划。 */
@Operation(summary = "入仓单转入仓计划")
public ApiResponse<InboundPlanResponse> convert(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<InboundPlanResponse> convert(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody ConvertInboundPlanRequest request) { @Valid @RequestBody ConvertInboundPlanRequest request) {
return ApiResponse.success(service.convert(scope(user, request.customerId()), request.orderId())); return ApiResponse.success(service.convert(scope(user, request.customerId()), request.orderId()));
} }
@GetMapping("/{planNo}") @GetMapping("/{planNo}")
@Operation(summary = "Get an inbound plan with lines, pallets and allocations") /** 查询入仓计划及其明细、托盘和分配信息。 */
@Operation(summary = "查询入仓计划详情")
public ApiResponse<InboundPlanResponse> detail(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<InboundPlanResponse> detail(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String planNo, @PathVariable String planNo,
@RequestParam(required = false) String customerId) { @RequestParam(required = false) String customerId) {
@@ -41,7 +43,8 @@ public class InboundPlanController {
} }
@PostMapping("/{planNo}/pallets/supplement") @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, public ApiResponse<InboundPlanResponse.Pallet> supplementPallet(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String planNo, @PathVariable String planNo,
@Valid @RequestBody SupplementPalletRequest request) { @Valid @RequestBody SupplementPalletRequest request) {
@@ -49,7 +52,8 @@ public class InboundPlanController {
} }
@PostMapping("/fba-appointments/batch") @PostMapping("/fba-appointments/batch")
@Operation(summary = "Create one FBA appointment for selected inbound lines") /** 为选中的入仓明细创建 FBA 预约。 */
@Operation(summary = "创建 FBA 预约")
public ApiResponse<FbaAppointmentResponse> batchFbaAppointment( public ApiResponse<FbaAppointmentResponse> batchFbaAppointment(
@AuthenticationPrincipal AuthenticatedUser user, @AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody BatchFbaAppointmentRequest request) { @Valid @RequestBody BatchFbaAppointmentRequest request) {
@@ -59,7 +63,8 @@ public class InboundPlanController {
} }
@GetMapping("/fba-appointments/{appointmentNo}") @GetMapping("/fba-appointments/{appointmentNo}")
@Operation(summary = "Get an FBA appointment and its selected inbound lines") /** 查询 FBA 预约及其选中的入仓明细。 */
@Operation(summary = "查询 FBA 预约详情")
public ApiResponse<FbaAppointmentResponse> fbaAppointmentDetail( public ApiResponse<FbaAppointmentResponse> fbaAppointmentDetail(
@AuthenticationPrincipal AuthenticatedUser user, @AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String appointmentNo, @PathVariable String appointmentNo,
@@ -39,10 +39,11 @@ public class InboundUnloadCompletedOutboxService {
throw new IllegalStateException("Inbound plan has no source container booking ID"); throw new IllegalStateException("Inbound plan has no source container booking ID");
} }
Instant photoUploadedAt = task.getUnloadPhotoUploadedAt().toInstant(ZoneOffset.UTC); Instant photoUploadedAt = task.getUnloadPhotoUploadedAt().toInstant(ZoneOffset.UTC);
var payload = new OverseasContainerUnloadedV1(sourceOrder.getSourceContainerBookingId(), var payload = new OverseasContainerUnloadedV1(plan.getContainerNo(), photoUploadedAt,
plan.getContainerNo(), photoUploadedAt, photoUploadedAt, null); photoUploadedAt, null);
JsonNode payloadNode = objectMapper.valueToTree(payload); 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 eventKey = EventTypes.OVERSEAS_CONTAINER_UNLOADED + ':' + task.getId();
String traceId = EventIdentifiers.newUuid(); String traceId = EventIdentifiers.newUuid();
EventEnvelope envelope = new EventEnvelope(EventIdentifiers.newUuid(), EventTypes.OVERSEAS_CONTAINER_UNLOADED, EventEnvelope envelope = new EventEnvelope(EventIdentifiers.newUuid(), EventTypes.OVERSEAS_CONTAINER_UNLOADED,
@@ -19,14 +19,15 @@ import org.springframework.web.server.ResponseStatusException;
@RestController @RestController
@RequestMapping("/api/platform/v1/last-leg/inventory") @RequestMapping("/api/platform/v1/last-leg/inventory")
@Tag(name = "Tail-leg inbound-order package inventory commands") @Tag(name = "尾程入仓单库存操作")
public class InventoryCommandController { public class InventoryCommandController {
public static final String MANAGE_PERMISSION = "lastleg:inventory:manage"; public static final String MANAGE_PERMISSION = "lastleg:inventory:manage";
public static final String ADJUST_PERMISSION = "lastleg:inventory:adjust"; public static final String ADJUST_PERMISSION = "lastleg:inventory:adjust";
@Resource private InventoryCommandService service; @Resource private InventoryCommandService service;
@PostMapping("/hold") @PostMapping("/hold")
@Operation(summary = "Move available inbound-order packages to hold") /** 将可用入仓单箱数转为冻结。 */
@Operation(summary = "冻结入仓单库存")
public ApiResponse<InventoryCommandResponse> hold(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<InventoryCommandResponse> hold(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody InventoryCommandRequests.Move request) { @Valid @RequestBody InventoryCommandRequests.Move request) {
requirePermission(user, MANAGE_PERMISSION); requirePermission(user, MANAGE_PERMISSION);
@@ -34,7 +35,8 @@ public class InventoryCommandController {
} }
@PostMapping("/release") @PostMapping("/release")
@Operation(summary = "Release held inbound-order packages") /** 释放已冻结的入仓单箱数。 */
@Operation(summary = "释放入仓单库存")
public ApiResponse<InventoryCommandResponse> release(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<InventoryCommandResponse> release(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody InventoryCommandRequests.Move request) { @Valid @RequestBody InventoryCommandRequests.Move request) {
requirePermission(user, MANAGE_PERMISSION); requirePermission(user, MANAGE_PERMISSION);
@@ -42,7 +44,8 @@ public class InventoryCommandController {
} }
@PostMapping("/allocate") @PostMapping("/allocate")
@Operation(summary = "Allocate available inbound-order packages to an outbound order") /** 将可用入仓单箱数分配给出库单。 */
@Operation(summary = "分配出库库存")
public ApiResponse<InventoryCommandResponse> allocate(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<InventoryCommandResponse> allocate(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody InventoryCommandRequests.Move request) { @Valid @RequestBody InventoryCommandRequests.Move request) {
requirePermission(user, MANAGE_PERMISSION); requirePermission(user, MANAGE_PERMISSION);
@@ -50,7 +53,8 @@ public class InventoryCommandController {
} }
@PostMapping("/unallocate") @PostMapping("/unallocate")
@Operation(summary = "Cancel an outbound allocation") /** 取消出库库存分配。 */
@Operation(summary = "取消出库分配")
public ApiResponse<InventoryCommandResponse> unallocate(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<InventoryCommandResponse> unallocate(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody InventoryCommandRequests.Move request) { @Valid @RequestBody InventoryCommandRequests.Move request) {
requirePermission(user, MANAGE_PERMISSION); requirePermission(user, MANAGE_PERMISSION);
@@ -58,7 +62,8 @@ public class InventoryCommandController {
} }
@PostMapping("/pick") @PostMapping("/pick")
@Operation(summary = "Confirm picked inbound-order packages") /** 确认入仓单箱数已拣货。 */
@Operation(summary = "确认拣货")
public ApiResponse<InventoryCommandResponse> pick(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<InventoryCommandResponse> pick(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody InventoryCommandRequests.Move request) { @Valid @RequestBody InventoryCommandRequests.Move request) {
requirePermission(user, MANAGE_PERMISSION); requirePermission(user, MANAGE_PERMISSION);
@@ -66,7 +71,8 @@ public class InventoryCommandController {
} }
@PostMapping("/ship") @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, public ApiResponse<InventoryCommandResponse> ship(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody InventoryCommandRequests.Move request) { @Valid @RequestBody InventoryCommandRequests.Move request) {
requirePermission(user, MANAGE_PERMISSION); requirePermission(user, MANAGE_PERMISSION);
@@ -74,7 +80,8 @@ public class InventoryCommandController {
} }
@PostMapping("/adjust") @PostMapping("/adjust")
@Operation(summary = "Adjust inbound-order package inventory after a physical count") /** 根据实物盘点结果调整入仓单库存。 */
@Operation(summary = "盘点调整库存")
public ApiResponse<InventoryCommandResponse> adjust(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<InventoryCommandResponse> adjust(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody InventoryCommandRequests.Adjustment request) { @Valid @RequestBody InventoryCommandRequests.Adjustment request) {
requirePermission(user, ADJUST_PERMISSION); requirePermission(user, ADJUST_PERMISSION);
@@ -28,14 +28,15 @@ import java.nio.charset.StandardCharsets;
import java.util.List; import java.util.List;
@RestController @RestController
@Tag(name = "Inventory queries") @Tag(name = "库存查询")
public class InventoryQueryController { public class InventoryQueryController {
private static final String BASE_PATH = "/api/platform/v1/last-leg/inventory"; private static final String BASE_PATH = "/api/platform/v1/last-leg/inventory";
@Resource private InventoryQueryService service; @Resource private InventoryQueryService service;
@GetMapping(BASE_PATH + "/summary") @GetMapping(BASE_PATH + "/summary")
@Operation(summary = "Get inbound-order package inventory totals") /** 查询入仓单库存汇总。 */
@Operation(summary = "查询库存汇总")
public ApiResponse<InventoryDashboardResponse> summary( public ApiResponse<InventoryDashboardResponse> summary(
@AuthenticationPrincipal AuthenticatedUser user, @AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId, @RequestParam(required = false) String customerId,
@@ -44,7 +45,8 @@ public class InventoryQueryController {
} }
@GetMapping(BASE_PATH + "/balances") @GetMapping(BASE_PATH + "/balances")
@Operation(summary = "Page inventory by customer, warehouse and inbound order") /** 按客户、仓库和入仓单分页查询库存。 */
@Operation(summary = "分页查询库存余额")
public ApiResponse<PageResult<InventoryBalanceView>> balances( public ApiResponse<PageResult<InventoryBalanceView>> balances(
@AuthenticationPrincipal AuthenticatedUser user, @AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId, @RequestParam(required = false) String customerId,
@@ -53,7 +55,8 @@ public class InventoryQueryController {
} }
@GetMapping(BASE_PATH + "/balances/{balanceId}/positions") @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( public ApiResponse<List<InventoryPositionView>> positions(
@AuthenticationPrincipal AuthenticatedUser user, @AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId, @RequestParam(required = false) String customerId,
@@ -63,7 +66,8 @@ public class InventoryQueryController {
} }
@GetMapping(BASE_PATH + "/ledger") @GetMapping(BASE_PATH + "/ledger")
@Operation(summary = "Page append-only inventory movements") /** 分页查询只增不改的库存流水。 */
@Operation(summary = "分页查询库存流水")
public ApiResponse<PageResult<InventoryLedgerView>> ledgers( public ApiResponse<PageResult<InventoryLedgerView>> ledgers(
@AuthenticationPrincipal AuthenticatedUser user, @AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId, @RequestParam(required = false) String customerId,
@@ -72,7 +76,8 @@ public class InventoryQueryController {
} }
@GetMapping(value = BASE_PATH + "/balances/export", produces = "text/csv") @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( public ResponseEntity<byte[]> exportBalances(
@AuthenticationPrincipal AuthenticatedUser user, @AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId, @RequestParam(required = false) String customerId,
@@ -81,7 +86,8 @@ public class InventoryQueryController {
} }
@GetMapping(value = BASE_PATH + "/ledger/export", produces = "text/csv") @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( public ResponseEntity<byte[]> exportLedgers(
@AuthenticationPrincipal AuthenticatedUser user, @AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId, @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 { 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 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,"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 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)); }