From 3082167411b435f89293333f36d993ca828065dc Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Mon, 17 Aug 2026 13:38:48 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B4=BE=E8=BD=A6=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dispatch/DispatchMapperConfiguration.java | 9 + .../controller/DispatchController.java | 124 +++++ .../dispatch/dto/DispatchRequests.java | 35 ++ .../dispatch/dto/DispatchResponse.java | 18 + .../lastleg/dispatch/entity/DispatchItem.java | 36 ++ .../dispatch/entity/DispatchOrder.java | 35 ++ .../dispatch/entity/DispatchPalletLock.java | 29 ++ .../mapper/DispatchCandidateMapper.java | 66 +++ .../dispatch/mapper/DispatchItemMapper.java | 25 + .../dispatch/mapper/DispatchOrderMapper.java | 16 + .../mapper/DispatchPalletLockMapper.java | 15 + .../model/DispatchPalletCandidate.java | 26 + .../pda/DispatchPdaMapperConfiguration.java | 8 + .../pda/controller/DispatchPdaController.java | 51 ++ .../dispatch/pda/dto/DispatchPdaRequests.java | 14 + .../pda/dto/DispatchPdaResponses.java | 19 + .../pda/entity/DispatchPdaOperation.java | 23 + .../dispatch/pda/entity/DispatchPdaPick.java | 23 + .../dispatch/pda/entity/DispatchPdaTask.java | 24 + .../mapper/DispatchPdaOperationMapper.java | 11 + .../pda/mapper/DispatchPdaPickMapper.java | 14 + .../pda/mapper/DispatchPdaTaskMapper.java | 14 + .../service/DispatchPdaWorkflowService.java | 213 ++++++++ .../dispatch/service/DispatchService.java | 246 +++++++++ .../controller/InboundPlanController.java | 23 + .../dto/BatchFbaAppointmentRequest.java | 19 + .../dto/FbaAppointmentResponse.java | 23 + .../inboundplan/dto/InboundPlanResponse.java | 2 +- .../entity/InboundFbaAppointment.java | 25 + .../entity/InboundFbaAppointmentItem.java | 22 + .../inboundplan/entity/InboundPlanLine.java | 1 + .../InboundFbaAppointmentItemMapper.java | 9 + .../mapper/InboundFbaAppointmentMapper.java | 9 + .../InboundFbaAppointmentQueryMapper.java | 22 + .../mapper/InboundPlanOrderMapper.java | 3 +- .../model/FbaAppointmentLineSnapshot.java | 10 + .../model/FbaAppointmentStatus.java | 8 + .../model/SourceOrderSnapshot.java | 10 + .../pda/entity/InboundStockReceipt.java | 38 ++ .../gateway/HttpWarehouseResourceGateway.java | 13 + .../pda/gateway/WarehouseResourceGateway.java | 1 + .../pda/mapper/InboundStockReceiptMapper.java | 14 + .../service/InboundPdaWorkflowService.java | 9 +- .../service/InboundStockReceiptService.java | 221 ++++++++ .../service/InboundFbaAppointmentService.java | 180 +++++++ .../service/InboundPlanService.java | 3 +- .../InventoryMapperConfiguration.java | 9 + .../InventoryCommandMapperConfiguration.java | 9 + .../InventoryCommandController.java | 97 ++++ .../command/dto/InventoryCommandRequests.java | 63 +++ .../command/dto/InventoryCommandResponse.java | 19 + .../mapper/InventoryCommandMapper.java | 22 + .../service/InventoryCommandService.java | 493 ++++++++++++++++++ .../inventory/entity/InventoryBalance.java | 43 ++ .../inventory/entity/InventoryLedger.java | 46 ++ .../inventory/entity/InventoryOperation.java | 25 + .../inventory/entity/InventoryPosition.java | 36 ++ .../mapper/InventoryBalanceMapper.java | 52 ++ .../mapper/InventoryLedgerMapper.java | 33 ++ .../mapper/InventoryOperationMapper.java | 16 + .../mapper/InventoryPositionMapper.java | 27 + .../inventory/model/InventorySummary.java | 22 + .../InventoryQueryMapperConfiguration.java | 9 + .../controller/InventoryQueryController.java | 112 ++++ .../query/dto/InventoryBalanceView.java | 35 ++ .../query/dto/InventoryDashboardResponse.java | 23 + .../query/dto/InventoryLedgerView.java | 30 ++ .../query/dto/InventoryPositionView.java | 27 + .../query/dto/InventoryQueryRequests.java | 52 ++ .../query/mapper/InventoryQueryMapper.java | 124 +++++ .../model/InventoryAggregateProjection.java | 22 + .../query/service/InventoryQueryService.java | 202 +++++++ lastleg/src/main/resources/db/lastleg.sql | 233 +++++++++ .../dispatch/DispatchSchemaContractTest.java | 24 + .../DispatchPdaWorkflowServiceTest.java | 83 +++ .../dispatch/service/DispatchServiceTest.java | 93 ++++ .../InboundPlanSchemaContractTest.java | 4 + .../InboundPdaWorkflowServiceTest.java | 17 + .../InboundStockReceiptServiceTest.java | 164 ++++++ .../InboundFbaAppointmentServiceTest.java | 137 +++++ .../InventorySchemaContractTest.java | 78 +++ .../command/InventoryCommandServiceTest.java | 156 ++++++ .../InventoryQueryControllerTest.java | 58 +++ .../service/InventoryQueryServiceTest.java | 97 ++++ scripts/db/incremental.sql | 260 ++++++++- ...ternalWarehouseResourceLockController.java | 22 + .../baiyue/warehouse/dto/PalletRequests.java | 2 +- .../dto/WarehousePalletScrapResponse.java | 4 + .../dto/WarehouseResourceLockRequests.java | 5 + .../mapper/WarehouseLocationMapper.java | 15 + .../mapper/WarehouseResourceLockMapper.java | 31 ++ .../repository/WarehouseRepository.java | 14 + .../WarehouseResourceLockRepository.java | 24 + .../WarehouseResourceRepository.java | 27 + .../service/WarehouseApplicationService.java | 20 + .../service/WarehouseResourceLockService.java | 45 +- .../service/WarehouseResourceService.java | 123 ++++- warehouse/src/main/resources/db/warehouse.sql | 16 +- .../WarehouseApplicationServiceTest.java | 59 +++ .../WarehouseResourceLockServiceTest.java | 69 +++ .../service/WarehouseResourceServiceTest.java | 98 +++- 101 files changed, 5353 insertions(+), 32 deletions(-) create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/dispatch/DispatchMapperConfiguration.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchController.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchRequests.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchResponse.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchItem.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchOrder.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchPalletLock.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchItemMapper.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchOrderMapper.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchPalletLockMapper.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/dispatch/model/DispatchPalletCandidate.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/dispatch/pda/DispatchPdaMapperConfiguration.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/dispatch/pda/controller/DispatchPdaController.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/dispatch/pda/dto/DispatchPdaRequests.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/dispatch/pda/dto/DispatchPdaResponses.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaOperation.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaPick.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaTask.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaOperationMapper.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaPickMapper.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaTaskMapper.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowService.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/inboundplan/dto/BatchFbaAppointmentRequest.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/inboundplan/dto/FbaAppointmentResponse.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundFbaAppointment.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundFbaAppointmentItem.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundFbaAppointmentItemMapper.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundFbaAppointmentMapper.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundFbaAppointmentQueryMapper.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/inboundplan/model/FbaAppointmentLineSnapshot.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/inboundplan/model/FbaAppointmentStatus.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/InboundStockReceipt.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/InboundStockReceiptMapper.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptService.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentService.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/inventory/InventoryMapperConfiguration.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/inventory/command/InventoryCommandMapperConfiguration.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/inventory/command/controller/InventoryCommandController.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/inventory/command/dto/InventoryCommandRequests.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/inventory/command/dto/InventoryCommandResponse.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/inventory/command/mapper/InventoryCommandMapper.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/inventory/entity/InventoryBalance.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/inventory/entity/InventoryLedger.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/inventory/entity/InventoryOperation.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/inventory/entity/InventoryPosition.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryBalanceMapper.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryLedgerMapper.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryOperationMapper.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryPositionMapper.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/inventory/model/InventorySummary.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/inventory/query/InventoryQueryMapperConfiguration.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryController.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryBalanceView.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryDashboardResponse.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryLedgerView.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryPositionView.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryQueryRequests.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/inventory/query/mapper/InventoryQueryMapper.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/inventory/query/model/InventoryAggregateProjection.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/inventory/query/service/InventoryQueryService.java create mode 100644 lastleg/src/test/java/com/baiyue/lastleg/dispatch/DispatchSchemaContractTest.java create mode 100644 lastleg/src/test/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowServiceTest.java create mode 100644 lastleg/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java create mode 100644 lastleg/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java create mode 100644 lastleg/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentServiceTest.java create mode 100644 lastleg/src/test/java/com/baiyue/lastleg/inventory/InventorySchemaContractTest.java create mode 100644 lastleg/src/test/java/com/baiyue/lastleg/inventory/command/InventoryCommandServiceTest.java create mode 100644 lastleg/src/test/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryControllerTest.java create mode 100644 lastleg/src/test/java/com/baiyue/lastleg/inventory/query/service/InventoryQueryServiceTest.java create mode 100644 warehouse/src/main/java/com/baiyue/warehouse/dto/WarehousePalletScrapResponse.java create mode 100644 warehouse/src/test/java/com/baiyue/warehouse/service/WarehouseApplicationServiceTest.java diff --git a/lastleg/src/main/java/com/baiyue/lastleg/dispatch/DispatchMapperConfiguration.java b/lastleg/src/main/java/com/baiyue/lastleg/dispatch/DispatchMapperConfiguration.java new file mode 100644 index 0000000..9a43399 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/dispatch/DispatchMapperConfiguration.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.dispatch; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.context.annotation.Configuration; + +@Configuration +@MapperScan("com.baiyue.lastleg.dispatch.mapper") +public class DispatchMapperConfiguration { +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchController.java b/lastleg/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchController.java new file mode 100644 index 0000000..1ee53ca --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchController.java @@ -0,0 +1,124 @@ +package com.baiyue.lastleg.dispatch.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.dispatch.dto.DispatchRequests; +import com.baiyue.lastleg.dispatch.dto.DispatchResponse; +import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate; +import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaRequests; +import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaResponses; +import com.baiyue.lastleg.dispatch.pda.service.DispatchPdaWorkflowService; +import com.baiyue.lastleg.dispatch.service.DispatchService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +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.server.ResponseStatusException; + +import java.util.List; + +@RestController +@RequestMapping("/api/platform/v1/last-leg/dispatches") +@Tag(name = "Tail-leg vehicle dispatch") +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") + public ApiResponse> candidates( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String tenantId, + @RequestParam(required = false) String orderNo, + @RequestParam(required = false) String fbaWarehouseCode, + @RequestParam(required = false) String sourceStage) { + return ApiResponse.success(service.candidates(scope(user, tenantId), orderNo, fbaWarehouseCode, sourceStage)); + } + + @PostMapping("/pre-arrange") + @Operation(summary = "Create a pending-departure dispatch and lock all selected pallets") + public ApiResponse preArrange( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String tenantId, + @Valid @RequestBody DispatchRequests.PreArrange request) { + String scopedTenant = scope(user, tenantId); + String operatorId = user.userId() == null ? user.username() : String.valueOf(user.userId()); + return ApiResponse.success(service.preArrange(scopedTenant, operatorId, request)); + } + + @GetMapping + @Operation(summary = "Page dispatch orders") + public ApiResponse> page( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String tenantId, + @Valid DispatchRequests.Query query) { + return ApiResponse.success(service.page(scope(user, tenantId), query)); + } + + @GetMapping("/{dispatchNo}") + @Operation(summary = "Get dispatch details and all pallet destinations") + public ApiResponse detail( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String tenantId, + @PathVariable String dispatchNo) { + return ApiResponse.success(service.detail(scope(user, tenantId), dispatchNo)); + } + + @PutMapping("/{dispatchNo}") + @Operation(summary = "Edit vehicle, driver, appointment, route and departure data") + public ApiResponse update( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String tenantId, + @PathVariable String dispatchNo, + @Valid @RequestBody DispatchRequests.Update request) { + return ApiResponse.success(service.update(scope(user, tenantId), dispatchNo, request)); + } + + @DeleteMapping("/{dispatchNo}") + @Operation(summary = "Cancel a pending dispatch and release its pallet locks") + public ApiResponse cancel( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String tenantId, + @PathVariable String dispatchNo) { + service.cancel(scope(user, tenantId), dispatchNo); + return ApiResponse.success(null); + } + + @PostMapping("/{dispatchNo}/depart") + @Operation(summary = "Ship all PDA-picked pallets and mark the dispatch departed") + public ApiResponse depart( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String tenantId, + @PathVariable String dispatchNo, + @Valid @RequestBody DispatchPdaRequests.Depart request) { + String scopedTenant = scope(user, tenantId); + String operatorId = user.userId() == null ? user.username() : String.valueOf(user.userId()); + return ApiResponse.success(pdaWorkflow.depart(scopedTenant, dispatchNo, operatorId, request)); + } + + private String scope(AuthenticatedUser user, String requestedTenant) { + if (user == null || user.tenantId() == null) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + } + if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) { + if (requestedTenant == null || requestedTenant.isBlank() || "0".equals(requestedTenant)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Platform request must specify target tenant"); + } + return requestedTenant; + } + return String.valueOf(user.tenantId()); + } +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchRequests.java b/lastleg/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchRequests.java new file mode 100644 index 0000000..370d811 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchRequests.java @@ -0,0 +1,35 @@ +package com.baiyue.lastleg.dispatch.dto; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import java.time.LocalDateTime; +import java.util.List; + +public final class DispatchRequests { + private DispatchRequests() { + } + + public record Query(@Min(1) long page, @Min(1) @Max(200) long size, String dispatchNo, + String vehiclePlate, String status, String fbaAppointmentNo) { + public Query { if (page == 0) page = 1; if (size == 0) size = 20; } + } + + public record PreArrange(@NotEmpty @Size(max = 500) List<@NotNull Long> planPalletIds, + @Size(max = 255) String route, @Size(max = 64) String fbaAppointmentNo, + @Size(max = 32) String fbaAppointmentStatus, + @Size(max = 32) String vehicleAppointmentStatus) { + } + + public record Update(@Size(max = 64) String vehiclePlate, @Size(max = 128) String driverName, + @Size(max = 64) String driverPhone, @Size(max = 255) String carrierName, + @Size(max = 255) String route, LocalDateTime departAt, + @Size(max = 64) String fbaAppointmentNo, + @Size(max = 32) String fbaAppointmentStatus, + @Size(max = 32) String vehicleAppointmentStatus, + LocalDateTime estimatedArrivalAt, @Size(max = 32) String status) { + } +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchResponse.java b/lastleg/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchResponse.java new file mode 100644 index 0000000..1eaec00 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchResponse.java @@ -0,0 +1,18 @@ +package com.baiyue.lastleg.dispatch.dto; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +public record DispatchResponse(Long id, String dispatchNo, String vehiclePlate, String driverName, + String driverPhone, String carrierName, String orderNos, String route, + LocalDateTime departAt, String fbaAppointmentNo, String fbaAppointmentStatus, + String vehicleAppointmentStatus, LocalDateTime estimatedArrivalAt, String status, + int itemCount, int palletCount, int packageCount, LocalDateTime createdAt, + List items) { + public record Item(Long id, Long planPalletId, Long warehousePalletId, String palletNo, + String orderNo, String orderRefs, String inboundCode, String destinationWarehouseCode, + String destinationWarehouseName, String fbaWarehouseCode, int packageCount, + BigDecimal volumeCbm, String sourceStage) { + } +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchItem.java b/lastleg/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchItem.java new file mode 100644 index 0000000..85142ec --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchItem.java @@ -0,0 +1,36 @@ +package com.baiyue.lastleg.dispatch.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** Immutable pallet/cargo snapshot selected into a dispatch order. */ +@Data +@TableName("ll_dispatch_item") +public class DispatchItem { + @TableId(type = IdType.INPUT) + private Long id; + private Long dispatchOrderId; + private String tenantId; + private Long planId; + private Long planPalletId; + private Long warehousePalletId; + private Long orderId; + private String orderNo; + private String orderRefs; + private String inboundCode; + private String palletNo; + private String destinationWarehouseId; + private String destinationWarehouseCode; + private String destinationWarehouseName; + private String fbaWarehouseCode; + private Integer packageCount; + private BigDecimal volumeCbm; + /** PLAN for planned pallets; RECEIVED for warehouse/entity pallets. */ + private String sourceStage; + private LocalDateTime createdAt; +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchOrder.java b/lastleg/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchOrder.java new file mode 100644 index 0000000..600e3c8 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchOrder.java @@ -0,0 +1,35 @@ +package com.baiyue.lastleg.dispatch.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +/** Header for a pre-dispatched vehicle load. Vehicle information remains editable before departure. */ +@Data +@TableName("ll_dispatch_order") +public class DispatchOrder { + @TableId(type = IdType.INPUT) + private Long id; + private String tenantId; + private String dispatchNo; + private String vehiclePlate; + private String driverName; + private String driverPhone; + private String carrierName; + private String route; + private LocalDateTime departAt; + private String fbaAppointmentNo; + private String fbaAppointmentStatus; + private String vehicleAppointmentStatus; + private LocalDateTime estimatedArrivalAt; + private String status; + private Integer itemCount; + private Integer palletCount; + private Integer packageCount; + private String createdBy; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchPalletLock.java b/lastleg/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchPalletLock.java new file mode 100644 index 0000000..9a0847e --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchPalletLock.java @@ -0,0 +1,29 @@ +package com.baiyue.lastleg.dispatch.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +/** Prevents a source pallet from being added to more than one active dispatch order. */ +@Data +@TableName("ll_dispatch_pallet_lock") +public class DispatchPalletLock { + @TableId(type = IdType.INPUT) + private Long id; + private String tenantId; + private String sourcePalletKey; + /** Equals sourcePalletKey while ACTIVE and is null after release; unique index enforces exclusivity. */ + private String activeKey; + private Long planId; + private Long planPalletId; + private Long warehousePalletId; + private Long dispatchOrderId; + private String status; + private LocalDateTime lockedAt; + private LocalDateTime releasedAt; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java b/lastleg/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java new file mode 100644 index 0000000..2f236c9 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java @@ -0,0 +1,66 @@ +package com.baiyue.lastleg.dispatch.mapper; + +import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; + +@Mapper +public interface DispatchCandidateMapper { + @Select("") + List available(@Param("tenantId") String tenantId, + @Param("orderNo") String orderNo, + @Param("fbaWarehouseCode") String fbaWarehouseCode, + @Param("sourceStage") String sourceStage); + + @Select("") + List lockSelected(@Param("tenantId") String tenantId, + @Param("planPalletIds") List planPalletIds); +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchItemMapper.java b/lastleg/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchItemMapper.java new file mode 100644 index 0000000..4757bc8 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchItemMapper.java @@ -0,0 +1,25 @@ +package com.baiyue.lastleg.dispatch.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.dispatch.entity.DispatchItem; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +import java.util.List; + +@Mapper +public interface DispatchItemMapper extends BaseMapper { + @Select("SELECT * FROM ll_dispatch_item WHERE tenant_id=#{tenantId} AND dispatch_order_id=#{dispatchOrderId} ORDER BY id") + List findByDispatchOrder(@Param("tenantId") String tenantId, + @Param("dispatchOrderId") Long dispatchOrderId); + + @Update("UPDATE ll_dispatch_item i JOIN ll_inbound_plan_pallet p ON p.id=i.plan_pallet_id " + + "JOIN ll_inbound_plan ip ON ip.id=p.plan_id AND ip.tenant_id=i.tenant_id " + + "SET i.warehouse_pallet_id=p.warehouse_pallet_id,i.source_stage='RECEIVED' " + + "WHERE i.tenant_id=#{tenantId} AND i.dispatch_order_id=#{dispatchOrderId} " + + "AND i.warehouse_pallet_id IS NULL AND p.warehouse_pallet_id IS NOT NULL") + int bindReceivedPallets(@Param("tenantId") String tenantId, + @Param("dispatchOrderId") Long dispatchOrderId); +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchOrderMapper.java b/lastleg/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchOrderMapper.java new file mode 100644 index 0000000..df46d02 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchOrderMapper.java @@ -0,0 +1,16 @@ +package com.baiyue.lastleg.dispatch.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.dispatch.entity.DispatchOrder; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface DispatchOrderMapper extends BaseMapper { + @Select("SELECT * FROM ll_dispatch_order WHERE tenant_id=#{tenantId} AND dispatch_no=#{dispatchNo} LIMIT 1") + DispatchOrder findByDispatchNo(@Param("tenantId") String tenantId, @Param("dispatchNo") String dispatchNo); + + @Select("SELECT * FROM ll_dispatch_order WHERE tenant_id=#{tenantId} AND id=#{id} LIMIT 1 FOR UPDATE") + DispatchOrder lockById(@Param("tenantId") String tenantId, @Param("id") Long id); +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchPalletLockMapper.java b/lastleg/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchPalletLockMapper.java new file mode 100644 index 0000000..3df6a12 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchPalletLockMapper.java @@ -0,0 +1,15 @@ +package com.baiyue.lastleg.dispatch.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.dispatch.entity.DispatchPalletLock; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface DispatchPalletLockMapper extends BaseMapper { + @Select("SELECT * FROM ll_dispatch_pallet_lock WHERE tenant_id=#{tenantId} AND source_pallet_key=#{sourcePalletKey} " + + "AND status='ACTIVE' LIMIT 1 FOR UPDATE") + DispatchPalletLock lockActive(@Param("tenantId") String tenantId, + @Param("sourcePalletKey") String sourcePalletKey); +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/dispatch/model/DispatchPalletCandidate.java b/lastleg/src/main/java/com/baiyue/lastleg/dispatch/model/DispatchPalletCandidate.java new file mode 100644 index 0000000..f1ca90a --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/dispatch/model/DispatchPalletCandidate.java @@ -0,0 +1,26 @@ +package com.baiyue.lastleg.dispatch.model; + +import java.math.BigDecimal; + +public record DispatchPalletCandidate( + Long planId, + Long planPalletId, + Long warehousePalletId, + Long orderId, + String orderNo, + String orderRefs, + String inboundCode, + String palletNo, + String destinationWarehouseId, + String destinationWarehouseCode, + String destinationWarehouseName, + String fbaWarehouseCode, + Integer packageCount, + BigDecimal volumeCbm, + String sourceStage +) { + public String sourcePalletKey() { + // A plan pallet keeps the same lock identity before and after the physical pallet is received. + return planPalletId != null ? "PLAN:" + planPalletId : "RECEIVED:" + warehousePalletId; + } +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/dispatch/pda/DispatchPdaMapperConfiguration.java b/lastleg/src/main/java/com/baiyue/lastleg/dispatch/pda/DispatchPdaMapperConfiguration.java new file mode 100644 index 0000000..ddfbe6b --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/dispatch/pda/DispatchPdaMapperConfiguration.java @@ -0,0 +1,8 @@ +package com.baiyue.lastleg.dispatch.pda; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.context.annotation.Configuration; + +@Configuration +@MapperScan("com.baiyue.lastleg.dispatch.pda.mapper") +public class DispatchPdaMapperConfiguration {} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/dispatch/pda/controller/DispatchPdaController.java b/lastleg/src/main/java/com/baiyue/lastleg/dispatch/pda/controller/DispatchPdaController.java new file mode 100644 index 0000000..6609b78 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/dispatch/pda/controller/DispatchPdaController.java @@ -0,0 +1,51 @@ +package com.baiyue.lastleg.dispatch.pda.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaRequests; +import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaResponses; +import com.baiyue.lastleg.dispatch.pda.service.DispatchPdaWorkflowService; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; + +@RestController +@RequestMapping("/api/platform/v1/last-leg/dispatches/{dispatchNo}/pda") +public class DispatchPdaController { + @Resource private DispatchPdaWorkflowService service; + + @PostMapping("/push") + public ApiResponse push(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String dispatchNo, + @RequestParam(required = false) String tenantId, + @Valid @RequestBody DispatchPdaRequests.Push request) { + return ApiResponse.success(service.push(scope(user, tenantId), dispatchNo, request)); + } + @GetMapping + public ApiResponse task(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String dispatchNo, + @RequestParam(required = false) String tenantId) { + return ApiResponse.success(service.task(scope(user, tenantId), dispatchNo)); + } + @PostMapping("/items/{itemId}/pick") + public ApiResponse pick(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String dispatchNo, @PathVariable long itemId, + @RequestParam(required = false) String tenantId, + @Valid @RequestBody DispatchPdaRequests.Pick request) { + return ApiResponse.success(service.pick(scope(user, tenantId), dispatchNo, itemId, operator(user), request)); + } + private String scope(AuthenticatedUser user, String requestedTenant) { + if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) { + if (requestedTenant == null || requestedTenant.isBlank() || "0".equals(requestedTenant)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Platform request must specify target tenant"); + } + return requestedTenant.trim(); + } + return String.valueOf(user.tenantId()); + } + private String operator(AuthenticatedUser user) { return user.userId() == null ? user.username() : String.valueOf(user.userId()); } +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/dispatch/pda/dto/DispatchPdaRequests.java b/lastleg/src/main/java/com/baiyue/lastleg/dispatch/pda/dto/DispatchPdaRequests.java new file mode 100644 index 0000000..240d73d --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/dispatch/pda/dto/DispatchPdaRequests.java @@ -0,0 +1,14 @@ +package com.baiyue.lastleg.dispatch.pda.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.PositiveOrZero; + +public final class DispatchPdaRequests { + private DispatchPdaRequests() {} + public record Push(@NotBlank String idempotencyKey, String operatorId, String operatorName) {} + public record Pick(@NotNull Long positionId, @Positive long packages, @PositiveOrZero long productQuantity, + @NotBlank String idempotencyKey, String remark) {} + public record Depart(@NotBlank String idempotencyKey, String remark) {} +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/dispatch/pda/dto/DispatchPdaResponses.java b/lastleg/src/main/java/com/baiyue/lastleg/dispatch/pda/dto/DispatchPdaResponses.java new file mode 100644 index 0000000..09484fa --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/dispatch/pda/dto/DispatchPdaResponses.java @@ -0,0 +1,19 @@ +package com.baiyue.lastleg.dispatch.pda.dto; + +import java.util.List; + +public final class DispatchPdaResponses { + private DispatchPdaResponses() {} + public record Task(Long id, String taskNo, String dispatchNo, String vehiclePlate, String status, + List items) {} + public record Item(Long dispatchItemId, String palletNo, String destinationWarehouseCode, + String destinationWarehouseName, String fbaWarehouseCode, int scheduledPackages, + long pickedPackages, List positions) {} + public record Position(Long id, String skuCode, long availablePackages, long allocatedPackages, + long pickedPackages, long productQuantity) {} + public record Picked(Long dispatchItemId, Long positionId, long packages, long pickedPackages, + boolean palletScrapped, boolean replayed) {} + public record Departed(String dispatchNo, String status, long shippedPackages, boolean replayed) { + public Departed withReplay() { return new Departed(dispatchNo, status, shippedPackages, true); } + } +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaOperation.java b/lastleg/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaOperation.java new file mode 100644 index 0000000..fd57cbb --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaOperation.java @@ -0,0 +1,23 @@ +package com.baiyue.lastleg.dispatch.pda.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@TableName("ll_dispatch_pda_operation") +public class DispatchPdaOperation { + @TableId(type = IdType.INPUT) private Long id; + private String tenantId; + private Long taskId; + private String operationType; + private String idempotencyKey; + private String requestHash; + private String resultJson; + private String status; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaPick.java b/lastleg/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaPick.java new file mode 100644 index 0000000..b6e6453 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaPick.java @@ -0,0 +1,23 @@ +package com.baiyue.lastleg.dispatch.pda.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@TableName("ll_dispatch_pda_pick") +public class DispatchPdaPick { + @TableId(type = IdType.INPUT) private Long id; + private String tenantId; + private Long taskId; + private Long dispatchItemId; + private Long inventoryPositionId; + private Long packages; + private Long productQuantity; + private String idempotencyKey; + private LocalDateTime pickedAt; + private LocalDateTime createdAt; +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaTask.java b/lastleg/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaTask.java new file mode 100644 index 0000000..b92f694 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaTask.java @@ -0,0 +1,24 @@ +package com.baiyue.lastleg.dispatch.pda.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@TableName("ll_dispatch_pda_task") +public class DispatchPdaTask { + @TableId(type = IdType.INPUT) private Long id; + private String tenantId; + private Long dispatchOrderId; + private String taskNo; + private String status; + private String operatorId; + private String operatorName; + private LocalDateTime pushedAt; + private LocalDateTime completedAt; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaOperationMapper.java b/lastleg/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaOperationMapper.java new file mode 100644 index 0000000..2245925 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaOperationMapper.java @@ -0,0 +1,11 @@ +package com.baiyue.lastleg.dispatch.pda.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaOperation; +import org.apache.ibatis.annotations.*; + +@Mapper +public interface DispatchPdaOperationMapper extends BaseMapper { + @Select("SELECT * FROM ll_dispatch_pda_operation WHERE tenant_id=#{tenantId} AND idempotency_key=#{key} LIMIT 1") + DispatchPdaOperation findKey(@Param("tenantId") String tenantId, @Param("key") String key); +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaPickMapper.java b/lastleg/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaPickMapper.java new file mode 100644 index 0000000..a88e9f8 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaPickMapper.java @@ -0,0 +1,14 @@ +package com.baiyue.lastleg.dispatch.pda.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaPick; +import org.apache.ibatis.annotations.*; +import java.util.List; + +@Mapper +public interface DispatchPdaPickMapper extends BaseMapper { + @Select("SELECT * FROM ll_dispatch_pda_pick WHERE tenant_id=#{tenantId} AND task_id=#{taskId} ORDER BY id") + List listTask(@Param("tenantId") String tenantId, @Param("taskId") Long taskId); + @Select("SELECT COALESCE(SUM(packages),0) FROM ll_dispatch_pda_pick WHERE tenant_id=#{tenantId} AND task_id=#{taskId} AND dispatch_item_id=#{itemId}") + long totalItem(@Param("tenantId") String tenantId, @Param("taskId") Long taskId, @Param("itemId") Long itemId); +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaTaskMapper.java b/lastleg/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaTaskMapper.java new file mode 100644 index 0000000..6122060 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaTaskMapper.java @@ -0,0 +1,14 @@ +package com.baiyue.lastleg.dispatch.pda.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaTask; +import org.apache.ibatis.annotations.*; + +@Mapper +public interface DispatchPdaTaskMapper extends BaseMapper { + @Select("SELECT * FROM ll_dispatch_pda_task WHERE tenant_id=#{tenantId} AND dispatch_order_id=#{dispatchOrderId} LIMIT 1 FOR UPDATE") + DispatchPdaTask lockByDispatch(@Param("tenantId") String tenantId, @Param("dispatchOrderId") Long dispatchOrderId); + + @Select("SELECT * FROM ll_dispatch_pda_task WHERE tenant_id=#{tenantId} AND dispatch_order_id=#{dispatchOrderId} LIMIT 1") + DispatchPdaTask findByDispatch(@Param("tenantId") String tenantId, @Param("dispatchOrderId") Long dispatchOrderId); +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowService.java b/lastleg/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowService.java new file mode 100644 index 0000000..9710a42 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowService.java @@ -0,0 +1,213 @@ +package com.baiyue.lastleg.dispatch.pda.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.lastleg.dispatch.entity.DispatchItem; +import com.baiyue.lastleg.dispatch.entity.DispatchOrder; +import com.baiyue.lastleg.dispatch.entity.DispatchPalletLock; +import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper; +import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper; +import com.baiyue.lastleg.dispatch.mapper.DispatchPalletLockMapper; +import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaRequests; +import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaResponses; +import com.baiyue.lastleg.dispatch.pda.entity.*; +import com.baiyue.lastleg.dispatch.pda.mapper.*; +import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests; +import com.baiyue.lastleg.inventory.command.service.InventoryCommandService; +import com.baiyue.lastleg.inventory.entity.InventoryPosition; +import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.LocalDateTime; +import java.util.*; +import java.util.stream.Collectors; + +@Service +public class DispatchPdaWorkflowService { + @Resource private DispatchOrderMapper orders; + @Resource private DispatchItemMapper items; + @Resource private DispatchPdaTaskMapper tasks; + @Resource private DispatchPdaPickMapper picks; + @Resource private DispatchPdaOperationMapper operations; + @Resource private DispatchPalletLockMapper palletLocks; + @Resource private InventoryPositionMapper positions; + @Resource private InventoryCommandService inventory; + @Resource private ObjectMapper objectMapper; + + @Transactional + public DispatchPdaResponses.Task push(String tenantId, String dispatchNo, DispatchPdaRequests.Push request) { + DispatchOrder order = requireOrder(tenantId, dispatchNo); + requirePdaStatus(order); + requireVehicle(order); + DispatchPdaOperation replay = replay(tenantId, "PUSH", request.idempotencyKey(), request); + if (replay != null) return read(replay, DispatchPdaResponses.Task.class); + items.bindReceivedPallets(tenantId, order.getId()); + List dispatchItems = items.findByDispatchOrder(tenantId, order.getId()); + if (dispatchItems.isEmpty()) throw conflict("Dispatch has no pallets to push"); + if (dispatchItems.stream().anyMatch(item -> item.getWarehousePalletId() == null)) { + throw conflict("All pre-arranged pallets must be received before PDA push"); + } + DispatchPdaTask task = tasks.lockByDispatch(tenantId, order.getId()); + LocalDateTime now = LocalDateTime.now(); + if (task == null) { + task = new DispatchPdaTask(); + task.setId(IdWorker.getId()); task.setTenantId(tenantId); task.setDispatchOrderId(order.getId()); + task.setTaskNo("DPDA" + task.getId()); task.setStatus("PDA_PENDING"); + task.setOperatorId(trim(request.operatorId())); task.setOperatorName(trim(request.operatorName())); + task.setPushedAt(now); task.setCreatedAt(now); task.setUpdatedAt(now); tasks.insert(task); + } + if ("PENDING_DEPARTURE".equals(order.getStatus())) { order.setStatus("PICKING"); order.setUpdatedAt(now); orders.updateById(order); } + DispatchPdaResponses.Task result = task(tenantId, order, task, dispatchItems); + save(tenantId, task.getId(), "PUSH", request.idempotencyKey(), request, result); + return result; + } + + @Transactional(readOnly = true) + public DispatchPdaResponses.Task task(String tenantId, String dispatchNo) { + DispatchOrder order = requireOrder(tenantId, dispatchNo); + DispatchPdaTask task = tasks.findByDispatch(tenantId, order.getId()); + if (task == null) throw notFound("Dispatch PDA task not found"); + return task(tenantId, order, task, items.findByDispatchOrder(tenantId, order.getId())); + } + + @Transactional + public DispatchPdaResponses.Picked pick(String tenantId, String dispatchNo, long itemId, + String operatorId, DispatchPdaRequests.Pick request) { + DispatchOrder order = requireOrder(tenantId, dispatchNo); + requirePdaStatus(order); + requireVehicle(order); + DispatchPdaTask task = tasks.lockByDispatch(tenantId, order.getId()); + if (task == null || !("PDA_PENDING".equals(task.getStatus()) || "PICKING".equals(task.getStatus()))) { + throw conflict("Dispatch PDA task is not available for picking"); + } + DispatchItem item = items.selectById(itemId); + if (item == null || !tenantId.equals(item.getTenantId()) || !order.getId().equals(item.getDispatchOrderId())) { + throw notFound("Dispatch pallet item not found"); + } + DispatchPdaOperation replay = replay(tenantId, "PICK", request.idempotencyKey(), request); + if (replay != null) return read(replay, DispatchPdaResponses.Picked.class); + InventoryPosition position = positions.selectById(request.positionId()); + if (position == null || !tenantId.equals(position.getTenantId()) + || item.getWarehousePalletId() == null || !item.getWarehousePalletId().equals(position.getPalletId())) { + throw conflict("Inventory position does not belong to this dispatch pallet"); + } + long picked = picks.totalItem(tenantId, task.getId(), itemId); + if (picked + request.packages() > item.getPackageCount()) throw conflict("Picked packages exceed dispatch pallet packages"); + String keyBase = "DISPATCH:" + order.getDispatchNo() + ":" + itemId + ":" + request.idempotencyKey(); + InventoryCommandRequests.Move move = new InventoryCommandRequests.Move(tenantId, request.positionId(), + request.packages(), request.productQuantity(), order.getDispatchNo(), order.getDispatchNo(), null, + keyBase + ":ALLOCATE", request.remark()); + inventory.allocate(tenantId, operatorId, move); + inventory.pick(tenantId, operatorId, new InventoryCommandRequests.Move(tenantId, request.positionId(), + request.packages(), request.productQuantity(), order.getDispatchNo(), order.getDispatchNo(), null, + keyBase + ":PICK", request.remark())); + DispatchPdaPick pick = new DispatchPdaPick(); + pick.setId(IdWorker.getId()); pick.setTenantId(tenantId); pick.setTaskId(task.getId()); + pick.setDispatchItemId(itemId); pick.setInventoryPositionId(request.positionId()); pick.setPackages(request.packages()); + pick.setProductQuantity(request.productQuantity()); pick.setIdempotencyKey(request.idempotencyKey()); + pick.setPickedAt(LocalDateTime.now()); pick.setCreatedAt(pick.getPickedAt()); picks.insert(pick); + long total = picked + request.packages(); + task.setStatus("PICKING"); task.setUpdatedAt(LocalDateTime.now()); + if (allPicked(tenantId, task.getId(), items.findByDispatchOrder(tenantId, order.getId()))) { + task.setStatus("COMPLETED"); task.setCompletedAt(LocalDateTime.now()); order.setStatus("READY_TO_DEPART"); + order.setUpdatedAt(LocalDateTime.now()); orders.updateById(order); + } + tasks.updateById(task); + DispatchPdaResponses.Picked result = new DispatchPdaResponses.Picked(itemId, request.positionId(), request.packages(), total, false, false); + save(tenantId, task.getId(), "PICK", request.idempotencyKey(), request, result); + return result; + } + + /** Final warehouse exit: only fully PDA-picked dispatches can consume picked inventory. */ + @Transactional + public DispatchPdaResponses.Departed depart(String tenantId, String dispatchNo, String operatorId, + DispatchPdaRequests.Depart request) { + DispatchOrder found = requireOrder(tenantId, dispatchNo); + DispatchOrder order = orders.lockById(tenantId, found.getId()); + if (order == null) throw notFound("Dispatch order not found"); + DispatchPdaOperation replay = replay(tenantId, "DEPART", request.idempotencyKey(), request); + if (replay != null) return read(replay, DispatchPdaResponses.Departed.class).withReplay(); + requireVehicle(order); + DispatchPdaTask task = tasks.lockByDispatch(tenantId, order.getId()); + if (task == null || !"COMPLETED".equals(task.getStatus()) || !"READY_TO_DEPART".equals(order.getStatus())) { + throw conflict("Dispatch must complete PDA picking before departure"); + } + + List dispatchItems = items.findByDispatchOrder(tenantId, order.getId()); + if (!allPicked(tenantId, task.getId(), dispatchItems)) { + throw conflict("Every dispatch pallet must be fully picked before departure"); + } + List dispatchPicks = picks.listTask(tenantId, task.getId()); + if (dispatchPicks.isEmpty()) throw conflict("Dispatch has no PDA picks to ship"); + long shipped = 0; + for (DispatchPdaPick pick : dispatchPicks) { + String key = "DISPATCH:" + order.getDispatchNo() + ":PICK:" + pick.getId() + ":SHIP"; + inventory.ship(tenantId, operatorId, new InventoryCommandRequests.Move(tenantId, + pick.getInventoryPositionId(), n(pick.getPackages()), n(pick.getProductQuantity()), + order.getDispatchNo(), order.getDispatchNo(), null, key, request.remark())); + shipped += n(pick.getPackages()); + } + LocalDateTime now = LocalDateTime.now(); + order.setStatus("DEPARTED"); + order.setUpdatedAt(now); + orders.updateById(order); + releasePalletLocks(tenantId, order.getId(), now); + DispatchPdaResponses.Departed result = new DispatchPdaResponses.Departed(order.getDispatchNo(), + order.getStatus(), shipped, false); + save(tenantId, task.getId(), "DEPART", request.idempotencyKey(), request, result); + return result; + } + + private DispatchPdaResponses.Task task(String tenantId, DispatchOrder order, DispatchPdaTask task, List source) { + List values = new ArrayList<>(); + for (DispatchItem item : source) { + List palletPositions = item.getWarehousePalletId() == null ? List.of() + : positions.listPallet(tenantId, item.getWarehousePalletId()); + values.add(new DispatchPdaResponses.Item(item.getId(), item.getPalletNo(), item.getDestinationWarehouseCode(), + item.getDestinationWarehouseName(), item.getFbaWarehouseCode(), item.getPackageCount(), + picks.totalItem(tenantId, task.getId(), item.getId()), palletPositions.stream().map(p -> + new DispatchPdaResponses.Position(p.getId(), p.getSkuCode(), n(p.getAvailablePackages()), n(p.getAllocatedPackages()), + n(p.getPickedPackages()), n(p.getProductQuantity()))).toList())); + } + return new DispatchPdaResponses.Task(task.getId(), task.getTaskNo(), order.getDispatchNo(), order.getVehiclePlate(), task.getStatus(), values); + } + + private boolean allPicked(String tenantId, Long taskId, List source) { + return source.stream().allMatch(item -> picks.totalItem(tenantId, taskId, item.getId()) == item.getPackageCount()); + } + private void releasePalletLocks(String tenantId, Long dispatchOrderId, LocalDateTime now) { + List activeLocks = palletLocks.selectList(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() + .eq(DispatchPalletLock::getTenantId, tenantId) + .eq(DispatchPalletLock::getDispatchOrderId, dispatchOrderId) + .eq(DispatchPalletLock::getStatus, "ACTIVE")); + for (DispatchPalletLock lock : activeLocks) { + lock.setStatus("RELEASED"); + lock.setActiveKey(null); + lock.setReleasedAt(now); + lock.setUpdatedAt(now); + palletLocks.updateById(lock); + } + } + private DispatchOrder requireOrder(String tenantId, String dispatchNo) { DispatchOrder order = orders.findByDispatchNo(tenantId, dispatchNo); if (order == null) throw notFound("Dispatch order not found"); return order; } + private void requirePdaStatus(DispatchOrder order) { + if (!("PENDING_DEPARTURE".equals(order.getStatus()) || "PICKING".equals(order.getStatus()) + || "READY_TO_DEPART".equals(order.getStatus()))) throw conflict("Dispatch has already departed and cannot be changed by PDA"); + } + private void requireVehicle(DispatchOrder order) { if (trim(order.getVehiclePlate()) == null) throw conflict("Vehicle plate is required before PDA push or picking"); } + private DispatchPdaOperation replay(String tenantId, String type, String key, Object request) { DispatchPdaOperation op = operations.findKey(tenantId, required(key, "Idempotency key is required")); if (op == null) return null; if (!type.equals(op.getOperationType()) || !hash(request).equals(op.getRequestHash())) throw conflict("Idempotency key was reused with a different request"); if (!"COMPLETED".equals(op.getStatus())) throw conflict("PDA operation is still processing"); return op; } + private void save(String tenantId, Long taskId, String type, String key, Object request, Object result) { DispatchPdaOperation op = new DispatchPdaOperation(); op.setId(IdWorker.getId()); op.setTenantId(tenantId); op.setTaskId(taskId); op.setOperationType(type); op.setIdempotencyKey(required(key,"Idempotency key is required")); op.setRequestHash(hash(request)); op.setResultJson(json(result)); op.setStatus("COMPLETED"); op.setCreatedAt(LocalDateTime.now()); op.setUpdatedAt(op.getCreatedAt()); operations.insert(op); } + private T read(DispatchPdaOperation op, Class type) { try { return objectMapper.readValue(op.getResultJson(), type); } catch (Exception e) { throw new IllegalStateException("Cannot read PDA replay", e); } } + private String hash(Object value) { try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json(value).getBytes(StandardCharsets.UTF_8))); } catch (Exception e) { throw new IllegalStateException(e); } } + private String json(Object value) { try { return objectMapper.writeValueAsString(value); } catch (Exception e) { throw new IllegalStateException(e); } } + private static long n(Long value) { return value == null ? 0 : value; } + private static String trim(String value) { return value == null || value.trim().isEmpty() ? null : value.trim(); } + private static String required(String value, String message) { String result = trim(value); if (result == null) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, message); return result; } + private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } + private static ResponseStatusException notFound(String message) { return new ResponseStatusException(HttpStatus.NOT_FOUND, message); } +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java b/lastleg/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java new file mode 100644 index 0000000..214cbf9 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java @@ -0,0 +1,246 @@ +package com.baiyue.lastleg.dispatch.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.lastleg.dispatch.dto.DispatchRequests; +import com.baiyue.lastleg.dispatch.dto.DispatchResponse; +import com.baiyue.lastleg.dispatch.entity.DispatchItem; +import com.baiyue.lastleg.dispatch.entity.DispatchOrder; +import com.baiyue.lastleg.dispatch.entity.DispatchPalletLock; +import com.baiyue.lastleg.dispatch.mapper.DispatchCandidateMapper; +import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper; +import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper; +import com.baiyue.lastleg.dispatch.mapper.DispatchPalletLockMapper; +import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate; +import jakarta.annotation.Resource; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; +import org.springframework.web.server.ResponseStatusException; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; + +@Service +public class DispatchService { + public static final String PENDING_DEPARTURE = "PENDING_DEPARTURE"; + public static final String CANCELLED = "CANCELLED"; + private static final Set STATUSES = Set.of(PENDING_DEPARTURE, "PICKING", "READY_TO_DEPART", + "DEPARTED", "COMPLETED", CANCELLED); + + @Resource private DispatchOrderMapper orders; + @Resource private DispatchItemMapper items; + @Resource private DispatchPalletLockMapper locks; + @Resource private DispatchCandidateMapper candidates; + + public List candidates(String tenantId, String orderNo, + String fbaWarehouseCode, String sourceStage) { + if (StringUtils.hasText(sourceStage) && !Set.of("PLAN", "RECEIVED").contains(sourceStage)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "sourceStage must be PLAN or RECEIVED"); + } + return candidates.available(tenantId, trimToNull(orderNo), trimToNull(fbaWarehouseCode), trimToNull(sourceStage)); + } + + @Transactional + public DispatchResponse preArrange(String tenantId, String operatorId, DispatchRequests.PreArrange request) { + List palletIds = request.planPalletIds().stream().filter(Objects::nonNull).distinct().sorted().toList(); + if (palletIds.isEmpty() || palletIds.size() != request.planPalletIds().size()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Plan pallet IDs must be unique and non-empty"); + } + List selected = candidates.lockSelected(tenantId, palletIds); + if (selected.size() != palletIds.size()) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "One or more selected pallets are not in this tenant"); + } + for (DispatchPalletCandidate candidate : selected) { + boolean planLocked = candidate.planPalletId() != null + && locks.lockActive(tenantId, "PLAN:" + candidate.planPalletId()) != null; + boolean receivedLocked = candidate.warehousePalletId() != null + && locks.lockActive(tenantId, "RECEIVED:" + candidate.warehousePalletId()) != null; + if (planLocked || receivedLocked) { + throw new ResponseStatusException(HttpStatus.CONFLICT, + "Pallet is already locked by another active dispatch: " + candidate.palletNo()); + } + } + + LocalDateTime now = LocalDateTime.now(); + DispatchOrder dispatch = new DispatchOrder(); + dispatch.setId(IdWorker.getId()); + dispatch.setTenantId(tenantId); + dispatch.setDispatchNo("PC" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + dispatch.getId()); + dispatch.setRoute(StringUtils.hasText(request.route()) ? request.route().trim() : route(selected)); + dispatch.setFbaAppointmentNo(trimToNull(request.fbaAppointmentNo())); + dispatch.setFbaAppointmentStatus(defaultStatus(request.fbaAppointmentStatus())); + dispatch.setVehicleAppointmentStatus(defaultStatus(request.vehicleAppointmentStatus())); + dispatch.setStatus(PENDING_DEPARTURE); + dispatch.setItemCount(selected.size()); + dispatch.setPalletCount(selected.size()); + dispatch.setPackageCount(selected.stream().map(DispatchPalletCandidate::packageCount) + .filter(Objects::nonNull).mapToInt(Integer::intValue).sum()); + dispatch.setCreatedBy(trimToNull(operatorId)); + dispatch.setCreatedAt(now); + dispatch.setUpdatedAt(now); + orders.insert(dispatch); + + for (DispatchPalletCandidate candidate : selected) { + DispatchPalletLock lock = new DispatchPalletLock(); + lock.setId(IdWorker.getId()); + lock.setTenantId(tenantId); + lock.setSourcePalletKey(candidate.sourcePalletKey()); + lock.setActiveKey(candidate.sourcePalletKey()); + lock.setPlanId(candidate.planId()); + lock.setPlanPalletId(candidate.planPalletId()); + lock.setWarehousePalletId(candidate.warehousePalletId()); + lock.setDispatchOrderId(dispatch.getId()); + lock.setStatus("ACTIVE"); + lock.setLockedAt(now); + lock.setCreatedAt(now); + lock.setUpdatedAt(now); + try { + locks.insert(lock); + } catch (DuplicateKeyException exception) { + throw new ResponseStatusException(HttpStatus.CONFLICT, + "Pallet is already locked by another active dispatch: " + candidate.palletNo(), exception); + } + DispatchItem item = new DispatchItem(); + item.setId(IdWorker.getId()); + item.setDispatchOrderId(dispatch.getId()); + item.setTenantId(tenantId); + item.setPlanId(candidate.planId()); + item.setPlanPalletId(candidate.planPalletId()); + item.setWarehousePalletId(candidate.warehousePalletId()); + item.setOrderId(candidate.orderId()); + item.setOrderNo(candidate.orderNo()); + item.setOrderRefs(candidate.orderRefs()); + item.setInboundCode(candidate.inboundCode()); + item.setPalletNo(candidate.palletNo()); + item.setDestinationWarehouseId(candidate.destinationWarehouseId()); + item.setDestinationWarehouseCode(candidate.destinationWarehouseCode()); + item.setDestinationWarehouseName(candidate.destinationWarehouseName()); + item.setFbaWarehouseCode(candidate.fbaWarehouseCode()); + item.setPackageCount(Objects.requireNonNullElse(candidate.packageCount(), 0)); + item.setVolumeCbm(Objects.requireNonNullElse(candidate.volumeCbm(), BigDecimal.ZERO)); + item.setSourceStage(candidate.sourceStage()); + item.setCreatedAt(now); + items.insert(item); + } + return response(dispatch, items.findByDispatchOrder(tenantId, dispatch.getId())); + } + + public PageResult page(String tenantId, DispatchRequests.Query query) { + Page page = orders.selectPage(new Page<>(query.page(), query.size()), + new LambdaQueryWrapper().eq(DispatchOrder::getTenantId, tenantId) + .like(StringUtils.hasText(query.dispatchNo()), DispatchOrder::getDispatchNo, query.dispatchNo()) + .like(StringUtils.hasText(query.vehiclePlate()), DispatchOrder::getVehiclePlate, query.vehiclePlate()) + .eq(StringUtils.hasText(query.status()), DispatchOrder::getStatus, query.status()) + .like(StringUtils.hasText(query.fbaAppointmentNo()), DispatchOrder::getFbaAppointmentNo, + query.fbaAppointmentNo()) + .orderByDesc(DispatchOrder::getCreatedAt)); + List records = page.getRecords().stream() + .map(order -> response(order, items.findByDispatchOrder(tenantId, order.getId()))).toList(); + return new PageResult<>(records, page.getCurrent(), page.getSize(), page.getTotal(), page.getPages()); + } + + public DispatchResponse detail(String tenantId, String dispatchNo) { + DispatchOrder dispatch = find(tenantId, dispatchNo); + return response(dispatch, items.findByDispatchOrder(tenantId, dispatch.getId())); + } + + @Transactional + public DispatchResponse update(String tenantId, String dispatchNo, DispatchRequests.Update request) { + DispatchOrder dispatch = orders.lockById(tenantId, find(tenantId, dispatchNo).getId()); + if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found"); + // Vehicle plate is intentionally editable through all dispatch states. + dispatch.setVehiclePlate(trimToNull(request.vehiclePlate())); + dispatch.setDriverName(trimToNull(request.driverName())); + dispatch.setDriverPhone(trimToNull(request.driverPhone())); + dispatch.setCarrierName(trimToNull(request.carrierName())); + dispatch.setRoute(trimToNull(request.route())); + dispatch.setDepartAt(request.departAt()); + dispatch.setFbaAppointmentNo(trimToNull(request.fbaAppointmentNo())); + dispatch.setFbaAppointmentStatus(defaultStatus(request.fbaAppointmentStatus())); + dispatch.setVehicleAppointmentStatus(defaultStatus(request.vehicleAppointmentStatus())); + dispatch.setEstimatedArrivalAt(request.estimatedArrivalAt()); + if (StringUtils.hasText(request.status())) { + String status = request.status().trim().toUpperCase(); + if (!STATUSES.contains(status) || CANCELLED.equals(status)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Use cancel endpoint or a valid dispatch status"); + } + dispatch.setStatus(status); + } + dispatch.setUpdatedAt(LocalDateTime.now()); + orders.updateById(dispatch); + return response(dispatch, items.findByDispatchOrder(tenantId, dispatch.getId())); + } + + @Transactional + public void cancel(String tenantId, String dispatchNo) { + DispatchOrder dispatch = orders.lockById(tenantId, find(tenantId, dispatchNo).getId()); + if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found"); + if (!(PENDING_DEPARTURE.equals(dispatch.getStatus()))) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "Dispatched order cannot be cancelled"); + } + LocalDateTime now = LocalDateTime.now(); + dispatch.setStatus(CANCELLED); + dispatch.setUpdatedAt(now); + orders.updateById(dispatch); + List activeLocks = locks.selectList(new LambdaQueryWrapper() + .eq(DispatchPalletLock::getTenantId, tenantId) + .eq(DispatchPalletLock::getDispatchOrderId, dispatch.getId()) + .eq(DispatchPalletLock::getStatus, "ACTIVE")); + for (DispatchPalletLock lock : activeLocks) { + lock.setStatus("RELEASED"); + lock.setActiveKey(null); + lock.setReleasedAt(now); + lock.setUpdatedAt(now); + locks.updateById(lock); + } + } + + private DispatchOrder find(String tenantId, String dispatchNo) { + DispatchOrder dispatch = orders.findByDispatchNo(tenantId, dispatchNo); + if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found"); + return dispatch; + } + + private DispatchResponse response(DispatchOrder order, List itemList) { + String orderNos = itemList.stream().flatMap(item -> splitOrderRefs(item.getOrderRefs(), item.getOrderNo()).stream()) + .distinct().collect(Collectors.joining(",")); + return new DispatchResponse(order.getId(), order.getDispatchNo(), order.getVehiclePlate(), order.getDriverName(), + order.getDriverPhone(), order.getCarrierName(), orderNos, order.getRoute(), order.getDepartAt(), + order.getFbaAppointmentNo(), order.getFbaAppointmentStatus(), order.getVehicleAppointmentStatus(), + order.getEstimatedArrivalAt(), order.getStatus(), Objects.requireNonNullElse(order.getItemCount(), 0), + Objects.requireNonNullElse(order.getPalletCount(), 0), Objects.requireNonNullElse(order.getPackageCount(), 0), + order.getCreatedAt(), itemList.stream().map(item -> new DispatchResponse.Item(item.getId(), item.getPlanPalletId(), + item.getWarehousePalletId(), item.getPalletNo(), item.getOrderNo(), item.getOrderRefs(), item.getInboundCode(), + item.getDestinationWarehouseCode(), item.getDestinationWarehouseName(), item.getFbaWarehouseCode(), + Objects.requireNonNullElse(item.getPackageCount(), 0), item.getVolumeCbm(), item.getSourceStage())).toList()); + } + + private static String route(List selected) { + return selected.stream().map(DispatchPalletCandidate::destinationWarehouseCode).filter(StringUtils::hasText) + .distinct().collect(Collectors.joining(" -> ")); + } + + private static List splitOrderRefs(String refs, String fallback) { + String value = StringUtils.hasText(refs) ? refs : fallback; + return StringUtils.hasText(value) ? List.of(value.split(",")) : List.of(); + } + + private static String defaultStatus(String status) { + return StringUtils.hasText(status) ? status.trim().toUpperCase() : "PENDING"; + } + + private static String trimToNull(String value) { + return StringUtils.hasText(value) ? value.trim() : null; + } +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java b/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java index 59ba597..4f102e6 100644 --- a/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java +++ b/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java @@ -3,7 +3,10 @@ package com.baiyue.lastleg.inboundplan.controller; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.lastleg.inboundplan.dto.ConvertInboundPlanRequest; +import com.baiyue.lastleg.inboundplan.dto.BatchFbaAppointmentRequest; +import com.baiyue.lastleg.inboundplan.dto.FbaAppointmentResponse; import com.baiyue.lastleg.inboundplan.dto.InboundPlanResponse; +import com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService; import com.baiyue.lastleg.inboundplan.service.InboundPlanService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -19,6 +22,7 @@ import org.springframework.web.server.ResponseStatusException; @Tag(name = "Tail-leg inbound plans") public class InboundPlanController { @Resource private InboundPlanService service; + @Resource private InboundFbaAppointmentService appointmentService; @PostMapping("/convert") @Operation(summary = "Convert the entire container to one inbound plan") @@ -35,6 +39,25 @@ public class InboundPlanController { return ApiResponse.success(service.detailByPlanNo(scope(user, tenantId), planNo)); } + @PostMapping("/fba-appointments/batch") + @Operation(summary = "Create one FBA appointment for selected inbound lines") + public ApiResponse batchFbaAppointment( + @AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody BatchFbaAppointmentRequest request) { + String tenantId = scope(user, request.tenantId()); + String operatorId = user.userId() == null ? user.username() : String.valueOf(user.userId()); + return ApiResponse.success(appointmentService.batchCreate(tenantId, operatorId, request)); + } + + @GetMapping("/fba-appointments/{appointmentNo}") + @Operation(summary = "Get an FBA appointment and its selected inbound lines") + public ApiResponse fbaAppointmentDetail( + @AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String appointmentNo, + @RequestParam(required = false) String tenantId) { + return ApiResponse.success(appointmentService.detail(scope(user, tenantId), appointmentNo)); + } + private String scope(AuthenticatedUser user, String requestedTenant) { if (user == null || user.tenantId() == null) { throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); diff --git a/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/dto/BatchFbaAppointmentRequest.java b/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/dto/BatchFbaAppointmentRequest.java new file mode 100644 index 0000000..50f1bdd --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/dto/BatchFbaAppointmentRequest.java @@ -0,0 +1,19 @@ +package com.baiyue.lastleg.inboundplan.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import java.time.LocalDateTime; +import java.util.List; + +public record BatchFbaAppointmentRequest( + String tenantId, + @NotEmpty @Size(max = 500) List<@NotNull Long> lineIds, + @NotBlank @Size(max = 64) String appointmentNo, + @NotNull LocalDateTime appointmentAt, + @NotBlank @Size(max = 32) String appointmentStatus, + @Size(max = 1000) String appointmentRemark +) { +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/dto/FbaAppointmentResponse.java b/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/dto/FbaAppointmentResponse.java new file mode 100644 index 0000000..7a25616 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/dto/FbaAppointmentResponse.java @@ -0,0 +1,23 @@ +package com.baiyue.lastleg.inboundplan.dto; + +import java.time.LocalDateTime; +import java.util.List; + +public record FbaAppointmentResponse( + Long id, + String tenantId, + String appointmentNo, + LocalDateTime appointmentAt, + String appointmentStatus, + String appointmentRemark, + String fbaWarehouseCode, + int selectedCount, + String createdBy, + LocalDateTime createdAt, + LocalDateTime updatedAt, + List items +) { + public record Item(Long id, Long planId, Long lineId, Long orderId, String inboundCode, + String fbaWarehouseCode) { + } +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanResponse.java b/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanResponse.java index 3dcc527..9f4fb13 100644 --- a/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanResponse.java +++ b/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanResponse.java @@ -17,7 +17,7 @@ public record InboundPlanResponse( List pallets, List allocations ) { - public record Line(Long id, Long orderId, String inboundCode, String destinationWarehouseId, + public record Line(Long id, Long orderId, String inboundCode, String fbaWarehouseCode, String destinationWarehouseId, String destinationWarehouseCode, String destinationWarehouseName, BigDecimal volumeCbm, int requiredPackageCount, int plannedPalletCount) { } diff --git a/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundFbaAppointment.java b/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundFbaAppointment.java new file mode 100644 index 0000000..f0b306f --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundFbaAppointment.java @@ -0,0 +1,25 @@ +package com.baiyue.lastleg.inboundplan.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@TableName("ll_inbound_fba_appointment") +public class InboundFbaAppointment { + @TableId(type = IdType.INPUT) + private Long id; + private String tenantId; + private String appointmentNo; + private LocalDateTime appointmentAt; + private String appointmentStatus; + private String appointmentRemark; + private String fbaWarehouseCode; + private Integer selectedCount; + private String createdBy; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundFbaAppointmentItem.java b/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundFbaAppointmentItem.java new file mode 100644 index 0000000..96a2627 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundFbaAppointmentItem.java @@ -0,0 +1,22 @@ +package com.baiyue.lastleg.inboundplan.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@TableName("ll_inbound_fba_appointment_item") +public class InboundFbaAppointmentItem { + @TableId(type = IdType.INPUT) + private Long id; + private Long appointmentId; + private Long planId; + private Long lineId; + private Long orderId; + private String inboundCode; + private String fbaWarehouseCode; + private LocalDateTime createdAt; +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlanLine.java b/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlanLine.java index a50b965..070d180 100644 --- a/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlanLine.java +++ b/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlanLine.java @@ -15,6 +15,7 @@ public class InboundPlanLine { private Long planId; private Long orderId; private String inboundCode; + private String fbaWarehouseCode; private String destinationWarehouseId; private String destinationWarehouseCode; private String destinationWarehouseName; diff --git a/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundFbaAppointmentItemMapper.java b/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundFbaAppointmentItemMapper.java new file mode 100644 index 0000000..d80900d --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundFbaAppointmentItemMapper.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.inboundplan.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.inboundplan.entity.InboundFbaAppointmentItem; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface InboundFbaAppointmentItemMapper extends BaseMapper { +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundFbaAppointmentMapper.java b/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundFbaAppointmentMapper.java new file mode 100644 index 0000000..c944582 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundFbaAppointmentMapper.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.inboundplan.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.inboundplan.entity.InboundFbaAppointment; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface InboundFbaAppointmentMapper extends BaseMapper { +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundFbaAppointmentQueryMapper.java b/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundFbaAppointmentQueryMapper.java new file mode 100644 index 0000000..690ba19 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundFbaAppointmentQueryMapper.java @@ -0,0 +1,22 @@ +package com.baiyue.lastleg.inboundplan.mapper; + +import com.baiyue.lastleg.inboundplan.model.FbaAppointmentLineSnapshot; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; + +@Mapper +public interface InboundFbaAppointmentQueryMapper { + @Select("") + List lockLines(@Param("tenantId") String tenantId, + @Param("lineIds") List lineIds); +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapper.java b/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapper.java index 3355627..0a9ffa9 100644 --- a/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapper.java +++ b/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapper.java @@ -18,6 +18,7 @@ public interface InboundPlanOrderMapper { @Select("SELECT o.id AS orderId,o.tenant_id AS tenantId,o.container_no AS containerNo," + "o.inbound_code AS inboundCode,o.current_status AS currentStatus,o.inbound_order_no AS inboundOrderNo," + "COALESCE(o.volume_cbm,0) AS volumeCbm,COALESCE(o.package_count,0) AS packageCount," + + "NULLIF(o.fba_warehouse_code,'') AS fbaWarehouseCode," + "COALESCE(NULLIF(o.transfer_warehouse_id,''),MIN(NULLIF(d.destination_warehouse_id,''))) AS destinationWarehouseId," + "COALESCE(NULLIF(o.transfer_warehouse_code,''),MIN(NULLIF(d.destination_warehouse_code,''))) AS destinationWarehouseCode," + "COALESCE(NULLIF(o.transfer_warehouse_name,''),MIN(NULLIF(d.destination_warehouse_name,''))) AS destinationWarehouseName," + @@ -29,7 +30,7 @@ public interface InboundPlanOrderMapper { "AS destinationMismatchCount " + "FROM ll_order o LEFT JOIN ll_order_detail d ON d.order_id=o.id " + "WHERE o.tenant_id=#{tenantId} AND o.container_no=#{containerNo} " + - "GROUP BY o.id,o.tenant_id,o.container_no,o.inbound_code,o.current_status,o.inbound_order_no,o.volume_cbm,o.package_count," + + "GROUP BY o.id,o.tenant_id,o.container_no,o.inbound_code,o.current_status,o.inbound_order_no,o.volume_cbm,o.package_count,o.fba_warehouse_code," + "o.transfer_warehouse_id,o.transfer_warehouse_code,o.transfer_warehouse_name " + "ORDER BY o.id FOR UPDATE") List lockContainerOrders(@Param("tenantId") String tenantId, diff --git a/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/model/FbaAppointmentLineSnapshot.java b/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/model/FbaAppointmentLineSnapshot.java new file mode 100644 index 0000000..2244c4d --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/model/FbaAppointmentLineSnapshot.java @@ -0,0 +1,10 @@ +package com.baiyue.lastleg.inboundplan.model; + +public record FbaAppointmentLineSnapshot( + Long lineId, + Long planId, + Long orderId, + String inboundCode, + String fbaWarehouseCode +) { +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/model/FbaAppointmentStatus.java b/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/model/FbaAppointmentStatus.java new file mode 100644 index 0000000..9964437 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/model/FbaAppointmentStatus.java @@ -0,0 +1,8 @@ +package com.baiyue.lastleg.inboundplan.model; + +public enum FbaAppointmentStatus { + PENDING, + SUCCESS, + FAILED, + CANCELLED +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/model/SourceOrderSnapshot.java b/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/model/SourceOrderSnapshot.java index 560d847..0907a67 100644 --- a/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/model/SourceOrderSnapshot.java +++ b/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/model/SourceOrderSnapshot.java @@ -11,10 +11,20 @@ public record SourceOrderSnapshot( String inboundOrderNo, BigDecimal volumeCbm, int packageCount, + String fbaWarehouseCode, String destinationWarehouseId, String destinationWarehouseCode, String destinationWarehouseName, int destinationCount, int destinationMismatchCount ) { + public SourceOrderSnapshot(Long orderId, String tenantId, String containerNo, String inboundCode, + String currentStatus, String inboundOrderNo, BigDecimal volumeCbm, + int packageCount, String destinationWarehouseId, + String destinationWarehouseCode, String destinationWarehouseName, + int destinationCount, int destinationMismatchCount) { + this(orderId, tenantId, containerNo, inboundCode, currentStatus, inboundOrderNo, volumeCbm, + packageCount, null, destinationWarehouseId, destinationWarehouseCode, + destinationWarehouseName, destinationCount, destinationMismatchCount); + } } diff --git a/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/InboundStockReceipt.java b/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/InboundStockReceipt.java new file mode 100644 index 0000000..c16d357 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/InboundStockReceipt.java @@ -0,0 +1,38 @@ +package com.baiyue.lastleg.inboundplan.pda.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +/** Immutable SKU/pallet receipt created when a PDA inbound task completes. */ +@Data +@TableName("ll_inbound_stock_receipt") +public class InboundStockReceipt { + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private String tenantId; + private Long planId; + private Long taskId; + private Long allocationId; + private Long lineId; + private Long orderId; + private String inboundCode; + private String skuCode; + private String skuNameZh; + private String sourceDetailIds; + private Long warehousePalletId; + private String palletNo; + private Long warehouseLocationId; + private String warehouseLocationCode; + private String batchNo; + private Long receivedPackages; + private Long receivedQuantity; + private Long inventoryBalanceId; + private Long inventoryPositionId; + private String inventoryOperationKey; + private LocalDateTime receivedAt; + private LocalDateTime createdAt; +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java b/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java index 484c975..ddb824b 100644 --- a/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java +++ b/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java @@ -57,6 +57,19 @@ public class HttpWarehouseResourceGateway implements WarehouseResourceGateway { .toBodilessEntity(); } + @Override + public void scrapPallet(String tenantId, long palletId) { + client.post() + .uri("/internal/v1/warehouse-resource-locks/pallets/{palletId}/scrap", palletId) + .header("X-Internal-Service-Token", internalToken) + .body(Map.of("tenantId", Long.parseLong(tenantId))) + .retrieve() + .onStatus(HttpStatusCode::isError, (request, response) -> { + throw new IllegalStateException("Warehouse rejected pallet scrap: HTTP " + response.getStatusCode()); + }) + .toBodilessEntity(); + } + private record ReserveEnvelope(ReserveData data) {} private record ReserveData(long lockId) {} } diff --git a/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/WarehouseResourceGateway.java b/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/WarehouseResourceGateway.java index b5ceb7f..4554845 100644 --- a/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/WarehouseResourceGateway.java +++ b/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/WarehouseResourceGateway.java @@ -4,4 +4,5 @@ public interface WarehouseResourceGateway { long reserve(String tenantId, long warehouseId, long palletId, long locationId, String businessType, String businessId, String reservationKey); void scrap(String tenantId, long resourceLockId); + void scrapPallet(String tenantId, long palletId); } diff --git a/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/InboundStockReceiptMapper.java b/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/InboundStockReceiptMapper.java new file mode 100644 index 0000000..2a60e9e --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/InboundStockReceiptMapper.java @@ -0,0 +1,14 @@ +package com.baiyue.lastleg.inboundplan.pda.mapper; + +import com.baiyue.lastleg.inboundplan.pda.entity.InboundStockReceipt; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; + +public interface InboundStockReceiptMapper extends BaseMapper { + @Select("SELECT * FROM ll_inbound_stock_receipt WHERE tenant_id=#{tenantId} AND task_id=#{taskId} " + + "ORDER BY allocation_id,sku_code") + List listTask(@Param("tenantId") String tenantId, @Param("taskId") Long taskId); +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java b/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java index d19b06c..dd2d6ce 100644 --- a/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java +++ b/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java @@ -42,6 +42,7 @@ public class InboundPdaWorkflowService { @Resource private PdaWorkflowRepository repository; @Resource private WarehouseResourceGateway warehouse; + @Resource private InboundStockReceiptService stockReceipts; @Resource private ObjectMapper sourceObjectMapper; private ObjectMapper objectMapper; @@ -276,9 +277,12 @@ public class InboundPdaWorkflowService { @Transactional public PdaResponses.Task complete(String tenantId, String planNo) { InboundPlan plan = requirePlan(tenantId, planNo); - InboundPdaTask task = requireActiveTask(tenantId, plan); + InboundPdaTask task = repository.lockTask(tenantId, plan.getId()); + if (task == null) throw conflict("Push the inbound plan to PDA before operating"); + if ("COMPLETED".equals(task.getStatus())) return task(task, planNo); Map pickedByLine = new HashMap<>(); - for (InboundPlanPalletAllocation allocation : repository.allocations(plan.getId())) { + List allocations = repository.allocations(plan.getId()); + for (InboundPlanPalletAllocation allocation : allocations) { pickedByLine.merge(allocation.getLineId(), value(allocation.getPickedPackageCount()), Integer::sum); } List lines = repository.lines(plan.getId()); @@ -289,6 +293,7 @@ public class InboundPdaWorkflowService { } } LocalDateTime now = LocalDateTime.now(); + stockReceipts.stockIn(tenantId, plan, task, lines, allocations, repository.pallets(plan.getId()), now); task.setStatus("COMPLETED"); task.setCompletedAt(now); task.setUpdatedAt(now); diff --git a/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptService.java b/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptService.java new file mode 100644 index 0000000..201c1c9 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptService.java @@ -0,0 +1,221 @@ +package com.baiyue.lastleg.inboundplan.pda.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.entity.LastlegOrderDetail; +import com.baiyue.lastleg.inboundplan.entity.InboundPlan; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation; +import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask; +import com.baiyue.lastleg.inboundplan.pda.entity.InboundStockReceipt; +import com.baiyue.lastleg.inboundplan.pda.mapper.InboundStockReceiptMapper; +import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests; +import com.baiyue.lastleg.inventory.command.dto.InventoryCommandResponse; +import com.baiyue.lastleg.inventory.command.service.InventoryCommandService; +import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import jakarta.annotation.Resource; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; + +@Service +public class InboundStockReceiptService { + @Resource private LastlegOrderMapper orders; + @Resource private LastlegOrderDetailMapper details; + @Resource private InboundStockReceiptMapper receipts; + @Resource private InventoryCommandService inventory; + + @Transactional + public void stockIn(String tenantId, InboundPlan plan, InboundPdaTask task, + List lines, List allocations, + List pallets, LocalDateTime receivedAt) { + if (!receipts.listTask(tenantId, task.getId()).isEmpty()) return; + + var requestedOrderIds = lines.stream().map(InboundPlanLine::getOrderId).collect(Collectors.toSet()); + Map orderById = orders.selectBatchIds(requestedOrderIds).stream() + .filter(order -> tenantId.equals(order.getTenantId())) + .collect(Collectors.toMap(LastlegOrder::getId, order -> order)); + if (orderById.size() != requestedOrderIds.size()) { + throw conflict("Inbound line tail order is missing or outside the tenant"); + } + Map> detailsByOrder = details.selectList( + new LambdaQueryWrapper().in(LastlegOrderDetail::getOrderId, orderById.keySet())) + .stream().collect(Collectors.groupingBy(LastlegOrderDetail::getOrderId)); + Map palletById = pallets.stream() + .collect(Collectors.toMap(InboundPlanPallet::getId, pallet -> pallet)); + Map> allocationsByLine = allocations.stream() + .filter(allocation -> value(allocation.getPickedPackageCount()) > 0) + .sorted(Comparator.comparing(InboundPlanPalletAllocation::getId)) + .collect(Collectors.groupingBy(InboundPlanPalletAllocation::getLineId, + LinkedHashMap::new, Collectors.toList())); + + for (InboundPlanLine line : lines) { + LastlegOrder order = orderById.get(line.getOrderId()); + if (order == null) throw conflict("Inbound line tail order is missing or outside the tenant"); + List lineAllocations = allocationsByLine + .getOrDefault(line.getId(), List.of()); + List skuReceipts = skuReceipts(line, detailsByOrder.getOrDefault(order.getId(), List.of())); + if (lineAllocations.stream().map(InboundPlanPalletAllocation::getPalletId).distinct().count() > 1 + && skuReceipts.size() > 1) { + throw conflict("Multi-SKU inbound lines split across pallets require SKU-level PDA receipt counts"); + } + if (skuReceipts.size() > 1) { + if (lineAllocations.size() != 1) { + throw conflict("Multi-SKU inbound lines require one unambiguous pallet allocation"); + } + InboundPlanPalletAllocation allocation = lineAllocations.get(0); + InboundPlanPallet pallet = requirePallet(palletById, allocation); + for (SkuReceipt sku : skuReceipts) { + persist(tenantId, plan, task, line, allocation, pallet, order, sku, + sku.packages(), sku.quantity(), receivedAt); + } + continue; + } + + SkuReceipt sku = skuReceipts.get(0); + long cumulativePackages = 0; + for (InboundPlanPalletAllocation allocation : lineAllocations) { + long packages = value(allocation.getPickedPackageCount()); + long nextPackages = cumulativePackages + packages; + long quantity = proportionalCumulative(sku.quantity(), nextPackages, sku.packages()) + - proportionalCumulative(sku.quantity(), cumulativePackages, sku.packages()); + persist(tenantId, plan, task, line, allocation, requirePallet(palletById, allocation), + order, sku, packages, quantity, receivedAt); + cumulativePackages = nextPackages; + } + if (cumulativePackages != sku.packages()) { + throw conflict("PDA picked packages do not conserve the frozen SKU package total"); + } + } + } + + private List skuReceipts(InboundPlanLine line, List source) { + if (source.isEmpty()) throw conflict("Inbound line has no frozen SKU details"); + Map grouped = new LinkedHashMap<>(); + long frozenPackages = 0; + for (LastlegOrderDetail detail : source) { + String skuCode = required(detail.getSkuCode(), "Frozen SKU code is required"); + long packages = value(detail.getPackageCount()); + if (packages < 0) throw conflict("Frozen SKU package count cannot be negative"); + frozenPackages += packages; + if (packages == 0) continue; + MutableSku sku = grouped.computeIfAbsent(skuCode, ignored -> new MutableSku(skuCode)); + sku.packages += packages; + sku.quantity += value(detail.getProductQuantity()); + sku.nameZh = first(sku.nameZh, detail.getProductNameZh()); + sku.detailIds.add(detail.getId()); + } + if (frozenPackages != value(line.getRequiredPackageCount())) { + throw conflict("Frozen SKU package totals must equal the inbound line required packages"); + } + if (grouped.isEmpty()) throw conflict("Inbound line has no positive frozen SKU packages"); + return grouped.values().stream().map(MutableSku::snapshot).toList(); + } + + private void persist(String tenantId, InboundPlan plan, InboundPdaTask task, InboundPlanLine line, + InboundPlanPalletAllocation allocation, InboundPlanPallet pallet, + LastlegOrder order, SkuReceipt sku, long packages, long quantity, + LocalDateTime receivedAt) { + if (packages <= 0 || pallet.getWarehousePalletId() == null) { + throw conflict("Stock-in requires a positive package count and an assigned warehouse pallet"); + } + String destinationCode = required(first(line.getDestinationWarehouseCode(), order.getFbaWarehouseCode()), + "Destination warehouse code is required for stock-in"); + String overseasWarehouseId = required(order.getOverseasWarehouseId(), + "Actual overseas warehouse ID is required for stock-in"); + String palletNo = required(pallet.getPalletNo(), "Pallet number is required for stock-in"); + String locationCode = "LOC-" + overseasWarehouseId + "-DEFAULT"; + String operationKey = "PDA_STOCK_IN:" + task.getId() + ":" + allocation.getId() + ":" + sku.skuCode(); + InventoryCommandResponse result = inventory.stockIn(tenantId, task.getOperatorId(), + new InventoryCommandRequests.StockIn(null, order.getCustomerName(), overseasWarehouseId, + null, order.getOverseasWarehouseName(), line.getDestinationWarehouseId(), destinationCode, + line.getDestinationWarehouseName(), sku.skuCode(), sku.nameZh(), pallet.getWarehousePalletId(), + palletNo, plan.getPlanNo(), pallet.getWarehouseLocationId(), locationCode, + packages, quantity, order.getOrderNo(), plan.getPlanNo(), order.getSourceEventId(), operationKey, + "PDA inbound completion for " + line.getInboundCode())); + + InboundStockReceipt receipt = new InboundStockReceipt(); + receipt.setId(IdWorker.getId()); + receipt.setTenantId(tenantId); + receipt.setPlanId(plan.getId()); + receipt.setTaskId(task.getId()); + receipt.setAllocationId(allocation.getId()); + receipt.setLineId(line.getId()); + receipt.setOrderId(order.getId()); + receipt.setInboundCode(line.getInboundCode()); + receipt.setSkuCode(sku.skuCode()); + receipt.setSkuNameZh(sku.nameZh()); + receipt.setSourceDetailIds(sku.detailIds()); + receipt.setWarehousePalletId(pallet.getWarehousePalletId()); + receipt.setPalletNo(palletNo); + receipt.setWarehouseLocationId(pallet.getWarehouseLocationId()); + receipt.setWarehouseLocationCode(locationCode); + receipt.setBatchNo(plan.getPlanNo()); + receipt.setReceivedPackages(packages); + receipt.setReceivedQuantity(quantity); + receipt.setInventoryBalanceId(result.balanceId()); + receipt.setInventoryPositionId(result.positionId()); + receipt.setInventoryOperationKey(operationKey); + receipt.setReceivedAt(receivedAt); + receipt.setCreatedAt(receivedAt); + receipts.insert(receipt); + } + + private static InboundPlanPallet requirePallet(Map pallets, + InboundPlanPalletAllocation allocation) { + InboundPlanPallet pallet = pallets.get(allocation.getPalletId()); + if (pallet == null) throw conflict("PDA allocation pallet is missing"); + return pallet; + } + + private static long proportionalCumulative(long quantity, long packages, long totalPackages) { + return Math.floorDiv(Math.multiplyExact(quantity, packages), totalPackages); + } + + private static String required(String value, String message) { + String normalized = trim(value); + if (normalized == null) throw conflict(message); + return normalized; + } + + private static String first(String first, String second) { + String normalized = trim(first); + return normalized == null ? trim(second) : normalized; + } + + private static String trim(String value) { return value == null || value.isBlank() ? null : value.trim(); } + private static long value(Integer value) { return value == null ? 0L : value.longValue(); } + private static ResponseStatusException conflict(String message) { + return new ResponseStatusException(HttpStatus.CONFLICT, message); + } + + private static final class MutableSku { + private final String skuCode; + private String nameZh; + private long packages; + private long quantity; + private final List detailIds = new ArrayList<>(); + + private MutableSku(String skuCode) { this.skuCode = skuCode; } + private SkuReceipt snapshot() { + return new SkuReceipt(skuCode, nameZh, packages, quantity, + detailIds.stream().filter(Objects::nonNull).map(String::valueOf).collect(Collectors.joining(","))); + } + } + + private record SkuReceipt(String skuCode, String nameZh, long packages, long quantity, String detailIds) {} +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentService.java b/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentService.java new file mode 100644 index 0000000..525418f --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentService.java @@ -0,0 +1,180 @@ +package com.baiyue.lastleg.inboundplan.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.lastleg.inboundplan.dto.BatchFbaAppointmentRequest; +import com.baiyue.lastleg.inboundplan.dto.FbaAppointmentResponse; +import com.baiyue.lastleg.inboundplan.entity.InboundFbaAppointment; +import com.baiyue.lastleg.inboundplan.entity.InboundFbaAppointmentItem; +import com.baiyue.lastleg.inboundplan.mapper.InboundFbaAppointmentItemMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundFbaAppointmentMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundFbaAppointmentQueryMapper; +import com.baiyue.lastleg.inboundplan.model.FbaAppointmentLineSnapshot; +import com.baiyue.lastleg.inboundplan.model.FbaAppointmentStatus; +import jakarta.annotation.Resource; +import org.springframework.http.HttpStatus; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.time.LocalDateTime; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.Set; + +@Service +public class InboundFbaAppointmentService { + @Resource private InboundFbaAppointmentMapper appointmentMapper; + @Resource private InboundFbaAppointmentItemMapper itemMapper; + @Resource private InboundFbaAppointmentQueryMapper queryMapper; + + @Transactional + public FbaAppointmentResponse batchCreate(String tenantId, String operatorId, + BatchFbaAppointmentRequest request) { + List lineIds = List.copyOf(request.lineIds()); + if (new HashSet<>(lineIds).size() != lineIds.size()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Selected inbound lines contain duplicates"); + } + String appointmentNo = requiredTrimmed(request.appointmentNo(), "Appointment number is required"); + String status = normalizeStatus(request.appointmentStatus()); + String remark = trimToNull(request.appointmentRemark()); + + InboundFbaAppointment existing = find(tenantId, appointmentNo); + if (existing != null) { + return verifyReplay(existing, lineIds, request.appointmentAt(), status, remark); + } + + List lines = queryMapper.lockLines(tenantId, lineIds); + if (lines.size() != lineIds.size()) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, + "One or more selected inbound lines do not exist in this tenant"); + } + String fbaWarehouseCode = commonFbaWarehouse(lines); + + // The row locks serialize concurrent submissions selecting the same inbound lines. + existing = find(tenantId, appointmentNo); + if (existing != null) { + return verifyReplay(existing, lineIds, request.appointmentAt(), status, remark); + } + + LocalDateTime now = LocalDateTime.now(); + InboundFbaAppointment appointment = new InboundFbaAppointment(); + appointment.setId(IdWorker.getId()); + appointment.setTenantId(tenantId); + appointment.setAppointmentNo(appointmentNo); + appointment.setAppointmentAt(request.appointmentAt()); + appointment.setAppointmentStatus(status); + appointment.setAppointmentRemark(remark); + appointment.setFbaWarehouseCode(fbaWarehouseCode); + appointment.setSelectedCount(lines.size()); + appointment.setCreatedBy(trimToNull(operatorId)); + appointment.setCreatedAt(now); + appointment.setUpdatedAt(now); + try { + appointmentMapper.insert(appointment); + } catch (DuplicateKeyException exception) { + InboundFbaAppointment concurrent = find(tenantId, appointmentNo); + if (concurrent != null) { + return verifyReplay(concurrent, lineIds, request.appointmentAt(), status, remark); + } + throw exception; + } + + for (FbaAppointmentLineSnapshot line : lines) { + InboundFbaAppointmentItem item = new InboundFbaAppointmentItem(); + item.setId(IdWorker.getId()); + item.setAppointmentId(appointment.getId()); + item.setPlanId(line.planId()); + item.setLineId(line.lineId()); + item.setOrderId(line.orderId()); + item.setInboundCode(line.inboundCode()); + item.setFbaWarehouseCode(line.fbaWarehouseCode()); + item.setCreatedAt(now); + itemMapper.insert(item); + } + return response(appointment, items(appointment.getId())); + } + + @Transactional(readOnly = true) + public FbaAppointmentResponse detail(String tenantId, String appointmentNo) { + InboundFbaAppointment appointment = find(tenantId, + requiredTrimmed(appointmentNo, "Appointment number is required")); + if (appointment == null) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "FBA appointment not found"); + } + return response(appointment, items(appointment.getId())); + } + + private FbaAppointmentResponse verifyReplay(InboundFbaAppointment existing, List requestedLineIds, + LocalDateTime appointmentAt, String status, String remark) { + List existingItems = items(existing.getId()); + Set existingLineIds = existingItems.stream() + .map(InboundFbaAppointmentItem::getLineId).collect(java.util.stream.Collectors.toSet()); + if (!Objects.equals(existing.getAppointmentAt(), appointmentAt) + || !Objects.equals(existing.getAppointmentStatus(), status) + || !Objects.equals(existing.getAppointmentRemark(), remark) + || existingLineIds.size() != requestedLineIds.size() + || !existingLineIds.equals(new HashSet<>(requestedLineIds))) { + throw new ResponseStatusException(HttpStatus.CONFLICT, + "Appointment number already exists with different appointment data"); + } + return response(existing, existingItems); + } + + private String commonFbaWarehouse(List lines) { + List warehouses = lines.stream().map(FbaAppointmentLineSnapshot::fbaWarehouseCode) + .map(InboundFbaAppointmentService::trimToNull).toList(); + if (warehouses.stream().anyMatch(Objects::isNull) || new HashSet<>(warehouses).size() != 1) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Selected inbound lines must belong to the same non-empty FBA warehouse"); + } + return warehouses.get(0); + } + + private InboundFbaAppointment find(String tenantId, String appointmentNo) { + return appointmentMapper.selectOne(new LambdaQueryWrapper() + .eq(InboundFbaAppointment::getTenantId, tenantId) + .eq(InboundFbaAppointment::getAppointmentNo, appointmentNo)); + } + + private List items(Long appointmentId) { + return itemMapper.selectList(new LambdaQueryWrapper() + .eq(InboundFbaAppointmentItem::getAppointmentId, appointmentId) + .orderByAsc(InboundFbaAppointmentItem::getLineId)); + } + + private FbaAppointmentResponse response(InboundFbaAppointment appointment, + List items) { + return new FbaAppointmentResponse(appointment.getId(), appointment.getTenantId(), + appointment.getAppointmentNo(), appointment.getAppointmentAt(), appointment.getAppointmentStatus(), + appointment.getAppointmentRemark(), appointment.getFbaWarehouseCode(), appointment.getSelectedCount(), + appointment.getCreatedBy(), appointment.getCreatedAt(), appointment.getUpdatedAt(), + items.stream().map(item -> new FbaAppointmentResponse.Item(item.getId(), item.getPlanId(), + item.getLineId(), item.getOrderId(), item.getInboundCode(), item.getFbaWarehouseCode())).toList()); + } + + private static String normalizeStatus(String value) { + String normalized = requiredTrimmed(value, "Appointment status is required").toUpperCase(Locale.ROOT); + try { + return FbaAppointmentStatus.valueOf(normalized).name(); + } catch (IllegalArgumentException exception) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Appointment status must be PENDING, SUCCESS, FAILED or CANCELLED"); + } + } + + private static String requiredTrimmed(String value, String message) { + String normalized = trimToNull(value); + if (normalized == null) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, message); + } + return normalized; + } + + private static String trimToNull(String value) { + return value == null || value.isBlank() ? null : value.trim(); + } +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java b/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java index afbdccd..60efded 100644 --- a/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java +++ b/lastleg/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java @@ -115,6 +115,7 @@ public class InboundPlanService { line.setPlanId(plan.getId()); line.setOrderId(order.orderId()); line.setInboundCode(order.inboundCode()); + line.setFbaWarehouseCode(order.fbaWarehouseCode()); line.setDestinationWarehouseId(order.destinationWarehouseId()); line.setDestinationWarehouseCode(order.destinationWarehouseCode()); line.setDestinationWarehouseName(order.destinationWarehouseName()); @@ -208,7 +209,7 @@ public class InboundPlanService { .orderByAsc(InboundPlanPalletAllocation::getId)); return new InboundPlanResponse(plan.getId(), plan.getPlanNo(), plan.getTenantId(), plan.getContainerNo(), plan.getStatus(), plan.getLineCount(), plan.getPalletCount(), plan.getTotalVolumeCbm(), - lines.stream().map(line -> new InboundPlanResponse.Line(line.getId(), line.getOrderId(), line.getInboundCode(), + lines.stream().map(line -> new InboundPlanResponse.Line(line.getId(), line.getOrderId(), line.getInboundCode(), line.getFbaWarehouseCode(), line.getDestinationWarehouseId(), line.getDestinationWarehouseCode(), line.getDestinationWarehouseName(), line.getVolumeCbm(), line.getRequiredPackageCount(), line.getPlannedPalletCount())).toList(), pallets.stream().map(pallet -> new InboundPlanResponse.Pallet(pallet.getId(), pallet.getPalletNo(), diff --git a/lastleg/src/main/java/com/baiyue/lastleg/inventory/InventoryMapperConfiguration.java b/lastleg/src/main/java/com/baiyue/lastleg/inventory/InventoryMapperConfiguration.java new file mode 100644 index 0000000..c3cd7e4 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/inventory/InventoryMapperConfiguration.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.inventory; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.context.annotation.Configuration; + +@Configuration +@MapperScan("com.baiyue.lastleg.inventory.mapper") +public class InventoryMapperConfiguration { +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/inventory/command/InventoryCommandMapperConfiguration.java b/lastleg/src/main/java/com/baiyue/lastleg/inventory/command/InventoryCommandMapperConfiguration.java new file mode 100644 index 0000000..953a3ce --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/inventory/command/InventoryCommandMapperConfiguration.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.inventory.command; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.context.annotation.Configuration; + +@Configuration +@MapperScan("com.baiyue.lastleg.inventory.command.mapper") +public class InventoryCommandMapperConfiguration { +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/inventory/command/controller/InventoryCommandController.java b/lastleg/src/main/java/com/baiyue/lastleg/inventory/command/controller/InventoryCommandController.java new file mode 100644 index 0000000..4052fd1 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/inventory/command/controller/InventoryCommandController.java @@ -0,0 +1,97 @@ +package com.baiyue.lastleg.inventory.command.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests; +import com.baiyue.lastleg.inventory.command.dto.InventoryCommandResponse; +import com.baiyue.lastleg.inventory.command.service.InventoryCommandService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +@RestController +@RequestMapping("/api/platform/v1/last-leg/inventory") +@Tag(name = "Tail-leg SKU package inventory commands") +public class InventoryCommandController { + @Resource private InventoryCommandService service; + + @PostMapping("/stock-in") + @Operation(summary = "Put SKU packages into an overseas warehouse pallet") + public ApiResponse stockIn(@AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody InventoryCommandRequests.StockIn request) { + return ApiResponse.success(service.stockIn(scope(user, request.tenantId()), operator(user), request)); + } + + @PostMapping("/hold") + @Operation(summary = "Move available SKU packages to hold") + public ApiResponse hold(@AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody InventoryCommandRequests.Move request) { + return ApiResponse.success(service.hold(scope(user, request.tenantId()), operator(user), request)); + } + + @PostMapping("/release") + @Operation(summary = "Release held SKU packages") + public ApiResponse release(@AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody InventoryCommandRequests.Move request) { + return ApiResponse.success(service.release(scope(user, request.tenantId()), operator(user), request)); + } + + @PostMapping("/allocate") + @Operation(summary = "Allocate available SKU packages to an outbound order") + public ApiResponse allocate(@AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody InventoryCommandRequests.Move request) { + return ApiResponse.success(service.allocate(scope(user, request.tenantId()), operator(user), request)); + } + + @PostMapping("/unallocate") + @Operation(summary = "Cancel an outbound allocation") + public ApiResponse unallocate(@AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody InventoryCommandRequests.Move request) { + return ApiResponse.success(service.unallocate(scope(user, request.tenantId()), operator(user), request)); + } + + @PostMapping("/pick") + @Operation(summary = "Confirm picked SKU packages") + public ApiResponse pick(@AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody InventoryCommandRequests.Move request) { + return ApiResponse.success(service.pick(scope(user, request.tenantId()), operator(user), request)); + } + + @PostMapping("/ship") + @Operation(summary = "Ship picked SKU packages and report whether the pallet is empty") + public ApiResponse ship(@AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody InventoryCommandRequests.Move request) { + return ApiResponse.success(service.ship(scope(user, request.tenantId()), operator(user), request)); + } + + @PostMapping("/adjust") + @Operation(summary = "Adjust SKU package inventory after a physical count") + public ApiResponse adjust(@AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody InventoryCommandRequests.Adjustment request) { + return ApiResponse.success(service.adjust(scope(user, request.tenantId()), operator(user), request)); + } + + private String scope(AuthenticatedUser user, String requestedTenant) { + if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) { + if (requestedTenant == null || requestedTenant.isBlank() || "0".equals(requestedTenant)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Platform request must specify target tenant"); + } + return requestedTenant.trim(); + } + return String.valueOf(user.tenantId()); + } + + private String operator(AuthenticatedUser user) { + return user.userId() == null ? user.username() : String.valueOf(user.userId()); + } +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/inventory/command/dto/InventoryCommandRequests.java b/lastleg/src/main/java/com/baiyue/lastleg/inventory/command/dto/InventoryCommandRequests.java new file mode 100644 index 0000000..eede4d6 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/inventory/command/dto/InventoryCommandRequests.java @@ -0,0 +1,63 @@ +package com.baiyue.lastleg.inventory.command.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.PositiveOrZero; +import jakarta.validation.constraints.Size; + +public final class InventoryCommandRequests { + private InventoryCommandRequests() { + } + + public record StockIn( + String tenantId, + String customerName, + @NotBlank String overseasWarehouseId, + String overseasWarehouseCode, + String overseasWarehouseName, + String destinationWarehouseId, + @NotBlank String destinationWarehouseCode, + String destinationWarehouseName, + @NotBlank String skuCode, + String skuNameZh, + @NotNull Long palletId, + @NotBlank String palletNo, + String batchNo, + Long warehouseLocationId, + String warehouseLocationCode, + @Positive long packages, + @PositiveOrZero long productQuantity, + String businessOrderNo, + String inboundOrderNo, + String sourceEventId, + @NotBlank @Size(max = 255) String idempotencyKey, + @Size(max = 1000) String remark + ) { + } + + public record Move( + String tenantId, + @NotNull Long positionId, + @Positive long packages, + @PositiveOrZero long productQuantity, + String businessOrderNo, + String outboundOrderNo, + String sourceEventId, + @NotBlank @Size(max = 255) String idempotencyKey, + @Size(max = 1000) String remark + ) { + } + + public record Adjustment( + String tenantId, + @NotNull Long positionId, + long packageDelta, + long quantityDelta, + @NotBlank String reason, + String businessOrderNo, + @NotBlank @Size(max = 255) String idempotencyKey, + @Size(max = 1000) String remark + ) { + } +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/inventory/command/dto/InventoryCommandResponse.java b/lastleg/src/main/java/com/baiyue/lastleg/inventory/command/dto/InventoryCommandResponse.java new file mode 100644 index 0000000..944c383 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/inventory/command/dto/InventoryCommandResponse.java @@ -0,0 +1,19 @@ +package com.baiyue.lastleg.inventory.command.dto; + +public record InventoryCommandResponse( + Long operationId, + String operationType, + Long balanceId, + Long positionId, + long availablePackages, + long holdPackages, + long allocatedPackages, + long pickedPackages, + boolean palletEmpty, + boolean replayed +) { + public InventoryCommandResponse asReplay() { + return new InventoryCommandResponse(operationId, operationType, balanceId, positionId, + availablePackages, holdPackages, allocatedPackages, pickedPackages, palletEmpty, true); + } +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/inventory/command/mapper/InventoryCommandMapper.java b/lastleg/src/main/java/com/baiyue/lastleg/inventory/command/mapper/InventoryCommandMapper.java new file mode 100644 index 0000000..83586e2 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/inventory/command/mapper/InventoryCommandMapper.java @@ -0,0 +1,22 @@ +package com.baiyue.lastleg.inventory.command.mapper; + +import com.baiyue.lastleg.inventory.entity.InventoryBalance; +import com.baiyue.lastleg.inventory.entity.InventoryPosition; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface InventoryCommandMapper { + @Select("SELECT * FROM ll_inventory_position WHERE tenant_id=#{tenantId} AND id=#{positionId} LIMIT 1 FOR UPDATE") + InventoryPosition lockPosition(@Param("tenantId") String tenantId, + @Param("positionId") Long positionId); + + @Select("SELECT * FROM ll_inventory_balance WHERE tenant_id=#{tenantId} AND id=#{balanceId} LIMIT 1 FOR UPDATE") + InventoryBalance lockBalance(@Param("tenantId") String tenantId, + @Param("balanceId") Long balanceId); + + @Select("SELECT COALESCE(SUM(available_packages+hold_packages+allocated_packages+picked_packages+receiving_packages),0) " + + "FROM ll_inventory_position WHERE tenant_id=#{tenantId} AND pallet_id=#{palletId}") + long palletPackages(@Param("tenantId") String tenantId, @Param("palletId") Long palletId); +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java b/lastleg/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java new file mode 100644 index 0000000..36f7684 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java @@ -0,0 +1,493 @@ +package com.baiyue.lastleg.inventory.command.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests; +import com.baiyue.lastleg.inventory.command.dto.InventoryCommandResponse; +import com.baiyue.lastleg.inventory.command.mapper.InventoryCommandMapper; +import com.baiyue.lastleg.inventory.entity.InventoryBalance; +import com.baiyue.lastleg.inventory.entity.InventoryLedger; +import com.baiyue.lastleg.inventory.entity.InventoryOperation; +import com.baiyue.lastleg.inventory.entity.InventoryPosition; +import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper; +import com.baiyue.lastleg.inventory.mapper.InventoryLedgerMapper; +import com.baiyue.lastleg.inventory.mapper.InventoryOperationMapper; +import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper; +import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.LocalDateTime; +import java.util.HexFormat; + +@Service +public class InventoryCommandService { + @Resource private InventoryBalanceMapper balances; + @Resource private InventoryPositionMapper positions; + @Resource private InventoryLedgerMapper ledgers; + @Resource private InventoryOperationMapper operations; + @Resource private InventoryCommandMapper commands; + @Resource private WarehouseResourceGateway warehouseGateway; + @Resource private ObjectMapper objectMapper; + + @Transactional + public InventoryCommandResponse stockIn(String tenantId, String operatorId, + InventoryCommandRequests.StockIn request) { + return execute(tenantId, operatorId, "STOCK_IN", request.idempotencyKey(), request, operation -> { + InventoryBalance balance = balance(tenantId, request); + InventoryPosition position = position(tenantId, balance, request); + long before = value(position.getAvailablePackages()); + position.setAvailablePackages(before + request.packages()); + position.setProductQuantity(value(position.getProductQuantity()) + request.productQuantity()); + position.setStatus("OCCUPIED"); + position.setVersion(value(position.getVersion()) + 1); + position.setUpdatedAt(LocalDateTime.now()); + positions.updateById(position); + + balance.setAvailablePackages(value(balance.getAvailablePackages()) + request.packages()); + balance.setAvailableQuantity(value(balance.getAvailableQuantity()) + request.productQuantity()); + touch(balance); + balances.updateById(balance); + ledger(tenantId, operatorId, operation.getId(), balance, position, "STOCK_IN", + "OUTSIDE", "AVAILABLE", before, request.packages(), before + request.packages(), + request.businessOrderNo(), request.inboundOrderNo(), null, request.sourceEventId(), + request.idempotencyKey(), value(balance.getAvailableQuantity()) - request.productQuantity(), + request.productQuantity(), value(balance.getAvailableQuantity()), request.remark()); + return response(operation, balance, position, false); + }); + } + + @Transactional + public InventoryCommandResponse hold(String tenantId, String operatorId, InventoryCommandRequests.Move request) { + return move(tenantId, operatorId, "HOLD", Bucket.AVAILABLE, Bucket.HOLD, request); + } + + @Transactional + public InventoryCommandResponse release(String tenantId, String operatorId, InventoryCommandRequests.Move request) { + return move(tenantId, operatorId, "RELEASE", Bucket.HOLD, Bucket.AVAILABLE, request); + } + + @Transactional + public InventoryCommandResponse allocate(String tenantId, String operatorId, InventoryCommandRequests.Move request) { + return move(tenantId, operatorId, "ALLOCATE", Bucket.AVAILABLE, Bucket.ALLOCATED, request); + } + + @Transactional + public InventoryCommandResponse unallocate(String tenantId, String operatorId, InventoryCommandRequests.Move request) { + return move(tenantId, operatorId, "UNALLOCATE", Bucket.ALLOCATED, Bucket.AVAILABLE, request); + } + + @Transactional + public InventoryCommandResponse pick(String tenantId, String operatorId, InventoryCommandRequests.Move request) { + return move(tenantId, operatorId, "PICK", Bucket.ALLOCATED, Bucket.PICKED, request); + } + + @Transactional + public InventoryCommandResponse ship(String tenantId, String operatorId, InventoryCommandRequests.Move request) { + return execute(tenantId, operatorId, "SHIP", request.idempotencyKey(), request, operation -> { + Locked locked = locked(tenantId, request.positionId()); + InventoryPosition position = locked.position(); + InventoryBalance balance = locked.balance(); + requireAtLeast(value(position.getPickedPackages()), request.packages(), "picked"); + requireAtLeast(value(position.getProductQuantity()), request.productQuantity(), "product quantity"); + requireAtLeast(value(balance.getPickedPackages()), request.packages(), "balance picked"); + requireAtLeast(value(balance.getPickedQuantity()), request.productQuantity(), "balance picked quantity"); + long before = value(position.getPickedPackages()); + long beforeQuantity = value(balance.getPickedQuantity()); + position.setPickedPackages(before - request.packages()); + position.setProductQuantity(value(position.getProductQuantity()) - request.productQuantity()); + position.setStatus(positionPackages(position) == 0 ? "EMPTY" : "OCCUPIED"); + position.setVersion(value(position.getVersion()) + 1); + position.setUpdatedAt(LocalDateTime.now()); + positions.updateById(position); + + balance.setPickedPackages(value(balance.getPickedPackages()) - request.packages()); + balance.setPickedQuantity(value(balance.getPickedQuantity()) - request.productQuantity()); + touch(balance); + balances.updateById(balance); + boolean palletEmpty = commands.palletPackages(tenantId, position.getPalletId()) == 0; + if (palletEmpty) warehouseGateway.scrapPallet(tenantId, position.getPalletId()); + ledger(tenantId, operatorId, operation.getId(), balance, position, "SHIP", + "PICKED", "SHIPPED", before, -request.packages(), before - request.packages(), + request.businessOrderNo(), null, request.outboundOrderNo(), request.sourceEventId(), + request.idempotencyKey(), beforeQuantity, -request.productQuantity(), + value(balance.getPickedQuantity()), request.remark()); + return response(operation, balance, position, palletEmpty); + }); + } + + @Transactional + public InventoryCommandResponse adjust(String tenantId, String operatorId, + InventoryCommandRequests.Adjustment request) { + if (request.packageDelta() == 0 && request.quantityDelta() == 0) { + throw badRequest("Adjustment package and quantity deltas cannot both be zero"); + } + return execute(tenantId, operatorId, "ADJUST", request.idempotencyKey(), request, operation -> { + Locked locked = locked(tenantId, request.positionId()); + InventoryPosition position = locked.position(); + InventoryBalance balance = locked.balance(); + long before = value(position.getAvailablePackages()); + long after = before + request.packageDelta(); + long quantityAfter = value(position.getProductQuantity()) + request.quantityDelta(); + long balanceAfter = value(balance.getAvailablePackages()) + request.packageDelta(); + long balanceQuantityBefore = value(balance.getAvailableQuantity()); + long balanceQuantityAfter = balanceQuantityBefore + request.quantityDelta(); + if (after < 0 || quantityAfter < 0 || balanceAfter < 0 || balanceQuantityAfter < 0) { + throw conflict("Adjustment would make inventory negative"); + } + position.setAvailablePackages(after); + position.setProductQuantity(quantityAfter); + position.setStatus(positionPackages(position) == 0 ? "EMPTY" : "OCCUPIED"); + position.setVersion(value(position.getVersion()) + 1); + position.setUpdatedAt(LocalDateTime.now()); + positions.updateById(position); + balance.setAvailablePackages(balanceAfter); + balance.setAvailableQuantity(balanceQuantityAfter); + touch(balance); + balances.updateById(balance); + ledger(tenantId, operatorId, operation.getId(), balance, position, "ADJUST", + "AVAILABLE", "AVAILABLE", before, request.packageDelta(), after, + request.businessOrderNo(), null, null, null, request.idempotencyKey(), + balanceQuantityBefore, request.quantityDelta(), balanceQuantityAfter, + request.reason() + remarkSuffix(request.remark())); + return response(operation, balance, position, false); + }); + } + + private InventoryCommandResponse move(String tenantId, String operatorId, String operationType, + Bucket from, Bucket to, InventoryCommandRequests.Move request) { + return execute(tenantId, operatorId, operationType, request.idempotencyKey(), request, operation -> { + Locked locked = locked(tenantId, request.positionId()); + InventoryPosition position = locked.position(); + InventoryBalance balance = locked.balance(); + requireAtLeast(packages(position, from), request.packages(), from.name().toLowerCase()); + requireAtLeast(packages(balance, from), request.packages(), "balance " + from.name().toLowerCase()); + requireAtLeast(quantity(balance, from), request.productQuantity(), + "balance " + from.name().toLowerCase() + " quantity"); + long beforeTarget = packages(position, to); + long beforeTargetQuantity = quantity(balance, to); + packages(position, from, packages(position, from) - request.packages()); + packages(position, to, beforeTarget + request.packages()); + position.setVersion(value(position.getVersion()) + 1); + position.setUpdatedAt(LocalDateTime.now()); + positions.updateById(position); + + packages(balance, from, packages(balance, from) - request.packages()); + packages(balance, to, packages(balance, to) + request.packages()); + quantity(balance, from, quantity(balance, from) - request.productQuantity()); + quantity(balance, to, quantity(balance, to) + request.productQuantity()); + touch(balance); + balances.updateById(balance); + ledger(tenantId, operatorId, operation.getId(), balance, position, operationType, + from.name(), to.name(), beforeTarget, request.packages(), beforeTarget + request.packages(), + request.businessOrderNo(), null, request.outboundOrderNo(), request.sourceEventId(), + request.idempotencyKey(), beforeTargetQuantity, request.productQuantity(), + quantity(balance, to), request.remark()); + return response(operation, balance, position, false); + }); + } + + private InventoryBalance balance(String tenantId, InventoryCommandRequests.StockIn request) { + InventoryBalance balance = balances.lockDimension(tenantId, request.overseasWarehouseId(), + request.destinationWarehouseCode(), request.skuCode()); + if (balance != null) return balance; + balance = new InventoryBalance(); + balance.setId(IdWorker.getId()); + balance.setTenantId(tenantId); + balance.setCustomerName(trim(request.customerName())); + balance.setOverseasWarehouseId(request.overseasWarehouseId().trim()); + balance.setOverseasWarehouseCode(trim(request.overseasWarehouseCode())); + balance.setOverseasWarehouseName(trim(request.overseasWarehouseName())); + balance.setDestinationWarehouseId(trim(request.destinationWarehouseId())); + balance.setDestinationWarehouseCode(request.destinationWarehouseCode().trim()); + balance.setDestinationWarehouseName(trim(request.destinationWarehouseName())); + balance.setSkuCode(request.skuCode().trim()); + balance.setSkuNameZh(trim(request.skuNameZh())); + zero(balance); + balance.setVersion(0L); + balance.setCreatedAt(LocalDateTime.now()); + balance.setUpdatedAt(balance.getCreatedAt()); + try { + balances.insert(balance); + return balance; + } catch (DuplicateKeyException exception) { + InventoryBalance concurrent = balances.lockDimension(tenantId, request.overseasWarehouseId(), + request.destinationWarehouseCode(), request.skuCode()); + if (concurrent != null) return concurrent; + throw exception; + } + } + + private InventoryPosition position(String tenantId, InventoryBalance balance, + InventoryCommandRequests.StockIn request) { + String batchNo = trim(request.batchNo()) == null ? "DEFAULT" : request.batchNo().trim(); + InventoryPosition position = positions.lockPosition(tenantId, balance.getId(), request.palletId(), batchNo); + if (position != null) return position; + position = new InventoryPosition(); + position.setId(IdWorker.getId()); + position.setTenantId(tenantId); + position.setBalanceId(balance.getId()); + position.setOverseasWarehouseId(balance.getOverseasWarehouseId()); + position.setDestinationWarehouseCode(balance.getDestinationWarehouseCode()); + position.setSkuCode(balance.getSkuCode()); + position.setPalletId(request.palletId()); + position.setPalletNo(request.palletNo().trim()); + position.setBatchNo(batchNo); + position.setWarehouseLocationId(request.warehouseLocationId()); + String locationCode = trim(request.warehouseLocationCode()); + position.setWarehouseLocationCode(locationCode == null + ? "LOC-" + defaultCode(balance) + "-DEFAULT" : locationCode); + position.setAvailablePackages(0L); + position.setHoldPackages(0L); + position.setAllocatedPackages(0L); + position.setPickedPackages(0L); + position.setReceivingPackages(0L); + position.setProductQuantity(0L); + position.setStatus("OCCUPIED"); + position.setVersion(0L); + position.setCreatedAt(LocalDateTime.now()); + position.setUpdatedAt(position.getCreatedAt()); + try { + positions.insert(position); + return position; + } catch (DuplicateKeyException exception) { + InventoryPosition concurrent = positions.lockPosition(tenantId, balance.getId(), request.palletId(), batchNo); + if (concurrent != null) return concurrent; + throw exception; + } + } + + private Locked locked(String tenantId, Long positionId) { + InventoryPosition position = commands.lockPosition(tenantId, positionId); + if (position == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Inventory position not found"); + InventoryBalance balance = commands.lockBalance(tenantId, position.getBalanceId()); + if (balance == null) throw new IllegalStateException("Inventory position balance is missing"); + return new Locked(balance, position); + } + + private InventoryCommandResponse execute(String tenantId, String operatorId, String operationType, + String key, Object request, Work work) { + String normalizedKey = required(key, "Idempotency key is required"); + String requestHash = hash(request); + InventoryOperation existing = operations.findIdempotency(tenantId, normalizedKey); + if (existing != null) return replay(existing, operationType, requestHash); + + InventoryOperation operation = new InventoryOperation(); + operation.setId(IdWorker.getId()); + operation.setTenantId(tenantId); + operation.setIdempotencyKey(normalizedKey); + operation.setOperationType(operationType); + operation.setRequestHash(requestHash); + operation.setStatus("PROCESSING"); + operation.setCreatedAt(LocalDateTime.now()); + operation.setUpdatedAt(operation.getCreatedAt()); + try { + operations.insert(operation); + } catch (DuplicateKeyException exception) { + InventoryOperation concurrent = operations.findIdempotency(tenantId, normalizedKey); + if (concurrent != null) return replay(concurrent, operationType, requestHash); + throw exception; + } + + InventoryCommandResponse response = work.run(operation); + operation.setStatus("COMPLETED"); + operation.setResultJson(write(response)); + operation.setUpdatedAt(LocalDateTime.now()); + operations.updateById(operation); + return response; + } + + private InventoryCommandResponse replay(InventoryOperation operation, String type, String requestHash) { + if (!type.equals(operation.getOperationType()) || !requestHash.equals(operation.getRequestHash())) { + throw conflict("Idempotency key was already used with a different inventory request"); + } + if (!"COMPLETED".equals(operation.getStatus()) || trim(operation.getResultJson()) == null) { + throw conflict("Inventory operation is still processing"); + } + try { + return objectMapper.readValue(operation.getResultJson(), InventoryCommandResponse.class).asReplay(); + } catch (JsonProcessingException exception) { + throw new IllegalStateException("Cannot read inventory idempotency result", exception); + } + } + + private void ledger(String tenantId, String operatorId, Long operationId, InventoryBalance balance, + InventoryPosition position, String businessType, String from, String to, + long beforePackages, long deltaPackages, long afterPackages, + String businessOrderNo, String inboundOrderNo, String outboundOrderNo, + String sourceEventId, String idempotencyKey, long beforeQuantity, + long deltaQuantity, long afterQuantity, String remark) { + InventoryLedger ledger = new InventoryLedger(); + ledger.setId(IdWorker.getId()); + ledger.setTenantId(tenantId); + ledger.setBalanceId(balance.getId()); + ledger.setPositionId(position.getId()); + ledger.setOverseasWarehouseId(balance.getOverseasWarehouseId()); + ledger.setDestinationWarehouseCode(balance.getDestinationWarehouseCode()); + ledger.setSkuCode(balance.getSkuCode()); + ledger.setPalletId(position.getPalletId()); + ledger.setPalletNo(position.getPalletNo()); + ledger.setBatchNo(position.getBatchNo()); + ledger.setWarehouseLocationCode(position.getWarehouseLocationCode()); + ledger.setBusinessType(businessType); + ledger.setBusinessOrderNo(trim(businessOrderNo)); + ledger.setInboundOrderNo(trim(inboundOrderNo)); + ledger.setOutboundOrderNo(trim(outboundOrderNo)); + ledger.setSourceEventId(trim(sourceEventId)); + ledger.setIdempotencyKey(idempotencyKey + ":" + operationId); + ledger.setStatusFrom(from); + ledger.setStatusTo(to); + ledger.setBeforePackages(beforePackages); + ledger.setDeltaPackages(deltaPackages); + ledger.setAfterPackages(afterPackages); + ledger.setBeforeQuantity(beforeQuantity); + ledger.setDeltaQuantity(deltaQuantity); + ledger.setAfterQuantity(afterQuantity); + ledger.setOperatorId(trim(operatorId)); + ledger.setRemark(trim(remark)); + ledger.setOccurredAt(LocalDateTime.now()); + ledger.setCreatedAt(ledger.getOccurredAt()); + ledgers.insert(ledger); + } + + private InventoryCommandResponse response(InventoryOperation operation, InventoryBalance balance, + InventoryPosition position, boolean palletEmpty) { + return new InventoryCommandResponse(operation.getId(), operation.getOperationType(), balance.getId(), + position.getId(), value(balance.getAvailablePackages()), value(balance.getHoldPackages()), + value(balance.getAllocatedPackages()), value(balance.getPickedPackages()), palletEmpty, false); + } + + private static void touch(InventoryBalance balance) { + balance.setVersion(value(balance.getVersion()) + 1); + balance.setUpdatedAt(LocalDateTime.now()); + } + + private static void zero(InventoryBalance balance) { + balance.setAvailablePackages(0L); balance.setHoldPackages(0L); balance.setAllocatedPackages(0L); + balance.setPickedPackages(0L); balance.setInTransitPackages(0L); balance.setReceivingPackages(0L); + balance.setProcurementPackages(0L); balance.setAvailableQuantity(0L); balance.setHoldQuantity(0L); + balance.setAllocatedQuantity(0L); balance.setPickedQuantity(0L); balance.setInTransitQuantity(0L); + balance.setReceivingQuantity(0L); balance.setProcurementQuantity(0L); + } + + private static long positionPackages(InventoryPosition position) { + return value(position.getAvailablePackages()) + value(position.getHoldPackages()) + + value(position.getAllocatedPackages()) + value(position.getPickedPackages()) + + value(position.getReceivingPackages()); + } + + private static long packages(InventoryPosition position, Bucket bucket) { + return switch (bucket) { + case AVAILABLE -> value(position.getAvailablePackages()); + case HOLD -> value(position.getHoldPackages()); + case ALLOCATED -> value(position.getAllocatedPackages()); + case PICKED -> value(position.getPickedPackages()); + }; + } + + private static void packages(InventoryPosition position, Bucket bucket, long value) { + switch (bucket) { + case AVAILABLE -> position.setAvailablePackages(value); + case HOLD -> position.setHoldPackages(value); + case ALLOCATED -> position.setAllocatedPackages(value); + case PICKED -> position.setPickedPackages(value); + } + } + + private static long packages(InventoryBalance balance, Bucket bucket) { + return switch (bucket) { + case AVAILABLE -> value(balance.getAvailablePackages()); + case HOLD -> value(balance.getHoldPackages()); + case ALLOCATED -> value(balance.getAllocatedPackages()); + case PICKED -> value(balance.getPickedPackages()); + }; + } + + private static void packages(InventoryBalance balance, Bucket bucket, long value) { + switch (bucket) { + case AVAILABLE -> balance.setAvailablePackages(value); + case HOLD -> balance.setHoldPackages(value); + case ALLOCATED -> balance.setAllocatedPackages(value); + case PICKED -> balance.setPickedPackages(value); + } + } + + private static long quantity(InventoryBalance balance, Bucket bucket) { + return switch (bucket) { + case AVAILABLE -> value(balance.getAvailableQuantity()); + case HOLD -> value(balance.getHoldQuantity()); + case ALLOCATED -> value(balance.getAllocatedQuantity()); + case PICKED -> value(balance.getPickedQuantity()); + }; + } + + private static void quantity(InventoryBalance balance, Bucket bucket, long value) { + switch (bucket) { + case AVAILABLE -> balance.setAvailableQuantity(value); + case HOLD -> balance.setHoldQuantity(value); + case ALLOCATED -> balance.setAllocatedQuantity(value); + case PICKED -> balance.setPickedQuantity(value); + } + } + + private String hash(Object value) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(write(value).getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException(exception); + } + } + + private String write(Object value) { + try { + return objectMapper.writeValueAsString(value); + } catch (JsonProcessingException exception) { + throw new IllegalStateException("Cannot serialize inventory operation", exception); + } + } + + private static String defaultCode(InventoryBalance balance) { + String code = trim(balance.getOverseasWarehouseCode()); + return code == null ? balance.getOverseasWarehouseId() : code; + } + + private static void requireAtLeast(long actual, long requested, String field) { + if (actual < requested) throw conflict("Insufficient " + field + " packages or quantity"); + } + + private static String required(String value, String message) { + String normalized = trim(value); + if (normalized == null) throw badRequest(message); + return normalized; + } + + private static String trim(String value) { + return value == null || value.isBlank() ? null : value.trim(); + } + + private static String remarkSuffix(String remark) { + String value = trim(remark); + return value == null ? "" : "; " + value; + } + + private static long value(Long value) { return value == null ? 0L : value; } + private static ResponseStatusException conflict(String message) { + return new ResponseStatusException(HttpStatus.CONFLICT, message); + } + private static ResponseStatusException badRequest(String message) { + return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); + } + + private enum Bucket { AVAILABLE, HOLD, ALLOCATED, PICKED } + private record Locked(InventoryBalance balance, InventoryPosition position) {} + @FunctionalInterface private interface Work { InventoryCommandResponse run(InventoryOperation operation); } +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/inventory/entity/InventoryBalance.java b/lastleg/src/main/java/com/baiyue/lastleg/inventory/entity/InventoryBalance.java new file mode 100644 index 0000000..8a24f7e --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/inventory/entity/InventoryBalance.java @@ -0,0 +1,43 @@ +package com.baiyue.lastleg.inventory.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +/** Real-time SKU/package balance for one tenant, warehouse, destination and SKU. */ +@Data +@TableName("ll_inventory_balance") +public class InventoryBalance { + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private String tenantId; + private String customerName; + private String overseasWarehouseId; + private String overseasWarehouseCode; + private String overseasWarehouseName; + private String destinationWarehouseId; + private String destinationWarehouseCode; + private String destinationWarehouseName; + private String skuCode; + private String skuNameZh; + private Long availablePackages; + private Long holdPackages; + private Long allocatedPackages; + private Long pickedPackages; + private Long inTransitPackages; + private Long receivingPackages; + private Long procurementPackages; + private Long availableQuantity; + private Long holdQuantity; + private Long allocatedQuantity; + private Long pickedQuantity; + private Long inTransitQuantity; + private Long receivingQuantity; + private Long procurementQuantity; + private Long version; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/inventory/entity/InventoryLedger.java b/lastleg/src/main/java/com/baiyue/lastleg/inventory/entity/InventoryLedger.java new file mode 100644 index 0000000..74d0425 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/inventory/entity/InventoryLedger.java @@ -0,0 +1,46 @@ +package com.baiyue.lastleg.inventory.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +/** Append-only inventory fact. Never update or delete this entity. */ +@Data +@TableName("ll_inventory_ledger") +public class InventoryLedger { + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private String tenantId; + private Long balanceId; + private Long positionId; + private String overseasWarehouseId; + private String destinationWarehouseCode; + private String skuCode; + private Long palletId; + private String palletNo; + private String batchNo; + private String warehouseLocationCode; + private String businessType; + private Long businessOrderId; + private String businessOrderNo; + private String inboundCode; + private String inboundOrderNo; + private String outboundOrderNo; + private String sourceEventId; + private String idempotencyKey; + private String statusFrom; + private String statusTo; + private Long beforePackages; + private Long deltaPackages; + private Long afterPackages; + private Long beforeQuantity; + private Long deltaQuantity; + private Long afterQuantity; + private String operatorId; + private String remark; + private LocalDateTime occurredAt; + private LocalDateTime createdAt; +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/inventory/entity/InventoryOperation.java b/lastleg/src/main/java/com/baiyue/lastleg/inventory/entity/InventoryOperation.java new file mode 100644 index 0000000..66e2f7a --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/inventory/entity/InventoryOperation.java @@ -0,0 +1,25 @@ +package com.baiyue.lastleg.inventory.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +/** Command-level idempotency record for inventory mutations. */ +@Data +@TableName("ll_inventory_operation") +public class InventoryOperation { + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private String tenantId; + private String idempotencyKey; + private String operationType; + private String requestHash; + private String status; + private String resultJson; + private String lastError; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/inventory/entity/InventoryPosition.java b/lastleg/src/main/java/com/baiyue/lastleg/inventory/entity/InventoryPosition.java new file mode 100644 index 0000000..902697a --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/inventory/entity/InventoryPosition.java @@ -0,0 +1,36 @@ +package com.baiyue.lastleg.inventory.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +/** Physical stock snapshot. A location is currently informational only. */ +@Data +@TableName("ll_inventory_position") +public class InventoryPosition { + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private String tenantId; + private Long balanceId; + private String overseasWarehouseId; + private String destinationWarehouseCode; + private String skuCode; + private Long palletId; + private String palletNo; + private String batchNo; + private Long warehouseLocationId; + private String warehouseLocationCode; + private Long availablePackages; + private Long holdPackages; + private Long allocatedPackages; + private Long pickedPackages; + private Long receivingPackages; + private Long productQuantity; + private String status; + private Long version; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryBalanceMapper.java b/lastleg/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryBalanceMapper.java new file mode 100644 index 0000000..9a7e201 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryBalanceMapper.java @@ -0,0 +1,52 @@ +package com.baiyue.lastleg.inventory.mapper; + +import com.baiyue.lastleg.inventory.entity.InventoryBalance; +import com.baiyue.lastleg.inventory.model.InventorySummary; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; + +public interface InventoryBalanceMapper extends BaseMapper { + @Select("SELECT * FROM ll_inventory_balance WHERE tenant_id=#{tenantId} " + + "AND overseas_warehouse_id=#{overseasWarehouseId} " + + "AND destination_warehouse_code=#{destinationWarehouseCode} AND sku_code=#{skuCode} LIMIT 1") + InventoryBalance findDimension(@Param("tenantId") String tenantId, + @Param("overseasWarehouseId") String overseasWarehouseId, + @Param("destinationWarehouseCode") String destinationWarehouseCode, + @Param("skuCode") String skuCode); + + @Select("SELECT * FROM ll_inventory_balance WHERE tenant_id=#{tenantId} " + + "AND overseas_warehouse_id=#{overseasWarehouseId} " + + "AND destination_warehouse_code=#{destinationWarehouseCode} AND sku_code=#{skuCode} LIMIT 1 FOR UPDATE") + InventoryBalance lockDimension(@Param("tenantId") String tenantId, + @Param("overseasWarehouseId") String overseasWarehouseId, + @Param("destinationWarehouseCode") String destinationWarehouseCode, + @Param("skuCode") String skuCode); + + @Select("") + List listTenant(@Param("tenantId") String tenantId, + @Param("overseasWarehouseId") String overseasWarehouseId, + @Param("destinationWarehouseCode") String destinationWarehouseCode, + @Param("skuCode") String skuCode, + @Param("offset") long offset, + @Param("limit") int limit); + + @Select("") + InventorySummary summarize(@Param("tenantId") String tenantId, + @Param("overseasWarehouseId") String overseasWarehouseId); +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryLedgerMapper.java b/lastleg/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryLedgerMapper.java new file mode 100644 index 0000000..b07bcf2 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryLedgerMapper.java @@ -0,0 +1,33 @@ +package com.baiyue.lastleg.inventory.mapper; + +import com.baiyue.lastleg.inventory.entity.InventoryLedger; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.time.LocalDateTime; +import java.util.List; + +public interface InventoryLedgerMapper extends BaseMapper { + @Select("SELECT * FROM ll_inventory_ledger WHERE tenant_id=#{tenantId} AND idempotency_key=#{idempotencyKey} LIMIT 1") + InventoryLedger findIdempotency(@Param("tenantId") String tenantId, + @Param("idempotencyKey") String idempotencyKey); + + @Select("") + List listTenant(@Param("tenantId") String tenantId, + @Param("overseasWarehouseId") String overseasWarehouseId, + @Param("skuCode") String skuCode, + @Param("businessType") String businessType, + @Param("businessOrderNo") String businessOrderNo, + @Param("startedAt") LocalDateTime startedAt, + @Param("endedAt") LocalDateTime endedAt, + @Param("offset") long offset, + @Param("limit") int limit); +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryOperationMapper.java b/lastleg/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryOperationMapper.java new file mode 100644 index 0000000..718d3b9 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryOperationMapper.java @@ -0,0 +1,16 @@ +package com.baiyue.lastleg.inventory.mapper; + +import com.baiyue.lastleg.inventory.entity.InventoryOperation; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +public interface InventoryOperationMapper extends BaseMapper { + @Select("SELECT * FROM ll_inventory_operation WHERE tenant_id=#{tenantId} AND idempotency_key=#{idempotencyKey} LIMIT 1") + InventoryOperation findIdempotency(@Param("tenantId") String tenantId, + @Param("idempotencyKey") String idempotencyKey); + + @Select("SELECT * FROM ll_inventory_operation WHERE tenant_id=#{tenantId} AND idempotency_key=#{idempotencyKey} LIMIT 1 FOR UPDATE") + InventoryOperation lockIdempotency(@Param("tenantId") String tenantId, + @Param("idempotencyKey") String idempotencyKey); +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryPositionMapper.java b/lastleg/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryPositionMapper.java new file mode 100644 index 0000000..14f26ec --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryPositionMapper.java @@ -0,0 +1,27 @@ +package com.baiyue.lastleg.inventory.mapper; + +import com.baiyue.lastleg.inventory.entity.InventoryPosition; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; + +public interface InventoryPositionMapper extends BaseMapper { + @Select("SELECT p.* FROM ll_inventory_position p JOIN ll_inventory_balance b ON b.id=p.balance_id " + + "WHERE b.tenant_id=#{tenantId} AND p.balance_id=#{balanceId} ORDER BY p.pallet_no,p.batch_no") + List listBalance(@Param("tenantId") String tenantId, + @Param("balanceId") Long balanceId); + + @Select("SELECT * FROM ll_inventory_position WHERE tenant_id=#{tenantId} AND pallet_id=#{palletId} " + + "ORDER BY sku_code,batch_no") + List listPallet(@Param("tenantId") String tenantId, + @Param("palletId") Long palletId); + + @Select("SELECT * FROM ll_inventory_position WHERE tenant_id=#{tenantId} AND balance_id=#{balanceId} " + + "AND pallet_id=#{palletId} AND batch_no=#{batchNo} LIMIT 1 FOR UPDATE") + InventoryPosition lockPosition(@Param("tenantId") String tenantId, + @Param("balanceId") Long balanceId, + @Param("palletId") Long palletId, + @Param("batchNo") String batchNo); +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/inventory/model/InventorySummary.java b/lastleg/src/main/java/com/baiyue/lastleg/inventory/model/InventorySummary.java new file mode 100644 index 0000000..101d8bb --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/inventory/model/InventorySummary.java @@ -0,0 +1,22 @@ +package com.baiyue.lastleg.inventory.model; + +import lombok.Data; + +@Data +public class InventorySummary { + private Long balanceCount; + private Long availablePackages; + private Long holdPackages; + private Long allocatedPackages; + private Long pickedPackages; + private Long inTransitPackages; + private Long receivingPackages; + private Long procurementPackages; + private Long availableQuantity; + private Long holdQuantity; + private Long allocatedQuantity; + private Long pickedQuantity; + private Long inTransitQuantity; + private Long receivingQuantity; + private Long procurementQuantity; +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/inventory/query/InventoryQueryMapperConfiguration.java b/lastleg/src/main/java/com/baiyue/lastleg/inventory/query/InventoryQueryMapperConfiguration.java new file mode 100644 index 0000000..049e9f8 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/inventory/query/InventoryQueryMapperConfiguration.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.inventory.query; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.context.annotation.Configuration; + +@Configuration +@MapperScan("com.baiyue.lastleg.inventory.query.mapper") +public class InventoryQueryMapperConfiguration { +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryController.java b/lastleg/src/main/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryController.java new file mode 100644 index 0000000..85d513a --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryController.java @@ -0,0 +1,112 @@ +package com.baiyue.lastleg.inventory.query.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.inventory.query.dto.InventoryBalanceView; +import com.baiyue.lastleg.inventory.query.dto.InventoryDashboardResponse; +import com.baiyue.lastleg.inventory.query.dto.InventoryLedgerView; +import com.baiyue.lastleg.inventory.query.dto.InventoryPositionView; +import com.baiyue.lastleg.inventory.query.dto.InventoryQueryRequests; +import com.baiyue.lastleg.inventory.query.service.InventoryQueryService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +import java.nio.charset.StandardCharsets; +import java.util.List; + +@RestController +@Tag(name = "Inventory queries") +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 SKU/package inventory totals") + public ApiResponse summary( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String tenantId, + @Valid InventoryQueryRequests.BalanceQuery query) { + return ApiResponse.success(service.dashboard(scope(user, tenantId), query)); + } + + @GetMapping(BASE_PATH + "/balances") + @Operation(summary = "Page inventory by customer, destination warehouse and SKU") + public ApiResponse> balances( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String tenantId, + @Valid InventoryQueryRequests.BalanceQuery query) { + return ApiResponse.success(service.balances(scope(user, tenantId), query)); + } + + @GetMapping(BASE_PATH + "/balances/{balanceId}/positions") + @Operation(summary = "List pallet and batch positions for one SKU balance") + public ApiResponse> positions( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String tenantId, + @PathVariable long balanceId, + @Valid InventoryQueryRequests.PositionQuery query) { + return ApiResponse.success(service.positions(scope(user, tenantId), balanceId, query)); + } + + @GetMapping(BASE_PATH + "/ledger") + @Operation(summary = "Page append-only inventory movements") + public ApiResponse> ledgers( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String tenantId, + @Valid InventoryQueryRequests.LedgerQuery query) { + return ApiResponse.success(service.ledgers(scope(user, tenantId), query)); + } + + @GetMapping(value = BASE_PATH + "/balances/export", produces = "text/csv") + @Operation(summary = "Export filtered SKU inventory as UTF-8 CSV") + public ResponseEntity exportBalances( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String tenantId, + @Valid InventoryQueryRequests.BalanceQuery query) { + return csv(service.exportBalances(scope(user, tenantId), query), "inventory-balances.csv"); + } + + @GetMapping(value = BASE_PATH + "/ledger/export", produces = "text/csv") + @Operation(summary = "Export filtered inventory movements as UTF-8 CSV") + public ResponseEntity exportLedgers( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String tenantId, + @Valid InventoryQueryRequests.LedgerQuery query) { + return csv(service.exportLedgers(scope(user, tenantId), query), "inventory-ledger.csv"); + } + + private static ResponseEntity csv(byte[] body, String filename) { + return ResponseEntity.ok() + .contentType(new MediaType("text", "csv", StandardCharsets.UTF_8)) + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=" + filename) + .body(body); + } + + private String scope(AuthenticatedUser user, String requestedTenant) { + if (user == null || user.tenantId() == null) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + } + if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) { + if (requestedTenant == null || requestedTenant.isBlank() || "0".equals(requestedTenant)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Platform queries must specify a customer tenant"); + } + return requestedTenant; + } + return String.valueOf(user.tenantId()); + } +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryBalanceView.java b/lastleg/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryBalanceView.java new file mode 100644 index 0000000..6247228 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryBalanceView.java @@ -0,0 +1,35 @@ +package com.baiyue.lastleg.inventory.query.dto; + +import java.time.LocalDateTime; + +public record InventoryBalanceView( + Long id, + String customerId, + String customerName, + String overseasWarehouseId, + String overseasWarehouseCode, + String overseasWarehouseName, + String destinationWarehouseId, + String destinationWarehouseCode, + String destinationWarehouseName, + String skuCode, + String skuName, + long totalPackages, + long availablePackages, + long holdPackages, + long allocatedPackages, + long pickedPackages, + long inTransitPackages, + long receivingPackages, + long procurementPackages, + long totalQuantity, + long availableQuantity, + long holdQuantity, + long allocatedQuantity, + long pickedQuantity, + long inTransitQuantity, + long receivingQuantity, + long procurementQuantity, + LocalDateTime updatedAt +) { +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryDashboardResponse.java b/lastleg/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryDashboardResponse.java new file mode 100644 index 0000000..a0ca3f6 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryDashboardResponse.java @@ -0,0 +1,23 @@ +package com.baiyue.lastleg.inventory.query.dto; + +public record InventoryDashboardResponse( + long skuDimensionCount, + long palletCount, + long totalPackages, + long availablePackages, + long holdPackages, + long allocatedPackages, + long pickedPackages, + long inTransitPackages, + long receivingPackages, + long procurementPackages, + long totalQuantity, + long availableQuantity, + long holdQuantity, + long allocatedQuantity, + long pickedQuantity, + long inTransitQuantity, + long receivingQuantity, + long procurementQuantity +) { +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryLedgerView.java b/lastleg/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryLedgerView.java new file mode 100644 index 0000000..c1d2249 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryLedgerView.java @@ -0,0 +1,30 @@ +package com.baiyue.lastleg.inventory.query.dto; + +import java.time.LocalDateTime; + +public record InventoryLedgerView( + Long id, + String customerId, + String overseasWarehouseId, + String destinationWarehouseCode, + String skuCode, + String palletNo, + String batchNo, + String warehouseLocationCode, + String businessType, + String businessOrderNo, + String inboundOrderNo, + String outboundOrderNo, + String statusFrom, + String statusTo, + long beforePackages, + long deltaPackages, + long afterPackages, + long beforeQuantity, + long deltaQuantity, + long afterQuantity, + String operatorId, + String remark, + LocalDateTime occurredAt +) { +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryPositionView.java b/lastleg/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryPositionView.java new file mode 100644 index 0000000..6edd47c --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryPositionView.java @@ -0,0 +1,27 @@ +package com.baiyue.lastleg.inventory.query.dto; + +import java.time.LocalDateTime; + +public record InventoryPositionView( + Long id, + Long balanceId, + String customerId, + String overseasWarehouseId, + String destinationWarehouseCode, + String skuCode, + Long palletId, + String palletNo, + String batchNo, + Long warehouseLocationId, + String warehouseLocationCode, + long totalPackages, + long availablePackages, + long holdPackages, + long allocatedPackages, + long pickedPackages, + long receivingPackages, + long productQuantity, + String status, + LocalDateTime updatedAt +) { +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryQueryRequests.java b/lastleg/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryQueryRequests.java new file mode 100644 index 0000000..21fb6ca --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryQueryRequests.java @@ -0,0 +1,52 @@ +package com.baiyue.lastleg.inventory.query.dto; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; + +import java.time.LocalDateTime; + +public final class InventoryQueryRequests { + private InventoryQueryRequests() { + } + + public record BalanceQuery( + @Min(1) long page, + @Min(1) @Max(200) long size, + String customerName, + String overseasWarehouseId, + String overseasWarehouseCode, + String destinationWarehouseCode, + String skuCode, + String skuName, + String inventoryStatus + ) { + public BalanceQuery { + if (page == 0) page = 1; + if (size == 0) size = 20; + } + } + + public record PositionQuery(String palletNo, String batchNo, String inventoryStatus) { + } + + public record LedgerQuery( + @Min(1) long page, + @Min(1) @Max(200) long size, + String overseasWarehouseId, + String destinationWarehouseCode, + String skuCode, + String businessType, + String businessOrderNo, + String inboundOrderNo, + String outboundOrderNo, + String palletNo, + String batchNo, + LocalDateTime occurredFrom, + LocalDateTime occurredTo + ) { + public LedgerQuery { + if (page == 0) page = 1; + if (size == 0) size = 20; + } + } +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/inventory/query/mapper/InventoryQueryMapper.java b/lastleg/src/main/java/com/baiyue/lastleg/inventory/query/mapper/InventoryQueryMapper.java new file mode 100644 index 0000000..d342069 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/inventory/query/mapper/InventoryQueryMapper.java @@ -0,0 +1,124 @@ +package com.baiyue.lastleg.inventory.query.mapper; + +import com.baiyue.lastleg.inventory.entity.InventoryBalance; +import com.baiyue.lastleg.inventory.entity.InventoryLedger; +import com.baiyue.lastleg.inventory.entity.InventoryPosition; +import com.baiyue.lastleg.inventory.query.dto.InventoryQueryRequests; +import com.baiyue.lastleg.inventory.query.model.InventoryAggregateProjection; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; + +public interface InventoryQueryMapper { + String BALANCE_FILTER = " WHERE tenant_id=#{tenantId}" + + " AND customer_name LIKE CONCAT('%',#{q.customerName},'%')" + + " AND overseas_warehouse_id=#{q.overseasWarehouseId}" + + " AND overseas_warehouse_code=#{q.overseasWarehouseCode}" + + " AND destination_warehouse_code=#{q.destinationWarehouseCode}" + + " AND sku_code LIKE CONCAT('%',#{q.skuCode},'%')" + + " AND sku_name_zh LIKE CONCAT('%',#{q.skuName},'%')" + + "" + + " AND available_packages > 0" + + " AND hold_packages > 0" + + " AND allocated_packages > 0" + + " AND picked_packages > 0" + + " AND in_transit_packages > 0" + + " AND receiving_packages > 0" + + " AND procurement_packages > 0" + + ""; + + String JOINED_BALANCE_FILTER = " WHERE b.tenant_id=#{tenantId}" + + " AND b.customer_name LIKE CONCAT('%',#{q.customerName},'%')" + + " AND b.overseas_warehouse_id=#{q.overseasWarehouseId}" + + " AND b.overseas_warehouse_code=#{q.overseasWarehouseCode}" + + " AND b.destination_warehouse_code=#{q.destinationWarehouseCode}" + + " AND b.sku_code LIKE CONCAT('%',#{q.skuCode},'%')" + + " AND b.sku_name_zh LIKE CONCAT('%',#{q.skuName},'%')" + + "" + + " AND b.available_packages > 0" + + " AND b.hold_packages > 0" + + " AND b.allocated_packages > 0" + + " AND b.picked_packages > 0" + + " AND b.in_transit_packages > 0" + + " AND b.receiving_packages > 0" + + " AND b.procurement_packages > 0" + + ""; + + @Select("") + List balances(@Param("tenantId") String tenantId, + @Param("q") InventoryQueryRequests.BalanceQuery query, + @Param("offset") long offset, + @Param("limit") long limit); + + @Select("") + long countBalances(@Param("tenantId") String tenantId, + @Param("q") InventoryQueryRequests.BalanceQuery query); + + @Select("") + InventoryAggregateProjection aggregate(@Param("tenantId") String tenantId, + @Param("q") InventoryQueryRequests.BalanceQuery query); + + @Select("") + long countPallets(@Param("tenantId") String tenantId, + @Param("q") InventoryQueryRequests.BalanceQuery query); + + @Select("") + List positions(@Param("tenantId") String tenantId, + @Param("balanceId") long balanceId, + @Param("q") InventoryQueryRequests.PositionQuery query); + + @Select("SELECT COUNT(*) FROM ll_inventory_balance WHERE tenant_id=#{tenantId} AND id=#{balanceId}") + long countOwnedBalance(@Param("tenantId") String tenantId, @Param("balanceId") long balanceId); + + String LEDGER_FILTER = " WHERE tenant_id=#{tenantId}" + + " AND overseas_warehouse_id=#{q.overseasWarehouseId}" + + " AND destination_warehouse_code=#{q.destinationWarehouseCode}" + + " AND sku_code LIKE CONCAT('%',#{q.skuCode},'%')" + + " AND business_type=#{q.businessType}" + + " AND business_order_no LIKE CONCAT('%',#{q.businessOrderNo},'%')" + + " AND inbound_order_no LIKE CONCAT('%',#{q.inboundOrderNo},'%')" + + " AND outbound_order_no LIKE CONCAT('%',#{q.outboundOrderNo},'%')" + + " AND pallet_no LIKE CONCAT('%',#{q.palletNo},'%')" + + " AND batch_no LIKE CONCAT('%',#{q.batchNo},'%')" + + " AND occurred_at >=#{q.occurredFrom}" + + " AND occurred_at <=#{q.occurredTo}"; + + @Select("") + List ledgers(@Param("tenantId") String tenantId, + @Param("q") InventoryQueryRequests.LedgerQuery query, + @Param("offset") long offset, + @Param("limit") long limit); + + @Select("") + long countLedgers(@Param("tenantId") String tenantId, + @Param("q") InventoryQueryRequests.LedgerQuery query); +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/inventory/query/model/InventoryAggregateProjection.java b/lastleg/src/main/java/com/baiyue/lastleg/inventory/query/model/InventoryAggregateProjection.java new file mode 100644 index 0000000..85eecfa --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/inventory/query/model/InventoryAggregateProjection.java @@ -0,0 +1,22 @@ +package com.baiyue.lastleg.inventory.query.model; + +import lombok.Data; + +@Data +public class InventoryAggregateProjection { + private Long dimensionCount; + private Long availablePackages; + private Long holdPackages; + private Long allocatedPackages; + private Long pickedPackages; + private Long inTransitPackages; + private Long receivingPackages; + private Long procurementPackages; + private Long availableQuantity; + private Long holdQuantity; + private Long allocatedQuantity; + private Long pickedQuantity; + private Long inTransitQuantity; + private Long receivingQuantity; + private Long procurementQuantity; +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/inventory/query/service/InventoryQueryService.java b/lastleg/src/main/java/com/baiyue/lastleg/inventory/query/service/InventoryQueryService.java new file mode 100644 index 0000000..ef1214a --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/inventory/query/service/InventoryQueryService.java @@ -0,0 +1,202 @@ +package com.baiyue.lastleg.inventory.query.service; + +import com.baiyue.common.core.api.PageResult; +import com.baiyue.lastleg.inventory.entity.InventoryBalance; +import com.baiyue.lastleg.inventory.entity.InventoryLedger; +import com.baiyue.lastleg.inventory.entity.InventoryPosition; +import com.baiyue.lastleg.inventory.query.dto.InventoryBalanceView; +import com.baiyue.lastleg.inventory.query.dto.InventoryDashboardResponse; +import com.baiyue.lastleg.inventory.query.dto.InventoryLedgerView; +import com.baiyue.lastleg.inventory.query.dto.InventoryPositionView; +import com.baiyue.lastleg.inventory.query.dto.InventoryQueryRequests; +import com.baiyue.lastleg.inventory.query.mapper.InventoryQueryMapper; +import com.baiyue.lastleg.inventory.query.model.InventoryAggregateProjection; +import jakarta.annotation.Resource; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +@Service +public class InventoryQueryService { + private static final int EXPORT_LIMIT = 10_000; + private static final Set BALANCE_STATUSES = Set.of( + "AVAILABLE", "HOLD", "ALLOCATED", "PICKED", "IN_TRANSIT", "RECEIVING", "PROCUREMENT"); + private static final Set POSITION_STATUSES = Set.of( + "AVAILABLE", "HOLD", "ALLOCATED", "PICKED", "RECEIVING"); + + @Resource private InventoryQueryMapper mapper; + + public InventoryDashboardResponse dashboard(String tenantId, InventoryQueryRequests.BalanceQuery query) { + validateStatus(query.inventoryStatus(), BALANCE_STATUSES); + InventoryAggregateProjection value = mapper.aggregate(tenantId, query); + long availablePackages = number(value.getAvailablePackages()); + long holdPackages = number(value.getHoldPackages()); + long allocatedPackages = number(value.getAllocatedPackages()); + long pickedPackages = number(value.getPickedPackages()); + long inTransitPackages = number(value.getInTransitPackages()); + long receivingPackages = number(value.getReceivingPackages()); + long procurementPackages = number(value.getProcurementPackages()); + long availableQuantity = number(value.getAvailableQuantity()); + long holdQuantity = number(value.getHoldQuantity()); + long allocatedQuantity = number(value.getAllocatedQuantity()); + long pickedQuantity = number(value.getPickedQuantity()); + long inTransitQuantity = number(value.getInTransitQuantity()); + long receivingQuantity = number(value.getReceivingQuantity()); + long procurementQuantity = number(value.getProcurementQuantity()); + return new InventoryDashboardResponse(number(value.getDimensionCount()), mapper.countPallets(tenantId, query), + availablePackages + holdPackages + allocatedPackages + pickedPackages + inTransitPackages + + receivingPackages + procurementPackages, + availablePackages, holdPackages, allocatedPackages, pickedPackages, inTransitPackages, + receivingPackages, procurementPackages, + availableQuantity + holdQuantity + allocatedQuantity + pickedQuantity + inTransitQuantity + + receivingQuantity + procurementQuantity, + availableQuantity, holdQuantity, allocatedQuantity, pickedQuantity, inTransitQuantity, + receivingQuantity, procurementQuantity); + } + + public PageResult balances(String tenantId, InventoryQueryRequests.BalanceQuery query) { + validateStatus(query.inventoryStatus(), BALANCE_STATUSES); + long total = mapper.countBalances(tenantId, query); + List records = total == 0 ? List.of() : mapper.balances( + tenantId, query, offset(query.page(), query.size()), query.size()).stream().map(this::balanceView).toList(); + return page(records, query.page(), query.size(), total); + } + + public List positions(String tenantId, long balanceId, + InventoryQueryRequests.PositionQuery query) { + validateStatus(query.inventoryStatus(), POSITION_STATUSES); + if (mapper.countOwnedBalance(tenantId, balanceId) == 0) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Inventory balance not found"); + } + return mapper.positions(tenantId, balanceId, query).stream().map(this::positionView).toList(); + } + + public PageResult ledgers(String tenantId, InventoryQueryRequests.LedgerQuery query) { + validateDateRange(query); + long total = mapper.countLedgers(tenantId, query); + List records = total == 0 ? List.of() : mapper.ledgers( + tenantId, query, offset(query.page(), query.size()), query.size()).stream().map(this::ledgerView).toList(); + return page(records, query.page(), query.size(), total); + } + + public byte[] exportBalances(String tenantId, InventoryQueryRequests.BalanceQuery query) { + validateStatus(query.inventoryStatus(), BALANCE_STATUSES); + List rows = new ArrayList<>(); + rows.add("customerId,customerName,overseasWarehouseCode,overseasWarehouseName,destinationWarehouseCode," + + "destinationWarehouseName,skuCode,skuName,totalPackages,availablePackages,holdPackages," + + "allocatedPackages,pickedPackages,inTransitPackages,receivingPackages,procurementPackages,totalQuantity"); + mapper.balances(tenantId, query, 0, EXPORT_LIMIT).stream().map(this::balanceView).forEach(v -> rows.add( + String.join(",", csv(v.customerId()), csv(v.customerName()), csv(v.overseasWarehouseCode()), csv(v.overseasWarehouseName()), + csv(v.destinationWarehouseCode()), csv(v.destinationWarehouseName()), csv(v.skuCode()), csv(v.skuName()), + csv(v.totalPackages()), csv(v.availablePackages()), csv(v.holdPackages()), csv(v.allocatedPackages()), + csv(v.pickedPackages()), csv(v.inTransitPackages()), csv(v.receivingPackages()), + csv(v.procurementPackages()), csv(v.totalQuantity())))); + return ("\uFEFF" + String.join("\r\n", rows)).getBytes(StandardCharsets.UTF_8); + } + + public byte[] exportLedgers(String tenantId, InventoryQueryRequests.LedgerQuery query) { + validateDateRange(query); + List rows = new ArrayList<>(); + rows.add("occurredAt,customerId,overseasWarehouseId,destinationWarehouseCode,skuCode,businessType," + + "businessOrderNo,inboundOrderNo,outboundOrderNo,palletNo,batchNo,statusFrom,statusTo," + + "beforePackages,deltaPackages,afterPackages,beforeQuantity,deltaQuantity,afterQuantity,operatorId,remark"); + mapper.ledgers(tenantId, query, 0, EXPORT_LIMIT).stream().map(this::ledgerView).forEach(v -> rows.add( + String.join(",", csv(v.occurredAt()), csv(v.customerId()), csv(v.overseasWarehouseId()), + csv(v.destinationWarehouseCode()), csv(v.skuCode()), csv(v.businessType()), csv(v.businessOrderNo()), + csv(v.inboundOrderNo()), csv(v.outboundOrderNo()), csv(v.palletNo()), csv(v.batchNo()), + csv(v.statusFrom()), csv(v.statusTo()), csv(v.beforePackages()), csv(v.deltaPackages()), + csv(v.afterPackages()), csv(v.beforeQuantity()), csv(v.deltaQuantity()), csv(v.afterQuantity()), + csv(v.operatorId()), csv(v.remark())))); + return ("\uFEFF" + String.join("\r\n", rows)).getBytes(StandardCharsets.UTF_8); + } + + private InventoryBalanceView balanceView(InventoryBalance value) { + long availablePackages = number(value.getAvailablePackages()); + long holdPackages = number(value.getHoldPackages()); + long allocatedPackages = number(value.getAllocatedPackages()); + long pickedPackages = number(value.getPickedPackages()); + long inTransitPackages = number(value.getInTransitPackages()); + long receivingPackages = number(value.getReceivingPackages()); + long procurementPackages = number(value.getProcurementPackages()); + long availableQuantity = number(value.getAvailableQuantity()); + long holdQuantity = number(value.getHoldQuantity()); + long allocatedQuantity = number(value.getAllocatedQuantity()); + long pickedQuantity = number(value.getPickedQuantity()); + long inTransitQuantity = number(value.getInTransitQuantity()); + long receivingQuantity = number(value.getReceivingQuantity()); + long procurementQuantity = number(value.getProcurementQuantity()); + return new InventoryBalanceView(value.getId(), value.getTenantId(), value.getCustomerName(), value.getOverseasWarehouseId(), + value.getOverseasWarehouseCode(), value.getOverseasWarehouseName(), value.getDestinationWarehouseId(), + value.getDestinationWarehouseCode(), value.getDestinationWarehouseName(), value.getSkuCode(), + value.getSkuNameZh(), availablePackages + holdPackages + allocatedPackages + pickedPackages + + inTransitPackages + receivingPackages + procurementPackages, + availablePackages, holdPackages, allocatedPackages, pickedPackages, inTransitPackages, + receivingPackages, procurementPackages, + availableQuantity + holdQuantity + allocatedQuantity + pickedQuantity + inTransitQuantity + + receivingQuantity + procurementQuantity, + availableQuantity, holdQuantity, allocatedQuantity, pickedQuantity, inTransitQuantity, + receivingQuantity, procurementQuantity, value.getUpdatedAt()); + } + + private InventoryPositionView positionView(InventoryPosition value) { + long available = number(value.getAvailablePackages()); + long hold = number(value.getHoldPackages()); + long allocated = number(value.getAllocatedPackages()); + long picked = number(value.getPickedPackages()); + long receiving = number(value.getReceivingPackages()); + return new InventoryPositionView(value.getId(), value.getBalanceId(), value.getTenantId(), + value.getOverseasWarehouseId(), value.getDestinationWarehouseCode(), value.getSkuCode(), + value.getPalletId(), value.getPalletNo(), value.getBatchNo(), value.getWarehouseLocationId(), + value.getWarehouseLocationCode(), available + hold + allocated + picked + receiving, + available, hold, allocated, picked, receiving, number(value.getProductQuantity()), value.getStatus(), + value.getUpdatedAt()); + } + + private InventoryLedgerView ledgerView(InventoryLedger value) { + return new InventoryLedgerView(value.getId(), value.getTenantId(), value.getOverseasWarehouseId(), + value.getDestinationWarehouseCode(), value.getSkuCode(), value.getPalletNo(), value.getBatchNo(), + value.getWarehouseLocationCode(), value.getBusinessType(), value.getBusinessOrderNo(), + value.getInboundOrderNo(), value.getOutboundOrderNo(), value.getStatusFrom(), value.getStatusTo(), + number(value.getBeforePackages()), number(value.getDeltaPackages()), number(value.getAfterPackages()), + number(value.getBeforeQuantity()), number(value.getDeltaQuantity()), number(value.getAfterQuantity()), + value.getOperatorId(), value.getRemark(), value.getOccurredAt()); + } + + private static void validateStatus(String status, Set accepted) { + if (status != null && !status.isBlank() && !accepted.contains(status)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Unsupported inventory status: " + status); + } + } + + private static void validateDateRange(InventoryQueryRequests.LedgerQuery query) { + if (query.occurredFrom() != null && query.occurredTo() != null + && query.occurredFrom().isAfter(query.occurredTo())) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "occurredFrom must not be after occurredTo"); + } + } + + private static long offset(long page, long size) { + return Math.multiplyExact(page - 1, size); + } + + private static long number(Long value) { + return value == null ? 0 : value; + } + + private static PageResult page(List records, long page, long size, long total) { + long pages = total == 0 ? 0 : (total + size - 1) / size; + return new PageResult<>(records, page, size, total, pages); + } + + private static String csv(Object value) { + if (value == null) return ""; + String text = String.valueOf(value); + if (!text.isEmpty() && "=+-@".indexOf(text.charAt(0)) >= 0) text = "'" + text; + return '"' + text.replace("\"", "\"\"") + '"'; + } +} diff --git a/lastleg/src/main/resources/db/lastleg.sql b/lastleg/src/main/resources/db/lastleg.sql index 3091523..6589496 100644 --- a/lastleg/src/main/resources/db/lastleg.sql +++ b/lastleg/src/main/resources/db/lastleg.sql @@ -495,6 +495,44 @@ CREATE TABLE IF NOT EXISTS ll_inbound_pallet_merge ( KEY idx_ll_inbound_merge_task (tenant_id, task_id), KEY idx_ll_inbound_merge_group (merge_group_no) ) ENGINE=InnoDB; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='fba_warehouse_code'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_line ADD COLUMN fba_warehouse_code VARCHAR(128) NULL AFTER inbound_code')); +PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt; +UPDATE ll_inbound_plan_line l JOIN ll_order o ON o.id=l.order_id +SET l.fba_warehouse_code=o.fba_warehouse_code +WHERE l.fba_warehouse_code IS NULL AND o.fba_warehouse_code IS NOT NULL; + +CREATE TABLE IF NOT EXISTS ll_inbound_fba_appointment ( + id BIGINT NOT NULL COMMENT 'Snowflake ID', + tenant_id VARCHAR(128) NOT NULL, + appointment_no VARCHAR(64) NOT NULL, + appointment_at DATETIME(3) NOT NULL, + appointment_status VARCHAR(32) NOT NULL DEFAULT 'PENDING', + appointment_remark VARCHAR(1000) NULL, + fba_warehouse_code VARCHAR(128) NOT NULL, + selected_count INT NOT NULL DEFAULT 0, + created_by VARCHAR(128) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_ll_fba_appointment_no (tenant_id, appointment_no), + KEY idx_ll_fba_appointment_list (tenant_id, appointment_status, fba_warehouse_code, created_at) +) ENGINE=InnoDB COMMENT='Batch FBA appointment'; + +CREATE TABLE IF NOT EXISTS ll_inbound_fba_appointment_item ( + id BIGINT NOT NULL COMMENT 'Snowflake ID', + appointment_id BIGINT NOT NULL, + plan_id BIGINT NOT NULL, + line_id BIGINT NOT NULL, + order_id BIGINT NOT NULL, + inbound_code VARCHAR(128) NOT NULL, + fba_warehouse_code VARCHAR(128) NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_ll_fba_appointment_item_line (appointment_id, line_id), + KEY idx_ll_fba_appointment_item_plan (plan_id), + KEY idx_ll_fba_appointment_item_order (order_id) +) ENGINE=InnoDB COMMENT='FBA appointment line snapshot'; + ALTER TABLE ll_loading_order_status_history MODIFY COLUMN id BIGINT NOT NULL AUTO_INCREMENT COMMENT '状态历史主键', MODIFY COLUMN order_id BIGINT NOT NULL COMMENT '尾程订单 ID', @@ -503,3 +541,198 @@ ALTER TABLE ll_loading_order_status_history MODIFY COLUMN event_id VARCHAR(128) NULL COMMENT '触发事件 ID', MODIFY COLUMN remark VARCHAR(500) NULL COMMENT '状态变更备注', MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录时间'; + +CREATE TABLE IF NOT EXISTS ll_inventory_balance ( + id BIGINT NOT NULL COMMENT 'Snowflake ID', + tenant_id VARCHAR(128) NOT NULL COMMENT 'Customer tenant', customer_name VARCHAR(255) NULL COMMENT 'Customer name snapshot', + overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT 'Actual overseas warehouse ID', + overseas_warehouse_code VARCHAR(128) NULL COMMENT 'Actual overseas warehouse code snapshot', + overseas_warehouse_name VARCHAR(255) NULL COMMENT 'Actual overseas warehouse name snapshot', + destination_warehouse_id VARCHAR(128) NULL COMMENT 'Customer destination warehouse ID snapshot', + destination_warehouse_code VARCHAR(128) NOT NULL DEFAULT '' COMMENT 'Customer destination warehouse code snapshot', + destination_warehouse_name VARCHAR(255) NULL COMMENT 'Customer destination warehouse name snapshot', + sku_code VARCHAR(128) NOT NULL COMMENT 'SKU code', sku_name_zh VARCHAR(255) NULL COMMENT 'Chinese product name', + available_packages BIGINT NOT NULL DEFAULT 0, hold_packages BIGINT NOT NULL DEFAULT 0, + allocated_packages BIGINT NOT NULL DEFAULT 0, picked_packages BIGINT NOT NULL DEFAULT 0, + in_transit_packages BIGINT NOT NULL DEFAULT 0, receiving_packages BIGINT NOT NULL DEFAULT 0, + procurement_packages BIGINT NOT NULL DEFAULT 0, + available_quantity BIGINT NOT NULL DEFAULT 0, hold_quantity BIGINT NOT NULL DEFAULT 0, + allocated_quantity BIGINT NOT NULL DEFAULT 0, picked_quantity BIGINT NOT NULL DEFAULT 0, + in_transit_quantity BIGINT NOT NULL DEFAULT 0, receiving_quantity BIGINT NOT NULL DEFAULT 0, + procurement_quantity BIGINT NOT NULL DEFAULT 0, + version BIGINT NOT NULL DEFAULT 0 COMMENT 'Optimistic lock version', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_ll_inventory_dimension (tenant_id,overseas_warehouse_id,destination_warehouse_code,sku_code), + KEY idx_ll_inventory_list (tenant_id,overseas_warehouse_id,updated_at), + KEY idx_ll_inventory_sku (tenant_id,sku_code) +) ENGINE=InnoDB COMMENT='Real-time SKU package inventory balance'; + +CREATE TABLE IF NOT EXISTS ll_inventory_position ( + id BIGINT NOT NULL COMMENT 'Snowflake ID', tenant_id VARCHAR(128) NOT NULL, balance_id BIGINT NOT NULL, + overseas_warehouse_id VARCHAR(128) NOT NULL, destination_warehouse_code VARCHAR(128) NOT NULL DEFAULT '', + sku_code VARCHAR(128) NOT NULL, pallet_id BIGINT NOT NULL, pallet_no VARCHAR(64) NOT NULL, + batch_no VARCHAR(128) NOT NULL DEFAULT '', warehouse_location_id BIGINT NULL, + warehouse_location_code VARCHAR(128) NOT NULL COMMENT 'Informational default location code', + available_packages BIGINT NOT NULL DEFAULT 0, hold_packages BIGINT NOT NULL DEFAULT 0, + allocated_packages BIGINT NOT NULL DEFAULT 0, picked_packages BIGINT NOT NULL DEFAULT 0, + receiving_packages BIGINT NOT NULL DEFAULT 0, product_quantity BIGINT NOT NULL DEFAULT 0, + status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE', version BIGINT NOT NULL DEFAULT 0, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), UNIQUE KEY uk_ll_inventory_position (balance_id,pallet_id,batch_no), + KEY idx_ll_inventory_position_pallet (tenant_id,pallet_id), + KEY idx_ll_inventory_position_location (tenant_id,overseas_warehouse_id,warehouse_location_code) +) ENGINE=InnoDB COMMENT='SKU package stock by pallet, default location and batch'; + +CREATE TABLE IF NOT EXISTS ll_inventory_ledger ( + id BIGINT NOT NULL COMMENT 'Snowflake ID', tenant_id VARCHAR(128) NOT NULL, + balance_id BIGINT NOT NULL, position_id BIGINT NULL, overseas_warehouse_id VARCHAR(128) NOT NULL, + destination_warehouse_code VARCHAR(128) NOT NULL DEFAULT '', sku_code VARCHAR(128) NOT NULL, + pallet_id BIGINT NULL, pallet_no VARCHAR(64) NULL, batch_no VARCHAR(128) NULL, + warehouse_location_code VARCHAR(128) NULL, business_type VARCHAR(32) NOT NULL, + business_order_id BIGINT NULL, business_order_no VARCHAR(128) NULL, inbound_code VARCHAR(128) NULL, + inbound_order_no VARCHAR(128) NULL, outbound_order_no VARCHAR(128) NULL, + source_event_id VARCHAR(128) NULL, idempotency_key VARCHAR(255) NOT NULL, + status_from VARCHAR(32) NULL, status_to VARCHAR(32) NULL, + before_packages BIGINT NOT NULL, delta_packages BIGINT NOT NULL, after_packages BIGINT NOT NULL, + before_quantity BIGINT NOT NULL, delta_quantity BIGINT NOT NULL, after_quantity BIGINT NOT NULL, + operator_id VARCHAR(128) NULL, remark VARCHAR(1000) NULL, + occurred_at DATETIME(3) NOT NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), UNIQUE KEY uk_ll_inventory_ledger_idempotency (tenant_id,idempotency_key), + KEY idx_ll_inventory_ledger_query (tenant_id,overseas_warehouse_id,occurred_at), + KEY idx_ll_inventory_ledger_sku (tenant_id,sku_code,occurred_at), + KEY idx_ll_inventory_ledger_business (tenant_id,business_type,business_order_no) +) ENGINE=InnoDB COMMENT='Immutable inventory package and quantity ledger'; + +CREATE TABLE IF NOT EXISTS ll_inventory_operation ( + id BIGINT NOT NULL COMMENT 'Snowflake ID', tenant_id VARCHAR(128) NOT NULL, + idempotency_key VARCHAR(255) NOT NULL, operation_type VARCHAR(32) NOT NULL, + request_hash VARCHAR(128) NOT NULL, status VARCHAR(32) NOT NULL DEFAULT 'PROCESSING', + result_json LONGTEXT NULL, last_error VARCHAR(2000) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), UNIQUE KEY uk_ll_inventory_operation_key (tenant_id,idempotency_key), + KEY idx_ll_inventory_operation_status (tenant_id,status,updated_at) +) ENGINE=InnoDB COMMENT='Inventory command idempotency record'; + +CREATE TABLE IF NOT EXISTS ll_inbound_stock_receipt ( + id BIGINT NOT NULL COMMENT 'Snowflake ID', tenant_id VARCHAR(128) NOT NULL, + plan_id BIGINT NOT NULL, task_id BIGINT NOT NULL, allocation_id BIGINT NOT NULL, + line_id BIGINT NOT NULL, order_id BIGINT NOT NULL, inbound_code VARCHAR(128) NOT NULL, + sku_code VARCHAR(128) NOT NULL, sku_name_zh VARCHAR(255) NULL, source_detail_ids VARCHAR(2000) NULL, + warehouse_pallet_id BIGINT NOT NULL, pallet_no VARCHAR(64) NOT NULL, + warehouse_location_id BIGINT NULL, warehouse_location_code VARCHAR(128) NULL, + batch_no VARCHAR(128) NOT NULL, received_packages BIGINT NOT NULL, received_quantity BIGINT NOT NULL, + inventory_balance_id BIGINT NOT NULL, inventory_position_id BIGINT NOT NULL, + inventory_operation_key VARCHAR(255) NOT NULL, received_at DATETIME(3) NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), UNIQUE KEY uk_ll_stock_receipt_task_allocation_sku (tenant_id,task_id,allocation_id,sku_code), + KEY idx_ll_stock_receipt_plan (tenant_id,plan_id,line_id), KEY idx_ll_stock_receipt_inventory (inventory_balance_id,inventory_position_id) +) ENGINE=InnoDB COMMENT='Immutable SKU-pallet PDA stock-in receipt snapshot'; + +CREATE TABLE IF NOT EXISTS ll_dispatch_order ( + id BIGINT NOT NULL COMMENT 'Snowflake ID', + tenant_id VARCHAR(128) NOT NULL, + dispatch_no VARCHAR(64) NOT NULL COMMENT 'Dispatch order number', + vehicle_plate VARCHAR(64) NULL COMMENT 'Editable vehicle plate; required before PDA picking', + driver_name VARCHAR(128) NULL, + driver_phone VARCHAR(64) NULL, + carrier_name VARCHAR(255) NULL, + route VARCHAR(255) NULL, + depart_at DATETIME(3) NULL, + fba_appointment_no VARCHAR(64) NULL, + fba_appointment_status VARCHAR(32) NOT NULL DEFAULT 'PENDING', + vehicle_appointment_status VARCHAR(32) NOT NULL DEFAULT 'PENDING', + estimated_arrival_at DATETIME(3) NULL, + status VARCHAR(32) NOT NULL DEFAULT 'PENDING_DEPARTURE', + item_count INT NOT NULL DEFAULT 0, + pallet_count INT NOT NULL DEFAULT 0, + package_count INT NOT NULL DEFAULT 0, + created_by VARCHAR(128) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_ll_dispatch_order_no (tenant_id,dispatch_no), + KEY idx_ll_dispatch_order_list (tenant_id,status,depart_at), + KEY idx_ll_dispatch_order_vehicle (tenant_id,vehicle_plate) +) ENGINE=InnoDB COMMENT='Last-leg pre-dispatch order'; + +CREATE TABLE IF NOT EXISTS ll_dispatch_item ( + id BIGINT NOT NULL COMMENT 'Snowflake ID', + dispatch_order_id BIGINT NOT NULL, + tenant_id VARCHAR(128) NOT NULL, + plan_id BIGINT NULL, + plan_pallet_id BIGINT NULL, + warehouse_pallet_id BIGINT NULL, + order_id BIGINT NULL, + order_no VARCHAR(64) NULL, + order_refs VARCHAR(2000) NULL, + inbound_code VARCHAR(128) NULL, + pallet_no VARCHAR(128) NOT NULL, + destination_warehouse_id VARCHAR(128) NULL, + destination_warehouse_code VARCHAR(128) NULL, + destination_warehouse_name VARCHAR(255) NULL, + fba_warehouse_code VARCHAR(128) NULL, + package_count INT NOT NULL DEFAULT 0, + volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0, + source_stage VARCHAR(32) NOT NULL COMMENT 'PLAN or RECEIVED', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_ll_dispatch_item_source (dispatch_order_id,plan_pallet_id,warehouse_pallet_id), + KEY idx_ll_dispatch_item_order (tenant_id,order_id), + KEY idx_ll_dispatch_item_inbound (tenant_id,inbound_code), + KEY idx_ll_dispatch_item_pallet (tenant_id,plan_pallet_id,warehouse_pallet_id) +) ENGINE=InnoDB COMMENT='Frozen pallet and cargo snapshot for a dispatch order'; + +CREATE TABLE IF NOT EXISTS ll_dispatch_pallet_lock ( + id BIGINT NOT NULL COMMENT 'Snowflake ID', + tenant_id VARCHAR(128) NOT NULL, + source_pallet_key VARCHAR(160) NOT NULL COMMENT 'PLAN:{planPalletId} or RECEIVED:{warehousePalletId}', + active_key VARCHAR(160) NULL COMMENT 'Equals source key only while lock is active', + plan_id BIGINT NULL, + plan_pallet_id BIGINT NULL, + warehouse_pallet_id BIGINT NULL, + dispatch_order_id BIGINT NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE', + locked_at DATETIME(3) NOT NULL, + released_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_ll_dispatch_active_pallet (tenant_id,active_key), + KEY idx_ll_dispatch_pallet_lock_order (tenant_id,dispatch_order_id,status), + KEY idx_ll_dispatch_pallet_lock_source (tenant_id,source_pallet_key,status) +) ENGINE=InnoDB COMMENT='Active dispatch pallet exclusion lock'; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='order_refs'), 'SELECT 1', 'ALTER TABLE ll_dispatch_item ADD COLUMN order_refs VARCHAR(2000) NULL AFTER order_no')); +PREPARE dispatch_schema_stmt FROM @ddl; EXECUTE dispatch_schema_stmt; DEALLOCATE PREPARE dispatch_schema_stmt; + +CREATE TABLE IF NOT EXISTS ll_dispatch_pda_task ( + id BIGINT NOT NULL, tenant_id VARCHAR(128) NOT NULL, dispatch_order_id BIGINT NOT NULL, + task_no VARCHAR(64) NOT NULL, status VARCHAR(32) NOT NULL, operator_id VARCHAR(128) NULL, + operator_name VARCHAR(128) NULL, pushed_at DATETIME(3) NOT NULL, completed_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), UNIQUE KEY uk_ll_dispatch_pda_task_dispatch (tenant_id,dispatch_order_id), + UNIQUE KEY uk_ll_dispatch_pda_task_no (tenant_id,task_no) +) ENGINE=InnoDB COMMENT='PDA task for a pre-arranged dispatch'; + +CREATE TABLE IF NOT EXISTS ll_dispatch_pda_pick ( + id BIGINT NOT NULL, tenant_id VARCHAR(128) NOT NULL, task_id BIGINT NOT NULL, + dispatch_item_id BIGINT NOT NULL, inventory_position_id BIGINT NOT NULL, + packages BIGINT NOT NULL, product_quantity BIGINT NOT NULL DEFAULT 0, idempotency_key VARCHAR(255) NOT NULL, + picked_at DATETIME(3) NOT NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), UNIQUE KEY uk_ll_dispatch_pda_pick_key (tenant_id,idempotency_key), + KEY idx_ll_dispatch_pda_pick_item (tenant_id,task_id,dispatch_item_id) +) ENGINE=InnoDB COMMENT='Immutable dispatch PDA pallet package picks'; + +CREATE TABLE IF NOT EXISTS ll_dispatch_pda_operation ( + id BIGINT NOT NULL, tenant_id VARCHAR(128) NOT NULL, task_id BIGINT NULL, + operation_type VARCHAR(32) NOT NULL, idempotency_key VARCHAR(255) NOT NULL, request_hash CHAR(64) NOT NULL, + result_json LONGTEXT NULL, status VARCHAR(32) NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), UNIQUE KEY uk_ll_dispatch_pda_operation_key (tenant_id,idempotency_key) +) ENGINE=InnoDB COMMENT='Idempotent dispatch PDA operations'; diff --git a/lastleg/src/test/java/com/baiyue/lastleg/dispatch/DispatchSchemaContractTest.java b/lastleg/src/test/java/com/baiyue/lastleg/dispatch/DispatchSchemaContractTest.java new file mode 100644 index 0000000..4554bd9 --- /dev/null +++ b/lastleg/src/test/java/com/baiyue/lastleg/dispatch/DispatchSchemaContractTest.java @@ -0,0 +1,24 @@ +package com.baiyue.lastleg.dispatch; + +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +class DispatchSchemaContractTest { + @Test + void baselineDefinesDispatchHeadersItemsAndActivePalletLock() throws Exception { + String sql = Files.readString(Path.of("src/main/resources/db/lastleg.sql")); + assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_dispatch_order")); + assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_dispatch_item")); + assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_dispatch_pallet_lock")); + assertTrue(sql.contains("uk_ll_dispatch_active_pallet")); + assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_dispatch_pda_task")); + assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_dispatch_pda_pick")); + assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_dispatch_pda_operation")); + assertTrue(sql.contains("active_key")); + assertTrue(sql.contains("order_refs")); + } +} diff --git a/lastleg/src/test/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowServiceTest.java b/lastleg/src/test/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowServiceTest.java new file mode 100644 index 0000000..7892eed --- /dev/null +++ b/lastleg/src/test/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowServiceTest.java @@ -0,0 +1,83 @@ +package com.baiyue.lastleg.dispatch.pda.service; + +import com.baiyue.lastleg.dispatch.entity.DispatchOrder; +import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper; +import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaRequests; +import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaResponses; +import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaOperation; +import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaOperationMapper; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.server.ResponseStatusException; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class DispatchPdaWorkflowServiceTest { + @Mock private DispatchOrderMapper orders; + @Mock private DispatchPdaOperationMapper operations; + + @Test + void pushRequiresVehiclePlateBeforeCreatingPdaTask() { + DispatchPdaWorkflowService service = new DispatchPdaWorkflowService(); + ReflectionTestUtils.setField(service, "orders", orders); + DispatchOrder order = new DispatchOrder(); + order.setId(1L); order.setTenantId("100"); order.setDispatchNo("PC1"); order.setVehiclePlate(" "); + when(orders.findByDispatchNo("100", "PC1")).thenReturn(order); + + ResponseStatusException exception = assertThrows(ResponseStatusException.class, + () -> service.push("100", "PC1", new DispatchPdaRequests.Push("push-1", null, null))); + + assertEquals(409, exception.getStatusCode().value()); + } + + @Test + void departRequiresVehiclePlate() { + DispatchPdaWorkflowService service = new DispatchPdaWorkflowService(); + ReflectionTestUtils.setField(service, "orders", orders); + ReflectionTestUtils.setField(service, "operations", operations); + DispatchOrder order = new DispatchOrder(); + order.setId(1L); order.setTenantId("100"); order.setDispatchNo("PC1"); + order.setStatus("READY_TO_DEPART"); order.setVehiclePlate(" "); + when(orders.findByDispatchNo("100", "PC1")).thenReturn(order); + when(orders.lockById("100", 1L)).thenReturn(order); + when(operations.findKey("100", "depart-1")).thenReturn(null); + + ResponseStatusException exception = assertThrows(ResponseStatusException.class, + () -> service.depart("100", "PC1", "operator", new DispatchPdaRequests.Depart("depart-1", null))); + + assertEquals(409, exception.getStatusCode().value()); + } + + @Test + void departedRequestReplaysAfterStatusChanged() throws Exception { + DispatchPdaWorkflowService service = new DispatchPdaWorkflowService(); + ObjectMapper objectMapper = new ObjectMapper(); + ReflectionTestUtils.setField(service, "orders", orders); + ReflectionTestUtils.setField(service, "operations", operations); + ReflectionTestUtils.setField(service, "objectMapper", objectMapper); + DispatchOrder order = new DispatchOrder(); + order.setId(1L); order.setTenantId("100"); order.setDispatchNo("PC1"); order.setStatus("DEPARTED"); + when(orders.findByDispatchNo("100", "PC1")).thenReturn(order); + when(orders.lockById("100", 1L)).thenReturn(order); + DispatchPdaOperation operation = new DispatchPdaOperation(); + operation.setOperationType("DEPART"); operation.setStatus("COMPLETED"); + operation.setResultJson(objectMapper.writeValueAsString(new DispatchPdaResponses.Departed("PC1", "DEPARTED", 12, false))); + when(operations.findKey("100", "depart-1")).thenReturn(operation); + + // This exact key/hash pair models the persisted first execution result. + DispatchPdaRequests.Depart request = new DispatchPdaRequests.Depart("depart-1", null); + operation.setRequestHash(java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256") + .digest(objectMapper.writeValueAsString(request).getBytes(java.nio.charset.StandardCharsets.UTF_8)))); + DispatchPdaResponses.Departed result = service.depart("100", "PC1", "operator", request); + + assertEquals("DEPARTED", result.status()); + assertEquals(true, result.replayed()); + } +} diff --git a/lastleg/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java b/lastleg/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java new file mode 100644 index 0000000..c0dde72 --- /dev/null +++ b/lastleg/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java @@ -0,0 +1,93 @@ +package com.baiyue.lastleg.dispatch.service; + +import com.baiyue.lastleg.dispatch.dto.DispatchRequests; +import com.baiyue.lastleg.dispatch.entity.DispatchItem; +import com.baiyue.lastleg.dispatch.entity.DispatchOrder; +import com.baiyue.lastleg.dispatch.entity.DispatchPalletLock; +import com.baiyue.lastleg.dispatch.mapper.DispatchCandidateMapper; +import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper; +import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper; +import com.baiyue.lastleg.dispatch.mapper.DispatchPalletLockMapper; +import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate; +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 org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.math.BigDecimal; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class DispatchServiceTest { + @Mock DispatchOrderMapper orders; + @Mock DispatchItemMapper items; + @Mock DispatchPalletLockMapper locks; + @Mock DispatchCandidateMapper candidates; + private DispatchService service; + + @BeforeEach + void setUp() { + service = new DispatchService(); + ReflectionTestUtils.setField(service, "orders", orders); + ReflectionTestUtils.setField(service, "items", items); + ReflectionTestUtils.setField(service, "locks", locks); + ReflectionTestUtils.setField(service, "candidates", candidates); + lenient().when(items.findByDispatchOrder(any(), any())).thenReturn(List.of()); + } + + @Test + void preArrangeLocksPalletWithoutVehicleCapacityValidation() throws Exception { + assertNotNull(DispatchService.class.getMethod("preArrange", String.class, String.class, + DispatchRequests.PreArrange.class).getAnnotation(Transactional.class)); + when(candidates.lockSelected("100", List.of(10L))).thenReturn(List.of(candidate(10L, null, "ONT8"))); + + var result = service.preArrange("100", "7", new DispatchRequests.PreArrange(List.of(10L), null, + null, null, null)); + + assertEquals(DispatchService.PENDING_DEPARTURE, result.status()); + assertEquals("ONT8", result.route()); + ArgumentCaptor dispatch = ArgumentCaptor.forClass(DispatchOrder.class); + verify(orders).insert(dispatch.capture()); + assertEquals(1, dispatch.getValue().getPalletCount()); + verify(locks).insert(any(DispatchPalletLock.class)); + verify(items).insert(any(DispatchItem.class)); + } + + @Test + void rejectsPalletAlreadyLockedByAnotherDispatch() { + when(candidates.lockSelected("100", List.of(10L))).thenReturn(List.of(candidate(10L, null, "ONT8"))); + DispatchPalletLock locked = new DispatchPalletLock(); + when(locks.lockActive(eq("100"), eq("PLAN:10"))).thenReturn(locked); + + assertThrows(ResponseStatusException.class, () -> service.preArrange("100", "7", + new DispatchRequests.PreArrange(List.of(10L), null, null, null, null))); + verify(orders, never()).insert(any(DispatchOrder.class)); + } + + @Test + void invalidCandidateStageIsRejected() { + assertThrows(ResponseStatusException.class, () -> service.candidates("100", null, null, "INVALID")); + verify(candidates, never()).available(any(), any(), any(), any()); + } + + private DispatchPalletCandidate candidate(long planPalletId, Long warehousePalletId, String destination) { + return new DispatchPalletCandidate(1L, planPalletId, warehousePalletId, 2L, "LMO1", "LMO1", "RK1", + "LIP1-P001", destination, destination, destination, destination, 12, + new BigDecimal("1.2000"), warehousePalletId == null ? "PLAN" : "RECEIVED"); + } +} diff --git a/lastleg/src/test/java/com/baiyue/lastleg/inboundplan/InboundPlanSchemaContractTest.java b/lastleg/src/test/java/com/baiyue/lastleg/inboundplan/InboundPlanSchemaContractTest.java index 9ccee6c..466d96c 100644 --- a/lastleg/src/test/java/com/baiyue/lastleg/inboundplan/InboundPlanSchemaContractTest.java +++ b/lastleg/src/test/java/com/baiyue/lastleg/inboundplan/InboundPlanSchemaContractTest.java @@ -22,6 +22,10 @@ class InboundPlanSchemaContractTest { assertTrue(schema.contains("create table if not exists ll_inbound_pda_task")); assertTrue(schema.contains("create table if not exists ll_inbound_pda_operation")); assertTrue(schema.contains("create table if not exists ll_inbound_pallet_merge")); + assertTrue(schema.contains("create table if not exists ll_inbound_fba_appointment")); + assertTrue(schema.contains("unique key uk_ll_fba_appointment_no (tenant_id, appointment_no)")); + assertTrue(schema.contains("create table if not exists ll_inbound_fba_appointment_item")); + assertTrue(schema.contains("fba_warehouse_code")); assertFalse(schema.contains("cabinet_no")); } } diff --git a/lastleg/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowServiceTest.java b/lastleg/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowServiceTest.java index aa25ab1..20d86e9 100644 --- a/lastleg/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowServiceTest.java +++ b/lastleg/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowServiceTest.java @@ -29,6 +29,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; 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.never; import static org.mockito.Mockito.times; @@ -39,6 +40,7 @@ import static org.mockito.Mockito.when; class InboundPdaWorkflowServiceTest { @Mock PdaWorkflowRepository repository; @Mock WarehouseResourceGateway warehouse; + @Mock InboundStockReceiptService stockReceipts; private InboundPdaWorkflowService service; private InboundPlan plan; @@ -57,6 +59,7 @@ class InboundPdaWorkflowServiceTest { service = new InboundPdaWorkflowService(); ReflectionTestUtils.setField(service, "repository", repository); ReflectionTestUtils.setField(service, "warehouse", warehouse); + ReflectionTestUtils.setField(service, "stockReceipts", stockReceipts); ReflectionTestUtils.setField(service, "sourceObjectMapper", new ObjectMapper().findAndRegisterModules()); service.initializeObjectMapper(); plan = plan(); @@ -249,6 +252,20 @@ class InboundPdaWorkflowServiceTest { assertEquals("COMPLETED", plan.getStatus()); verify(repository).update(task); verify(repository).update(plan); + verify(stockReceipts).stockIn(eq("100"), eq(plan), eq(task), eq(List.of(targetLine)), + eq(List.of(target, moved)), any(), any()); + } + + @Test + void completedTaskReplayDoesNotRunStockInAgain() { + task.setStatus("COMPLETED"); + activeTask(); + + var result = service.complete("100", "LIP1"); + + assertEquals("COMPLETED", result.status()); + verify(stockReceipts, never()).stockIn(any(), any(), any(), any(), any(), any(), any()); + verify(repository, never()).allocations(plan.getId()); } @Test diff --git a/lastleg/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java b/lastleg/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java new file mode 100644 index 0000000..307d54d --- /dev/null +++ b/lastleg/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java @@ -0,0 +1,164 @@ +package com.baiyue.lastleg.inboundplan.pda.service; + +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.entity.LastlegOrderDetail; +import com.baiyue.lastleg.inboundplan.entity.InboundPlan; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation; +import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask; +import com.baiyue.lastleg.inboundplan.pda.entity.InboundStockReceipt; +import com.baiyue.lastleg.inboundplan.pda.mapper.InboundStockReceiptMapper; +import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests; +import com.baiyue.lastleg.inventory.command.dto.InventoryCommandResponse; +import com.baiyue.lastleg.inventory.command.service.InventoryCommandService; +import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +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 org.springframework.web.server.ResponseStatusException; + +import java.math.BigDecimal; +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.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class InboundStockReceiptServiceTest { + @Mock LastlegOrderMapper orders; + @Mock LastlegOrderDetailMapper details; + @Mock InboundStockReceiptMapper receipts; + @Mock InventoryCommandService inventory; + private InboundStockReceiptService service; + + @BeforeEach + void setUp() { + service = new InboundStockReceiptService(); + ReflectionTestUtils.setField(service, "orders", orders); + ReflectionTestUtils.setField(service, "details", details); + ReflectionTestUtils.setField(service, "receipts", receipts); + ReflectionTestUtils.setField(service, "inventory", inventory); + when(receipts.listTask("100", 2L)).thenReturn(List.of()); + } + + @Test + void writesOneReceiptPerSkuAndPalletAndUsesPdaPackageCount() { + Fixture fixture = fixture(5, 5, 5, 10); + when(orders.selectBatchIds(any())).thenReturn(List.of(fixture.order)); + when(details.selectList(any())).thenReturn(List.of(fixture.detail)); + when(inventory.stockIn(eq("100"), eq("operator"), any(InventoryCommandRequests.StockIn.class))) + .thenReturn(new InventoryCommandResponse(8L, "STOCK_IN", 9L, 10L, 5, 0, 0, 0, false, false)); + + service.stockIn("100", fixture.plan, fixture.task, List.of(fixture.line), + List.of(fixture.allocation), List.of(fixture.pallet), LocalDateTime.now()); + + ArgumentCaptor request = ArgumentCaptor.forClass(InventoryCommandRequests.StockIn.class); + verify(inventory).stockIn(eq("100"), eq("operator"), request.capture()); + assertEquals(5L, request.getValue().packages()); + assertEquals(10L, request.getValue().productQuantity()); + assertEquals("SKU-1", request.getValue().skuCode()); + ArgumentCaptor receipt = ArgumentCaptor.forClass(InboundStockReceipt.class); + verify(receipts).insert(receipt.capture()); + assertEquals(5L, receipt.getValue().getReceivedPackages()); + assertEquals(10L, receipt.getValue().getReceivedQuantity()); + assertEquals(10L, receipt.getValue().getInventoryPositionId()); + } + + @Test + void rejectsMultiSkuLineSplitAcrossPalletsInsteadOfProportionalMisallocation() { + Fixture fixture = fixture(10, 5, 5, 10); + fixture.detail.setPackageCount(5); + LastlegOrderDetail second = detail(202L, "SKU-2", 5, 7); + InboundPlanPallet secondPallet = pallet(22L, 2002L); + InboundPlanPalletAllocation secondAllocation = allocation(32L, 12L, 22L, 5); + when(orders.selectBatchIds(any())).thenReturn(List.of(fixture.order)); + when(details.selectList(any())).thenReturn(List.of(fixture.detail, second)); + + assertThrows(ResponseStatusException.class, () -> service.stockIn("100", fixture.plan, fixture.task, + List.of(fixture.line), List.of(fixture.allocation, secondAllocation), + List.of(fixture.pallet, secondPallet), LocalDateTime.now())); + verify(inventory, never()).stockIn(any(), any(), any()); + verify(receipts, never()).insert(any(InboundStockReceipt.class)); + } + + @Test + void conservesSingleSkuPackagesAndQuantityAcrossPallets() { + Fixture fixture = fixture(5, 2, 2, 11); + InboundPlanPallet secondPallet = pallet(22L, 2002L); + InboundPlanPalletAllocation secondAllocation = allocation(32L, 12L, 22L, 3); + when(orders.selectBatchIds(any())).thenReturn(List.of(fixture.order)); + when(details.selectList(any())).thenReturn(List.of(fixture.detail)); + when(inventory.stockIn(eq("100"), eq("operator"), any(InventoryCommandRequests.StockIn.class))) + .thenReturn(new InventoryCommandResponse(8L, "STOCK_IN", 9L, 10L, 5, 0, 0, 0, false, false)); + + service.stockIn("100", fixture.plan, fixture.task, List.of(fixture.line), + List.of(fixture.allocation, secondAllocation), List.of(fixture.pallet, secondPallet), LocalDateTime.now()); + + ArgumentCaptor requests = ArgumentCaptor.forClass(InventoryCommandRequests.StockIn.class); + verify(inventory, times(2)).stockIn(eq("100"), eq("operator"), requests.capture()); + assertEquals(5L, requests.getAllValues().stream().mapToLong(InventoryCommandRequests.StockIn::packages).sum()); + assertEquals(11L, requests.getAllValues().stream().mapToLong(InventoryCommandRequests.StockIn::productQuantity).sum()); + } + + @Test + void existingTaskReceiptMakesStockInReplayANoOp() { + Fixture fixture = fixture(5, 5, 5, 10); + when(receipts.listTask("100", fixture.task.getId())).thenReturn(List.of(new InboundStockReceipt())); + + service.stockIn("100", fixture.plan, fixture.task, List.of(fixture.line), + List.of(fixture.allocation), List.of(fixture.pallet), LocalDateTime.now()); + + verify(inventory, never()).stockIn(any(), any(), any()); + verify(receipts, never()).insert(any(InboundStockReceipt.class)); + } + + private Fixture fixture(int required, int planned, int picked, int quantity) { + InboundPlan plan = new InboundPlan(); plan.setId(1L); plan.setPlanNo("IP-1"); + InboundPdaTask task = new InboundPdaTask(); task.setId(2L); task.setOperatorId("operator"); + InboundPlanLine line = new InboundPlanLine(); line.setId(12L); line.setPlanId(1L); line.setOrderId(101L); + line.setInboundCode("IN-1"); line.setDestinationWarehouseId("WH-DEST"); + line.setDestinationWarehouseCode("ONT8"); line.setDestinationWarehouseName("ONT8"); + line.setRequiredPackageCount(required); line.setVolumeCbm(new BigDecimal("1.0")); + LastlegOrder order = new LastlegOrder(); order.setId(101L); order.setTenantId("100"); + order.setOrderNo("LMO-1"); order.setCustomerName("Customer"); order.setOverseasWarehouseId("OW-1"); + order.setOverseasWarehouseName("Warehouse"); order.setSourceEventId("evt-1"); order.setFbaWarehouseCode("ONT8"); + LastlegOrderDetail detail = detail(201L, "SKU-1", required, quantity); + return new Fixture(plan, task, line, order, detail, allocation(31L, 12L, 21L, picked), pallet(21L, 2001L)); + } + + private LastlegOrderDetail detail(long id, String sku, int packages, int quantity) { + LastlegOrderDetail detail = new LastlegOrderDetail(); detail.setId(id); detail.setOrderId(101L); + detail.setSkuCode(sku); detail.setPackageCount(packages); detail.setProductQuantity(quantity); + detail.setProductNameZh(sku + " name"); return detail; + } + + private InboundPlanPallet pallet(long id, long warehousePalletId) { + InboundPlanPallet pallet = new InboundPlanPallet(); pallet.setId(id); pallet.setPlanId(1L); + pallet.setPalletNo("PLT-" + id); pallet.setWarehousePalletId(warehousePalletId); + pallet.setWarehouseLocationId(3001L); return pallet; + } + + private InboundPlanPalletAllocation allocation(long id, long lineId, long palletId, int picked) { + InboundPlanPalletAllocation allocation = new InboundPlanPalletAllocation(); allocation.setId(id); + allocation.setPlanId(1L); allocation.setLineId(lineId); allocation.setPalletId(palletId); + allocation.setPlannedPackageCount(picked); allocation.setPickedPackageCount(picked); return allocation; + } + + private record Fixture(InboundPlan plan, InboundPdaTask task, InboundPlanLine line, LastlegOrder order, + LastlegOrderDetail detail, InboundPlanPalletAllocation allocation, + InboundPlanPallet pallet) {} +} diff --git a/lastleg/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentServiceTest.java b/lastleg/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentServiceTest.java new file mode 100644 index 0000000..2890520 --- /dev/null +++ b/lastleg/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentServiceTest.java @@ -0,0 +1,137 @@ +package com.baiyue.lastleg.inboundplan.service; + +import com.baiyue.lastleg.inboundplan.dto.BatchFbaAppointmentRequest; +import com.baiyue.lastleg.inboundplan.entity.InboundFbaAppointment; +import com.baiyue.lastleg.inboundplan.entity.InboundFbaAppointmentItem; +import com.baiyue.lastleg.inboundplan.mapper.InboundFbaAppointmentItemMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundFbaAppointmentMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundFbaAppointmentQueryMapper; +import com.baiyue.lastleg.inboundplan.model.FbaAppointmentLineSnapshot; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +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.ArgumentMatchers.any; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class InboundFbaAppointmentServiceTest { + @Mock InboundFbaAppointmentMapper appointmentMapper; + @Mock InboundFbaAppointmentItemMapper itemMapper; + @Mock InboundFbaAppointmentQueryMapper queryMapper; + + private InboundFbaAppointmentService service; + private final LocalDateTime appointmentAt = LocalDateTime.of(2026, 8, 20, 10, 30); + + @BeforeEach + void setUp() { + service = new InboundFbaAppointmentService(); + ReflectionTestUtils.setField(service, "appointmentMapper", appointmentMapper); + ReflectionTestUtils.setField(service, "itemMapper", itemMapper); + ReflectionTestUtils.setField(service, "queryMapper", queryMapper); + lenient().when(itemMapper.selectList(any())).thenReturn(List.of()); + } + + @Test + void createsOneBatchForSelectedLinesAndFreezesFbaWarehouse() throws Exception { + assertEquals(true, InboundFbaAppointmentService.class + .getMethod("batchCreate", String.class, String.class, BatchFbaAppointmentRequest.class) + .getAnnotation(Transactional.class) != null); + when(queryMapper.lockLines("100", List.of(11L, 12L))).thenReturn(List.of( + line(11L, "IN-11", "ONT8"), line(12L, "IN-12", "ONT8"))); + + var result = service.batchCreate("100", "7", request(List.of(11L, 12L), "APT-1")); + + assertEquals("APT-1", result.appointmentNo()); + assertEquals("ONT8", result.fbaWarehouseCode()); + assertEquals(2, result.selectedCount()); + verify(appointmentMapper).insert(any(InboundFbaAppointment.class)); + verify(itemMapper, org.mockito.Mockito.times(2)).insert(any(InboundFbaAppointmentItem.class)); + } + + @Test + void rejectsMixedFbaWarehouses() { + when(queryMapper.lockLines("100", List.of(11L, 12L))).thenReturn(List.of( + line(11L, "IN-11", "ONT8"), line(12L, "IN-12", "LAX9"))); + + assertThrows(ResponseStatusException.class, + () -> service.batchCreate("100", "7", request(List.of(11L, 12L), "APT-1"))); + verify(appointmentMapper, never()).insert(any(InboundFbaAppointment.class)); + } + + @Test + void rejectsMissingLineFromAnotherTenant() { + when(queryMapper.lockLines("100", List.of(11L, 12L))).thenReturn(List.of(line(11L, "IN-11", "ONT8"))); + + assertThrows(ResponseStatusException.class, + () -> service.batchCreate("100", "7", request(List.of(11L, 12L), "APT-1"))); + } + + @Test + void duplicateLineIdsAreRejectedBeforeDatabaseAccess() { + assertThrows(ResponseStatusException.class, + () -> service.batchCreate("100", "7", request(List.of(11L, 11L), "APT-1"))); + verify(queryMapper, never()).lockLines(any(), any()); + } + + @Test + void sameAppointmentReplayReturnsExistingResult() { + InboundFbaAppointment existing = appointment(99L, "APT-1", "ONT8"); + InboundFbaAppointmentItem item = new InboundFbaAppointmentItem(); + item.setLineId(11L); + item.setInboundCode("IN-11"); + item.setFbaWarehouseCode("ONT8"); + when(appointmentMapper.selectOne(any())).thenReturn(existing); + when(itemMapper.selectList(any())).thenReturn(List.of(item)); + + var result = service.batchCreate("100", "7", request(List.of(11L), "APT-1")); + + assertEquals(99L, result.id()); + verify(appointmentMapper, never()).insert(any(InboundFbaAppointment.class)); + } + + @Test + void sameAppointmentNumberWithDifferentSelectionConflicts() { + when(appointmentMapper.selectOne(any())).thenReturn(appointment(99L, "APT-1", "ONT8")); + InboundFbaAppointmentItem item = new InboundFbaAppointmentItem(); + item.setLineId(11L); + when(itemMapper.selectList(any())).thenReturn(List.of(item)); + + assertThrows(ResponseStatusException.class, + () -> service.batchCreate("100", "7", request(List.of(12L), "APT-1"))); + } + + private BatchFbaAppointmentRequest request(List lineIds, String number) { + return new BatchFbaAppointmentRequest("100", lineIds, number, appointmentAt, "SUCCESS", "dock"); + } + + private FbaAppointmentLineSnapshot line(Long id, String inboundCode, String fba) { + return new FbaAppointmentLineSnapshot(id, 1000L, id + 100L, inboundCode, fba); + } + + private InboundFbaAppointment appointment(Long id, String number, String fba) { + InboundFbaAppointment value = new InboundFbaAppointment(); + value.setId(id); + value.setTenantId("100"); + value.setAppointmentNo(number); + value.setAppointmentAt(appointmentAt); + value.setAppointmentStatus("SUCCESS"); + value.setAppointmentRemark("dock"); + value.setFbaWarehouseCode(fba); + value.setSelectedCount(1); + return value; + } +} diff --git a/lastleg/src/test/java/com/baiyue/lastleg/inventory/InventorySchemaContractTest.java b/lastleg/src/test/java/com/baiyue/lastleg/inventory/InventorySchemaContractTest.java new file mode 100644 index 0000000..7642972 --- /dev/null +++ b/lastleg/src/test/java/com/baiyue/lastleg/inventory/InventorySchemaContractTest.java @@ -0,0 +1,78 @@ +package com.baiyue.lastleg.inventory; + +import com.baiyue.lastleg.inventory.entity.InventoryBalance; +import com.baiyue.lastleg.inventory.entity.InventoryLedger; +import com.baiyue.lastleg.inventory.entity.InventoryOperation; +import com.baiyue.lastleg.inventory.entity.InventoryPosition; +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class InventorySchemaContractTest { + @Test + void baselineDefinesInventoryDimensionsStatesAndIdempotency() throws IOException { + String schema; + try (var stream = getClass().getClassLoader().getResourceAsStream("db/lastleg.sql")) { + assertNotNull(stream, "lastleg schema must be available"); + schema = new String(stream.readAllBytes(), StandardCharsets.UTF_8).toLowerCase(); + } + + assertTrue(schema.contains("create table if not exists ll_inventory_balance")); + assertTrue(schema.contains("unique key uk_ll_inventory_dimension (tenant_id,overseas_warehouse_id,destination_warehouse_code,sku_code)")); + assertTrue(schema.contains("available_packages bigint not null default 0")); + assertTrue(schema.contains("in_transit_packages bigint not null default 0")); + assertTrue(schema.contains("procurement_quantity bigint not null default 0")); + assertTrue(schema.contains("create table if not exists ll_inventory_position")); + assertTrue(schema.contains("unique key uk_ll_inventory_position (balance_id,pallet_id,batch_no)")); + assertTrue(schema.contains("warehouse_location_code varchar(128) not null")); + assertTrue(schema.contains("create table if not exists ll_inventory_ledger")); + assertTrue(schema.contains("business_order_id bigint null")); + assertTrue(schema.contains("inbound_code varchar(128) null")); + assertTrue(schema.contains("before_packages bigint not null")); + assertTrue(schema.contains("delta_packages bigint not null")); + assertTrue(schema.contains("after_packages bigint not null")); + assertTrue(schema.contains("unique key uk_ll_inventory_ledger_idempotency (tenant_id,idempotency_key)")); + assertTrue(schema.contains("create table if not exists ll_inventory_operation")); + assertTrue(schema.contains("create table if not exists ll_inbound_stock_receipt")); + assertTrue(schema.contains("unique key uk_ll_stock_receipt_task_allocation_sku (tenant_id,task_id,allocation_id,sku_code)")); + assertFalse(tableDefinition(schema, "ll_inventory_ledger").contains("updated_at")); + } + + @Test + void inventoryEntitiesUseSnowflakeIds() throws NoSuchFieldException { + for (Class type : new Class[] { + InventoryBalance.class, InventoryPosition.class, InventoryLedger.class, InventoryOperation.class + }) { + TableId tableId = type.getDeclaredField("id").getAnnotation(TableId.class); + assertNotNull(tableId, type.getSimpleName()); + assertEquals(IdType.ASSIGN_ID, tableId.type(), type.getSimpleName()); + } + } + + @Test + void incrementalMigrationIsAdditiveAndRerunnable() throws IOException { + String migration = Files.readString(Path.of("..", "scripts", "db", "incremental.sql"), StandardCharsets.UTF_8) + .toLowerCase(); + assertTrue(migration.contains("2026-08-17 / 008")); + assertTrue(migration.contains("create table if not exists ll_inventory_balance")); + assertTrue(migration.contains("create table if not exists ll_inventory_position")); + assertTrue(migration.contains("create table if not exists ll_inventory_ledger")); + assertTrue(migration.contains("create table if not exists ll_inventory_operation")); + } + + private static String tableDefinition(String schema, String tableName) { + int start = schema.indexOf("create table if not exists " + tableName); + int end = schema.indexOf("engine=innodb", start); + return schema.substring(start, end); + } +} diff --git a/lastleg/src/test/java/com/baiyue/lastleg/inventory/command/InventoryCommandServiceTest.java b/lastleg/src/test/java/com/baiyue/lastleg/inventory/command/InventoryCommandServiceTest.java new file mode 100644 index 0000000..7170b11 --- /dev/null +++ b/lastleg/src/test/java/com/baiyue/lastleg/inventory/command/InventoryCommandServiceTest.java @@ -0,0 +1,156 @@ +package com.baiyue.lastleg.inventory.command; + +import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests; +import com.baiyue.lastleg.inventory.command.dto.InventoryCommandResponse; +import com.baiyue.lastleg.inventory.command.mapper.InventoryCommandMapper; +import com.baiyue.lastleg.inventory.command.service.InventoryCommandService; +import com.baiyue.lastleg.inventory.entity.InventoryBalance; +import com.baiyue.lastleg.inventory.entity.InventoryLedger; +import com.baiyue.lastleg.inventory.entity.InventoryOperation; +import com.baiyue.lastleg.inventory.entity.InventoryPosition; +import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper; +import com.baiyue.lastleg.inventory.mapper.InventoryLedgerMapper; +import com.baiyue.lastleg.inventory.mapper.InventoryOperationMapper; +import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper; +import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; +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 org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class InventoryCommandServiceTest { + @Mock InventoryBalanceMapper balances; + @Mock InventoryPositionMapper positions; + @Mock InventoryLedgerMapper ledgers; + @Mock InventoryOperationMapper operations; + @Mock InventoryCommandMapper commands; + @Mock WarehouseResourceGateway warehouseGateway; + + private InventoryCommandService service; + + @BeforeEach + void setUp() { + service = new InventoryCommandService(); + ReflectionTestUtils.setField(service, "balances", balances); + ReflectionTestUtils.setField(service, "positions", positions); + ReflectionTestUtils.setField(service, "ledgers", ledgers); + ReflectionTestUtils.setField(service, "operations", operations); + ReflectionTestUtils.setField(service, "commands", commands); + ReflectionTestUtils.setField(service, "warehouseGateway", warehouseGateway); + ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper()); + lenient().when(operations.findIdempotency(any(), any())).thenReturn(null); + } + + @Test + void stockInUpdatesBalancePositionAndLedgerInOneTransaction() throws Exception { + assertTrue(InventoryCommandService.class.getMethod("stockIn", String.class, String.class, + InventoryCommandRequests.StockIn.class).getAnnotation(Transactional.class) != null); + when(balances.lockDimension("100", "WH-1", "ONT8", "SKU-A")).thenReturn(null); + var result = service.stockIn("100", "7", stockIn("IN-1", "stock-in-1", 12, 24)); + + assertEquals(12, result.availablePackages()); + assertFalse(result.replayed()); + ArgumentCaptor balance = ArgumentCaptor.forClass(InventoryBalance.class); + verify(balances).updateById(balance.capture()); + assertEquals(12L, balance.getValue().getAvailablePackages()); + verify(positions).insert(any(InventoryPosition.class)); + verify(ledgers).insert(any(InventoryLedger.class)); + verify(operations).updateById(any(InventoryOperation.class)); + } + + @Test + void holdRejectsWhenAvailablePackagesAreInsufficient() { + InventoryBalance balance = balance(200L, 5); + InventoryPosition position = position(201L, 200L, 5); + when(commands.lockPosition("100", 201L)).thenReturn(position); + when(commands.lockBalance("100", 200L)).thenReturn(balance); + + assertThrows(ResponseStatusException.class, + () -> service.hold("100", "7", new InventoryCommandRequests.Move( + "100", 201L, 6, 0, "ORD-1", null, null, "hold-1", null))); + verify(positions, never()).updateById(any(InventoryPosition.class)); + verify(balances, never()).updateById(any(InventoryBalance.class)); + } + + @Test + void replayUsesPersistedResultWithoutMutatingInventoryAgain() throws Exception { + var request = stockIn("IN-1", "stock-in-1", 12, 24); + when(balances.lockDimension("100", "WH-1", "ONT8", "SKU-A")).thenReturn(null); + service.stockIn("100", "7", request); + ArgumentCaptor operation = ArgumentCaptor.forClass(InventoryOperation.class); + verify(operations).insert(operation.capture()); + when(operations.findIdempotency("100", "stock-in-1")).thenReturn(operation.getValue()); + + InventoryCommandResponse replay = service.stockIn("100", "7", request); + + assertTrue(replay.replayed()); + verify(balances, times(1)).updateById(any(InventoryBalance.class)); + verify(ledgers, times(1)).insert(any(InventoryLedger.class)); + } + + @Test + void shippingLastPackagesScrapsPalletWithoutReleasingItsVirtualLocation() { + InventoryBalance balance = balance(200L, 0); + balance.setPickedPackages(5L); + balance.setPickedQuantity(5L); + InventoryPosition position = position(201L, 200L, 0); + position.setPickedPackages(5L); + position.setProductQuantity(5L); + when(commands.lockPosition("100", 201L)).thenReturn(position); + when(commands.lockBalance("100", 200L)).thenReturn(balance); + when(commands.palletPackages("100", 300L)).thenReturn(0L); + + var result = service.ship("100", "7", new InventoryCommandRequests.Move( + "100", 201L, 5, 5, "OUT-1", "OUT-1", null, "ship-1", null)); + + assertTrue(result.palletEmpty()); + assertEquals("EMPTY", position.getStatus()); + verify(warehouseGateway).scrapPallet("100", 300L); + verify(ledgers).insert(any(InventoryLedger.class)); + } + + private InventoryCommandRequests.StockIn stockIn(String inbound, String key, long packages, long quantity) { + return new InventoryCommandRequests.StockIn("100", "Customer 100", "WH-1", "WH1", "Warehouse 1", + "DEST-1", "ONT8", "ONT8", "SKU-A", "Widget", 300L, "PLT-1", null, null, null, + packages, quantity, "ORD-1", inbound, "EVT-1", key, null); + } + + private InventoryBalance balance(Long id, long available) { + InventoryBalance value = new InventoryBalance(); + value.setId(id); value.setTenantId("100"); value.setOverseasWarehouseId("WH-1"); + value.setDestinationWarehouseCode("ONT8"); value.setSkuCode("SKU-A"); + value.setAvailablePackages(available); value.setAvailableQuantity(available); + value.setHoldPackages(0L); value.setAllocatedPackages(0L); value.setPickedPackages(0L); + value.setHoldQuantity(0L); value.setAllocatedQuantity(0L); value.setPickedQuantity(0L); + value.setVersion(0L); + return value; + } + + private InventoryPosition position(Long id, Long balanceId, long available) { + InventoryPosition value = new InventoryPosition(); + value.setId(id); value.setTenantId("100"); value.setBalanceId(balanceId); + value.setPalletId(300L); value.setPalletNo("PLT-1"); value.setBatchNo("DEFAULT"); + value.setAvailablePackages(available); value.setHoldPackages(0L); + value.setAllocatedPackages(0L); value.setPickedPackages(0L); value.setReceivingPackages(0L); + value.setProductQuantity(available); value.setVersion(0L); + return value; + } +} diff --git a/lastleg/src/test/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryControllerTest.java b/lastleg/src/test/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryControllerTest.java new file mode 100644 index 0000000..f371450 --- /dev/null +++ b/lastleg/src/test/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryControllerTest.java @@ -0,0 +1,58 @@ +package com.baiyue.lastleg.inventory.query.controller; + +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.inventory.query.dto.InventoryQueryRequests; +import com.baiyue.lastleg.inventory.query.service.InventoryQueryService; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.server.ResponseStatusException; + +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +class InventoryQueryControllerTest { + private final InventoryQueryService service = mock(InventoryQueryService.class); + private final InventoryQueryController controller = controller(); + private final InventoryQueryRequests.BalanceQuery query = new InventoryQueryRequests.BalanceQuery( + 1, 20, null, null, null, null, null, null, null); + + @Test + void merchantCannotOverrideCustomerScope() { + controller.balances(user(100L, "MERCHANT"), "200", query); + verify(service).balances(eq("100"), eq(query)); + } + + @Test + void platformMustSelectCustomer() { + assertThrows(ResponseStatusException.class, + () -> controller.summary(user(0L, "PLATFORM"), null, query)); + } + + @Test + void platformCanQuerySelectedCustomer() { + controller.summary(user(0L, "PLATFORM"), "200", query); + verify(service).dashboard(eq("200"), eq(query)); + } + + @Test + void unauthenticatedPositionDetailIsRejected() { + InventoryQueryRequests.PositionQuery positionQuery = + new InventoryQueryRequests.PositionQuery(null, null, null); + assertThrows(ResponseStatusException.class, + () -> controller.positions(null, null, 1L, positionQuery)); + } + + private static AuthenticatedUser user(long tenantId, String appCode) { + return new AuthenticatedUser(1L, tenantId, tenantId, 1L, "tester", appCode, "WEB", Set.of()); + } + + private InventoryQueryController controller() { + InventoryQueryController controller = new InventoryQueryController(); + ReflectionTestUtils.setField(controller, "service", service); + return controller; + } +} diff --git a/lastleg/src/test/java/com/baiyue/lastleg/inventory/query/service/InventoryQueryServiceTest.java b/lastleg/src/test/java/com/baiyue/lastleg/inventory/query/service/InventoryQueryServiceTest.java new file mode 100644 index 0000000..0e9f1c5 --- /dev/null +++ b/lastleg/src/test/java/com/baiyue/lastleg/inventory/query/service/InventoryQueryServiceTest.java @@ -0,0 +1,97 @@ +package com.baiyue.lastleg.inventory.query.service; + +import com.baiyue.lastleg.inventory.entity.InventoryBalance; +import com.baiyue.lastleg.inventory.query.dto.InventoryDashboardResponse; +import com.baiyue.lastleg.inventory.query.dto.InventoryQueryRequests; +import com.baiyue.lastleg.inventory.query.mapper.InventoryQueryMapper; +import com.baiyue.lastleg.inventory.query.model.InventoryAggregateProjection; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.server.ResponseStatusException; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class InventoryQueryServiceTest { + private final InventoryQueryMapper mapper = mock(InventoryQueryMapper.class); + private final InventoryQueryService service = new InventoryQueryService(); + + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(service, "mapper", mapper); + } + + @Test + void dashboardUsesSameTenantAndFiltersForTotalsAndPalletCount() { + InventoryQueryRequests.BalanceQuery query = balanceQuery("AVAILABLE"); + InventoryAggregateProjection aggregate = new InventoryAggregateProjection(); + aggregate.setDimensionCount(2L); + aggregate.setAvailablePackages(12L); + aggregate.setAvailableQuantity(120L); + when(mapper.aggregate("100", query)).thenReturn(aggregate); + when(mapper.countPallets("100", query)).thenReturn(3L); + + InventoryDashboardResponse result = service.dashboard("100", query); + + assertEquals(2, result.skuDimensionCount()); + assertEquals(3, result.palletCount()); + assertEquals(12, result.totalPackages()); + assertEquals(120, result.totalQuantity()); + } + + @Test + void balancePageIsBoundToRequestedTenant() { + InventoryQueryRequests.BalanceQuery query = balanceQuery(null); + InventoryBalance balance = new InventoryBalance(); + balance.setId(1L); + balance.setTenantId("100"); + when(mapper.countBalances("100", query)).thenReturn(1L); + when(mapper.balances("100", query, 0, 20)).thenReturn(List.of(balance)); + + service.balances("100", query); + + verify(mapper).countBalances("100", query); + verify(mapper).balances("100", query, 0, 20); + } + + @Test + void unknownStatusIsRejectedBeforeDatabaseQuery() { + InventoryQueryRequests.BalanceQuery query = balanceQuery("DAMAGED"); + + assertThrows(ResponseStatusException.class, () -> service.balances("100", query)); + verify(mapper, never()).countBalances(any(), any()); + } + + @Test + void positionDetailDoesNotLeakAnotherTenantBalance() { + InventoryQueryRequests.PositionQuery query = new InventoryQueryRequests.PositionQuery(null, null, null); + when(mapper.countOwnedBalance("100", 99L)).thenReturn(0L); + + assertThrows(ResponseStatusException.class, () -> service.positions("100", 99L, query)); + verify(mapper, never()).positions(eq("100"), eq(99L), any()); + } + + @Test + void reversedLedgerDateRangeIsRejected() { + InventoryQueryRequests.LedgerQuery query = new InventoryQueryRequests.LedgerQuery( + 1, 20, null, null, null, null, null, null, null, null, null, + java.time.LocalDateTime.of(2026, 8, 18, 0, 0), + java.time.LocalDateTime.of(2026, 8, 17, 0, 0)); + + assertThrows(ResponseStatusException.class, () -> service.ledgers("100", query)); + verify(mapper, never()).countLedgers(any(), any()); + } + + private static InventoryQueryRequests.BalanceQuery balanceQuery(String status) { + return new InventoryQueryRequests.BalanceQuery(1, 20, null, null, null, null, null, null, status); + } +} diff --git a/scripts/db/incremental.sql b/scripts/db/incremental.sql index e2c917d..c79d55f 100644 --- a/scripts/db/incremental.sql +++ b/scripts/db/incremental.sql @@ -101,7 +101,7 @@ CREATE TABLE IF NOT EXISTS wh_pallet ( tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID', location_id BIGINT UNSIGNED NULL COMMENT '同仓库内绑定的库位ID,可为空', - pallet_code VARCHAR(64) NOT NULL COMMENT '仓库内唯一托盘编码', + pallet_code VARCHAR(128) NOT NULL COMMENT '仓库内唯一托盘编码', pallet_type VARCHAR(32) NOT NULL COMMENT '托盘类型', capacity_cbm DECIMAL(18,4) NOT NULL DEFAULT 2.0000 COMMENT '托盘容量(立方米)', unit_price DECIMAL(18,2) NOT NULL DEFAULT 0.00 COMMENT '托盘单价', @@ -165,7 +165,7 @@ ALTER TABLE wh_pallet MODIFY COLUMN tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', MODIFY COLUMN warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID', MODIFY COLUMN location_id BIGINT UNSIGNED NULL COMMENT '同仓库内绑定的库位ID,可为空', - MODIFY COLUMN pallet_code VARCHAR(64) NOT NULL COMMENT '仓库内唯一托盘编码', + MODIFY COLUMN pallet_code VARCHAR(128) NOT NULL COMMENT '仓库内唯一托盘编码', MODIFY COLUMN pallet_type VARCHAR(32) NOT NULL COMMENT '托盘类型', MODIFY COLUMN capacity_cbm DECIMAL(18,4) NOT NULL DEFAULT 2.0000 COMMENT '托盘容量(立方米)', MODIFY COLUMN unit_price DECIMAL(18,2) NOT NULL DEFAULT 0.00 COMMENT '托盘单价', @@ -611,3 +611,259 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='wh_service_capability' AND index_name='idx_capability_query'), 'SELECT 1', 'CREATE INDEX idx_capability_query ON wh_service_capability(tenant_id,warehouse_id,enabled,deleted)')); PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; + +-- ============================================================================ +-- 2026-08-17 / 007 +-- Last-leg batch FBA appointments +-- ============================================================================ + +USE baiyue_lastleg; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='fba_warehouse_code'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_line ADD COLUMN fba_warehouse_code VARCHAR(128) NULL AFTER inbound_code')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +UPDATE ll_inbound_plan_line l JOIN ll_order o ON o.id=l.order_id +SET l.fba_warehouse_code=o.fba_warehouse_code +WHERE l.fba_warehouse_code IS NULL AND o.fba_warehouse_code IS NOT NULL; + +CREATE TABLE IF NOT EXISTS ll_inbound_fba_appointment ( + id BIGINT NOT NULL COMMENT 'Snowflake ID', + tenant_id VARCHAR(128) NOT NULL, + appointment_no VARCHAR(64) NOT NULL, + appointment_at DATETIME(3) NOT NULL, + appointment_status VARCHAR(32) NOT NULL DEFAULT 'PENDING', + appointment_remark VARCHAR(1000) NULL, + fba_warehouse_code VARCHAR(128) NOT NULL, + selected_count INT NOT NULL DEFAULT 0, + created_by VARCHAR(128) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_ll_fba_appointment_no (tenant_id, appointment_no), + KEY idx_ll_fba_appointment_list (tenant_id, appointment_status, fba_warehouse_code, created_at) +) ENGINE=InnoDB COMMENT='Batch FBA appointment'; + +CREATE TABLE IF NOT EXISTS ll_inbound_fba_appointment_item ( + id BIGINT NOT NULL COMMENT 'Snowflake ID', + appointment_id BIGINT NOT NULL, + plan_id BIGINT NOT NULL, + line_id BIGINT NOT NULL, + order_id BIGINT NOT NULL, + inbound_code VARCHAR(128) NOT NULL, + fba_warehouse_code VARCHAR(128) NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_ll_fba_appointment_item_line (appointment_id, line_id), + KEY idx_ll_fba_appointment_item_plan (plan_id), + KEY idx_ll_fba_appointment_item_order (order_id) +) ENGINE=InnoDB COMMENT='FBA appointment line snapshot'; + +-- ============================================================================ +-- 2026-08-17 / 008 +-- Last-leg SKU/package inventory balances, positions and immutable ledger +-- ============================================================================ + +USE baiyue_lastleg; + +CREATE TABLE IF NOT EXISTS ll_inventory_balance ( + id BIGINT NOT NULL COMMENT 'Snowflake ID', tenant_id VARCHAR(128) NOT NULL, customer_name VARCHAR(255) NULL, + overseas_warehouse_id VARCHAR(128) NOT NULL, overseas_warehouse_code VARCHAR(128) NULL, + overseas_warehouse_name VARCHAR(255) NULL, destination_warehouse_id VARCHAR(128) NULL, + destination_warehouse_code VARCHAR(128) NOT NULL DEFAULT '', destination_warehouse_name VARCHAR(255) NULL, + sku_code VARCHAR(128) NOT NULL, sku_name_zh VARCHAR(255) NULL, + available_packages BIGINT NOT NULL DEFAULT 0, hold_packages BIGINT NOT NULL DEFAULT 0, + allocated_packages BIGINT NOT NULL DEFAULT 0, picked_packages BIGINT NOT NULL DEFAULT 0, + in_transit_packages BIGINT NOT NULL DEFAULT 0, receiving_packages BIGINT NOT NULL DEFAULT 0, + procurement_packages BIGINT NOT NULL DEFAULT 0, + available_quantity BIGINT NOT NULL DEFAULT 0, hold_quantity BIGINT NOT NULL DEFAULT 0, + allocated_quantity BIGINT NOT NULL DEFAULT 0, picked_quantity BIGINT NOT NULL DEFAULT 0, + in_transit_quantity BIGINT NOT NULL DEFAULT 0, receiving_quantity BIGINT NOT NULL DEFAULT 0, + procurement_quantity BIGINT NOT NULL DEFAULT 0, version BIGINT NOT NULL DEFAULT 0, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_ll_inventory_dimension (tenant_id,overseas_warehouse_id,destination_warehouse_code,sku_code), + KEY idx_ll_inventory_list (tenant_id,overseas_warehouse_id,updated_at), + KEY idx_ll_inventory_sku (tenant_id,sku_code) +) ENGINE=InnoDB COMMENT='Real-time SKU package inventory balance'; + +CREATE TABLE IF NOT EXISTS ll_inventory_position ( + id BIGINT NOT NULL COMMENT 'Snowflake ID', tenant_id VARCHAR(128) NOT NULL, balance_id BIGINT NOT NULL, + overseas_warehouse_id VARCHAR(128) NOT NULL, destination_warehouse_code VARCHAR(128) NOT NULL DEFAULT '', + sku_code VARCHAR(128) NOT NULL, pallet_id BIGINT NOT NULL, pallet_no VARCHAR(64) NOT NULL, + batch_no VARCHAR(128) NOT NULL DEFAULT '', warehouse_location_id BIGINT NULL, + warehouse_location_code VARCHAR(128) NOT NULL, + available_packages BIGINT NOT NULL DEFAULT 0, hold_packages BIGINT NOT NULL DEFAULT 0, + allocated_packages BIGINT NOT NULL DEFAULT 0, picked_packages BIGINT NOT NULL DEFAULT 0, + receiving_packages BIGINT NOT NULL DEFAULT 0, product_quantity BIGINT NOT NULL DEFAULT 0, + status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE', version BIGINT NOT NULL DEFAULT 0, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), UNIQUE KEY uk_ll_inventory_position (balance_id,pallet_id,batch_no), + KEY idx_ll_inventory_position_pallet (tenant_id,pallet_id), + KEY idx_ll_inventory_position_location (tenant_id,overseas_warehouse_id,warehouse_location_code) +) ENGINE=InnoDB COMMENT='SKU package stock by pallet, default location and batch'; + +CREATE TABLE IF NOT EXISTS ll_inventory_ledger ( + id BIGINT NOT NULL COMMENT 'Snowflake ID', tenant_id VARCHAR(128) NOT NULL, + balance_id BIGINT NOT NULL, position_id BIGINT NULL, overseas_warehouse_id VARCHAR(128) NOT NULL, + destination_warehouse_code VARCHAR(128) NOT NULL DEFAULT '', sku_code VARCHAR(128) NOT NULL, + pallet_id BIGINT NULL, pallet_no VARCHAR(64) NULL, batch_no VARCHAR(128) NULL, + warehouse_location_code VARCHAR(128) NULL, business_type VARCHAR(32) NOT NULL, + business_order_id BIGINT NULL, business_order_no VARCHAR(128) NULL, inbound_code VARCHAR(128) NULL, + inbound_order_no VARCHAR(128) NULL, outbound_order_no VARCHAR(128) NULL, + source_event_id VARCHAR(128) NULL, idempotency_key VARCHAR(255) NOT NULL, + status_from VARCHAR(32) NULL, status_to VARCHAR(32) NULL, + before_packages BIGINT NOT NULL, delta_packages BIGINT NOT NULL, after_packages BIGINT NOT NULL, + before_quantity BIGINT NOT NULL, delta_quantity BIGINT NOT NULL, after_quantity BIGINT NOT NULL, + operator_id VARCHAR(128) NULL, remark VARCHAR(1000) NULL, + occurred_at DATETIME(3) NOT NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), UNIQUE KEY uk_ll_inventory_ledger_idempotency (tenant_id,idempotency_key), + KEY idx_ll_inventory_ledger_query (tenant_id,overseas_warehouse_id,occurred_at), + KEY idx_ll_inventory_ledger_sku (tenant_id,sku_code,occurred_at), + KEY idx_ll_inventory_ledger_business (tenant_id,business_type,business_order_no) +) ENGINE=InnoDB COMMENT='Immutable inventory package and quantity ledger'; + +CREATE TABLE IF NOT EXISTS ll_inventory_operation ( + id BIGINT NOT NULL COMMENT 'Snowflake ID', tenant_id VARCHAR(128) NOT NULL, + idempotency_key VARCHAR(255) NOT NULL, operation_type VARCHAR(32) NOT NULL, + request_hash VARCHAR(128) NOT NULL, status VARCHAR(32) NOT NULL DEFAULT 'PROCESSING', + result_json LONGTEXT NULL, last_error VARCHAR(2000) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), UNIQUE KEY uk_ll_inventory_operation_key (tenant_id,idempotency_key), + KEY idx_ll_inventory_operation_status (tenant_id,status,updated_at) +) ENGINE=InnoDB COMMENT='Inventory command idempotency record'; + +-- ============================================================================ +-- 2026-08-17 / 009 +-- PDA SKU/pallet receipt snapshots for stock-in closure +-- ============================================================================ + +USE baiyue_lastleg; + +CREATE TABLE IF NOT EXISTS ll_inbound_stock_receipt ( + id BIGINT NOT NULL COMMENT 'Snowflake ID', tenant_id VARCHAR(128) NOT NULL, + plan_id BIGINT NOT NULL, task_id BIGINT NOT NULL, allocation_id BIGINT NOT NULL, + line_id BIGINT NOT NULL, order_id BIGINT NOT NULL, inbound_code VARCHAR(128) NOT NULL, + sku_code VARCHAR(128) NOT NULL, sku_name_zh VARCHAR(255) NULL, source_detail_ids VARCHAR(2000) NULL, + warehouse_pallet_id BIGINT NOT NULL, pallet_no VARCHAR(64) NOT NULL, + warehouse_location_id BIGINT NULL, warehouse_location_code VARCHAR(128) NULL, + batch_no VARCHAR(128) NOT NULL, received_packages BIGINT NOT NULL, received_quantity BIGINT NOT NULL, + inventory_balance_id BIGINT NOT NULL, inventory_position_id BIGINT NOT NULL, + inventory_operation_key VARCHAR(255) NOT NULL, received_at DATETIME(3) NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), UNIQUE KEY uk_ll_stock_receipt_task_allocation_sku (tenant_id,task_id,allocation_id,sku_code), + KEY idx_ll_stock_receipt_plan (tenant_id,plan_id,line_id), KEY idx_ll_stock_receipt_inventory (inventory_balance_id,inventory_position_id) +) ENGINE=InnoDB COMMENT='Immutable SKU-pallet PDA stock-in receipt snapshot'; + +-- 2026-08-17 warehouse virtual location and generated pallet code support. +USE baiyue_lastlag_warehouse; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='wh_pallet' AND column_name='pallet_code'), + 'ALTER TABLE wh_pallet MODIFY COLUMN pallet_code VARCHAR(128) NOT NULL COMMENT ''Warehouse pallet code''', 'SELECT 1')); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +INSERT INTO wh_location (id, tenant_id, warehouse_id, zone_code, location_code, location_type, + max_load_kg, status, remark) +SELECT UUID_SHORT(), w.tenant_id, w.id, 'DEFAULT', CONCAT('LOC-', w.warehouse_code, '-DEFAULT'), + 'VIRTUAL', 999999999.000, 'ACTIVE', 'System default virtual location' +FROM wh_warehouse w +WHERE NOT EXISTS ( + SELECT 1 FROM wh_location l + WHERE l.tenant_id = w.tenant_id AND l.warehouse_id = w.id + AND l.location_code = CONCAT('LOC-', w.warehouse_code, '-DEFAULT') AND l.deleted = 0 +); + +-- ============================================================================ +-- 2026-08-17 / 010 +-- Last-leg pre-dispatch orders and pallet locks +-- ============================================================================ + +USE baiyue_lastleg; + +CREATE TABLE IF NOT EXISTS ll_dispatch_order ( + id BIGINT NOT NULL COMMENT 'Snowflake ID', tenant_id VARCHAR(128) NOT NULL, + dispatch_no VARCHAR(64) NOT NULL, vehicle_plate VARCHAR(64) NULL, driver_name VARCHAR(128) NULL, + driver_phone VARCHAR(64) NULL, carrier_name VARCHAR(255) NULL, route VARCHAR(255) NULL, + depart_at DATETIME(3) NULL, fba_appointment_no VARCHAR(64) NULL, + fba_appointment_status VARCHAR(32) NOT NULL DEFAULT 'PENDING', + vehicle_appointment_status VARCHAR(32) NOT NULL DEFAULT 'PENDING', + estimated_arrival_at DATETIME(3) NULL, status VARCHAR(32) NOT NULL DEFAULT 'PENDING_DEPARTURE', + item_count INT NOT NULL DEFAULT 0, pallet_count INT NOT NULL DEFAULT 0, package_count INT NOT NULL DEFAULT 0, + created_by VARCHAR(128) NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), UNIQUE KEY uk_ll_dispatch_order_no (tenant_id,dispatch_no), + KEY idx_ll_dispatch_order_list (tenant_id,status,depart_at), KEY idx_ll_dispatch_order_vehicle (tenant_id,vehicle_plate) +) ENGINE=InnoDB COMMENT='Last-leg pre-dispatch order'; + +CREATE TABLE IF NOT EXISTS ll_dispatch_item ( + id BIGINT NOT NULL COMMENT 'Snowflake ID', dispatch_order_id BIGINT NOT NULL, tenant_id VARCHAR(128) NOT NULL, + plan_id BIGINT NULL, plan_pallet_id BIGINT NULL, warehouse_pallet_id BIGINT NULL, + order_id BIGINT NULL, order_no VARCHAR(64) NULL, order_refs VARCHAR(2000) NULL, inbound_code VARCHAR(128) NULL, pallet_no VARCHAR(128) NOT NULL, + destination_warehouse_id VARCHAR(128) NULL, destination_warehouse_code VARCHAR(128) NULL, + destination_warehouse_name VARCHAR(255) NULL, fba_warehouse_code VARCHAR(128) NULL, + package_count INT NOT NULL DEFAULT 0, volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0, + source_stage VARCHAR(32) NOT NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), UNIQUE KEY uk_ll_dispatch_item_source (dispatch_order_id,plan_pallet_id,warehouse_pallet_id), + KEY idx_ll_dispatch_item_order (tenant_id,order_id), KEY idx_ll_dispatch_item_inbound (tenant_id,inbound_code), + KEY idx_ll_dispatch_item_pallet (tenant_id,plan_pallet_id,warehouse_pallet_id) +) ENGINE=InnoDB COMMENT='Frozen pallet and cargo snapshot for a dispatch order'; + +CREATE TABLE IF NOT EXISTS ll_dispatch_pallet_lock ( + id BIGINT NOT NULL COMMENT 'Snowflake ID', tenant_id VARCHAR(128) NOT NULL, + source_pallet_key VARCHAR(160) NOT NULL, active_key VARCHAR(160) NULL, + plan_id BIGINT NULL, plan_pallet_id BIGINT NULL, warehouse_pallet_id BIGINT NULL, + dispatch_order_id BIGINT NOT NULL, status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE', + locked_at DATETIME(3) NOT NULL, released_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), UNIQUE KEY uk_ll_dispatch_active_pallet (tenant_id,active_key), + KEY idx_ll_dispatch_pallet_lock_order (tenant_id,dispatch_order_id,status), + KEY idx_ll_dispatch_pallet_lock_source (tenant_id,source_pallet_key,status) +) ENGINE=InnoDB COMMENT='Active dispatch pallet exclusion lock'; + +-- ============================================================================ +-- 2026-08-17 / 011 +-- Dispatch PDA task and immutable pallet pick operations +-- ============================================================================ + +USE baiyue_lastleg; + +CREATE TABLE IF NOT EXISTS ll_dispatch_pda_task ( + id BIGINT NOT NULL, tenant_id VARCHAR(128) NOT NULL, dispatch_order_id BIGINT NOT NULL, + task_no VARCHAR(64) NOT NULL, status VARCHAR(32) NOT NULL, operator_id VARCHAR(128) NULL, + operator_name VARCHAR(128) NULL, pushed_at DATETIME(3) NOT NULL, completed_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), UNIQUE KEY uk_ll_dispatch_pda_task_dispatch (tenant_id,dispatch_order_id), + UNIQUE KEY uk_ll_dispatch_pda_task_no (tenant_id,task_no) +) ENGINE=InnoDB COMMENT='PDA task for a pre-arranged dispatch'; + +CREATE TABLE IF NOT EXISTS ll_dispatch_pda_pick ( + id BIGINT NOT NULL, tenant_id VARCHAR(128) NOT NULL, task_id BIGINT NOT NULL, + dispatch_item_id BIGINT NOT NULL, inventory_position_id BIGINT NOT NULL, + packages BIGINT NOT NULL, product_quantity BIGINT NOT NULL DEFAULT 0, idempotency_key VARCHAR(255) NOT NULL, + picked_at DATETIME(3) NOT NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), UNIQUE KEY uk_ll_dispatch_pda_pick_key (tenant_id,idempotency_key), + KEY idx_ll_dispatch_pda_pick_item (tenant_id,task_id,dispatch_item_id) +) ENGINE=InnoDB COMMENT='Immutable dispatch PDA pallet package picks'; + +CREATE TABLE IF NOT EXISTS ll_dispatch_pda_operation ( + id BIGINT NOT NULL, tenant_id VARCHAR(128) NOT NULL, task_id BIGINT NULL, + operation_type VARCHAR(32) NOT NULL, idempotency_key VARCHAR(255) NOT NULL, request_hash CHAR(64) NOT NULL, + result_json LONGTEXT NULL, status VARCHAR(32) NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), UNIQUE KEY uk_ll_dispatch_pda_operation_key (tenant_id,idempotency_key) +) ENGINE=InnoDB COMMENT='Idempotent dispatch PDA operations'; + +-- ============================================================================ +-- 2026-08-17 / 012 +-- Preserve all tail-leg orders included in one merged dispatch pallet +-- ============================================================================ + +USE baiyue_lastleg; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='order_refs'), 'SELECT 1', 'ALTER TABLE ll_dispatch_item ADD COLUMN order_refs VARCHAR(2000) NULL AFTER order_no')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; diff --git a/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalWarehouseResourceLockController.java b/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalWarehouseResourceLockController.java index 5f9d195..f685d7a 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalWarehouseResourceLockController.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalWarehouseResourceLockController.java @@ -3,8 +3,11 @@ package com.baiyue.warehouse.controller; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.warehouse.dto.WarehouseResourceLockRequests; import com.baiyue.warehouse.dto.WarehouseResourceLockResponse; +import com.baiyue.warehouse.dto.WarehousePalletScrapResponse; +import com.baiyue.warehouse.entity.WarehouseLocationEntity; import com.baiyue.warehouse.security.InternalServiceTokenGuard; import com.baiyue.warehouse.service.WarehouseResourceLockService; +import com.baiyue.warehouse.service.WarehouseResourceService; import jakarta.validation.Valid; import jakarta.annotation.Resource; import org.springframework.web.bind.annotation.PathVariable; @@ -18,6 +21,7 @@ import org.springframework.web.bind.annotation.RestController; @RequestMapping("/internal/v1/warehouse-resource-locks") public class InternalWarehouseResourceLockController { @Resource private WarehouseResourceLockService service; + @Resource private WarehouseResourceService resourceService; @Resource private InternalServiceTokenGuard tokenGuard; @PostMapping @@ -45,4 +49,22 @@ public class InternalWarehouseResourceLockController { tokenGuard.verify(token); return ApiResponse.success(service.scrap(request.tenantId(), lockId)); } + + @PostMapping("/pallets/{palletId}/scrap") + public ApiResponse scrapPallet( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @PathVariable long palletId, + @Valid @RequestBody WarehouseResourceLockRequests.CloseRequest request) { + tokenGuard.verify(token); + return ApiResponse.success(service.scrapByPallet(request.tenantId(), palletId)); + } + + @PostMapping("/default-locations/ensure") + public ApiResponse ensureDefaultLocation( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @Valid @RequestBody WarehouseResourceLockRequests.DefaultLocationRequest request) { + tokenGuard.verify(token); + return ApiResponse.success(resourceService.ensureDefaultVirtualLocation( + request.tenantId(), request.warehouseId())); + } } diff --git a/warehouse/src/main/java/com/baiyue/warehouse/dto/PalletRequests.java b/warehouse/src/main/java/com/baiyue/warehouse/dto/PalletRequests.java index 8547cd8..f5238b4 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/dto/PalletRequests.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/dto/PalletRequests.java @@ -16,7 +16,7 @@ public final class PalletRequests { } public record SaveRequest( - @NotBlank @Size(max = 64) String palletCode, + @Size(max = 128) String palletCode, @NotBlank @Size(max = 32) String palletType, @DecimalMin(value = "0.0000", inclusive = false) BigDecimal capacityCbm, @NotNull @DecimalMin("0.00") BigDecimal unitPrice, diff --git a/warehouse/src/main/java/com/baiyue/warehouse/dto/WarehousePalletScrapResponse.java b/warehouse/src/main/java/com/baiyue/warehouse/dto/WarehousePalletScrapResponse.java new file mode 100644 index 0000000..386fb00 --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/dto/WarehousePalletScrapResponse.java @@ -0,0 +1,4 @@ +package com.baiyue.warehouse.dto; + +public record WarehousePalletScrapResponse(long palletId, long warehouseId, Long locationId, String status) { +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/dto/WarehouseResourceLockRequests.java b/warehouse/src/main/java/com/baiyue/warehouse/dto/WarehouseResourceLockRequests.java index ea98ce9..46b84eb 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/dto/WarehouseResourceLockRequests.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/dto/WarehouseResourceLockRequests.java @@ -17,4 +17,9 @@ public final class WarehouseResourceLockRequests { ) {} public record CloseRequest(@Positive long tenantId) {} + + public record DefaultLocationRequest( + @Positive long tenantId, + @Positive long warehouseId + ) {} } diff --git a/warehouse/src/main/java/com/baiyue/warehouse/mapper/WarehouseLocationMapper.java b/warehouse/src/main/java/com/baiyue/warehouse/mapper/WarehouseLocationMapper.java index 4d5a545..f69c679 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/mapper/WarehouseLocationMapper.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/mapper/WarehouseLocationMapper.java @@ -2,6 +2,21 @@ package com.baiyue.warehouse.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.warehouse.entity.WarehouseLocationEntity; +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Param; public interface WarehouseLocationMapper extends BaseMapper { + /** + * The unique location-code index makes this safe for concurrent first use of an older warehouse. + */ + @Insert(""" + INSERT IGNORE INTO wh_location + (id, tenant_id, warehouse_id, zone_code, location_code, location_type, + max_load_kg, status, remark, deleted, version) + VALUES + (#{location.id}, #{location.tenantId}, #{location.warehouseId}, #{location.zoneCode}, + #{location.locationCode}, #{location.locationType}, #{location.maxLoadKg}, + #{location.status}, #{location.remark}, 0, 0) + """) + int insertDefaultVirtualLocationIgnore(@Param("location") WarehouseLocationEntity location); } diff --git a/warehouse/src/main/java/com/baiyue/warehouse/mapper/WarehouseResourceLockMapper.java b/warehouse/src/main/java/com/baiyue/warehouse/mapper/WarehouseResourceLockMapper.java index 70c9387..aa2d346 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/mapper/WarehouseResourceLockMapper.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/mapper/WarehouseResourceLockMapper.java @@ -15,6 +15,11 @@ public interface WarehouseResourceLockMapper extends BaseMapper() + .eq(WarehouseLocationEntity::getTenantId, tenantId) + .eq(WarehouseLocationEntity::getWarehouseId, warehouseId) + .eq(WarehouseLocationEntity::getLocationCode, locationCode)) > 0; + } + public void saveWarehouse(WarehouseEntity warehouse) { warehouses.updateById(warehouse); } diff --git a/warehouse/src/main/java/com/baiyue/warehouse/repository/WarehouseResourceLockRepository.java b/warehouse/src/main/java/com/baiyue/warehouse/repository/WarehouseResourceLockRepository.java index d989081..65e1a27 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/repository/WarehouseResourceLockRepository.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/repository/WarehouseResourceLockRepository.java @@ -26,6 +26,16 @@ public class WarehouseResourceLockRepository { .eq(WarehouseResourceLockEntity::getId, lockId)); } + public WarehouseResourceLockEntity findActiveByPallet(long tenantId, long palletId) { + return locks.findActiveByPallet(tenantId, palletId); + } + + public WarehousePalletEntity findPallet(long tenantId, long palletId) { + return pallets.selectOne(new LambdaQueryWrapper() + .eq(WarehousePalletEntity::getTenantId, tenantId) + .eq(WarehousePalletEntity::getId, palletId)); + } + public WarehousePalletEntity findPallet(long tenantId, long warehouseId, long palletId) { return pallets.selectOne(new LambdaQueryWrapper() .eq(WarehousePalletEntity::getTenantId, tenantId) @@ -54,10 +64,24 @@ public class WarehouseResourceLockRepository { return locks.releasePallet(lock.getTenantId(), lock.getWarehouseId(), lock.getPalletId(), lock.getLocationId()); } + public int releasePalletAtVirtualLocation(WarehouseResourceLockEntity lock) { + return locks.releasePalletAtVirtualLocation(lock.getTenantId(), lock.getWarehouseId(), + lock.getPalletId(), lock.getLocationId()); + } + public int scrapPallet(WarehouseResourceLockEntity lock) { return locks.scrapPallet(lock.getTenantId(), lock.getWarehouseId(), lock.getPalletId(), lock.getLocationId()); } + public int scrapPalletWithoutLock(long tenantId, long palletId) { + return locks.scrapPalletWithoutLock(tenantId, palletId); + } + + public int scrapPalletAtVirtualLocation(WarehouseResourceLockEntity lock) { + return locks.scrapPalletAtVirtualLocation(lock.getTenantId(), lock.getWarehouseId(), + lock.getPalletId(), lock.getLocationId()); + } + public int releaseLocation(WarehouseResourceLockEntity lock) { return locks.releaseLocation(lock.getTenantId(), lock.getWarehouseId(), lock.getLocationId()); } diff --git a/warehouse/src/main/java/com/baiyue/warehouse/repository/WarehouseResourceRepository.java b/warehouse/src/main/java/com/baiyue/warehouse/repository/WarehouseResourceRepository.java index 8a8bfd4..cbdb40d 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/repository/WarehouseResourceRepository.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/repository/WarehouseResourceRepository.java @@ -27,10 +27,37 @@ public class WarehouseResourceRepository { .eq(WarehouseEntity::getId, warehouseId)) > 0; } + public WarehouseEntity findWarehouse(long tenantId, long warehouseId) { + return warehouses.selectOne(new LambdaQueryWrapper() + .eq(WarehouseEntity::getTenantId, tenantId) + .eq(WarehouseEntity::getId, warehouseId)); + } + + public WarehouseLocationEntity findDefaultVirtualLocation(long tenantId, long warehouseId) { + return locations.selectOne(new LambdaQueryWrapper() + .eq(WarehouseLocationEntity::getTenantId, tenantId) + .eq(WarehouseLocationEntity::getWarehouseId, warehouseId) + .eq(WarehouseLocationEntity::getLocationType, "VIRTUAL") + .eq(WarehouseLocationEntity::getStatus, "ACTIVE") + .orderByAsc(WarehouseLocationEntity::getCreatedAt) + .last("LIMIT 1")); + } + + public WarehouseLocationEntity findLocationByCode(long tenantId, long warehouseId, String locationCode) { + return locations.selectOne(new LambdaQueryWrapper() + .eq(WarehouseLocationEntity::getTenantId, tenantId) + .eq(WarehouseLocationEntity::getWarehouseId, warehouseId) + .eq(WarehouseLocationEntity::getLocationCode, locationCode)); + } + public void insertLocation(WarehouseLocationEntity entity) { locations.insert(entity); } + public int insertDefaultVirtualLocationIgnore(WarehouseLocationEntity entity) { + return locations.insertDefaultVirtualLocationIgnore(entity); + } + public void updateLocation(WarehouseLocationEntity entity) { locations.updateById(entity); } diff --git a/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseApplicationService.java b/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseApplicationService.java index 4ca54de..4cb1649 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseApplicationService.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseApplicationService.java @@ -10,11 +10,13 @@ import com.baiyue.warehouse.dto.WarehouseRequests; import com.baiyue.warehouse.entity.ServiceCapabilityEntity; import com.baiyue.warehouse.entity.WarehouseAddressEntity; import com.baiyue.warehouse.entity.WarehouseEntity; +import com.baiyue.warehouse.entity.WarehouseLocationEntity; import com.baiyue.warehouse.repository.WarehouseRepository; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.List; +import java.math.BigDecimal; /** * 仓库应用服务,负责仓库主数据、地址和服务能力的租户级用例编排、事务控制与业务校验。 @@ -37,10 +39,28 @@ public class WarehouseApplicationService { ensureCodeUnique(request.tenantId(), request.warehouseCode(), null); WarehouseEntity warehouse = toEntity(request); repository.insertWarehouse(warehouse); + ensureDefaultVirtualLocation(warehouse); insertChildren(warehouse.getTenantId(), warehouse.getId(), request.address(), request.capabilities()); return warehouse.getId(); } + private void ensureDefaultVirtualLocation(WarehouseEntity warehouse) { + String locationCode = "LOC-" + warehouse.getWarehouseCode() + "-DEFAULT"; + if (repository.existsLocationCode(warehouse.getTenantId(), warehouse.getId(), locationCode)) { + return; + } + WarehouseLocationEntity location = new WarehouseLocationEntity(); + location.setTenantId(warehouse.getTenantId()); + location.setWarehouseId(warehouse.getId()); + location.setZoneCode("DEFAULT"); + location.setLocationCode(locationCode); + location.setLocationType("VIRTUAL"); + location.setMaxLoadKg(new BigDecimal("999999999.000")); + location.setStatus("ACTIVE"); + location.setRemark("System default virtual location"); + repository.insertLocation(location); + } + @Transactional @BusinessAudit( event = "WAREHOUSE_UPDATE", diff --git a/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseResourceLockService.java b/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseResourceLockService.java index 3175674..a12ffd5 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseResourceLockService.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseResourceLockService.java @@ -4,6 +4,7 @@ import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.warehouse.dto.WarehouseResourceLockRequests; import com.baiyue.warehouse.dto.WarehouseResourceLockResponse; +import com.baiyue.warehouse.dto.WarehousePalletScrapResponse; import com.baiyue.warehouse.entity.WarehouseLocationEntity; import com.baiyue.warehouse.entity.WarehousePalletEntity; import com.baiyue.warehouse.entity.WarehouseResourceLockEntity; @@ -33,7 +34,9 @@ public class WarehouseResourceLockService { if (concurrent != null) return idempotentResult(concurrent, request); throw new BizException(ResultCode.NOT_FOUND, "Pallet or location does not belong to the tenant warehouse"); } - if (!"IDLE".equals(pallet.getStatus()) || !"IDLE".equals(location.getStatus())) { + boolean virtualLocation = isVirtual(location); + boolean locationAvailable = virtualLocation ? "ACTIVE".equals(location.getStatus()) : "IDLE".equals(location.getStatus()); + if (!"IDLE".equals(pallet.getStatus()) || !locationAvailable) { WarehouseResourceLockEntity concurrent = repository.findByReservationKey(request.tenantId(), request.reservationKey()); if (concurrent != null) return idempotentResult(concurrent, request); @@ -42,7 +45,7 @@ public class WarehouseResourceLockService { if (repository.occupyPallet(request.tenantId(), request.warehouseId(), request.palletId(), request.locationId()) != 1) { return concurrentIdempotentOrConflict(request); } - if (repository.occupyLocation(request.tenantId(), request.warehouseId(), request.locationId()) != 1) { + if (!virtualLocation && repository.occupyLocation(request.tenantId(), request.warehouseId(), request.locationId()) != 1) { throw new BizException(ResultCode.CONFLICT, "Location is no longer IDLE"); } @@ -65,7 +68,9 @@ public class WarehouseResourceLockService { WarehouseResourceLockEntity lock = requireLock(tenantId, lockId); if ("RELEASED".equals(lock.getStatus())) return WarehouseResourceLockResponse.from(lock); requireActive(lock); - if (repository.releasePallet(lock) != 1 || repository.releaseLocation(lock) != 1 + boolean virtualLocation = isVirtualLockLocation(lock); + int palletResult = virtualLocation ? repository.releasePalletAtVirtualLocation(lock) : repository.releasePallet(lock); + if (palletResult != 1 || (!virtualLocation && repository.releaseLocation(lock) != 1) || repository.close(lock, "RELEASED") != 1) { throw new BizException(ResultCode.CONFLICT, "Resource lock state changed concurrently"); } @@ -79,7 +84,9 @@ public class WarehouseResourceLockService { WarehouseResourceLockEntity lock = requireLock(tenantId, lockId); if ("SCRAPPED".equals(lock.getStatus())) return WarehouseResourceLockResponse.from(lock); requireActive(lock); - if (repository.scrapPallet(lock) != 1 || repository.releaseLocation(lock) != 1 + boolean virtualLocation = isVirtualLockLocation(lock); + int palletResult = virtualLocation ? repository.scrapPalletAtVirtualLocation(lock) : repository.scrapPallet(lock); + if (palletResult != 1 || (!virtualLocation && repository.releaseLocation(lock) != 1) || repository.close(lock, "SCRAPPED") != 1) { throw new BizException(ResultCode.CONFLICT, "Resource lock state changed concurrently"); } @@ -88,6 +95,25 @@ public class WarehouseResourceLockService { return WarehouseResourceLockResponse.from(lock); } + @Transactional + public WarehousePalletScrapResponse scrapByPallet(long tenantId, long palletId) { + WarehousePalletEntity pallet = repository.findPallet(tenantId, palletId); + if (pallet == null) throw new BizException(ResultCode.NOT_FOUND, "Pallet not found"); + if ("SCRAPPED".equals(pallet.getStatus())) { + return new WarehousePalletScrapResponse(palletId, pallet.getWarehouseId(), pallet.getLocationId(), "SCRAPPED"); + } + if (!"OCCUPIED".equals(pallet.getStatus())) { + throw new BizException(ResultCode.CONFLICT, "Only an OCCUPIED pallet can be scrapped after shipment"); + } + WarehouseResourceLockEntity lock = repository.findActiveByPallet(tenantId, palletId); + if (lock != null) { + scrap(tenantId, lock.getId()); + } else if (repository.scrapPalletWithoutLock(tenantId, palletId) != 1) { + throw new BizException(ResultCode.CONFLICT, "Pallet state changed concurrently"); + } + return new WarehousePalletScrapResponse(palletId, pallet.getWarehouseId(), pallet.getLocationId(), "SCRAPPED"); + } + private WarehouseResourceLockResponse concurrentIdempotentOrConflict( WarehouseResourceLockRequests.ReserveRequest request) { WarehouseResourceLockEntity existing = repository.findByReservationKey(request.tenantId(), request.reservationKey()); @@ -118,4 +144,15 @@ public class WarehouseResourceLockService { throw new BizException(ResultCode.CONFLICT, "Resource lock is not ACTIVE"); } } + + + private boolean isVirtualLockLocation(WarehouseResourceLockEntity lock) { + WarehouseLocationEntity location = repository.findLocation(lock.getTenantId(), lock.getWarehouseId(), lock.getLocationId()); + if (location == null) throw new BizException(ResultCode.NOT_FOUND, "Resource lock location not found"); + return isVirtual(location); + } + + private static boolean isVirtual(WarehouseLocationEntity location) { + return "VIRTUAL".equals(location.getLocationType()); + } } diff --git a/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseResourceService.java b/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseResourceService.java index a4329f2..c75c15f 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseResourceService.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseResourceService.java @@ -7,12 +7,17 @@ import com.baiyue.warehouse.dto.LocationRequests; import com.baiyue.warehouse.dto.PalletRequests; import com.baiyue.warehouse.entity.WarehouseLocationEntity; import com.baiyue.warehouse.entity.WarehousePalletEntity; +import com.baiyue.warehouse.entity.WarehouseEntity; import com.baiyue.warehouse.repository.WarehouseResourceRepository; +import com.baomidou.mybatisplus.core.toolkit.IdWorker; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import jakarta.annotation.Resource; import java.util.Set; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import org.springframework.util.StringUtils; @Service public class WarehouseResourceService { @@ -33,6 +38,11 @@ public class WarehouseResourceService { @Transactional public void updateLocation(long tenantId, long warehouseId, long id, LocationRequests.SaveRequest request) { WarehouseLocationEntity existing = requireLocation(tenantId, warehouseId, id); + if ("VIRTUAL".equals(existing.getLocationType()) + && (!"VIRTUAL".equals(request.locationType()) || !"ACTIVE".equals(request.status()) + || !existing.getLocationCode().equals(request.locationCode()))) { + throw new BizException(ResultCode.CONFLICT, "Default virtual location must remain VIRTUAL and ACTIVE"); + } validateLocation(request, tenantId, warehouseId, id); WarehouseLocationEntity entity = locationEntity(tenantId, warehouseId, request); entity.setId(existing.getId()); @@ -52,7 +62,10 @@ public class WarehouseResourceService { @Transactional public void deleteLocation(long tenantId, long warehouseId, long id) { - requireLocation(tenantId, warehouseId, id); + WarehouseLocationEntity location = requireLocation(tenantId, warehouseId, id); + if ("VIRTUAL".equals(location.getLocationType())) { + throw new BizException(ResultCode.CONFLICT, "Default virtual location cannot be deleted"); + } if (repository.locationHasPallets(tenantId, warehouseId, id)) { throw new BizException(ResultCode.CONFLICT, "库位仍绑定托盘,不能删除"); } @@ -61,18 +74,69 @@ public class WarehouseResourceService { @Transactional public Long createPallet(long tenantId, long warehouseId, PalletRequests.SaveRequest request) { - requireWarehouse(tenantId, warehouseId); - validatePallet(request, tenantId, warehouseId, null); - WarehousePalletEntity entity = palletEntity(tenantId, warehouseId, request); + WarehouseEntity warehouse = requireWarehouseEntity(tenantId, warehouseId); + validatePalletStatus(request.status()); + Long palletId = IdWorker.getId(); + String palletCode = StringUtils.hasText(request.palletCode()) ? request.palletCode().trim() + : generatedPalletCode(warehouse.getWarehouseCode(), palletId); + Long locationId = request.locationId(); + if (locationId == null) { + locationId = ensureDefaultVirtualLocation(tenantId, warehouseId).getId(); + } + validatePallet(request, palletCode, locationId, tenantId, warehouseId, null); + WarehousePalletEntity entity = palletEntity(tenantId, warehouseId, request, palletCode, locationId); + entity.setId(palletId); repository.insertPallet(entity); return entity.getId(); } + /** + * Returns the warehouse-scoped virtual location used only as a pallet/inventory reference. + * It is deliberately never made IDLE/OCCUPIED by resource locking. + */ + @Transactional + public WarehouseLocationEntity ensureDefaultVirtualLocation(long tenantId, long warehouseId) { + WarehouseEntity warehouse = requireWarehouseEntity(tenantId, warehouseId); + String locationCode = defaultLocationCode(warehouse.getWarehouseCode()); + WarehouseLocationEntity existing = repository.findLocationByCode(tenantId, warehouseId, locationCode); + if (existing != null) { + if (!"VIRTUAL".equals(existing.getLocationType()) || !"ACTIVE".equals(existing.getStatus())) { + throw new BizException(ResultCode.CONFLICT, + "Default virtual location code is occupied by a non-active virtual location"); + } + return existing; + } + + WarehouseLocationEntity location = new WarehouseLocationEntity(); + location.setId(IdWorker.getId()); + location.setTenantId(tenantId); + location.setWarehouseId(warehouseId); + location.setZoneCode("DEFAULT"); + location.setLocationCode(locationCode); + location.setLocationType("VIRTUAL"); + location.setMaxLoadKg(new java.math.BigDecimal("999999999.000")); + location.setStatus("ACTIVE"); + location.setRemark("System default virtual location"); + repository.insertDefaultVirtualLocationIgnore(location); + + WarehouseLocationEntity resolved = repository.findLocationByCode(tenantId, warehouseId, locationCode); + if (resolved == null) { + throw new BizException(ResultCode.CONFLICT, "Default virtual location could not be created"); + } + return resolved; + } + @Transactional public void updatePallet(long tenantId, long warehouseId, long id, PalletRequests.SaveRequest request) { WarehousePalletEntity existing = requirePallet(tenantId, warehouseId, id); - validatePallet(request, tenantId, warehouseId, id); - WarehousePalletEntity entity = palletEntity(tenantId, warehouseId, request); + if ("SCRAPPED".equals(existing.getStatus()) && !"SCRAPPED".equals(request.status())) { + throw new BizException(ResultCode.CONFLICT, "SCRAPPED pallet cannot be reused"); + } + String palletCode = StringUtils.hasText(request.palletCode()) ? request.palletCode().trim() + : existing.getPalletCode(); + Long locationId = request.locationId() == null ? existing.getLocationId() : request.locationId(); + validatePallet(request, palletCode, locationId, tenantId, warehouseId, id); + WarehousePalletEntity entity = palletEntity(tenantId, warehouseId, request, palletCode, locationId); entity.setId(existing.getId()); repository.updatePallet(entity); } @@ -94,6 +158,9 @@ public class WarehouseResourceService { if ("OCCUPIED".equals(pallet.getStatus())) { throw new BizException(ResultCode.CONFLICT, "占用中的托盘不能删除"); } + if ("SCRAPPED".equals(pallet.getStatus())) { + throw new BizException(ResultCode.CONFLICT, "Scrapped pallet must be retained and cannot be reused"); + } repository.deletePallet(id); } @@ -103,6 +170,14 @@ public class WarehouseResourceService { } } + private WarehouseEntity requireWarehouseEntity(long tenantId, long warehouseId) { + WarehouseEntity warehouse = repository.findWarehouse(tenantId, warehouseId); + if (warehouse == null) { + throw new BizException(ResultCode.NOT_FOUND, "Tenant warehouse not found"); + } + return warehouse; + } + private WarehouseLocationEntity requireLocation(long tenantId, long warehouseId, long id) { requireWarehouse(tenantId, warehouseId); WarehouseLocationEntity entity = repository.findLocation(tenantId, warehouseId, id); @@ -127,16 +202,20 @@ public class WarehouseResourceService { } } - private void validatePallet(PalletRequests.SaveRequest request, long tenantId, - long warehouseId, Long excludedId) { - if (!PALLET_STATUSES.contains(request.status())) { - throw new BizException("托盘状态仅支持 IDLE、OCCUPIED、SCRAPPED"); - } - if (repository.palletCodeExists(tenantId, warehouseId, request.palletCode(), excludedId)) { + private void validatePallet(PalletRequests.SaveRequest request, String palletCode, Long locationId, + long tenantId, long warehouseId, Long excludedId) { + validatePalletStatus(request.status()); + if (repository.palletCodeExists(tenantId, warehouseId, palletCode, excludedId)) { throw new BizException(ResultCode.CONFLICT, "当前仓库下托盘编码已存在"); } - if (request.locationId() != null) { - requireLocation(tenantId, warehouseId, request.locationId()); + if (locationId != null) { + requireLocation(tenantId, warehouseId, locationId); + } + } + + private void validatePalletStatus(String status) { + if (!PALLET_STATUSES.contains(status)) { + throw new BizException("托盘状态仅支持 IDLE、OCCUPIED、SCRAPPED"); } } @@ -155,12 +234,13 @@ public class WarehouseResourceService { } private WarehousePalletEntity palletEntity(long tenantId, long warehouseId, - PalletRequests.SaveRequest request) { + PalletRequests.SaveRequest request, + String palletCode, Long locationId) { WarehousePalletEntity entity = new WarehousePalletEntity(); entity.setTenantId(tenantId); entity.setWarehouseId(warehouseId); - entity.setLocationId(request.locationId()); - entity.setPalletCode(request.palletCode()); + entity.setLocationId(locationId); + entity.setPalletCode(palletCode); entity.setPalletType(request.palletType()); entity.setCapacityCbm(request.capacityCbm()); entity.setUnitPrice(request.unitPrice()); @@ -168,4 +248,13 @@ public class WarehouseResourceService { entity.setRemark(request.remark()); return entity; } + + private static String generatedPalletCode(String warehouseCode, long palletId) { + return "PLT-" + warehouseCode + "-" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE) + + "-" + Long.toUnsignedString(palletId); + } + + private static String defaultLocationCode(String warehouseCode) { + return "LOC-" + warehouseCode + "-DEFAULT"; + } } diff --git a/warehouse/src/main/resources/db/warehouse.sql b/warehouse/src/main/resources/db/warehouse.sql index a22a904..7b3d28b 100644 --- a/warehouse/src/main/resources/db/warehouse.sql +++ b/warehouse/src/main/resources/db/warehouse.sql @@ -29,7 +29,7 @@ CREATE TABLE IF NOT EXISTS wh_pallet ( tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID', location_id BIGINT UNSIGNED DEFAULT NULL COMMENT '同仓库内绑定的库位ID,可为空', - pallet_code VARCHAR(64) NOT NULL COMMENT '仓库内唯一托盘编码', + pallet_code VARCHAR(128) NOT NULL COMMENT '仓库内唯一托盘编码', pallet_type VARCHAR(32) NOT NULL COMMENT '托盘类型', capacity_cbm DECIMAL(18,4) NOT NULL DEFAULT 2.0000 COMMENT '托盘容量(立方米)', unit_price DECIMAL(18,2) NOT NULL DEFAULT 0.00 COMMENT '托盘单价', @@ -159,13 +159,25 @@ ALTER TABLE wh_location MODIFY COLUMN deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', MODIFY COLUMN version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号'; +-- Every warehouse has one non-occupiable virtual location used for inventory history. +INSERT INTO wh_location (id, tenant_id, warehouse_id, zone_code, location_code, location_type, + max_load_kg, status, remark) +SELECT UUID_SHORT(), w.tenant_id, w.id, 'DEFAULT', CONCAT('LOC-', w.warehouse_code, '-DEFAULT'), + 'VIRTUAL', 999999999.000, 'ACTIVE', 'System default virtual location' +FROM wh_warehouse w +WHERE NOT EXISTS ( + SELECT 1 FROM wh_location l + WHERE l.tenant_id = w.tenant_id AND l.warehouse_id = w.id + AND l.location_code = CONCAT('LOC-', w.warehouse_code, '-DEFAULT') AND l.deleted = 0 +); + ALTER TABLE wh_pallet COMMENT='仓库托盘表:按租户和仓库维护托盘、容量、单价及状态'; ALTER TABLE wh_pallet MODIFY COLUMN id BIGINT UNSIGNED NOT NULL COMMENT '托盘ID(雪花ID)', MODIFY COLUMN tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', MODIFY COLUMN warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID', MODIFY COLUMN location_id BIGINT UNSIGNED NULL COMMENT '同仓库内绑定的库位ID,可为空', - MODIFY COLUMN pallet_code VARCHAR(64) NOT NULL COMMENT '仓库内唯一托盘编码', + MODIFY COLUMN pallet_code VARCHAR(128) NOT NULL COMMENT '仓库内唯一托盘编码', MODIFY COLUMN pallet_type VARCHAR(32) NOT NULL COMMENT '托盘类型', MODIFY COLUMN capacity_cbm DECIMAL(18,4) NOT NULL DEFAULT 2.0000 COMMENT '托盘容量(立方米)', MODIFY COLUMN unit_price DECIMAL(18,2) NOT NULL DEFAULT 0.00 COMMENT '托盘单价', diff --git a/warehouse/src/test/java/com/baiyue/warehouse/service/WarehouseApplicationServiceTest.java b/warehouse/src/test/java/com/baiyue/warehouse/service/WarehouseApplicationServiceTest.java new file mode 100644 index 0000000..5391a71 --- /dev/null +++ b/warehouse/src/test/java/com/baiyue/warehouse/service/WarehouseApplicationServiceTest.java @@ -0,0 +1,59 @@ +package com.baiyue.warehouse.service; + +import com.baiyue.warehouse.dto.WarehouseRequests; +import com.baiyue.warehouse.entity.WarehouseEntity; +import com.baiyue.warehouse.entity.WarehouseLocationEntity; +import com.baiyue.warehouse.repository.WarehouseRepository; +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.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class WarehouseApplicationServiceTest { + @Mock WarehouseRepository repository; + private WarehouseApplicationService service; + + @BeforeEach + void setUp() { + service = new WarehouseApplicationService(); + ReflectionTestUtils.setField(service, "repository", repository); + } + + @Test + void warehouseCreationAlsoCreatesActiveDefaultVirtualLocation() { + doAnswer(call -> { + ((WarehouseEntity) call.getArgument(0)).setId(20001L); + return null; + }).when(repository).insertWarehouse(any()); + + service.create(request()); + + ArgumentCaptor location = ArgumentCaptor.forClass(WarehouseLocationEntity.class); + verify(repository).insertLocation(location.capture()); + assertThat(location.getValue().getTenantId()).isEqualTo(10001L); + assertThat(location.getValue().getWarehouseId()).isEqualTo(20001L); + assertThat(location.getValue().getLocationCode()).isEqualTo("LOC-US-LAX-01-DEFAULT"); + assertThat(location.getValue().getLocationType()).isEqualTo("VIRTUAL"); + assertThat(location.getValue().getStatus()).isEqualTo("ACTIVE"); + } + + private static WarehouseRequests.SaveRequest request() { + WarehouseRequests.AddressRequest address = new WarehouseRequests.AddressRequest( + "WAREHOUSE", "US", "CA", "Los Angeles", null, "90001", + "1 Logistics Ave", null, null, null); + return new WarehouseRequests.SaveRequest(10001L, "US-LAX-01", "LAX Warehouse", + "OVERSEAS", "US", "America/Los_Angeles", null, null, null, + "ACTIVE", null, address, List.of()); + } +} diff --git a/warehouse/src/test/java/com/baiyue/warehouse/service/WarehouseResourceLockServiceTest.java b/warehouse/src/test/java/com/baiyue/warehouse/service/WarehouseResourceLockServiceTest.java index 79575a9..87bcc8b 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/service/WarehouseResourceLockServiceTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/service/WarehouseResourceLockServiceTest.java @@ -122,6 +122,7 @@ class WarehouseResourceLockServiceTest { void releaseRestoresBothResourcesAndClosesLock() { WarehouseResourceLockEntity lock = activeLock(); when(repository.find(TENANT, lock.getId())).thenReturn(lock); + when(repository.findLocation(TENANT, WAREHOUSE, LOCATION)).thenReturn(location("OCCUPIED")); when(repository.releasePallet(lock)).thenReturn(1); when(repository.releaseLocation(lock)).thenReturn(1); when(repository.close(lock, "RELEASED")).thenReturn(1); @@ -137,6 +138,7 @@ class WarehouseResourceLockServiceTest { void scrapDiscardsPalletAndReleasesLocation() { WarehouseResourceLockEntity lock = activeLock(); when(repository.find(TENANT, lock.getId())).thenReturn(lock); + when(repository.findLocation(TENANT, WAREHOUSE, LOCATION)).thenReturn(location("OCCUPIED")); when(repository.scrapPallet(lock)).thenReturn(1); when(repository.releaseLocation(lock)).thenReturn(1); when(repository.close(lock, "SCRAPPED")).thenReturn(1); @@ -161,6 +163,73 @@ class WarehouseResourceLockServiceTest { verify(repository, never()).insert(any()); } + @Test + void virtualLocationIsNeverOccupiedOrReleased() { + WarehouseLocationEntity virtual = location("ACTIVE"); + virtual.setLocationType("VIRTUAL"); + when(repository.findPallet(TENANT, WAREHOUSE, PALLET)).thenReturn(pallet("IDLE")); + when(repository.findLocation(TENANT, WAREHOUSE, LOCATION)).thenReturn(virtual); + when(repository.occupyPallet(TENANT, WAREHOUSE, PALLET, LOCATION)).thenReturn(1); + doAnswer(call -> { + ((WarehouseResourceLockEntity) call.getArgument(0)).setId(502L); + return null; + }).when(repository).insert(any()); + + service.reserve(request("virtual-reserve")); + + verify(repository, never()).occupyLocation(TENANT, WAREHOUSE, LOCATION); + } + + @Test + void scrappingAtVirtualLocationKeepsLocationUntouched() { + WarehouseResourceLockEntity lock = activeLock(); + WarehouseLocationEntity virtual = location("ACTIVE"); + virtual.setLocationType("VIRTUAL"); + when(repository.find(TENANT, lock.getId())).thenReturn(lock); + when(repository.findLocation(TENANT, WAREHOUSE, LOCATION)).thenReturn(virtual); + when(repository.scrapPalletAtVirtualLocation(lock)).thenReturn(1); + when(repository.close(lock, "SCRAPPED")).thenReturn(1); + + var result = service.scrap(TENANT, lock.getId()); + + assertThat(result.status()).isEqualTo("SCRAPPED"); + verify(repository).scrapPalletAtVirtualLocation(lock); + verify(repository, never()).releaseLocation(lock); + } + + @Test + void scrapByPalletClosesItsActiveLockAndKeepsLocationReference() { + WarehousePalletEntity pallet = pallet("OCCUPIED"); + pallet.setWarehouseId(WAREHOUSE); + pallet.setLocationId(LOCATION); + WarehouseResourceLockEntity lock = activeLock(); + when(repository.findPallet(TENANT, PALLET)).thenReturn(pallet); + when(repository.findActiveByPallet(TENANT, PALLET)).thenReturn(lock); + when(repository.find(TENANT, lock.getId())).thenReturn(lock); + WarehouseLocationEntity virtual = location("ACTIVE"); + virtual.setLocationType("VIRTUAL"); + when(repository.findLocation(TENANT, WAREHOUSE, LOCATION)).thenReturn(virtual); + when(repository.scrapPalletAtVirtualLocation(lock)).thenReturn(1); + when(repository.close(lock, "SCRAPPED")).thenReturn(1); + + var result = service.scrapByPallet(TENANT, PALLET); + + assertThat(result.status()).isEqualTo("SCRAPPED"); + assertThat(result.locationId()).isEqualTo(LOCATION); + verify(repository).scrapPalletAtVirtualLocation(lock); + } + + @Test + void scrapByPalletIsIdempotentWhenAlreadyScrapped() { + WarehousePalletEntity pallet = pallet("SCRAPPED"); + pallet.setWarehouseId(WAREHOUSE); + pallet.setLocationId(LOCATION); + when(repository.findPallet(TENANT, PALLET)).thenReturn(pallet); + + assertThat(service.scrapByPallet(TENANT, PALLET).status()).isEqualTo("SCRAPPED"); + verify(repository, never()).findActiveByPallet(TENANT, PALLET); + } + private static WarehouseResourceLockRequests.ReserveRequest request(String key) { return new WarehouseResourceLockRequests.ReserveRequest(TENANT, WAREHOUSE, PALLET, LOCATION, "INBOUND_PLAN_PALLET", "plan-pallet-1", key); diff --git a/warehouse/src/test/java/com/baiyue/warehouse/service/WarehouseResourceServiceTest.java b/warehouse/src/test/java/com/baiyue/warehouse/service/WarehouseResourceServiceTest.java index 2093085..33be3a5 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/service/WarehouseResourceServiceTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/service/WarehouseResourceServiceTest.java @@ -7,6 +7,7 @@ import com.baiyue.warehouse.dto.LocationRequests; import com.baiyue.warehouse.dto.PalletRequests; import com.baiyue.warehouse.entity.WarehouseLocationEntity; import com.baiyue.warehouse.entity.WarehousePalletEntity; +import com.baiyue.warehouse.entity.WarehouseEntity; import com.baiyue.warehouse.repository.WarehouseResourceRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -89,6 +90,7 @@ class WarehouseResourceServiceTest { @Test void palletDefaultsToTwoCubicMetresAndBindsOwnedLocation() { when(repository.warehouseOwned(TENANT, WAREHOUSE)).thenReturn(true); + when(repository.findWarehouse(TENANT, WAREHOUSE)).thenReturn(warehouse()); WarehouseLocationEntity location = new WarehouseLocationEntity(); location.setId(30001L); when(repository.findLocation(TENANT, WAREHOUSE, 30001L)).thenReturn(location); @@ -111,6 +113,7 @@ class WarehouseResourceServiceTest { @Test void rejectsLocationFromAnotherWarehouseWhenBindingPallet() { when(repository.warehouseOwned(TENANT, WAREHOUSE)).thenReturn(true); + when(repository.findWarehouse(TENANT, WAREHOUSE)).thenReturn(warehouse()); when(repository.findLocation(TENANT, WAREHOUSE, 30002L)).thenReturn(null); assertThatThrownBy(() -> service.createPallet(TENANT, WAREHOUSE, @@ -147,8 +150,21 @@ class WarehouseResourceServiceTest { } @Test - void rejectsUnknownPalletStatus() { + void preventsDeletingScrappedPalletSoItsIdentityCannotBeReused() { when(repository.warehouseOwned(TENANT, WAREHOUSE)).thenReturn(true); + WarehousePalletEntity pallet = new WarehousePalletEntity(); + pallet.setStatus("SCRAPPED"); + when(repository.findPallet(TENANT, WAREHOUSE, 40003L)).thenReturn(pallet); + + assertThatThrownBy(() -> service.deletePallet(TENANT, WAREHOUSE, 40003L)) + .isInstanceOf(BizException.class) + .hasMessageContaining("cannot be reused"); + verify(repository, never()).deletePallet(40003L); + } + + @Test + void rejectsUnknownPalletStatus() { + when(repository.findWarehouse(TENANT, WAREHOUSE)).thenReturn(warehouse()); assertThatThrownBy(() -> service.createPallet(TENANT, WAREHOUSE, new PalletRequests.SaveRequest("PLT-01", "STANDARD", null, @@ -161,4 +177,84 @@ class WarehouseResourceServiceTest { return new LocationRequests.SaveRequest("A", code, "STORAGE", new BigDecimal("1000.000"), "ACTIVE", null); } + + @Test + void generatesRestartSafePalletCodeAndUsesDefaultVirtualLocation() { + when(repository.findWarehouse(TENANT, WAREHOUSE)).thenReturn(warehouse()); + WarehouseLocationEntity location = new WarehouseLocationEntity(); + location.setId(30003L); + location.setLocationType("VIRTUAL"); + location.setStatus("ACTIVE"); + when(repository.findLocationByCode(TENANT, WAREHOUSE, "LOC-US-LAX-01-DEFAULT")) + .thenReturn(location); + when(repository.warehouseOwned(TENANT, WAREHOUSE)).thenReturn(true); + when(repository.findLocation(TENANT, WAREHOUSE, 30003L)).thenReturn(location); + + service.createPallet(TENANT, WAREHOUSE, + new PalletRequests.SaveRequest(null, "STANDARD", null, BigDecimal.ONE, "IDLE", null, null)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(WarehousePalletEntity.class); + verify(repository).insertPallet(captor.capture()); + assertThat(captor.getValue().getPalletCode()).matches("PLT-US-LAX-01-[0-9]{8}-[0-9]+"); + assertThat(captor.getValue().getLocationId()).isEqualTo(30003L); + assertThat(captor.getValue().getId()).isNotNull(); + } + + @Test + void defaultVirtualLocationIsInsertedIdempotentlyForAnExistingWarehouse() { + when(repository.findWarehouse(TENANT, WAREHOUSE)).thenReturn(warehouse()); + WarehouseLocationEntity resolved = new WarehouseLocationEntity(); + resolved.setId(30004L); + resolved.setLocationType("VIRTUAL"); + resolved.setStatus("ACTIVE"); + when(repository.findLocationByCode(TENANT, WAREHOUSE, "LOC-US-LAX-01-DEFAULT")) + .thenReturn(null, resolved); + + WarehouseLocationEntity result = service.ensureDefaultVirtualLocation(TENANT, WAREHOUSE); + + assertThat(result.getId()).isEqualTo(30004L); + ArgumentCaptor location = ArgumentCaptor.forClass(WarehouseLocationEntity.class); + verify(repository).insertDefaultVirtualLocationIgnore(location.capture()); + assertThat(location.getValue().getLocationCode()).isEqualTo("LOC-US-LAX-01-DEFAULT"); + assertThat(location.getValue().getLocationType()).isEqualTo("VIRTUAL"); + assertThat(location.getValue().getStatus()).isEqualTo("ACTIVE"); + } + + @Test + void rejectsAConflictingDefaultLocationCode() { + when(repository.findWarehouse(TENANT, WAREHOUSE)).thenReturn(warehouse()); + WarehouseLocationEntity conflict = new WarehouseLocationEntity(); + conflict.setLocationType("STORAGE"); + conflict.setStatus("ACTIVE"); + when(repository.findLocationByCode(TENANT, WAREHOUSE, "LOC-US-LAX-01-DEFAULT")) + .thenReturn(conflict); + + assertThatThrownBy(() -> service.ensureDefaultVirtualLocation(TENANT, WAREHOUSE)) + .isInstanceOf(BizException.class) + .hasMessageContaining("Default virtual location code"); + verify(repository, never()).insertDefaultVirtualLocationIgnore(org.mockito.ArgumentMatchers.any()); + } + + @Test + void scrappedPalletCannotBeReactivated() { + when(repository.warehouseOwned(TENANT, WAREHOUSE)).thenReturn(true); + WarehousePalletEntity existing = new WarehousePalletEntity(); + existing.setId(40002L); + existing.setPalletCode("PLT-OLD"); + existing.setStatus("SCRAPPED"); + when(repository.findPallet(TENANT, WAREHOUSE, 40002L)).thenReturn(existing); + + assertThatThrownBy(() -> service.updatePallet(TENANT, WAREHOUSE, 40002L, + new PalletRequests.SaveRequest(null, "STANDARD", null, BigDecimal.ONE, "IDLE", null, null))) + .isInstanceOf(BizException.class) + .hasMessageContaining("cannot be reused"); + } + + private static WarehouseEntity warehouse() { + WarehouseEntity warehouse = new WarehouseEntity(); + warehouse.setId(WAREHOUSE); + warehouse.setTenantId(TENANT); + warehouse.setWarehouseCode("US-LAX-01"); + return warehouse; + } }