From ac6c0d0395f8bd85b258c7273447ae3037ca5a26 Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Mon, 17 Aug 2026 19:04:58 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../delivery/DeliveryMapperConfiguration.java | 9 + .../DeliveryTransferController.java | 74 ++++ .../delivery/dto/DeliveryRequests.java | 23 ++ .../delivery/dto/DeliveryResponses.java | 27 ++ .../delivery/entity/DeliveryOrder.java | 30 ++ .../delivery/entity/OutboundOrder.java | 28 ++ .../delivery/entity/OutboundPalletItem.java | 27 ++ .../delivery/mapper/DeliveryOrderMapper.java | 17 + .../delivery/mapper/OutboundOrderMapper.java | 17 + .../mapper/OutboundPalletItemMapper.java | 14 + .../service/DeliveryTransferService.java | 212 ++++++++++++ .../dispatch/dto/DispatchResponse.java | 8 +- .../lastleg/dispatch/entity/DispatchItem.java | 2 + .../dispatch/entity/DispatchOrder.java | 5 + .../mapper/DispatchCandidateMapper.java | 18 +- .../model/DispatchPalletCandidate.java | 11 + .../pda/controller/DispatchPdaController.java | 8 + .../dispatch/pda/dto/DispatchPdaRequests.java | 16 +- .../pda/dto/DispatchPdaResponses.java | 14 +- .../pda/entity/DispatchPdaPickOrder.java | 22 ++ .../dispatch/pda/entity/DispatchPdaTask.java | 1 + .../mapper/DispatchPdaPickOrderMapper.java | 31 ++ .../service/DispatchPdaWorkflowService.java | 84 ++++- .../DispatchPickOrderAttributionService.java | 105 ++++++ .../dispatch/service/DispatchService.java | 26 +- .../lastleg/dto/LastlegOrderSummary.java | 3 + .../baiyue/lastleg/entity/LastlegOrder.java | 2 + .../pda/entity/InboundStockReceipt.java | 6 +- .../pda/mapper/InboundStockReceiptMapper.java | 2 +- .../service/InboundStockReceiptService.java | 127 ++----- .../InventoryCommandController.java | 16 +- .../command/dto/InventoryCommandRequests.java | 10 +- .../service/InventoryCommandService.java | 81 ++--- .../inventory/entity/InventoryBalance.java | 14 +- .../inventory/entity/InventoryLedger.java | 5 +- .../inventory/entity/InventoryPosition.java | 2 - .../mapper/InventoryBalanceMapper.java | 22 +- .../mapper/InventoryLedgerMapper.java | 4 +- .../mapper/InventoryPositionMapper.java | 2 +- .../inventory/model/InventorySummary.java | 7 - .../controller/InventoryQueryController.java | 8 +- .../query/dto/InventoryBalanceView.java | 13 +- .../query/dto/InventoryDashboardResponse.java | 12 +- .../query/dto/InventoryLedgerView.java | 8 +- .../query/dto/InventoryPositionView.java | 5 +- .../query/dto/InventoryQueryRequests.java | 7 +- .../query/mapper/InventoryQueryMapper.java | 25 +- .../model/InventoryAggregateProjection.java | 7 - .../query/service/InventoryQueryService.java | 70 ++-- .../lastleg/mapper/LastlegOrderMapper.java | 19 ++ .../relabel/RelabelMapperConfiguration.java | 9 + .../RelabelApplicationController.java | 53 +++ .../lastleg/relabel/dto/RelabelRequests.java | 54 +++ .../lastleg/relabel/dto/RelabelResponses.java | 22 ++ .../relabel/entity/RelabelApplication.java | 32 ++ .../lastleg/relabel/entity/RelabelItem.java | 31 ++ .../relabel/entity/RelabelStatusHistory.java | 21 ++ .../mapper/RelabelApplicationMapper.java | 19 ++ .../relabel/mapper/RelabelItemMapper.java | 9 + .../mapper/RelabelStatusHistoryMapper.java | 9 + .../service/RelabelApplicationService.java | 252 ++++++++++++++ .../lastleg/service/LastlegOrderService.java | 11 +- lastleg/src/main/resources/db/lastleg.sql | 154 +++++++-- .../delivery/DeliverySchemaContractTest.java | 58 ++++ .../service/DeliveryTransferServiceTest.java | 297 ++++++++++++++++ .../DispatchPdaWorkflowServiceTest.java | 38 +++ ...spatchPickOrderAttributionServiceTest.java | 89 +++++ .../dispatch/service/DispatchServiceTest.java | 2 +- .../InboundStockReceiptServiceTest.java | 24 +- .../InventorySchemaContractTest.java | 45 ++- .../command/InventoryCommandServiceTest.java | 22 +- .../InventoryQueryControllerTest.java | 2 +- .../service/InventoryQueryServiceTest.java | 46 ++- .../lastleg/relabel/RelabelContractTest.java | 40 +++ .../RelabelApplicationServiceTest.java | 239 +++++++++++++ scripts/db/incremental.sql | 319 ++++++++++++++++-- 76 files changed, 2770 insertions(+), 433 deletions(-) create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/delivery/DeliveryMapperConfiguration.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryRequests.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/delivery/entity/DeliveryOrder.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/delivery/entity/OutboundOrder.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/delivery/entity/OutboundPalletItem.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliveryOrderMapper.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/delivery/mapper/OutboundOrderMapper.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/delivery/mapper/OutboundPalletItemMapper.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaPickOrder.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaPickOrderMapper.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPickOrderAttributionService.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/relabel/RelabelMapperConfiguration.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationController.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelRequests.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelResponses.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/relabel/entity/RelabelApplication.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/relabel/entity/RelabelItem.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/relabel/entity/RelabelStatusHistory.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelApplicationMapper.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelItemMapper.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelStatusHistoryMapper.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java create mode 100644 lastleg/src/test/java/com/baiyue/lastleg/delivery/DeliverySchemaContractTest.java create mode 100644 lastleg/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java create mode 100644 lastleg/src/test/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPickOrderAttributionServiceTest.java create mode 100644 lastleg/src/test/java/com/baiyue/lastleg/relabel/RelabelContractTest.java create mode 100644 lastleg/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java diff --git a/lastleg/src/main/java/com/baiyue/lastleg/delivery/DeliveryMapperConfiguration.java b/lastleg/src/main/java/com/baiyue/lastleg/delivery/DeliveryMapperConfiguration.java new file mode 100644 index 0000000..8d19ed2 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/delivery/DeliveryMapperConfiguration.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.delivery; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.context.annotation.Configuration; + +@Configuration +@MapperScan("com.baiyue.lastleg.delivery.mapper") +public class DeliveryMapperConfiguration { +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java b/lastleg/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java new file mode 100644 index 0000000..585bcb8 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java @@ -0,0 +1,74 @@ +package com.baiyue.lastleg.delivery.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.delivery.dto.DeliveryRequests; +import com.baiyue.lastleg.delivery.dto.DeliveryResponses; +import com.baiyue.lastleg.delivery.service.DeliveryTransferService; +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.*; +import org.springframework.web.server.ResponseStatusException; + +@RestController +@RequestMapping("/api/platform/v1/last-leg") +@Tag(name = "Outbound and delivery") +public class DeliveryTransferController { + @Resource private DeliveryTransferService service; + + @PostMapping("/dispatches/{dispatchNo}/transfer-delivery") + @Operation(summary = "Create one pending outbound order and one pending delivery order") + public ApiResponse transfer( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String dispatchNo, + @RequestParam(required = false) String tenantId, + @Valid @RequestBody DeliveryRequests.Transfer request) { + return ApiResponse.success(service.transfer(scope(user, tenantId), dispatchNo, operator(user), request)); + } + + @GetMapping("/outbound-orders") + public ApiResponse> outboundPage( + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String tenantId, + @Valid DeliveryRequests.Query query) { + return ApiResponse.success(service.outboundPage(scope(user, tenantId), query)); + } + + @GetMapping("/outbound-orders/{outboundNo}") + public ApiResponse outboundDetail( + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String tenantId, + @PathVariable String outboundNo, @Valid DeliveryRequests.PalletQuery query) { + return ApiResponse.success(service.outboundDetail(scope(user, tenantId), outboundNo, query)); + } + + @GetMapping("/delivery-orders") + public ApiResponse> deliveryPage( + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String tenantId, + @Valid DeliveryRequests.Query query) { + return ApiResponse.success(service.deliveryPage(scope(user, tenantId), query)); + } + + @GetMapping("/delivery-orders/{deliveryNo}") + public ApiResponse deliveryDetail( + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String tenantId, + @PathVariable String deliveryNo, @Valid DeliveryRequests.PalletQuery query) { + return ApiResponse.success(service.deliveryDetail(scope(user, tenantId), deliveryNo, query)); + } + + 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/delivery/dto/DeliveryRequests.java b/lastleg/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryRequests.java new file mode 100644 index 0000000..ac8083c --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryRequests.java @@ -0,0 +1,23 @@ +package com.baiyue.lastleg.delivery.dto; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public final class DeliveryRequests { + private DeliveryRequests() {} + + public record Transfer(@NotBlank @Size(max = 255) String idempotencyKey, + @Size(max = 1000) String remark) {} + + public record Query(@Min(1) long page, @Min(1) @Max(200) long size, + String documentNo, String warehouseId, String status) { + public Query { if (page == 0) page = 1; if (size == 0) size = 20; } + } + + public record PalletQuery(@Min(1) long page, @Min(1) @Max(200) long size, + String destinationWarehouseCode) { + public PalletQuery { if (page == 0) page = 1; if (size == 0) size = 20; } + } +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java b/lastleg/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java new file mode 100644 index 0000000..643058a --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java @@ -0,0 +1,27 @@ +package com.baiyue.lastleg.delivery.dto; + +import com.baiyue.common.core.api.PageResult; +import java.time.LocalDateTime; + +public final class DeliveryResponses { + private DeliveryResponses() {} + + public record TransferResult(String dispatchNo, String outboundNo, String outboundStatus, + String deliveryNo, String deliveryStatus, int palletCount, + int packageCount, boolean replayed) {} + + public record OutboundRow(Long id, String outboundNo, String dispatchNo, String overseasWarehouseId, + String status, int palletCount, int packageCount, LocalDateTime createdAt) {} + + public record DeliveryRow(Long id, String deliveryNo, String outboundNo, String dispatchNo, + String overseasWarehouseId, String status, int palletCount, + int packageCount, LocalDateTime createdAt) {} + + public record PalletRow(Long id, Long warehousePalletId, String palletNo, String inboundCode, + String orderRefs, String destinationWarehouseId, + String destinationWarehouseCode, String destinationWarehouseName, + int packageCount) {} + + public record OutboundDetail(OutboundRow outbound, DeliveryRow delivery, + PageResult pallets) {} +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/delivery/entity/DeliveryOrder.java b/lastleg/src/main/java/com/baiyue/lastleg/delivery/entity/DeliveryOrder.java new file mode 100644 index 0000000..0aba9ef --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/delivery/entity/DeliveryOrder.java @@ -0,0 +1,30 @@ +package com.baiyue.lastleg.delivery.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; + +/** Delivery document linked one-to-one to its outbound document and dispatch. */ +@Data +@TableName("ll_delivery_order") +public class DeliveryOrder { + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private String tenantId; + private String deliveryNo; + private Long outboundOrderId; + private String outboundNo; + private Long dispatchOrderId; + private String dispatchNo; + private String overseasWarehouseId; + private String status; + private Integer packageCount; + private Integer palletCount; + private String idempotencyKey; + private String createdBy; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/delivery/entity/OutboundOrder.java b/lastleg/src/main/java/com/baiyue/lastleg/delivery/entity/OutboundOrder.java new file mode 100644 index 0000000..8b65e38 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/delivery/entity/OutboundOrder.java @@ -0,0 +1,28 @@ +package com.baiyue.lastleg.delivery.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; + +/** Outbound document created when a dispatch is transferred to delivery. */ +@Data +@TableName("ll_outbound_order") +public class OutboundOrder { + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private String tenantId; + private String outboundNo; + private Long dispatchOrderId; + private String dispatchNo; + private String overseasWarehouseId; + private String status; + private Integer packageCount; + private Integer palletCount; + private String idempotencyKey; + private String createdBy; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/delivery/entity/OutboundPalletItem.java b/lastleg/src/main/java/com/baiyue/lastleg/delivery/entity/OutboundPalletItem.java new file mode 100644 index 0000000..56c21c7 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/delivery/entity/OutboundPalletItem.java @@ -0,0 +1,27 @@ +package com.baiyue.lastleg.delivery.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; + +/** Pallet-level immutable snapshot belonging to one outbound document. */ +@Data +@TableName("ll_outbound_pallet_item") +public class OutboundPalletItem { + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private String tenantId; + private Long outboundOrderId; + private Long warehousePalletId; + private String palletNo; + private String destinationWarehouseId; + private String destinationWarehouseCode; + private String destinationWarehouseName; + private Integer packageCount; + private String orderRefs; + private String inboundCode; + private LocalDateTime createdAt; +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliveryOrderMapper.java b/lastleg/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliveryOrderMapper.java new file mode 100644 index 0000000..4263d4e --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliveryOrderMapper.java @@ -0,0 +1,17 @@ +package com.baiyue.lastleg.delivery.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.delivery.entity.DeliveryOrder; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface DeliveryOrderMapper extends BaseMapper { + @Select("SELECT * FROM ll_delivery_order WHERE tenant_id=#{tenantId} AND delivery_no=#{deliveryNo} LIMIT 1") + DeliveryOrder findByNo(@Param("tenantId") String tenantId, @Param("deliveryNo") String deliveryNo); + @Select("SELECT * FROM ll_delivery_order WHERE tenant_id=#{tenantId} AND idempotency_key=#{key} LIMIT 1") + DeliveryOrder findByIdempotency(@Param("tenantId") String tenantId, @Param("key") String key); + @Select("SELECT * FROM ll_delivery_order WHERE tenant_id=#{tenantId} AND outbound_order_id=#{outboundOrderId} LIMIT 1 FOR UPDATE") + DeliveryOrder lockByOutbound(@Param("tenantId") String tenantId, @Param("outboundOrderId") Long outboundOrderId); +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/delivery/mapper/OutboundOrderMapper.java b/lastleg/src/main/java/com/baiyue/lastleg/delivery/mapper/OutboundOrderMapper.java new file mode 100644 index 0000000..af19598 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/delivery/mapper/OutboundOrderMapper.java @@ -0,0 +1,17 @@ +package com.baiyue.lastleg.delivery.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.delivery.entity.OutboundOrder; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface OutboundOrderMapper extends BaseMapper { + @Select("SELECT * FROM ll_outbound_order WHERE tenant_id=#{tenantId} AND outbound_no=#{outboundNo} LIMIT 1") + OutboundOrder findByNo(@Param("tenantId") String tenantId, @Param("outboundNo") String outboundNo); + @Select("SELECT * FROM ll_outbound_order WHERE tenant_id=#{tenantId} AND idempotency_key=#{key} LIMIT 1") + OutboundOrder findByIdempotency(@Param("tenantId") String tenantId, @Param("key") String key); + @Select("SELECT * FROM ll_outbound_order WHERE tenant_id=#{tenantId} AND dispatch_order_id=#{dispatchOrderId} LIMIT 1 FOR UPDATE") + OutboundOrder lockByDispatch(@Param("tenantId") String tenantId, @Param("dispatchOrderId") Long dispatchOrderId); +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/delivery/mapper/OutboundPalletItemMapper.java b/lastleg/src/main/java/com/baiyue/lastleg/delivery/mapper/OutboundPalletItemMapper.java new file mode 100644 index 0000000..23cb606 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/delivery/mapper/OutboundPalletItemMapper.java @@ -0,0 +1,14 @@ +package com.baiyue.lastleg.delivery.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.delivery.entity.OutboundPalletItem; +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 OutboundPalletItemMapper extends BaseMapper { + @Select("SELECT * FROM ll_outbound_pallet_item WHERE tenant_id=#{tenantId} AND outbound_order_id=#{outboundOrderId} ORDER BY id") + List findByOutbound(@Param("tenantId") String tenantId, @Param("outboundOrderId") Long outboundOrderId); +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java b/lastleg/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java new file mode 100644 index 0000000..0a3b62c --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java @@ -0,0 +1,212 @@ +package com.baiyue.lastleg.delivery.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.delivery.dto.DeliveryRequests; +import com.baiyue.lastleg.delivery.dto.DeliveryResponses; +import com.baiyue.lastleg.delivery.entity.DeliveryOrder; +import com.baiyue.lastleg.delivery.entity.OutboundOrder; +import com.baiyue.lastleg.delivery.entity.OutboundPalletItem; +import com.baiyue.lastleg.delivery.mapper.DeliveryOrderMapper; +import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper; +import com.baiyue.lastleg.delivery.mapper.OutboundPalletItemMapper; +import com.baiyue.lastleg.dispatch.entity.DispatchItem; +import com.baiyue.lastleg.dispatch.entity.DispatchOrder; +import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper; +import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanLineMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletAllocationMapper; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper; +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.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.*; + +@Service +public class DeliveryTransferService { + @Resource private DispatchOrderMapper dispatchOrders; + @Resource private DispatchItemMapper dispatchItems; + @Resource private OutboundOrderMapper outboundOrders; + @Resource private OutboundPalletItemMapper outboundPallets; + @Resource private DeliveryOrderMapper deliveryOrders; + @Resource private InboundPlanPalletAllocationMapper planAllocations; + @Resource private InboundPlanLineMapper planLines; + @Resource private LastlegOrderMapper tailOrders; + @Resource private LastlegOrderStatusHistoryMapper histories; + + @Transactional + public DeliveryResponses.TransferResult transfer(String tenantId, String dispatchNo, String operatorId, + DeliveryRequests.Transfer request) { + DispatchOrder found = dispatchOrders.findByDispatchNo(tenantId, dispatchNo); + if (found == null) throw notFound("Dispatch order not found"); + DispatchOrder dispatch = dispatchOrders.lockById(tenantId, found.getId()); + OutboundOrder replay = outboundOrders.findByIdempotency(tenantId, request.idempotencyKey().trim()); + if (replay != null) { + if (!dispatch.getId().equals(replay.getDispatchOrderId())) throw conflict("Idempotency key belongs to another dispatch"); + return result(replay, requireDelivery(tenantId, replay.getId()), true); + } + OutboundOrder existing = outboundOrders.lockByDispatch(tenantId, dispatch.getId()); + if (existing != null) return result(existing, requireDelivery(tenantId, existing.getId()), true); + if (Set.of("CANCELLED", "DEPARTED", "PARTIAL_DEPARTED", "COMPLETED").contains(dispatch.getStatus())) { + throw conflict("Dispatch cannot be transferred to delivery in its current status"); + } + List items = dispatchItems.findByDispatchOrder(tenantId, dispatch.getId()); + if (items.isEmpty()) throw conflict("Dispatch has no pallets"); + if (items.stream().anyMatch(item -> item.getWarehousePalletId() == null)) { + throw conflict("All dispatch pallets must be received before transfer to delivery"); + } + if (!StringUtils.hasText(dispatch.getOverseasWarehouseId())) throw conflict("Dispatch warehouse is required"); + + LocalDateTime now = LocalDateTime.now(); + OutboundOrder outbound = new OutboundOrder(); + outbound.setId(IdWorker.getId()); outbound.setTenantId(tenantId); + outbound.setOutboundNo("OUT" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + outbound.getId()); + outbound.setDispatchOrderId(dispatch.getId()); outbound.setDispatchNo(dispatch.getDispatchNo()); + outbound.setOverseasWarehouseId(dispatch.getOverseasWarehouseId()); outbound.setStatus("WAIT_OUTBOUND"); + outbound.setPackageCount(items.stream().map(DispatchItem::getPackageCount).filter(Objects::nonNull).mapToInt(Integer::intValue).sum()); + outbound.setPalletCount(items.size()); outbound.setIdempotencyKey(request.idempotencyKey().trim()); + outbound.setCreatedBy(operatorId); outbound.setCreatedAt(now); outbound.setUpdatedAt(now); + try { + outboundOrders.insert(outbound); + } catch (DuplicateKeyException e) { + // The unique keys are the final concurrent guard. A client retry must observe + // the document created by the winning transaction rather than receive a false failure. + OutboundOrder concurrent = outboundOrders.findByIdempotency(tenantId, request.idempotencyKey().trim()); + if (concurrent != null && dispatch.getId().equals(concurrent.getDispatchOrderId())) { + return result(concurrent, requireDelivery(tenantId, concurrent.getId()), true); + } + concurrent = outboundOrders.lockByDispatch(tenantId, dispatch.getId()); + if (concurrent != null) { + return result(concurrent, requireDelivery(tenantId, concurrent.getId()), true); + } + throw conflict("Dispatch was concurrently transferred to delivery"); + } + + for (DispatchItem source : items) { + OutboundPalletItem item = new OutboundPalletItem(); + item.setId(IdWorker.getId()); item.setTenantId(tenantId); item.setOutboundOrderId(outbound.getId()); + item.setWarehousePalletId(source.getWarehousePalletId()); item.setPalletNo(source.getPalletNo()); + item.setDestinationWarehouseId(source.getDestinationWarehouseId()); + item.setDestinationWarehouseCode(source.getDestinationWarehouseCode()); + item.setDestinationWarehouseName(source.getDestinationWarehouseName()); + item.setPackageCount(Objects.requireNonNullElse(source.getPackageCount(), 0)); + item.setOrderRefs(source.getOrderRefs()); item.setInboundCode(source.getInboundCode()); item.setCreatedAt(now); + outboundPallets.insert(item); + } + + DeliveryOrder delivery = new DeliveryOrder(); + delivery.setId(IdWorker.getId()); delivery.setTenantId(tenantId); + delivery.setDeliveryNo("DEL" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + delivery.getId()); + delivery.setOutboundOrderId(outbound.getId()); delivery.setOutboundNo(outbound.getOutboundNo()); + delivery.setDispatchOrderId(dispatch.getId()); delivery.setDispatchNo(dispatch.getDispatchNo()); + delivery.setOverseasWarehouseId(dispatch.getOverseasWarehouseId()); delivery.setStatus("WAIT_DELIVERY"); + delivery.setPackageCount(outbound.getPackageCount()); delivery.setPalletCount(outbound.getPalletCount()); + delivery.setIdempotencyKey(request.idempotencyKey().trim()); delivery.setCreatedBy(operatorId); + delivery.setCreatedAt(now); delivery.setUpdatedAt(now); deliveryOrders.insert(delivery); + bindTailOrders(tenantId, items, outbound.getOutboundNo(), delivery.getDeliveryNo(), request.remark(), now); + return result(outbound, delivery, false); + } + + public PageResult outboundPage(String tenantId, DeliveryRequests.Query query) { + Page page = outboundOrders.selectPage(new Page<>(query.page(), query.size()), + new LambdaQueryWrapper().eq(OutboundOrder::getTenantId, tenantId) + .like(StringUtils.hasText(query.documentNo()), OutboundOrder::getOutboundNo, query.documentNo()) + .eq(StringUtils.hasText(query.warehouseId()), OutboundOrder::getOverseasWarehouseId, query.warehouseId()) + .eq(StringUtils.hasText(query.status()), OutboundOrder::getStatus, query.status()) + .orderByDesc(OutboundOrder::getCreatedAt)); + return page(page, page.getRecords().stream().map(this::outboundRow).toList()); + } + + public DeliveryResponses.OutboundDetail outboundDetail(String tenantId, String outboundNo, + DeliveryRequests.PalletQuery query) { + OutboundOrder outbound = outboundOrders.findByNo(tenantId, outboundNo); + if (outbound == null) throw notFound("Outbound order not found"); + DeliveryOrder delivery = requireDelivery(tenantId, outbound.getId()); + Page page = outboundPallets.selectPage(new Page<>(query.page(), query.size()), + new LambdaQueryWrapper().eq(OutboundPalletItem::getTenantId, tenantId) + .eq(OutboundPalletItem::getOutboundOrderId, outbound.getId()) + .eq(StringUtils.hasText(query.destinationWarehouseCode()), + OutboundPalletItem::getDestinationWarehouseCode, query.destinationWarehouseCode()) + .orderByAsc(OutboundPalletItem::getId)); + return new DeliveryResponses.OutboundDetail(outboundRow(outbound), deliveryRow(delivery), + page(page, page.getRecords().stream().map(this::palletRow).toList())); + } + + public PageResult deliveryPage(String tenantId, DeliveryRequests.Query query) { + Page page = deliveryOrders.selectPage(new Page<>(query.page(), query.size()), + new LambdaQueryWrapper().eq(DeliveryOrder::getTenantId, tenantId) + .like(StringUtils.hasText(query.documentNo()), DeliveryOrder::getDeliveryNo, query.documentNo()) + .eq(StringUtils.hasText(query.warehouseId()), DeliveryOrder::getOverseasWarehouseId, query.warehouseId()) + .eq(StringUtils.hasText(query.status()), DeliveryOrder::getStatus, query.status()) + .orderByDesc(DeliveryOrder::getCreatedAt)); + return page(page, page.getRecords().stream().map(this::deliveryRow).toList()); + } + + public DeliveryResponses.OutboundDetail deliveryDetail(String tenantId, String deliveryNo, + DeliveryRequests.PalletQuery query) { + DeliveryOrder delivery = deliveryOrders.findByNo(tenantId, deliveryNo); + if (delivery == null) throw notFound("Delivery order not found"); + OutboundOrder outbound = outboundOrders.selectById(delivery.getOutboundOrderId()); + if (outbound == null || !tenantId.equals(outbound.getTenantId())) throw notFound("Outbound order not found"); + return outboundDetail(tenantId, outbound.getOutboundNo(), query); + } + + private void bindTailOrders(String tenantId, List items, String outboundNo, String deliveryNo, + String remark, LocalDateTime now) { + Set orderIds = new LinkedHashSet<>(); + items.stream().map(DispatchItem::getOrderId).filter(Objects::nonNull).forEach(orderIds::add); + List palletIds = items.stream().map(DispatchItem::getPlanPalletId).filter(Objects::nonNull).distinct().toList(); + if (!palletIds.isEmpty()) { + List allocations = planAllocations.selectList( + new LambdaQueryWrapper().in(InboundPlanPalletAllocation::getPalletId, palletIds)); + List lineIds = allocations.stream().map(InboundPlanPalletAllocation::getLineId) + .filter(Objects::nonNull).distinct().toList(); + if (!lineIds.isEmpty()) { + planLines.selectBatchIds(lineIds).stream().map(InboundPlanLine::getOrderId) + .filter(Objects::nonNull).forEach(orderIds::add); + } + } + if (orderIds.isEmpty()) return; + for (LastlegOrder order : tailOrders.selectList(new LambdaQueryWrapper() + .eq(LastlegOrder::getTenantId, tenantId).in(LastlegOrder::getId, orderIds))) { + String from = order.getCurrentStatus(); order.setOutboundOrderNo(outboundNo); + order.setDeliveryOrderNo(deliveryNo); order.setCurrentStatus("WAIT_OUTBOUND"); order.setUpdatedAt(now); + tailOrders.updateById(order); + LastlegOrderStatusHistory history = new LastlegOrderStatusHistory(); history.setOrderId(order.getId()); + history.setFromStatus(from); history.setToStatus("WAIT_OUTBOUND"); + history.setEventId("TRANSFER_DELIVERY:" + deliveryNo); + history.setRemark(StringUtils.hasText(remark) ? remark.trim() : "Transferred to outbound and delivery documents"); + history.setCreatedAt(now); histories.insert(history); + } + } + + private DeliveryOrder requireDelivery(String tenantId, Long outboundId) { + DeliveryOrder value = deliveryOrders.lockByOutbound(tenantId, outboundId); + if (value == null) throw new IllegalStateException("Delivery document is missing for outbound order"); + return value; + } + private DeliveryResponses.TransferResult result(OutboundOrder o, DeliveryOrder d, boolean replayed) { + return new DeliveryResponses.TransferResult(o.getDispatchNo(), o.getOutboundNo(), o.getStatus(), + d.getDeliveryNo(), d.getStatus(), o.getPalletCount(), o.getPackageCount(), replayed); + } + private DeliveryResponses.OutboundRow outboundRow(OutboundOrder o) { return new DeliveryResponses.OutboundRow(o.getId(), o.getOutboundNo(), o.getDispatchNo(), o.getOverseasWarehouseId(), o.getStatus(), o.getPalletCount(), o.getPackageCount(), o.getCreatedAt()); } + private DeliveryResponses.DeliveryRow deliveryRow(DeliveryOrder d) { return new DeliveryResponses.DeliveryRow(d.getId(), d.getDeliveryNo(), d.getOutboundNo(), d.getDispatchNo(), d.getOverseasWarehouseId(), d.getStatus(), d.getPalletCount(), d.getPackageCount(), d.getCreatedAt()); } + private DeliveryResponses.PalletRow palletRow(OutboundPalletItem p) { return new DeliveryResponses.PalletRow(p.getId(), p.getWarehousePalletId(), p.getPalletNo(), p.getInboundCode(), p.getOrderRefs(), p.getDestinationWarehouseId(), p.getDestinationWarehouseCode(), p.getDestinationWarehouseName(), p.getPackageCount()); } + private static PageResult page(Page page, List records) { return new PageResult<>(records, page.getCurrent(), page.getSize(), page.getTotal(), page.getPages()); } + 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/dto/DispatchResponse.java b/lastleg/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchResponse.java index 1eaec00..5e7d2a8 100644 --- a/lastleg/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchResponse.java +++ b/lastleg/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchResponse.java @@ -6,11 +6,15 @@ 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 overseasWarehouseId, String overseasWarehouseName, LocalDateTime departAt, + String fbaAppointmentNo, String fbaAppointmentStatus, String vehicleAppointmentStatus, LocalDateTime estimatedArrivalAt, String status, - int itemCount, int palletCount, int packageCount, LocalDateTime createdAt, + int itemCount, int palletCount, int packageCount, int pickedPackageCount, + int shippedPackageCount, int remainingPackageCount, String partialCompletionReason, + LocalDateTime createdAt, List items) { public record Item(Long id, Long planPalletId, Long warehousePalletId, String palletNo, + String overseasWarehouseId, String overseasWarehouseName, 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 index 85142ec..f131831 100644 --- a/lastleg/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchItem.java +++ b/lastleg/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchItem.java @@ -20,6 +20,8 @@ public class DispatchItem { private Long planPalletId; private Long warehousePalletId; private Long orderId; + private String overseasWarehouseId; + private String overseasWarehouseName; private String orderNo; private String orderRefs; private String inboundCode; 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 index 600e3c8..6553140 100644 --- a/lastleg/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchOrder.java +++ b/lastleg/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchOrder.java @@ -15,6 +15,8 @@ public class DispatchOrder { private Long id; private String tenantId; private String dispatchNo; + private String overseasWarehouseId; + private String overseasWarehouseName; private String vehiclePlate; private String driverName; private String driverPhone; @@ -29,6 +31,9 @@ public class DispatchOrder { private Integer itemCount; private Integer palletCount; private Integer packageCount; + private Integer pickedPackageCount; + private Integer shippedPackageCount; + private String partialCompletionReason; private String createdBy; 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 index 2f236c9..b5c35b8 100644 --- a/lastleg/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java +++ b/lastleg/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java @@ -11,13 +11,15 @@ import java.util.List; public interface DispatchCandidateMapper { @Select("") List listTenant(@Param("tenantId") String tenantId, @Param("overseasWarehouseId") String overseasWarehouseId, @Param("destinationWarehouseCode") String destinationWarehouseCode, - @Param("skuCode") String skuCode, + @Param("inboundCode") String inboundCode, @Param("offset") long offset, @Param("limit") int limit); @@ -41,11 +45,7 @@ public interface InventoryBalanceMapper extends BaseMapper { "COALESCE(SUM(available_packages),0) available_packages,COALESCE(SUM(hold_packages),0) hold_packages," + "COALESCE(SUM(allocated_packages),0) allocated_packages,COALESCE(SUM(picked_packages),0) picked_packages," + "COALESCE(SUM(in_transit_packages),0) in_transit_packages,COALESCE(SUM(receiving_packages),0) receiving_packages," + - "COALESCE(SUM(procurement_packages),0) procurement_packages," + - "COALESCE(SUM(available_quantity),0) available_quantity,COALESCE(SUM(hold_quantity),0) hold_quantity," + - "COALESCE(SUM(allocated_quantity),0) allocated_quantity,COALESCE(SUM(picked_quantity),0) picked_quantity," + - "COALESCE(SUM(in_transit_quantity),0) in_transit_quantity,COALESCE(SUM(receiving_quantity),0) receiving_quantity," + - "COALESCE(SUM(procurement_quantity),0) procurement_quantity FROM ll_inventory_balance WHERE tenant_id=#{tenantId}" + + "COALESCE(SUM(procurement_packages),0) procurement_packages FROM ll_inventory_balance WHERE tenant_id=#{tenantId}" + " AND overseas_warehouse_id=#{overseasWarehouseId}") 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 index b07bcf2..0720cbd 100644 --- a/lastleg/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryLedgerMapper.java +++ b/lastleg/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryLedgerMapper.java @@ -15,7 +15,7 @@ public interface InventoryLedgerMapper extends BaseMapper { @Select("") List listTenant(@Param("tenantId") String tenantId, @Param("overseasWarehouseId") String overseasWarehouseId, - @Param("skuCode") String skuCode, + @Param("inboundCode") String inboundCode, @Param("businessType") String businessType, @Param("businessOrderNo") String businessOrderNo, @Param("startedAt") LocalDateTime startedAt, 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 index 14f26ec..b94ceb0 100644 --- a/lastleg/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryPositionMapper.java +++ b/lastleg/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryPositionMapper.java @@ -14,7 +14,7 @@ public interface InventoryPositionMapper extends BaseMapper { @Param("balanceId") Long balanceId); @Select("SELECT * FROM ll_inventory_position WHERE tenant_id=#{tenantId} AND pallet_id=#{palletId} " + - "ORDER BY sku_code,batch_no") + "ORDER BY balance_id,batch_no") List listPallet(@Param("tenantId") String tenantId, @Param("palletId") Long palletId); 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 index 101d8bb..39b7585 100644 --- a/lastleg/src/main/java/com/baiyue/lastleg/inventory/model/InventorySummary.java +++ b/lastleg/src/main/java/com/baiyue/lastleg/inventory/model/InventorySummary.java @@ -12,11 +12,4 @@ public class InventorySummary { 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/controller/InventoryQueryController.java b/lastleg/src/main/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryController.java index 85d513a..c7496b5 100644 --- 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 @@ -35,7 +35,7 @@ public class InventoryQueryController { @Resource private InventoryQueryService service; @GetMapping(BASE_PATH + "/summary") - @Operation(summary = "Get SKU/package inventory totals") + @Operation(summary = "Get inbound-order package inventory totals") public ApiResponse summary( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String tenantId, @@ -44,7 +44,7 @@ public class InventoryQueryController { } @GetMapping(BASE_PATH + "/balances") - @Operation(summary = "Page inventory by customer, destination warehouse and SKU") + @Operation(summary = "Page inventory by customer, warehouse and inbound order") public ApiResponse> balances( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String tenantId, @@ -53,7 +53,7 @@ public class InventoryQueryController { } @GetMapping(BASE_PATH + "/balances/{balanceId}/positions") - @Operation(summary = "List pallet and batch positions for one SKU balance") + @Operation(summary = "List pallet and batch positions for one inbound-order balance") public ApiResponse> positions( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String tenantId, @@ -72,7 +72,7 @@ public class InventoryQueryController { } @GetMapping(value = BASE_PATH + "/balances/export", produces = "text/csv") - @Operation(summary = "Export filtered SKU inventory as UTF-8 CSV") + @Operation(summary = "Export filtered inbound-order inventory as UTF-8 CSV") public ResponseEntity exportBalances( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String 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 index 6247228..64fd75c 100644 --- 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 @@ -12,8 +12,9 @@ public record InventoryBalanceView( String destinationWarehouseId, String destinationWarehouseCode, String destinationWarehouseName, - String skuCode, - String skuName, + Long inboundOrderId, + String inboundOrderNo, + String inboundCode, long totalPackages, long availablePackages, long holdPackages, @@ -22,14 +23,6 @@ public record InventoryBalanceView( 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 index a0ca3f6..d73cd23 100644 --- 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 @@ -1,7 +1,7 @@ package com.baiyue.lastleg.inventory.query.dto; public record InventoryDashboardResponse( - long skuDimensionCount, + long inboundOrderCount, long palletCount, long totalPackages, long availablePackages, @@ -10,14 +10,6 @@ public record InventoryDashboardResponse( long pickedPackages, long inTransitPackages, long receivingPackages, - long procurementPackages, - long totalQuantity, - long availableQuantity, - long holdQuantity, - long allocatedQuantity, - long pickedQuantity, - long inTransitQuantity, - long receivingQuantity, - long procurementQuantity + long procurementPackages ) { } 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 index c1d2249..00b602c 100644 --- 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 @@ -7,22 +7,20 @@ public record InventoryLedgerView( String customerId, String overseasWarehouseId, String destinationWarehouseCode, - String skuCode, + Long inboundOrderId, + String inboundOrderNo, + String inboundCode, 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 index 6edd47c..8f67d79 100644 --- 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 @@ -8,7 +8,9 @@ public record InventoryPositionView( String customerId, String overseasWarehouseId, String destinationWarehouseCode, - String skuCode, + Long inboundOrderId, + String inboundOrderNo, + String inboundCode, Long palletId, String palletNo, String batchNo, @@ -20,7 +22,6 @@ public record InventoryPositionView( 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 index 21fb6ca..0783e3a 100644 --- 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 @@ -16,8 +16,9 @@ public final class InventoryQueryRequests { String overseasWarehouseId, String overseasWarehouseCode, String destinationWarehouseCode, - String skuCode, - String skuName, + String inboundOrderNo, + String inboundCode, + String businessOrderNo, String inventoryStatus ) { public BalanceQuery { @@ -34,10 +35,10 @@ public final class InventoryQueryRequests { @Min(1) @Max(200) long size, String overseasWarehouseId, String destinationWarehouseCode, - String skuCode, String businessType, String businessOrderNo, String inboundOrderNo, + String inboundCode, String outboundOrderNo, String palletNo, String batchNo, 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 index d342069..bc06cd4 100644 --- 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 @@ -16,8 +16,9 @@ public interface InventoryQueryMapper { " 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 inbound_order_no LIKE CONCAT('%',#{q.inboundOrderNo},'%')" + + " AND inbound_code LIKE CONCAT('%',#{q.inboundCode},'%')" + + " AND EXISTS (SELECT 1 FROM ll_inventory_ledger l WHERE l.balance_id=ll_inventory_balance.id AND l.tenant_id=#{tenantId} AND l.business_order_no LIKE CONCAT('%',#{q.businessOrderNo},'%'))" + "" + " AND available_packages > 0" + " AND hold_packages > 0" + @@ -33,8 +34,9 @@ public interface InventoryQueryMapper { " 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.inbound_order_no LIKE CONCAT('%',#{q.inboundOrderNo},'%')" + + " AND b.inbound_code LIKE CONCAT('%',#{q.inboundCode},'%')" + + " AND EXISTS (SELECT 1 FROM ll_inventory_ledger l WHERE l.balance_id=b.id AND l.tenant_id=#{tenantId} AND l.business_order_no LIKE CONCAT('%',#{q.businessOrderNo},'%'))" + "" + " AND b.available_packages > 0" + " AND b.hold_packages > 0" + @@ -63,14 +65,7 @@ public interface InventoryQueryMapper { "COALESCE(SUM(picked_packages),0) picked_packages," + "COALESCE(SUM(in_transit_packages),0) in_transit_packages," + "COALESCE(SUM(receiving_packages),0) receiving_packages," + - "COALESCE(SUM(procurement_packages),0) procurement_packages," + - "COALESCE(SUM(available_quantity),0) available_quantity," + - "COALESCE(SUM(hold_quantity),0) hold_quantity," + - "COALESCE(SUM(allocated_quantity),0) allocated_quantity," + - "COALESCE(SUM(picked_quantity),0) picked_quantity," + - "COALESCE(SUM(in_transit_quantity),0) in_transit_quantity," + - "COALESCE(SUM(receiving_quantity),0) receiving_quantity," + - "COALESCE(SUM(procurement_quantity),0) procurement_quantity " + + "COALESCE(SUM(procurement_packages),0) procurement_packages " + "FROM ll_inventory_balance" + BALANCE_FILTER + "") InventoryAggregateProjection aggregate(@Param("tenantId") String tenantId, @Param("q") InventoryQueryRequests.BalanceQuery query); @@ -95,16 +90,16 @@ public interface InventoryQueryMapper { @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); + @Select("SELECT * FROM ll_inventory_balance WHERE tenant_id=#{tenantId} AND id=#{balanceId} LIMIT 1") + InventoryBalance ownedBalance(@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 inbound_code LIKE CONCAT('%',#{q.inboundCode},'%')" + " AND outbound_order_no LIKE CONCAT('%',#{q.outboundOrderNo},'%')" + " AND pallet_no LIKE CONCAT('%',#{q.palletNo},'%')" + " AND batch_no LIKE CONCAT('%',#{q.batchNo},'%')" + 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 index 85eecfa..066d812 100644 --- 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 @@ -12,11 +12,4 @@ public class InventoryAggregateProjection { 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 index ef1214a..426904d 100644 --- 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 @@ -41,22 +41,11 @@ public class InventoryQueryService { 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); + receivingPackages, procurementPackages); } public PageResult balances(String tenantId, InventoryQueryRequests.BalanceQuery query) { @@ -70,10 +59,11 @@ public class InventoryQueryService { public List positions(String tenantId, long balanceId, InventoryQueryRequests.PositionQuery query) { validateStatus(query.inventoryStatus(), POSITION_STATUSES); - if (mapper.countOwnedBalance(tenantId, balanceId) == 0) { + InventoryBalance balance = mapper.ownedBalance(tenantId, balanceId); + if (balance == null) { throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Inventory balance not found"); } - return mapper.positions(tenantId, balanceId, query).stream().map(this::positionView).toList(); + return mapper.positions(tenantId, balanceId, query).stream().map(value -> positionView(value, balance)).toList(); } public PageResult ledgers(String tenantId, InventoryQueryRequests.LedgerQuery query) { @@ -88,30 +78,30 @@ public class InventoryQueryService { 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"); + "destinationWarehouseName,inboundOrderId,inboundOrderNo,inboundCode,totalPackages,availablePackages," + + "holdPackages,allocatedPackages,pickedPackages,inTransitPackages,receivingPackages,procurementPackages"); 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.destinationWarehouseCode()), csv(v.destinationWarehouseName()), csv(v.inboundOrderId()), + csv(v.inboundOrderNo()), csv(v.inboundCode()), 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())))); + csv(v.procurementPackages())))); 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"); + rows.add("occurredAt,customerId,overseasWarehouseId,destinationWarehouseCode,inboundOrderId,inboundOrderNo," + + "inboundCode,businessType,businessOrderNo,outboundOrderNo,palletNo,batchNo,statusFrom,statusTo," + + "beforePackages,deltaPackages,afterPackages,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.destinationWarehouseCode()), csv(v.inboundOrderId()), csv(v.inboundOrderNo()), csv(v.inboundCode()), + csv(v.businessType()), csv(v.businessOrderNo()), 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())))); + csv(v.afterPackages()), csv(v.operatorId()), csv(v.remark())))); return ("\uFEFF" + String.join("\r\n", rows)).getBytes(StandardCharsets.UTF_8); } @@ -123,47 +113,37 @@ public class InventoryQueryService { 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 + value.getDestinationWarehouseCode(), value.getDestinationWarehouseName(), value.getInboundOrderId(), + value.getInboundOrderNo(), value.getInboundCode(), 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()); + receivingPackages, procurementPackages, value.getUpdatedAt()); } - private InventoryPositionView positionView(InventoryPosition value) { + private InventoryPositionView positionView(InventoryPosition value, InventoryBalance balance) { 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.getOverseasWarehouseId(), value.getDestinationWarehouseCode(), balance.getInboundOrderId(), + balance.getInboundOrderNo(), balance.getInboundCode(), 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(), + available, hold, allocated, picked, receiving, 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.getDestinationWarehouseCode(), value.getInboundOrderId(), value.getInboundOrderNo(), value.getInboundCode(), + value.getPalletNo(), value.getBatchNo(), value.getWarehouseLocationCode(), value.getBusinessType(), value.getBusinessOrderNo(), - value.getInboundOrderNo(), value.getOutboundOrderNo(), value.getStatusFrom(), value.getStatusTo(), + 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()); } diff --git a/lastleg/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java b/lastleg/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java index 0d474db..f950f5f 100644 --- a/lastleg/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java +++ b/lastleg/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java @@ -5,6 +5,7 @@ import com.baiyue.lastleg.entity.LastlegOrder; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; @Mapper public interface LastlegOrderMapper extends BaseMapper { @@ -16,4 +17,22 @@ public interface LastlegOrderMapper extends BaseMapper { @Select("SELECT COUNT(*) FROM ll_order WHERE tenant_id=#{tenantId} AND container_no=#{containerNo}") long countContainer(@Param("tenantId") String tenantId, @Param("containerNo") String containerNo); + + @Select("SELECT * FROM ll_order WHERE tenant_id=#{tenantId} AND id=#{id} FOR UPDATE") + LastlegOrder lockById(@Param("tenantId") String tenantId, @Param("id") Long id); + + @Update("UPDATE ll_order SET picked_package_count=picked_package_count+#{packages},updated_at=NOW() " + + "WHERE tenant_id=#{tenantId} AND id=#{id} " + + "AND package_count-shipped_package_count-picked_package_count >= #{packages}") + int increasePicked(@Param("tenantId") String tenantId, @Param("id") Long id, + @Param("packages") long packages); + + @Update("UPDATE ll_order SET picked_package_count=picked_package_count-#{packages}," + + "shipped_package_count=shipped_package_count+#{packages},outbound_order_no=#{outboundNo}," + + "current_status=CASE WHEN shipped_package_count+#{packages}=package_count " + + "THEN 'COMPLETED' ELSE 'PARTIALLY_SHIPPED' END,updated_at=NOW() " + + "WHERE tenant_id=#{tenantId} AND id=#{id} AND picked_package_count >= #{packages} " + + "AND shipped_package_count+#{packages} <= package_count") + int transferPickedToShipped(@Param("tenantId") String tenantId, @Param("id") Long id, + @Param("packages") long packages, @Param("outboundNo") String outboundNo); } diff --git a/lastleg/src/main/java/com/baiyue/lastleg/relabel/RelabelMapperConfiguration.java b/lastleg/src/main/java/com/baiyue/lastleg/relabel/RelabelMapperConfiguration.java new file mode 100644 index 0000000..ab033a5 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/relabel/RelabelMapperConfiguration.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.relabel; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.context.annotation.Configuration; + +@Configuration +@MapperScan("com.baiyue.lastleg.relabel.mapper") +public class RelabelMapperConfiguration { +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationController.java b/lastleg/src/main/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationController.java new file mode 100644 index 0000000..4eb2700 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationController.java @@ -0,0 +1,53 @@ +package com.baiyue.lastleg.relabel.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.relabel.dto.RelabelRequests; +import com.baiyue.lastleg.relabel.dto.RelabelResponses; +import com.baiyue.lastleg.relabel.service.RelabelApplicationService; +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/relabel-applications") +public class RelabelApplicationController { + @Resource private RelabelApplicationService service; + + @PostMapping + public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String tenantId, @Valid @RequestBody RelabelRequests.Create request) { + return ApiResponse.success(service.create(scope(user, tenantId), operator(user), request)); + } + @GetMapping + public ApiResponse> page(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String tenantId, @Valid RelabelRequests.Query query) { + return ApiResponse.success(service.page(scope(user, tenantId), query)); + } + @GetMapping("/{relabelNo}") + public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String tenantId, @PathVariable String relabelNo) { + return ApiResponse.success(service.detail(scope(user, tenantId), relabelNo)); + } + @PutMapping("/{relabelNo}/status") + public ApiResponse status(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String tenantId, @PathVariable String relabelNo, + @Valid @RequestBody RelabelRequests.ChangeStatus request) { + return ApiResponse.success(service.changeStatus(scope(user, tenantId), relabelNo, operator(user), request)); + } + private String scope(AuthenticatedUser user, String requested) { + if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) { + if (requested == null || requested.isBlank() || "0".equals(requested)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Platform request must specify target tenant"); + } + return requested.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/relabel/dto/RelabelRequests.java b/lastleg/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelRequests.java new file mode 100644 index 0000000..32ddaea --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelRequests.java @@ -0,0 +1,54 @@ +package com.baiyue.lastleg.relabel.dto; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; + +import java.time.LocalDateTime; +import java.util.List; + +public final class RelabelRequests { + private RelabelRequests() {} + + public record Create( + @NotBlank @Size(max = 128) String customerOrderNo, + @NotBlank @Size(max = 128) String overseasWarehouseId, + @NotNull LocalDateTime labelDeadline, + @NotBlank @Size(max = 128) String contactName, + @NotBlank @Size(max = 64) String contactPhone, + @Size(max = 1000) String remark, + @NotBlank @Size(max = 255) String idempotencyKey, + @NotEmpty @Size(max = 500) List<@Valid Item> items + ) {} + + public record Item( + @NotBlank @Size(max = 128) String skuCode, + @Size(max = 128) String productImageFileId, + @Size(max = 255) String productImageFileName, + @Size(max = 1000) String productImageUrl, + @NotBlank @Size(max = 255) String productNameZh, + @Positive int totalPackageCount, + @Positive int notifiedPackageCount, + @NotBlank @Size(max = 128) String shipmentNo, + @Size(max = 255) String trackingCode, + @NotBlank @Size(max = 128) String newSkuCode, + @Size(max = 1000) String labelRequirement, + @Size(max = 128) String relabelFileId, + @Size(max = 255) String relabelFileName, + @Size(max = 1000) String relabelFileUrl + ) {} + + public record Query(@Min(1) long page, @Min(1) @Max(200) long size, String relabelNo, + String customerOrderNo, String skuCode, String status, + String overseasWarehouseId, LocalDateTime createdFrom, LocalDateTime createdTo) { + public Query { if (page == 0) page = 1; if (size == 0) size = 20; } + } + + public record ChangeStatus(@NotBlank @Size(max = 32) String status, + @Size(max = 1000) String remark) {} +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelResponses.java b/lastleg/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelResponses.java new file mode 100644 index 0000000..f148026 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelResponses.java @@ -0,0 +1,22 @@ +package com.baiyue.lastleg.relabel.dto; + +import java.time.LocalDateTime; +import java.util.List; + +public final class RelabelResponses { + private RelabelResponses() {} + + public record Summary(Long id, String relabelNo, Long lastlegOrderId, String lastlegOrderNo, + String customerOrderNo, String overseasWarehouseId, LocalDateTime labelDeadline, + String contactName, String contactPhone, String status, int itemCount, + int totalPackageCount, int notifiedPackageCount, String skuCodes, + LocalDateTime createdAt) {} + public record Item(Long id, String skuCode, String productImageFileId, String productImageFileName, + String productImageUrl, String productNameZh, int totalPackageCount, + int notifiedPackageCount, String shipmentNo, String trackingCode, + String newSkuCode, String labelRequirement, String relabelFileId, + String relabelFileName, String relabelFileUrl) {} + public record History(String fromStatus, String toStatus, String operatorId, + String remark, LocalDateTime createdAt) {} + public record Detail(Summary application, String remark, List items, List history) {} +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/relabel/entity/RelabelApplication.java b/lastleg/src/main/java/com/baiyue/lastleg/relabel/entity/RelabelApplication.java new file mode 100644 index 0000000..3deb066 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/relabel/entity/RelabelApplication.java @@ -0,0 +1,32 @@ +package com.baiyue.lastleg.relabel.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_relabel_application") +public class RelabelApplication { + @TableId(type = IdType.INPUT) private Long id; + private String tenantId; + private String relabelNo; + private Long lastlegOrderId; + private String customerOrderNo; + private String overseasWarehouseId; + private LocalDateTime labelDeadline; + private String contactName; + private String contactPhone; + private String remark; + private String status; + private Integer itemCount; + private Integer totalPackageCount; + private Integer notifiedPackageCount; + private String idempotencyKey; + private String requestHash; + private String createdBy; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/relabel/entity/RelabelItem.java b/lastleg/src/main/java/com/baiyue/lastleg/relabel/entity/RelabelItem.java new file mode 100644 index 0000000..3f6ec61 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/relabel/entity/RelabelItem.java @@ -0,0 +1,31 @@ +package com.baiyue.lastleg.relabel.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_relabel_item") +public class RelabelItem { + @TableId(type = IdType.INPUT) private Long id; + private Long applicationId; + private String tenantId; + private String skuCode; + private String productImageFileId; + private String productImageFileName; + private String productImageUrl; + private String productNameZh; + private Integer totalPackageCount; + private Integer notifiedPackageCount; + private String shipmentNo; + private String trackingCode; + private String newSkuCode; + private String labelRequirement; + private String relabelFileId; + private String relabelFileName; + private String relabelFileUrl; + private LocalDateTime createdAt; +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/relabel/entity/RelabelStatusHistory.java b/lastleg/src/main/java/com/baiyue/lastleg/relabel/entity/RelabelStatusHistory.java new file mode 100644 index 0000000..89ee442 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/relabel/entity/RelabelStatusHistory.java @@ -0,0 +1,21 @@ +package com.baiyue.lastleg.relabel.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_relabel_status_history") +public class RelabelStatusHistory { + @TableId(type = IdType.INPUT) private Long id; + private String tenantId; + private Long applicationId; + private String fromStatus; + private String toStatus; + private String operatorId; + private String remark; + private LocalDateTime createdAt; +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelApplicationMapper.java b/lastleg/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelApplicationMapper.java new file mode 100644 index 0000000..aa718dc --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelApplicationMapper.java @@ -0,0 +1,19 @@ +package com.baiyue.lastleg.relabel.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.relabel.entity.RelabelApplication; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface RelabelApplicationMapper extends BaseMapper { + @Select("SELECT * FROM ll_relabel_application WHERE tenant_id=#{tenantId} AND relabel_no=#{relabelNo} LIMIT 1") + RelabelApplication findNo(@Param("tenantId") String tenantId, @Param("relabelNo") String relabelNo); + + @Select("SELECT * FROM ll_relabel_application WHERE tenant_id=#{tenantId} AND idempotency_key=#{key} LIMIT 1") + RelabelApplication findKey(@Param("tenantId") String tenantId, @Param("key") String key); + + @Select("SELECT * FROM ll_relabel_application WHERE tenant_id=#{tenantId} AND relabel_no=#{relabelNo} LIMIT 1 FOR UPDATE") + RelabelApplication lockNo(@Param("tenantId") String tenantId, @Param("relabelNo") String relabelNo); +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelItemMapper.java b/lastleg/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelItemMapper.java new file mode 100644 index 0000000..1e9b91c --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelItemMapper.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.relabel.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.relabel.entity.RelabelItem; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface RelabelItemMapper extends BaseMapper { +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelStatusHistoryMapper.java b/lastleg/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelStatusHistoryMapper.java new file mode 100644 index 0000000..740e3b6 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelStatusHistoryMapper.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.relabel.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.relabel.entity.RelabelStatusHistory; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface RelabelStatusHistoryMapper extends BaseMapper { +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java b/lastleg/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java new file mode 100644 index 0000000..0203c73 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java @@ -0,0 +1,252 @@ +package com.baiyue.lastleg.relabel.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.entity.LastlegOrder; +import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper; +import com.baiyue.lastleg.relabel.dto.RelabelRequests; +import com.baiyue.lastleg.relabel.dto.RelabelResponses; +import com.baiyue.lastleg.relabel.entity.RelabelApplication; +import com.baiyue.lastleg.relabel.entity.RelabelItem; +import com.baiyue.lastleg.relabel.entity.RelabelStatusHistory; +import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper; +import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper; +import com.baiyue.lastleg.relabel.mapper.RelabelStatusHistoryMapper; +import com.baiyue.lastleg.service.LastlegOrderNoGenerator; +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.util.StringUtils; +import org.springframework.web.server.ResponseStatusException; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.HexFormat; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; + +@Service +public class RelabelApplicationService { + public static final String PENDING_PROCESSING = "PENDING_PROCESSING"; + private static final Set STATUSES = Set.of(PENDING_PROCESSING, "PROCESSING", "COMPLETED", "REJECTED", "CANCELLED"); + + @Resource private RelabelApplicationMapper applications; + @Resource private RelabelItemMapper items; + @Resource private RelabelStatusHistoryMapper histories; + @Resource private LastlegOrderMapper orders; + @Resource private LastlegOrderStatusHistoryMapper orderHistories; + @Resource private LastlegOrderNoGenerator orderNoGenerator; + @Resource private ObjectMapper objectMapper; + + @Transactional + public RelabelResponses.Detail create(String tenantId, String operatorId, RelabelRequests.Create request) { + int total = 0; + int notified = 0; + for (RelabelRequests.Item item : request.items()) { + if (item.notifiedPackageCount() > item.totalPackageCount()) { + throw badRequest("Notified relocation packages cannot exceed total packages for SKU " + item.skuCode()); + } + total = Math.addExact(total, item.totalPackageCount()); + notified = Math.addExact(notified, item.notifiedPackageCount()); + } + String requestHash = hash(request); + RelabelApplication replay = applications.findKey(tenantId, request.idempotencyKey().trim()); + if (replay != null) { + if (!requestHash.equals(replay.getRequestHash())) throw conflict("Idempotency key was reused with different relabel data"); + return detail(tenantId, replay.getRelabelNo()); + } + LocalDateTime now = LocalDateTime.now(); + long applicationId = IdWorker.getId(); + String relabelNo = "FBR" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + applicationId; + LastlegOrder order = new LastlegOrder(); + order.setId(IdWorker.getId()); + order.setOrderNo(orderNoGenerator.next()); + order.setTenantId(tenantId); + order.setOrderType("RELABEL_APPLICATION"); + order.setInboundCode(relabelNo); + order.setSourceRegion("CUSTOMER"); + order.setSourceInboundId(relabelNo); + order.setSourceOrderNo(request.customerOrderNo().trim()); + order.setOverseasWarehouseId(request.overseasWarehouseId().trim()); + order.setPalletCount(0); + order.setPackageCount(total); + order.setPickedPackageCount(0); + order.setShippedPackageCount(0); + order.setCurrentStatus(PENDING_PROCESSING); + order.setRelabelOrderNo(relabelNo); + order.setRelabelStatus(PENDING_PROCESSING); + order.setCustomerRemark(trim(request.remark())); + order.setSourceEventId("CUSTOMER_RELABEL:" + relabelNo); + order.setSourceLoadingPlanId("RELABEL_APPLICATION"); + order.setSourcePayloadHash("sha256:" + requestHash); + order.setCreatedAt(now); + order.setUpdatedAt(now); + orders.insert(order); + + RelabelApplication application = new RelabelApplication(); + application.setId(applicationId); + application.setTenantId(tenantId); + application.setRelabelNo(relabelNo); + application.setLastlegOrderId(order.getId()); + application.setCustomerOrderNo(request.customerOrderNo().trim()); + application.setOverseasWarehouseId(request.overseasWarehouseId().trim()); + application.setLabelDeadline(request.labelDeadline()); + application.setContactName(request.contactName().trim()); + application.setContactPhone(request.contactPhone().trim()); + application.setRemark(trim(request.remark())); + application.setStatus(PENDING_PROCESSING); + application.setItemCount(request.items().size()); + application.setTotalPackageCount(total); + application.setNotifiedPackageCount(notified); + application.setIdempotencyKey(request.idempotencyKey().trim()); + application.setRequestHash(requestHash); + application.setCreatedBy(trim(operatorId)); + application.setCreatedAt(now); + application.setUpdatedAt(now); + applications.insert(application); + + for (RelabelRequests.Item source : request.items()) insertItem(tenantId, applicationId, source, now); + insertHistory(tenantId, applicationId, null, PENDING_PROCESSING, operatorId, "Relabel application submitted", now); + insertOrderHistory(order.getId(), null, PENDING_PROCESSING, "Relabel application submitted", now); + return detail(tenantId, relabelNo); + } + + @Transactional(readOnly = true) + public PageResult page(String tenantId, RelabelRequests.Query query) { + if (query.createdFrom() != null && query.createdTo() != null && query.createdFrom().isAfter(query.createdTo())) { + throw badRequest("createdFrom must not be after createdTo"); + } + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(RelabelApplication::getTenantId, tenantId) + .like(StringUtils.hasText(query.relabelNo()), RelabelApplication::getRelabelNo, query.relabelNo()) + .like(StringUtils.hasText(query.customerOrderNo()), RelabelApplication::getCustomerOrderNo, query.customerOrderNo()) + .eq(StringUtils.hasText(query.status()), RelabelApplication::getStatus, query.status()) + .eq(StringUtils.hasText(query.overseasWarehouseId()), RelabelApplication::getOverseasWarehouseId, query.overseasWarehouseId()) + .ge(query.createdFrom() != null, RelabelApplication::getCreatedAt, query.createdFrom()) + .le(query.createdTo() != null, RelabelApplication::getCreatedAt, query.createdTo()) + .apply(StringUtils.hasText(query.skuCode()), + "EXISTS (SELECT 1 FROM ll_relabel_item ri WHERE ri.application_id=ll_relabel_application.id AND ri.tenant_id=ll_relabel_application.tenant_id AND ri.sku_code LIKE CONCAT('%',{0},'%'))", + query.skuCode()) + .orderByDesc(RelabelApplication::getCreatedAt); + Page page = applications.selectPage(new Page<>(query.page(), query.size()), wrapper); + List records = page.getRecords().stream().map(value -> summary(value, itemList(value.getId()))).toList(); + return new PageResult<>(records, page.getCurrent(), page.getSize(), page.getTotal(), page.getPages()); + } + + @Transactional(readOnly = true) + public RelabelResponses.Detail detail(String tenantId, String relabelNo) { + RelabelApplication application = require(tenantId, relabelNo); + List detailItems = itemList(application.getId()); + List statusHistory = histories.selectList(new LambdaQueryWrapper() + .eq(RelabelStatusHistory::getTenantId, tenantId) + .eq(RelabelStatusHistory::getApplicationId, application.getId()) + .orderByAsc(RelabelStatusHistory::getCreatedAt)); + return new RelabelResponses.Detail(summary(application, detailItems), application.getRemark(), + detailItems.stream().map(this::item).toList(), statusHistory.stream().map(value -> + new RelabelResponses.History(value.getFromStatus(), value.getToStatus(), value.getOperatorId(), + value.getRemark(), value.getCreatedAt())).toList()); + } + + @Transactional + public RelabelResponses.Detail changeStatus(String tenantId, String relabelNo, String operatorId, + RelabelRequests.ChangeStatus request) { + RelabelApplication application = applications.lockNo(tenantId, relabelNo); + if (application == null) throw notFound("Relabel application not found"); + String next = request.status().trim().toUpperCase(); + if (!STATUSES.contains(next)) throw badRequest("Unsupported relabel status"); + if (next.equals(application.getStatus())) return detail(tenantId, relabelNo); + validateTransition(application.getStatus(), next); + String before = application.getStatus(); + LocalDateTime now = LocalDateTime.now(); + application.setStatus(next); + application.setUpdatedAt(now); + applications.updateById(application); + LastlegOrder order = orders.selectById(application.getLastlegOrderId()); + if (order == null || !tenantId.equals(order.getTenantId())) throw conflict("Linked tail-leg order is missing"); + String orderBefore = order.getCurrentStatus(); + order.setCurrentStatus(next); + order.setRelabelStatus(next); + order.setUpdatedAt(now); + orders.updateById(order); + insertHistory(tenantId, application.getId(), before, next, operatorId, request.remark(), now); + insertOrderHistory(order.getId(), orderBefore, next, request.remark(), now); + return detail(tenantId, relabelNo); + } + + private void insertItem(String tenantId, long applicationId, RelabelRequests.Item source, LocalDateTime now) { + RelabelItem item = new RelabelItem(); + item.setId(IdWorker.getId()); item.setApplicationId(applicationId); item.setTenantId(tenantId); + item.setSkuCode(source.skuCode().trim()); item.setProductImageFileId(trim(source.productImageFileId())); + item.setProductImageFileName(trim(source.productImageFileName())); item.setProductImageUrl(trim(source.productImageUrl())); + item.setProductNameZh(source.productNameZh().trim()); item.setTotalPackageCount(source.totalPackageCount()); + item.setNotifiedPackageCount(source.notifiedPackageCount()); item.setShipmentNo(source.shipmentNo().trim()); + item.setTrackingCode(trim(source.trackingCode())); item.setNewSkuCode(source.newSkuCode().trim()); + item.setLabelRequirement(trim(source.labelRequirement())); item.setRelabelFileId(trim(source.relabelFileId())); + item.setRelabelFileName(trim(source.relabelFileName())); item.setRelabelFileUrl(trim(source.relabelFileUrl())); + item.setCreatedAt(now); items.insert(item); + } + + private RelabelResponses.Summary summary(RelabelApplication value, List detailItems) { + LastlegOrder order = orders.selectById(value.getLastlegOrderId()); + String skuCodes = detailItems.stream().map(RelabelItem::getSkuCode).filter(Objects::nonNull).distinct().collect(Collectors.joining(",")); + return new RelabelResponses.Summary(value.getId(), value.getRelabelNo(), value.getLastlegOrderId(), + order == null ? null : order.getOrderNo(), value.getCustomerOrderNo(), value.getOverseasWarehouseId(), + value.getLabelDeadline(), value.getContactName(), value.getContactPhone(), value.getStatus(), + Objects.requireNonNullElse(value.getItemCount(), detailItems.size()), + Objects.requireNonNullElse(value.getTotalPackageCount(), 0), + Objects.requireNonNullElse(value.getNotifiedPackageCount(), 0), skuCodes, value.getCreatedAt()); + } + + private RelabelResponses.Item item(RelabelItem value) { + return new RelabelResponses.Item(value.getId(), value.getSkuCode(), value.getProductImageFileId(), + value.getProductImageFileName(), value.getProductImageUrl(), value.getProductNameZh(), + value.getTotalPackageCount(), value.getNotifiedPackageCount(), value.getShipmentNo(), + value.getTrackingCode(), value.getNewSkuCode(), value.getLabelRequirement(), value.getRelabelFileId(), + value.getRelabelFileName(), value.getRelabelFileUrl()); + } + + private List itemList(Long applicationId) { + return items.selectList(new LambdaQueryWrapper().eq(RelabelItem::getApplicationId, applicationId) + .orderByAsc(RelabelItem::getId)); + } + private RelabelApplication require(String tenantId, String relabelNo) { + RelabelApplication value = applications.findNo(tenantId, relabelNo); + if (value == null) throw notFound("Relabel application not found"); + return value; + } + private void insertHistory(String tenantId, Long id, String from, String to, String operator, + String remark, LocalDateTime now) { + RelabelStatusHistory value = new RelabelStatusHistory(); value.setId(IdWorker.getId()); value.setTenantId(tenantId); + value.setApplicationId(id); value.setFromStatus(from); value.setToStatus(to); value.setOperatorId(trim(operator)); + value.setRemark(trim(remark)); value.setCreatedAt(now); histories.insert(value); + } + private void insertOrderHistory(Long id, String from, String to, String remark, LocalDateTime now) { + LastlegOrderStatusHistory value = new LastlegOrderStatusHistory(); value.setOrderId(id); value.setFromStatus(from); + value.setToStatus(to); value.setEventId("RELABEL_APPLICATION"); value.setRemark(trim(remark)); value.setCreatedAt(now); + orderHistories.insert(value); + } + private static void validateTransition(String from, String to) { + boolean allowed = PENDING_PROCESSING.equals(from) && Set.of("PROCESSING", "REJECTED", "CANCELLED").contains(to) + || "PROCESSING".equals(from) && Set.of("COMPLETED", "REJECTED", "CANCELLED").contains(to); + if (!allowed) throw conflict("Relabel status cannot change from " + from + " to " + to); + } + private String hash(Object value) { + try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(objectMapper.copy().findAndRegisterModules().writeValueAsString(value).getBytes(StandardCharsets.UTF_8))); } + catch (Exception exception) { throw new IllegalStateException(exception); } + } + private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; } + private static ResponseStatusException badRequest(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); } + 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/service/LastlegOrderService.java b/lastleg/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java index 6be392c..ee8ccf2 100644 --- a/lastleg/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java +++ b/lastleg/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java @@ -100,14 +100,17 @@ public class LastlegOrderService { List orders = orderMapper.selectList(filter(tenantId, query) .orderByDesc(LastlegOrder::getCreatedAt).last("LIMIT 10000")); List lines = new ArrayList<>(); - lines.add("ID,尾程单号,订单类型,入仓码,头程单号,货主,海外仓,转仓,转仓编码,FBA仓库,集装箱号,货柜状态,是否异常,是否暂扣,托数,箱数,体积(m3),数量,体积重量,预计到港时间,实际到港时间,预计到仓,入库单号,出库单号,派送单号,换标单号,换标状态,合托标记,当前状态,备注(仅自己),备注(客户),备注(仓库)"); + lines.add("ID,尾程单号,订单类型,入仓码,头程单号,货主,海外仓,转仓,转仓编码,FBA仓库,集装箱号,货柜状态,是否异常,是否暂扣,托数,箱数,已拣箱数,已出库箱数,剩余箱数,体积(m3),数量,体积重量,预计到港时间,实际到港时间,预计到仓,入库单号,出库单号,派送单号,换标单号,换标状态,合托标记,当前状态,备注(仅自己),备注(客户),备注(仓库)"); for (LastlegOrder order : orders) { lines.add(String.join(",", List.of( csv(order.getId()), csv(order.getOrderNo()), csv(order.getOrderType()), csv(order.getInboundCode()), csv(order.getSourceOrderNo()), csv(order.getCustomerName()), csv(order.getOverseasWarehouseName()), csv(order.getTransferWarehouseName()), csv(order.getTransferWarehouseCode()), csv(order.getFbaWarehouseCode()), csv(order.getContainerNo()), csv(order.getContainerStatus()), csv(order.getAbnormal()), csv(order.getDetained()), - csv(order.getPalletCount()), csv(order.getPackageCount()), csv(order.getVolumeCbm()), csv(order.getProductQuantity()), + csv(order.getPalletCount()), csv(order.getPackageCount()), csv(order.getPickedPackageCount()), + csv(order.getShippedPackageCount()), csv(Math.max(0, Objects.requireNonNullElse(order.getPackageCount(), 0) + - Objects.requireNonNullElse(order.getShippedPackageCount(), 0))), + csv(order.getVolumeCbm()), csv(order.getProductQuantity()), csv(order.getVolumeWeightKg()), csv(order.getEstimatedArrivalAt()), csv(order.getActualArrivalAt()), csv(order.getEstimatedWarehouseAt()), csv(order.getInboundOrderNo()), csv(order.getOutboundOrderNo()), csv(order.getDeliveryOrderNo()), csv(order.getRelabelOrderNo()), csv(order.getRelabelStatus()), @@ -155,7 +158,9 @@ public class LastlegOrderService { o.getInboundCode(), o.getSourceOrderNo(), o.getCustomerName(), o.getOverseasWarehouseId(), o.getOverseasWarehouseName(), o.getTransferWarehouseId(), o.getTransferWarehouseCode(), o.getTransferWarehouseName(), o.getFbaWarehouseCode(), o.getContainerNo(), o.getContainerStatus(), - o.getAbnormal(), o.getDetained(), o.getPalletCount(), o.getPackageCount(), o.getVolumeCbm(), + o.getAbnormal(), o.getDetained(), o.getPalletCount(), o.getPackageCount(), o.getPickedPackageCount(), + o.getShippedPackageCount(), Math.max(0, Objects.requireNonNullElse(o.getPackageCount(), 0) + - Objects.requireNonNullElse(o.getShippedPackageCount(), 0)), o.getVolumeCbm(), o.getProductQuantity(), o.getVolumeWeightKg(), o.getEstimatedArrivalAt(), o.getActualArrivalAt(), o.getEstimatedWarehouseAt(), o.getInboundOrderNo(), o.getOutboundOrderNo(), o.getDeliveryOrderNo(), o.getRelabelOrderNo(), o.getRelabelStatus(), o.getPalletMergeFlag(), o.getCurrentStatus(), diff --git a/lastleg/src/main/resources/db/lastleg.sql b/lastleg/src/main/resources/db/lastleg.sql index 6589496..25ad54a 100644 --- a/lastleg/src/main/resources/db/lastleg.sql +++ b/lastleg/src/main/resources/db/lastleg.sql @@ -41,6 +41,8 @@ CREATE TABLE IF NOT EXISTS ll_order ( detained TINYINT NOT NULL DEFAULT 0 COMMENT '是否暂扣:0否1是', pallet_count INT NOT NULL DEFAULT 0 COMMENT '托数', package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', + picked_package_count INT NOT NULL DEFAULT 0 COMMENT '累计已拣箱数', + shipped_package_count INT NOT NULL DEFAULT 0 COMMENT '累计已出库箱数', volume_cbm DECIMAL(18,4) COMMENT '体积(立方米)', product_quantity INT COMMENT '货物数量', volume_weight_kg DECIMAL(18,4) COMMENT '体积重量(千克)', @@ -551,45 +553,43 @@ CREATE TABLE IF NOT EXISTS ll_inventory_balance ( 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', + inbound_order_id BIGINT NOT NULL COMMENT 'Inbound order ID', + inbound_order_no VARCHAR(128) NULL COMMENT 'Inbound order number snapshot', + inbound_code VARCHAR(128) NOT NULL COMMENT 'Inbound code snapshot', 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), + UNIQUE KEY uk_ll_inventory_dimension (tenant_id,overseas_warehouse_id,destination_warehouse_code,inbound_order_id,inbound_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'; + KEY idx_ll_inventory_inbound (tenant_id,inbound_code) +) ENGINE=InnoDB COMMENT='Real-time inbound-order 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, + 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, + receiving_packages 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'; +) ENGINE=InnoDB COMMENT='Inbound-order 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, + destination_warehouse_code VARCHAR(128) NOT NULL DEFAULT '', inbound_order_id BIGINT 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, @@ -597,14 +597,13 @@ CREATE TABLE IF NOT EXISTS ll_inventory_ledger ( 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_inbound (tenant_id,inbound_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'; +) ENGINE=InnoDB COMMENT='Immutable inbound-order package inventory ledger'; CREATE TABLE IF NOT EXISTS ll_inventory_operation ( id BIGINT NOT NULL COMMENT 'Snowflake ID', tenant_id VARCHAR(128) NOT NULL, @@ -621,21 +620,22 @@ 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, + batch_no VARCHAR(128) NOT NULL, received_packages 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), + PRIMARY KEY (id), UNIQUE KEY uk_ll_stock_receipt_task_allocation (tenant_id,task_id,allocation_id), 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'; +) ENGINE=InnoDB COMMENT='Immutable inbound-order pallet package 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', + overseas_warehouse_id VARCHAR(128) NULL COMMENT 'Overseas warehouse scope for PDA operations', + overseas_warehouse_name VARCHAR(255) NULL, vehicle_plate VARCHAR(64) NULL COMMENT 'Editable vehicle plate; required before PDA picking', driver_name VARCHAR(128) NULL, driver_phone VARCHAR(64) NULL, @@ -650,6 +650,9 @@ CREATE TABLE IF NOT EXISTS ll_dispatch_order ( item_count INT NOT NULL DEFAULT 0, pallet_count INT NOT NULL DEFAULT 0, package_count INT NOT NULL DEFAULT 0, + picked_package_count INT NOT NULL DEFAULT 0 COMMENT 'PDA累计已拣箱数', + shipped_package_count INT NOT NULL DEFAULT 0 COMMENT '累计已出库箱数', + partial_completion_reason VARCHAR(1000) NULL COMMENT '部分完成原因', 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), @@ -671,6 +674,8 @@ CREATE TABLE IF NOT EXISTS ll_dispatch_item ( order_refs VARCHAR(2000) NULL, inbound_code VARCHAR(128) NULL, pallet_no VARCHAR(128) NOT NULL, + overseas_warehouse_id VARCHAR(128) NULL, + overseas_warehouse_name VARCHAR(255) NULL, destination_warehouse_id VARCHAR(128) NULL, destination_warehouse_code VARCHAR(128) NULL, destination_warehouse_name VARCHAR(255) NULL, @@ -712,6 +717,7 @@ PREPARE dispatch_schema_stmt FROM @ddl; EXECUTE dispatch_schema_stmt; DEALLOCATE 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, + warehouse_id VARCHAR(128) NULL COMMENT 'Selected overseas warehouse for PDA operation', 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), @@ -736,3 +742,113 @@ CREATE TABLE IF NOT EXISTS ll_dispatch_pda_operation ( 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'; + +CREATE TABLE IF NOT EXISTS ll_dispatch_pda_pick_order ( + id BIGINT NOT NULL, tenant_id VARCHAR(128) NOT NULL, pick_id BIGINT NOT NULL, + receipt_id BIGINT NOT NULL, order_id BIGINT NOT NULL, packages BIGINT NOT NULL, + product_quantity BIGINT NOT NULL DEFAULT 0, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), KEY idx_ll_dispatch_pick_order_pick (tenant_id,pick_id), + KEY idx_ll_dispatch_pick_order_receipt (tenant_id,receipt_id) +) ENGINE=InnoDB COMMENT='PDA pick attribution to frozen inbound receipt and tail order'; + +-- Transfer a dispatch to delivery: one outbound header, many pallet rows, one delivery header. +CREATE TABLE IF NOT EXISTS ll_outbound_order ( + id BIGINT NOT NULL COMMENT 'Snowflake ID', tenant_id VARCHAR(128) NOT NULL, + outbound_no VARCHAR(64) NOT NULL, dispatch_order_id BIGINT NOT NULL, dispatch_no VARCHAR(64) NOT NULL, + overseas_warehouse_id VARCHAR(128) NULL, status VARCHAR(32) NOT NULL DEFAULT 'WAIT_OUTBOUND', + package_count INT NOT NULL DEFAULT 0, pallet_count INT NOT NULL DEFAULT 0, + idempotency_key VARCHAR(255) NOT NULL, 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_outbound_no (tenant_id,outbound_no), + UNIQUE KEY uk_ll_outbound_idempotency (tenant_id,idempotency_key), UNIQUE KEY uk_ll_outbound_dispatch (tenant_id,dispatch_order_id), + KEY idx_ll_outbound_list (tenant_id,status,created_at) +) ENGINE=InnoDB COMMENT='Outbound document generated from a dispatch'; + +CREATE TABLE IF NOT EXISTS ll_outbound_pallet_item ( + id BIGINT NOT NULL COMMENT 'Snowflake ID', tenant_id VARCHAR(128) NOT NULL, outbound_order_id BIGINT NOT NULL, + warehouse_pallet_id BIGINT NOT 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, + package_count INT NOT NULL DEFAULT 0, order_refs VARCHAR(2000) NULL, inbound_code VARCHAR(128) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), PRIMARY KEY (id), + UNIQUE KEY uk_ll_outbound_pallet (tenant_id,outbound_order_id,warehouse_pallet_id), + KEY idx_ll_outbound_pallet_list (tenant_id,outbound_order_id), KEY idx_ll_outbound_pallet_destination (tenant_id,destination_warehouse_code) +) ENGINE=InnoDB COMMENT='Pallet detail snapshot for an outbound document'; + +CREATE TABLE IF NOT EXISTS ll_delivery_order ( + id BIGINT NOT NULL COMMENT 'Snowflake ID', tenant_id VARCHAR(128) NOT NULL, + delivery_no VARCHAR(64) NOT NULL, outbound_order_id BIGINT NOT NULL, outbound_no VARCHAR(64) NOT NULL, + dispatch_order_id BIGINT NOT NULL, dispatch_no VARCHAR(64) NOT NULL, overseas_warehouse_id VARCHAR(128) NULL, + status VARCHAR(32) NOT NULL DEFAULT 'WAIT_DELIVERY', package_count INT NOT NULL DEFAULT 0, pallet_count INT NOT NULL DEFAULT 0, + idempotency_key VARCHAR(255) NOT NULL, 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_delivery_no (tenant_id,delivery_no), + UNIQUE KEY uk_ll_delivery_idempotency (tenant_id,idempotency_key), UNIQUE KEY uk_ll_delivery_outbound (tenant_id,outbound_order_id), + KEY idx_ll_delivery_list (tenant_id,status,created_at) +) ENGINE=InnoDB COMMENT='Delivery document linked to an outbound document'; + +-- Customer relabel application: header, item snapshots, and immutable process history. +CREATE TABLE IF NOT EXISTS ll_relabel_application ( + id BIGINT NOT NULL COMMENT 'Snowflake ID', + tenant_id VARCHAR(128) NOT NULL, + relabel_no VARCHAR(64) NOT NULL, + lastleg_order_id BIGINT NOT NULL, + customer_order_no VARCHAR(128) NOT NULL, + overseas_warehouse_id VARCHAR(128) NOT NULL, + label_deadline DATETIME(3) NOT NULL, + contact_name VARCHAR(128) NOT NULL, + contact_phone VARCHAR(64) NOT NULL, + remark VARCHAR(1000) NULL, + status VARCHAR(32) NOT NULL DEFAULT 'PENDING_PROCESSING', + item_count INT NOT NULL DEFAULT 0, + total_package_count INT NOT NULL DEFAULT 0, + notified_package_count INT NOT NULL DEFAULT 0, + idempotency_key VARCHAR(255) NOT NULL, + request_hash CHAR(64) NOT NULL, + 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_relabel_application_no (tenant_id,relabel_no), + UNIQUE KEY uk_ll_relabel_application_idempotency (tenant_id,idempotency_key), + UNIQUE KEY uk_ll_relabel_application_order (lastleg_order_id), + KEY idx_ll_relabel_application_list (tenant_id,status,created_at), + KEY idx_ll_relabel_application_customer (tenant_id,customer_order_no), + KEY idx_ll_relabel_application_warehouse (tenant_id,overseas_warehouse_id) +) ENGINE=InnoDB COMMENT='Customer relabel application header'; + +CREATE TABLE IF NOT EXISTS ll_relabel_item ( + id BIGINT NOT NULL COMMENT 'Snowflake ID', + application_id BIGINT NOT NULL, + tenant_id VARCHAR(128) NOT NULL, + sku_code VARCHAR(128) NOT NULL, + product_image_file_id VARCHAR(128) NULL, + product_image_file_name VARCHAR(255) NULL, + product_image_url VARCHAR(1000) NULL, + product_name_zh VARCHAR(255) NOT NULL, + total_package_count INT NOT NULL, + notified_package_count INT NOT NULL, + shipment_no VARCHAR(128) NOT NULL, + tracking_code VARCHAR(255) NULL, + new_sku_code VARCHAR(128) NOT NULL, + label_requirement VARCHAR(1000) NULL, + relabel_file_id VARCHAR(128) NULL, + relabel_file_name VARCHAR(255) NULL, + relabel_file_url VARCHAR(1000) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + KEY idx_ll_relabel_item_application (tenant_id,application_id), + KEY idx_ll_relabel_item_sku (tenant_id,sku_code) +) ENGINE=InnoDB COMMENT='Frozen customer relabel cargo items'; + +CREATE TABLE IF NOT EXISTS ll_relabel_status_history ( + id BIGINT NOT NULL COMMENT 'Snowflake ID', + tenant_id VARCHAR(128) NOT NULL, + application_id BIGINT NOT NULL, + from_status VARCHAR(32) NULL, + to_status VARCHAR(32) NOT NULL, + operator_id VARCHAR(128) NULL, + remark VARCHAR(1000) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + KEY idx_ll_relabel_status_history_application (tenant_id,application_id,created_at) +) ENGINE=InnoDB COMMENT='Immutable customer relabel process history'; diff --git a/lastleg/src/test/java/com/baiyue/lastleg/delivery/DeliverySchemaContractTest.java b/lastleg/src/test/java/com/baiyue/lastleg/delivery/DeliverySchemaContractTest.java new file mode 100644 index 0000000..7b8f71d --- /dev/null +++ b/lastleg/src/test/java/com/baiyue/lastleg/delivery/DeliverySchemaContractTest.java @@ -0,0 +1,58 @@ +package com.baiyue.lastleg.delivery; + +import com.baiyue.lastleg.delivery.entity.DeliveryOrder; +import com.baiyue.lastleg.delivery.entity.OutboundOrder; +import com.baiyue.lastleg.delivery.entity.OutboundPalletItem; +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import org.junit.jupiter.api.Test; + +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.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class DeliverySchemaContractTest { + @Test + void baselineDefinesOutboundPalletsAndDeliveryContracts() throws Exception { + String schema = Files.readString(Path.of("src/main/resources/db/lastleg.sql"), StandardCharsets.UTF_8) + .toLowerCase(); + + assertTrue(schema.contains("create table if not exists ll_outbound_order")); + assertTrue(schema.contains("status varchar(32) not null default 'wait_outbound'")); + assertTrue(schema.contains("unique key uk_ll_outbound_idempotency (tenant_id,idempotency_key)")); + assertTrue(schema.contains("unique key uk_ll_outbound_dispatch (tenant_id,dispatch_order_id)")); + assertTrue(schema.contains("create table if not exists ll_outbound_pallet_item")); + assertTrue(schema.contains("unique key uk_ll_outbound_pallet (tenant_id,outbound_order_id,warehouse_pallet_id)")); + assertTrue(schema.contains("destination_warehouse_code varchar(128) null")); + assertTrue(schema.contains("order_refs varchar(2000) null")); + assertTrue(schema.contains("inbound_code varchar(128) null")); + assertTrue(schema.contains("create table if not exists ll_delivery_order")); + assertTrue(schema.contains("status varchar(32) not null default 'wait_delivery'")); + assertTrue(schema.contains("unique key uk_ll_delivery_idempotency (tenant_id,idempotency_key)")); + assertTrue(schema.contains("unique key uk_ll_delivery_outbound (tenant_id,outbound_order_id)")); + } + + @Test + void deliveryPersistenceEntitiesUseSnowflakeIds() throws Exception { + for (Class type : new Class[] { OutboundOrder.class, OutboundPalletItem.class, DeliveryOrder.class }) { + TableId id = type.getDeclaredField("id").getAnnotation(TableId.class); + assertNotNull(id, type.getSimpleName()); + assertEquals(IdType.ASSIGN_ID, id.type(), type.getSimpleName()); + } + } + + @Test + void incrementalMigrationAddsTransferTablesAndIsRerunnable() throws Exception { + String migration = Files.readString(Path.of("..", "scripts", "db", "incremental.sql"), StandardCharsets.UTF_8) + .toLowerCase(); + + assertTrue(migration.contains("2026-08-17 / 014")); + assertTrue(migration.contains("create table if not exists ll_outbound_order")); + assertTrue(migration.contains("create table if not exists ll_outbound_pallet_item")); + assertTrue(migration.contains("create table if not exists ll_delivery_order")); + } +} diff --git a/lastleg/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java b/lastleg/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java new file mode 100644 index 0000000..a998723 --- /dev/null +++ b/lastleg/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java @@ -0,0 +1,297 @@ +package com.baiyue.lastleg.delivery.service; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.lastleg.delivery.dto.DeliveryRequests; +import com.baiyue.lastleg.delivery.dto.DeliveryResponses; +import com.baiyue.lastleg.delivery.entity.DeliveryOrder; +import com.baiyue.lastleg.delivery.entity.OutboundOrder; +import com.baiyue.lastleg.delivery.entity.OutboundPalletItem; +import com.baiyue.lastleg.delivery.mapper.DeliveryOrderMapper; +import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper; +import com.baiyue.lastleg.delivery.mapper.OutboundPalletItemMapper; +import com.baiyue.lastleg.dispatch.entity.DispatchItem; +import com.baiyue.lastleg.dispatch.entity.DispatchOrder; +import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper; +import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanLineMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletAllocationMapper; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper; +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.time.LocalDateTime; +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.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class DeliveryTransferServiceTest { + @Mock DispatchOrderMapper dispatchOrders; + @Mock DispatchItemMapper dispatchItems; + @Mock OutboundOrderMapper outboundOrders; + @Mock OutboundPalletItemMapper outboundPallets; + @Mock DeliveryOrderMapper deliveryOrders; + @Mock InboundPlanPalletAllocationMapper planAllocations; + @Mock InboundPlanLineMapper planLines; + @Mock LastlegOrderMapper tailOrders; + @Mock LastlegOrderStatusHistoryMapper histories; + + private DeliveryTransferService service; + + @BeforeEach + void setUp() { + service = new DeliveryTransferService(); + ReflectionTestUtils.setField(service, "dispatchOrders", dispatchOrders); + ReflectionTestUtils.setField(service, "dispatchItems", dispatchItems); + ReflectionTestUtils.setField(service, "outboundOrders", outboundOrders); + ReflectionTestUtils.setField(service, "outboundPallets", outboundPallets); + ReflectionTestUtils.setField(service, "deliveryOrders", deliveryOrders); + ReflectionTestUtils.setField(service, "planAllocations", planAllocations); + ReflectionTestUtils.setField(service, "planLines", planLines); + ReflectionTestUtils.setField(service, "tailOrders", tailOrders); + ReflectionTestUtils.setField(service, "histories", histories); + } + + @Test + void firstTransferCreatesOneOutboundOneDeliveryAndAllPalletSnapshots() { + DispatchOrder dispatch = dispatch(101L, "DSP-101", "TENANT-A"); + when(dispatchOrders.findByDispatchNo("TENANT-A", "DSP-101")).thenReturn(dispatch); + when(dispatchOrders.lockById("TENANT-A", 101L)).thenReturn(dispatch); + when(dispatchItems.findByDispatchOrder("TENANT-A", 101L)).thenReturn(List.of( + item(201L, 501L, "PLT-501", "ONT8", "Ontario", "IN-A", 12), + item(202L, 502L, "PLT-502", "LAX9", "Los Angeles", "IN-B", 8) + )); + + DeliveryResponses.TransferResult result = service.transfer("TENANT-A", "DSP-101", "U-1", + new DeliveryRequests.Transfer("transfer-101", "ready")); + + ArgumentCaptor outbound = ArgumentCaptor.forClass(OutboundOrder.class); + ArgumentCaptor delivery = ArgumentCaptor.forClass(DeliveryOrder.class); + ArgumentCaptor pallets = ArgumentCaptor.forClass(OutboundPalletItem.class); + verify(outboundOrders).insert(outbound.capture()); + verify(deliveryOrders).insert(delivery.capture()); + verify(outboundPallets, org.mockito.Mockito.times(2)).insert(pallets.capture()); + + assertEquals("WAIT_OUTBOUND", outbound.getValue().getStatus()); + assertEquals(2, outbound.getValue().getPalletCount()); + assertEquals(20, outbound.getValue().getPackageCount()); + assertEquals("transfer-101", outbound.getValue().getIdempotencyKey()); + assertTrue(outbound.getValue().getOutboundNo().startsWith("OUT")); + assertEquals(outbound.getValue().getId(), delivery.getValue().getOutboundOrderId()); + assertEquals("WAIT_DELIVERY", delivery.getValue().getStatus()); + assertTrue(delivery.getValue().getDeliveryNo().startsWith("DEL")); + assertEquals(List.of("ONT8", "LAX9"), pallets.getAllValues().stream() + .map(OutboundPalletItem::getDestinationWarehouseCode).toList()); + assertEquals(List.of("IN-A", "IN-B"), pallets.getAllValues().stream() + .map(OutboundPalletItem::getInboundCode).toList()); + assertEquals(20, result.packageCount()); + assertEquals(2, result.palletCount()); + assertTrue(!result.replayed()); + } + + @Test + void sameIdempotencyKeyReturnsExistingDocumentsWithoutWrites() { + DispatchOrder dispatch = dispatch(101L, "DSP-101", "TENANT-A"); + OutboundOrder outbound = outbound(301L, 101L, "DSP-101", "OUT-301", "transfer-101"); + DeliveryOrder delivery = delivery(401L, outbound, "DEL-401"); + when(dispatchOrders.findByDispatchNo("TENANT-A", "DSP-101")).thenReturn(dispatch); + when(dispatchOrders.lockById("TENANT-A", 101L)).thenReturn(dispatch); + when(outboundOrders.findByIdempotency("TENANT-A", "transfer-101")).thenReturn(outbound); + when(deliveryOrders.lockByOutbound("TENANT-A", 301L)).thenReturn(delivery); + + DeliveryResponses.TransferResult result = service.transfer("TENANT-A", "DSP-101", "U-1", + new DeliveryRequests.Transfer(" transfer-101 ", null)); + + assertTrue(result.replayed()); + assertEquals("OUT-301", result.outboundNo()); + assertEquals("DEL-401", result.deliveryNo()); + verify(outboundOrders, never()).insert((OutboundOrder) any()); + verify(outboundPallets, never()).insert((OutboundPalletItem) any()); + verify(deliveryOrders, never()).insert((DeliveryOrder) any()); + } + + @Test + void idempotencyKeyCannotBeReusedForAnotherDispatch() { + DispatchOrder dispatch = dispatch(102L, "DSP-102", "TENANT-A"); + OutboundOrder other = outbound(301L, 101L, "DSP-101", "OUT-301", "shared-key"); + when(dispatchOrders.findByDispatchNo("TENANT-A", "DSP-102")).thenReturn(dispatch); + when(dispatchOrders.lockById("TENANT-A", 102L)).thenReturn(dispatch); + when(outboundOrders.findByIdempotency("TENANT-A", "shared-key")).thenReturn(other); + + ResponseStatusException exception = assertThrows(ResponseStatusException.class, () -> + service.transfer("TENANT-A", "DSP-102", "U-1", new DeliveryRequests.Transfer("shared-key", null))); + + assertEquals(409, exception.getStatusCode().value()); + verify(outboundOrders, never()).insert((OutboundOrder) any()); + } + + @Test + void concurrentDuplicateInsertReturnsWinningDocuments() { + DispatchOrder dispatch = dispatch(101L, "DSP-101", "TENANT-A"); + OutboundOrder winner = outbound(301L, 101L, "DSP-101", "OUT-301", "transfer-101"); + DeliveryOrder delivery = delivery(401L, winner, "DEL-401"); + when(dispatchOrders.findByDispatchNo("TENANT-A", "DSP-101")).thenReturn(dispatch); + when(dispatchOrders.lockById("TENANT-A", 101L)).thenReturn(dispatch); + when(dispatchItems.findByDispatchOrder("TENANT-A", 101L)).thenReturn(List.of( + item(201L, 501L, "PLT-501", "ONT8", "Ontario", "IN-A", 12))); + when(outboundOrders.insert(any(OutboundOrder.class))).thenThrow(new org.springframework.dao.DuplicateKeyException("duplicate")); + when(outboundOrders.findByIdempotency("TENANT-A", "transfer-101")).thenReturn(null, winner); + when(deliveryOrders.lockByOutbound("TENANT-A", 301L)).thenReturn(delivery); + + DeliveryResponses.TransferResult result = service.transfer("TENANT-A", "DSP-101", "U-1", + new DeliveryRequests.Transfer("transfer-101", null)); + + assertTrue(result.replayed()); + assertEquals("OUT-301", result.outboundNo()); + assertEquals("DEL-401", result.deliveryNo()); + verify(outboundPallets, never()).insert((OutboundPalletItem) any()); + verify(deliveryOrders, never()).insert((DeliveryOrder) any()); + } + + @Test + void directDispatchOrderReferenceIsBoundToCreatedDocuments() { + DispatchOrder dispatch = dispatch(101L, "DSP-101", "TENANT-A"); + DispatchItem source = item(201L, 501L, "PLT-501", "ONT8", "Ontario", "IN-A", 12); + source.setOrderId(901L); + LastlegOrder tailOrder = new LastlegOrder(); + tailOrder.setId(901L); tailOrder.setTenantId("TENANT-A"); tailOrder.setCurrentStatus("PENDING_ASSIGNMENT"); + when(dispatchOrders.findByDispatchNo("TENANT-A", "DSP-101")).thenReturn(dispatch); + when(dispatchOrders.lockById("TENANT-A", 101L)).thenReturn(dispatch); + when(dispatchItems.findByDispatchOrder("TENANT-A", 101L)).thenReturn(List.of(source)); + when(tailOrders.selectList(any())).thenReturn(List.of(tailOrder)); + + service.transfer("TENANT-A", "DSP-101", "U-1", new DeliveryRequests.Transfer("direct-order", null)); + + assertEquals("WAIT_OUTBOUND", tailOrder.getCurrentStatus()); + assertNotNull(tailOrder.getOutboundOrderNo()); + assertNotNull(tailOrder.getDeliveryOrderNo()); + verify(tailOrders).updateById(tailOrder); + verify(histories).insert(any(LastlegOrderStatusHistory.class)); + } + + @Test + void transferRejectsAPlannedPalletThatHasNotBeenReceived() { + DispatchOrder dispatch = dispatch(101L, "DSP-101", "TENANT-A"); + DispatchItem planned = item(201L, null, "PLAN-1", "ONT8", "Ontario", "IN-A", 12); + planned.setSourceStage("PLAN"); + when(dispatchOrders.findByDispatchNo("TENANT-A", "DSP-101")).thenReturn(dispatch); + when(dispatchOrders.lockById("TENANT-A", 101L)).thenReturn(dispatch); + when(dispatchItems.findByDispatchOrder("TENANT-A", 101L)).thenReturn(List.of(planned)); + + ResponseStatusException exception = assertThrows(ResponseStatusException.class, () -> + service.transfer("TENANT-A", "DSP-101", "U-1", new DeliveryRequests.Transfer("transfer-101", null))); + + assertEquals(409, exception.getStatusCode().value()); + assertTrue(exception.getReason().contains("received")); + verify(outboundOrders, never()).insert((OutboundOrder) any()); + } + + @Test + void outboundDetailReturnsPagedPalletDestinationPackagesAndInboundCode() { + OutboundOrder outbound = outbound(301L, 101L, "DSP-101", "OUT-301", "transfer-101"); + DeliveryOrder delivery = delivery(401L, outbound, "DEL-401"); + OutboundPalletItem first = pallet(501L, "PLT-501", "ONT8", "Ontario", "IN-A", 12); + OutboundPalletItem second = pallet(502L, "PLT-502", "LAX9", "Los Angeles", "IN-B", 8); + when(outboundOrders.findByNo("TENANT-A", "OUT-301")).thenReturn(outbound); + when(deliveryOrders.lockByOutbound("TENANT-A", 301L)).thenReturn(delivery); + when(outboundPallets.selectPage(any(Page.class), any())).thenAnswer(invocation -> { + @SuppressWarnings("unchecked") Page page = invocation.getArgument(0); + page.setRecords(List.of(first, second)); + page.setTotal(2); + return page; + }); + + DeliveryResponses.OutboundDetail detail = service.outboundDetail("TENANT-A", "OUT-301", + new DeliveryRequests.PalletQuery(1, 20, null)); + + PageResult pallets = detail.pallets(); + assertEquals(2, pallets.total()); + assertEquals(2, pallets.records().size()); + assertEquals("ONT8", pallets.records().get(0).destinationWarehouseCode()); + assertEquals("Ontario", pallets.records().get(0).destinationWarehouseName()); + assertEquals(12, pallets.records().get(0).packageCount()); + assertEquals("IN-A", pallets.records().get(0).inboundCode()); + assertEquals("LAX9", pallets.records().get(1).destinationWarehouseCode()); + assertEquals(8, pallets.records().get(1).packageCount()); + } + + @Test + void detailLookupCannotCrossTenantBoundary() { + when(outboundOrders.findByNo("TENANT-B", "OUT-301")).thenReturn(null); + + ResponseStatusException exception = assertThrows(ResponseStatusException.class, () -> + service.outboundDetail("TENANT-B", "OUT-301", new DeliveryRequests.PalletQuery(1, 20, null))); + + assertEquals(404, exception.getStatusCode().value()); + verify(outboundOrders).findByNo("TENANT-B", "OUT-301"); + verify(deliveryOrders, never()).lockByOutbound(any(), any()); + verify(outboundPallets, never()).selectPage(any(), any()); + } + + private static DispatchOrder dispatch(Long id, String dispatchNo, String tenantId) { + DispatchOrder value = new DispatchOrder(); + value.setId(id); value.setTenantId(tenantId); value.setDispatchNo(dispatchNo); + value.setOverseasWarehouseId("WH-US-1"); value.setStatus("READY_TO_DEPART"); + return value; + } + + private static DispatchItem item(Long id, Long warehousePalletId, String palletNo, String destinationCode, + String destinationName, String inboundCode, int packages) { + DispatchItem value = new DispatchItem(); + value.setId(id); value.setWarehousePalletId(warehousePalletId); value.setPalletNo(palletNo); + value.setDestinationWarehouseId("DEST-" + destinationCode); + value.setDestinationWarehouseCode(destinationCode); value.setDestinationWarehouseName(destinationName); + value.setInboundCode(inboundCode); value.setOrderRefs("LMO-1"); value.setPackageCount(packages); + value.setSourceStage("RECEIVED"); + return value; + } + + private static OutboundOrder outbound(Long id, Long dispatchId, String dispatchNo, String outboundNo, + String idempotencyKey) { + OutboundOrder value = new OutboundOrder(); + value.setId(id); value.setTenantId("TENANT-A"); value.setDispatchOrderId(dispatchId); + value.setDispatchNo(dispatchNo); value.setOutboundNo(outboundNo); value.setStatus("WAIT_OUTBOUND"); + value.setOverseasWarehouseId("WH-US-1"); value.setPalletCount(2); value.setPackageCount(20); + value.setIdempotencyKey(idempotencyKey); value.setCreatedAt(LocalDateTime.now()); + return value; + } + + private static DeliveryOrder delivery(Long id, OutboundOrder outbound, String deliveryNo) { + DeliveryOrder value = new DeliveryOrder(); + value.setId(id); value.setTenantId(outbound.getTenantId()); value.setDeliveryNo(deliveryNo); + value.setOutboundOrderId(outbound.getId()); value.setOutboundNo(outbound.getOutboundNo()); + value.setDispatchOrderId(outbound.getDispatchOrderId()); value.setDispatchNo(outbound.getDispatchNo()); + value.setOverseasWarehouseId(outbound.getOverseasWarehouseId()); value.setStatus("WAIT_DELIVERY"); + value.setPalletCount(outbound.getPalletCount()); value.setPackageCount(outbound.getPackageCount()); + value.setCreatedAt(LocalDateTime.now()); + return value; + } + + private static OutboundPalletItem pallet(Long warehousePalletId, String palletNo, String destinationCode, + String destinationName, String inboundCode, int packages) { + OutboundPalletItem value = new OutboundPalletItem(); + value.setId(warehousePalletId + 1000); value.setTenantId("TENANT-A"); value.setOutboundOrderId(301L); + value.setWarehousePalletId(warehousePalletId); value.setPalletNo(palletNo); + value.setDestinationWarehouseId("DEST-" + destinationCode); + value.setDestinationWarehouseCode(destinationCode); value.setDestinationWarehouseName(destinationName); + value.setInboundCode(inboundCode); value.setOrderRefs("LMO-1"); value.setPackageCount(packages); + return value; + } +} 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 index 7892eed..ee68706 100644 --- 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 @@ -1,5 +1,6 @@ package com.baiyue.lastleg.dispatch.pda.service; +import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper; import com.baiyue.lastleg.dispatch.entity.DispatchOrder; import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper; import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaRequests; @@ -22,6 +23,7 @@ import static org.mockito.Mockito.when; class DispatchPdaWorkflowServiceTest { @Mock private DispatchOrderMapper orders; @Mock private DispatchPdaOperationMapper operations; + @Mock private OutboundOrderMapper outboundOrders; @Test void pushRequiresVehiclePlateBeforeCreatingPdaTask() { @@ -37,6 +39,22 @@ class DispatchPdaWorkflowServiceTest { assertEquals(409, exception.getStatusCode().value()); } + @Test + void pushRequiresWarehouseSelection() { + DispatchPdaWorkflowService service = new DispatchPdaWorkflowService(); + ReflectionTestUtils.setField(service, "orders", orders); + DispatchOrder order = new DispatchOrder(); + order.setId(1L); order.setTenantId("100"); order.setDispatchNo("PC1"); + order.setStatus("PENDING_DEPARTURE"); order.setVehiclePlate("CA-100"); + order.setOverseasWarehouseId("WH-1"); + when(orders.findByDispatchNo("100", "PC1")).thenReturn(order); + + ResponseStatusException exception = assertThrows(ResponseStatusException.class, + () -> service.push("100", "PC1", new DispatchPdaRequests.Push("push-1", null, null))); + + assertEquals(400, exception.getStatusCode().value()); + } + @Test void departRequiresVehiclePlate() { DispatchPdaWorkflowService service = new DispatchPdaWorkflowService(); @@ -55,6 +73,26 @@ class DispatchPdaWorkflowServiceTest { assertEquals(409, exception.getStatusCode().value()); } + @Test + void departRequiresPendingOutboundOrder() { + DispatchPdaWorkflowService service = new DispatchPdaWorkflowService(); + ReflectionTestUtils.setField(service, "orders", orders); + ReflectionTestUtils.setField(service, "operations", operations); + ReflectionTestUtils.setField(service, "outboundOrders", outboundOrders); + DispatchOrder order = new DispatchOrder(); + order.setId(1L); order.setTenantId("100"); order.setDispatchNo("PC1"); + order.setStatus("READY_TO_DEPART"); order.setVehiclePlate("CA-100"); + when(orders.findByDispatchNo("100", "PC1")).thenReturn(order); + when(orders.lockById("100", 1L)).thenReturn(order); + when(operations.findKey("100", "depart-1")).thenReturn(null); + when(outboundOrders.lockByDispatch("100", 1L)).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(); diff --git a/lastleg/src/test/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPickOrderAttributionServiceTest.java b/lastleg/src/test/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPickOrderAttributionServiceTest.java new file mode 100644 index 0000000..d97919b --- /dev/null +++ b/lastleg/src/test/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPickOrderAttributionServiceTest.java @@ -0,0 +1,89 @@ +package com.baiyue.lastleg.dispatch.pda.service; + +import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaPick; +import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaPickOrder; +import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaPickOrderMapper; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; +import com.baiyue.lastleg.inboundplan.pda.entity.InboundStockReceipt; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.ArrayList; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class DispatchPickOrderAttributionServiceTest { + @Mock private DispatchPdaPickOrderMapper attributions; + @Mock private LastlegOrderMapper orders; + @Mock private LastlegOrderStatusHistoryMapper history; + + @Test + void attributesPackagesAcrossInboundOrdersInReceiptFifoOrder() { + DispatchPickOrderAttributionService service = service(); + InboundStockReceipt first = receipt(11L, 101L, 3L, 30L); + InboundStockReceipt second = receipt(12L, 102L, 4L, 40L); + when(attributions.availableReceipts("t1", 91L)).thenReturn(List.of(first, second)); + when(attributions.attributedPackages(eq("t1"), anyLong())).thenReturn(0L); + when(orders.increasePicked(eq("t1"), anyLong(), anyLong())).thenReturn(1); + DispatchPdaPick pick = pick(81L, 91L, 5L, 50L); + + List result = service.attribute("t1", pick); + + assertEquals(List.of(new DispatchPickOrderAttributionService.OrderPackages(101L, 3L), + new DispatchPickOrderAttributionService.OrderPackages(102L, 2L)), result); + } + + @Test + void departureMovesOnlyAttributedPackagesAndWritesOrderHistory() { + DispatchPickOrderAttributionService service = service(); + DispatchPdaPick pick = pick(81L, 91L, 4L, 40L); + DispatchPdaPickOrder row = new DispatchPdaPickOrder(); + row.setOrderId(101L); row.setPackages(4L); + when(attributions.listPick("t1", 81L)).thenReturn(List.of(row)); + LastlegOrder order = new LastlegOrder(); + order.setId(101L); order.setPackageCount(10); order.setPickedPackageCount(4); + order.setShippedPackageCount(2); order.setCurrentStatus("INBOUND_COMPLETED"); + when(orders.lockById("t1", 101L)).thenReturn(order); + when(orders.transferPickedToShipped("t1", 101L, 4L, "OUT1")).thenReturn(1); + + List result = service.depart( + "t1", "OUT1", "D1", List.of(pick), LocalDateTime.of(2026, 8, 17, 15, 0)); + + assertEquals(List.of(new DispatchPickOrderAttributionService.OrderPackages(101L, 4L)), result); + verify(history).insert(argThat((LastlegOrderStatusHistory value) -> "PARTIALLY_SHIPPED".equals(value.getToStatus()) + && value.getRemark().contains("6/10"))); + } + + private DispatchPickOrderAttributionService service() { + DispatchPickOrderAttributionService service = new DispatchPickOrderAttributionService(); + ReflectionTestUtils.setField(service, "attributions", attributions); + ReflectionTestUtils.setField(service, "orders", orders); + ReflectionTestUtils.setField(service, "history", history); + return service; + } + + private static InboundStockReceipt receipt(long id, long orderId, long packages, long quantity) { + InboundStockReceipt receipt = new InboundStockReceipt(); + receipt.setId(id); receipt.setOrderId(orderId); receipt.setReceivedPackages(packages); + return receipt; + } + + private static DispatchPdaPick pick(long id, long positionId, long packages, long quantity) { + DispatchPdaPick pick = new DispatchPdaPick(); + pick.setId(id); pick.setInventoryPositionId(positionId); pick.setPackages(packages); + pick.setProductQuantity(quantity); pick.setPickedAt(LocalDateTime.of(2026, 8, 17, 14, 0)); + return pick; + } +} 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 index c0dde72..c3cf584 100644 --- a/lastleg/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java +++ b/lastleg/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java @@ -86,7 +86,7 @@ class DispatchServiceTest { } private DispatchPalletCandidate candidate(long planPalletId, Long warehousePalletId, String destination) { - return new DispatchPalletCandidate(1L, planPalletId, warehousePalletId, 2L, "LMO1", "LMO1", "RK1", + return new DispatchPalletCandidate(1L, planPalletId, warehousePalletId, 2L, "WH-1", "Overseas warehouse", "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/pda/service/InboundStockReceiptServiceTest.java b/lastleg/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java index 307d54d..c114aea 100644 --- 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 @@ -12,7 +12,6 @@ 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; @@ -40,7 +39,6 @@ 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; @@ -49,7 +47,6 @@ class InboundStockReceiptServiceTest { 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()); @@ -59,7 +56,6 @@ class InboundStockReceiptServiceTest { 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)); @@ -69,30 +65,30 @@ class InboundStockReceiptServiceTest { 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()); + assertEquals(101L, request.getValue().inboundOrderId()); + assertEquals("IN-1", request.getValue().inboundCode()); 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() { + void storesOneInboundOrderAcrossMultiplePalletsWithoutSkuSplitting() { 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)); + 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)); - assertThrows(ResponseStatusException.class, () -> service.stockIn("100", fixture.plan, fixture.task, + 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)); + List.of(fixture.pallet, secondPallet), LocalDateTime.now()); + verify(inventory, times(2)).stockIn(eq("100"), eq("operator"), any()); + verify(receipts, times(2)).insert(any(InboundStockReceipt.class)); } @Test @@ -101,7 +97,6 @@ class InboundStockReceiptServiceTest { 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)); @@ -111,7 +106,6 @@ class InboundStockReceiptServiceTest { 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 diff --git a/lastleg/src/test/java/com/baiyue/lastleg/inventory/InventorySchemaContractTest.java b/lastleg/src/test/java/com/baiyue/lastleg/inventory/InventorySchemaContractTest.java index 7642972..dfb926e 100644 --- a/lastleg/src/test/java/com/baiyue/lastleg/inventory/InventorySchemaContractTest.java +++ b/lastleg/src/test/java/com/baiyue/lastleg/inventory/InventorySchemaContractTest.java @@ -28,23 +28,30 @@ class InventorySchemaContractTest { } 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("unique key uk_ll_inventory_dimension (tenant_id,overseas_warehouse_id,destination_warehouse_code,inbound_order_id,inbound_code)")); + assertTrue(schema.contains("inbound_order_id bigint not null")); + assertTrue(schema.contains("inbound_code varchar(128) not null")); 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")); + assertFalse(tableDefinition(schema, "ll_inventory_balance").contains("sku_code")); + assertFalse(tableDefinition(schema, "ll_inventory_balance").contains("_quantity")); 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")); + assertFalse(tableDefinition(schema, "ll_inventory_position").contains("sku_code")); + assertFalse(tableDefinition(schema, "ll_inventory_position").contains("product_quantity")); 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")); + assertFalse(tableDefinition(schema, "ll_inventory_ledger").contains("sku_code")); + assertFalse(tableDefinition(schema, "ll_inventory_ledger").contains("before_quantity")); 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)")); + assertTrue(schema.contains("unique key uk_ll_stock_receipt_task_allocation (tenant_id,task_id,allocation_id)")); assertFalse(tableDefinition(schema, "ll_inventory_ledger").contains("updated_at")); } @@ -68,6 +75,38 @@ class InventorySchemaContractTest { 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")); + assertTrue(migration.contains("2026-08-17 / 015")); + assertTrue(migration.contains("add column inbound_order_id bigint null")); + assertTrue(migration.contains("quantity columns remain untouched")); + assertTrue(migration.contains("2026-08-17 / 018")); + assertTrue(migration.contains("alter table ll_order add column picked_package_count")); + assertTrue(migration.contains("alter table ll_order add column shipped_package_count")); + assertTrue(migration.contains("alter table ll_inventory_balance add column inbound_order_no")); + assertTrue(migration.contains("update ll_inventory_balance")); + assertTrue(migration.contains("where inbound_order_id is null or inbound_code is null or inbound_code = ''")); + assertTrue(migration.contains("alter table ll_inventory_ledger add column inbound_order_id")); + assertTrue(migration.contains("update ll_inventory_ledger")); + assertTrue(migration.contains("alter table ll_dispatch_order add column partial_completion_reason")); + assertFalse(migration.contains("drop column sku_code")); + assertFalse(migration.contains("drop column product_quantity")); + } + + @Test + void entitiesExposeInboundOrderPackageDimensionOnly() throws NoSuchFieldException { + assertNotNull(InventoryBalance.class.getDeclaredField("inboundOrderId")); + assertNotNull(InventoryBalance.class.getDeclaredField("inboundCode")); + assertNotNull(InventoryLedger.class.getDeclaredField("inboundOrderId")); + assertThrowsNoField(InventoryBalance.class, "skuCode"); + assertThrowsNoField(InventoryBalance.class, "availableQuantity"); + assertThrowsNoField(InventoryPosition.class, "skuCode"); + assertThrowsNoField(InventoryPosition.class, "productQuantity"); + assertThrowsNoField(InventoryLedger.class, "skuCode"); + assertThrowsNoField(InventoryLedger.class, "deltaQuantity"); + } + + private static void assertThrowsNoField(Class type, String fieldName) { + assertTrue(java.util.Arrays.stream(type.getDeclaredFields()).noneMatch(field -> field.getName().equals(fieldName)), + () -> type.getSimpleName() + " must not expose " + fieldName); } private static String tableDefinition(String schema, String tableName) { 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 index 7170b11..1621ff1 100644 --- a/lastleg/src/test/java/com/baiyue/lastleg/inventory/command/InventoryCommandServiceTest.java +++ b/lastleg/src/test/java/com/baiyue/lastleg/inventory/command/InventoryCommandServiceTest.java @@ -63,7 +63,7 @@ class InventoryCommandServiceTest { 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); + when(balances.lockDimension("100", "WH-1", "ONT8", 101L, "IN-CODE-1")).thenReturn(null); var result = service.stockIn("100", "7", stockIn("IN-1", "stock-in-1", 12, 24)); assertEquals(12, result.availablePackages()); @@ -85,7 +85,7 @@ class InventoryCommandServiceTest { assertThrows(ResponseStatusException.class, () -> service.hold("100", "7", new InventoryCommandRequests.Move( - "100", 201L, 6, 0, "ORD-1", null, null, "hold-1", null))); + "100", 201L, 6, "ORD-1", null, null, "hold-1", null))); verify(positions, never()).updateById(any(InventoryPosition.class)); verify(balances, never()).updateById(any(InventoryBalance.class)); } @@ -93,7 +93,7 @@ class InventoryCommandServiceTest { @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); + when(balances.lockDimension("100", "WH-1", "ONT8", 101L, "IN-CODE-1")).thenReturn(null); service.stockIn("100", "7", request); ArgumentCaptor operation = ArgumentCaptor.forClass(InventoryOperation.class); verify(operations).insert(operation.capture()); @@ -110,16 +110,14 @@ class InventoryCommandServiceTest { 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)); + "100", 201L, 5, "OUT-1", "OUT-1", null, "ship-1", null)); assertTrue(result.palletEmpty()); assertEquals("EMPTY", position.getStatus()); @@ -129,17 +127,17 @@ class InventoryCommandServiceTest { 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); + "DEST-1", "ONT8", "ONT8", 101L, inbound, "IN-CODE-1", 300L, "PLT-1", null, null, null, + packages, "ORD-1", "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.setDestinationWarehouseCode("ONT8"); value.setInboundOrderId(101L); + value.setInboundOrderNo("IN-1"); value.setInboundCode("IN-CODE-1"); + value.setAvailablePackages(available); value.setHoldPackages(0L); value.setAllocatedPackages(0L); value.setPickedPackages(0L); - value.setHoldQuantity(0L); value.setAllocatedQuantity(0L); value.setPickedQuantity(0L); value.setVersion(0L); return value; } @@ -150,7 +148,7 @@ class InventoryCommandServiceTest { 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); + 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 index f371450..971a0fa 100644 --- 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 @@ -18,7 +18,7 @@ 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); + 1, 20, null, null, null, null, null, null, null, null); @Test void merchantCannotOverrideCustomerScope() { 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 index 0e9f1c5..193c59d 100644 --- 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 @@ -1,6 +1,7 @@ package com.baiyue.lastleg.inventory.query.service; import com.baiyue.lastleg.inventory.entity.InventoryBalance; +import com.baiyue.lastleg.inventory.entity.InventoryPosition; import com.baiyue.lastleg.inventory.query.dto.InventoryDashboardResponse; import com.baiyue.lastleg.inventory.query.dto.InventoryQueryRequests; import com.baiyue.lastleg.inventory.query.mapper.InventoryQueryMapper; @@ -10,10 +11,13 @@ import org.junit.jupiter.api.Test; import org.springframework.test.util.ReflectionTestUtils; import org.springframework.web.server.ResponseStatusException; +import java.nio.charset.StandardCharsets; import java.util.List; 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.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; @@ -36,16 +40,14 @@ class InventoryQueryServiceTest { 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(2, result.inboundOrderCount()); assertEquals(3, result.palletCount()); assertEquals(12, result.totalPackages()); - assertEquals(120, result.totalQuantity()); } @Test @@ -74,12 +76,32 @@ class InventoryQueryServiceTest { @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 positionDetailCarriesInboundOrderAndInboundCodeFromOwnedBalance() { + InventoryQueryRequests.PositionQuery query = new InventoryQueryRequests.PositionQuery(null, null, null); + InventoryBalance balance = new InventoryBalance(); + balance.setId(99L); + balance.setInboundOrderId(88L); + balance.setInboundOrderNo("IBP-20260817-001"); + balance.setInboundCode("RK-001"); + InventoryPosition position = new InventoryPosition(); + position.setBalanceId(99L); + position.setAvailablePackages(7L); + when(mapper.ownedBalance("100", 99L)).thenReturn(balance); + when(mapper.positions("100", 99L, query)).thenReturn(List.of(position)); + + var result = service.positions("100", 99L, query).get(0); + + assertEquals(88L, result.inboundOrderId()); + assertEquals("IBP-20260817-001", result.inboundOrderNo()); + assertEquals("RK-001", result.inboundCode()); + assertEquals(7L, result.totalPackages()); + } + @Test void reversedLedgerDateRangeIsRejected() { InventoryQueryRequests.LedgerQuery query = new InventoryQueryRequests.LedgerQuery( @@ -91,7 +113,19 @@ class InventoryQueryServiceTest { verify(mapper, never()).countLedgers(any(), any()); } + @Test + void balanceExportUsesInboundOrderPackageColumnsOnly() { + InventoryQueryRequests.BalanceQuery query = balanceQuery(null); + when(mapper.balances("100", query, 0, 10_000)).thenReturn(List.of()); + + String csv = new String(service.exportBalances("100", query), StandardCharsets.UTF_8); + + assertTrue(csv.contains("inboundOrderNo,inboundCode,totalPackages")); + assertFalse(csv.contains("skuCode")); + assertFalse(csv.contains("Quantity")); + } + private static InventoryQueryRequests.BalanceQuery balanceQuery(String status) { - return new InventoryQueryRequests.BalanceQuery(1, 20, null, null, null, null, null, null, status); + return new InventoryQueryRequests.BalanceQuery(1, 20, null, null, null, null, null, null, null, status); } } diff --git a/lastleg/src/test/java/com/baiyue/lastleg/relabel/RelabelContractTest.java b/lastleg/src/test/java/com/baiyue/lastleg/relabel/RelabelContractTest.java new file mode 100644 index 0000000..6fadddd --- /dev/null +++ b/lastleg/src/test/java/com/baiyue/lastleg/relabel/RelabelContractTest.java @@ -0,0 +1,40 @@ +package com.baiyue.lastleg.relabel; + +import com.baiyue.lastleg.relabel.dto.RelabelRequests; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.RecordComponent; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class RelabelContractTest { + @Test + void createHeaderDoesNotExposeWaybillLabelFileAndItemCarriesRequestedFileFields() { + assertFalse(Arrays.stream(RelabelRequests.Create.class.getRecordComponents()) + .map(RecordComponent::getName) + .anyMatch(name -> name.toLowerCase().contains("waybill") || name.toLowerCase().contains("labelFile"))); + assertTrue(Arrays.stream(RelabelRequests.Item.class.getRecordComponents()) + .map(RecordComponent::getName).toList().containsAll(Arrays.asList("skuCode", "productImageFileId", + "productNameZh", "totalPackageCount", "notifiedPackageCount", "shipmentNo", "trackingCode", + "newSkuCode", "labelRequirement", "relabelFileId"))); + } + + @Test + void schemasDefineRelabelApplicationItemsAndHistoryContracts() throws Exception { + String baseline = Files.readString(Path.of("src/main/resources/db/lastleg.sql"), StandardCharsets.UTF_8).toLowerCase(); + String migration = Files.readString(Path.of("..", "scripts", "db", "incremental.sql"), StandardCharsets.UTF_8).toLowerCase(); + for (String schema : new String[] { baseline, migration }) { + assertTrue(schema.contains("ll_relabel_application")); + assertTrue(schema.contains("ll_relabel_item")); + assertTrue(schema.contains("ll_relabel_status_history")); + assertTrue(schema.contains("tenant_id")); + assertTrue(schema.contains("idempotency_key")); + assertTrue(schema.contains("request_hash")); + } + } +} diff --git a/lastleg/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java b/lastleg/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java new file mode 100644 index 0000000..5830290 --- /dev/null +++ b/lastleg/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java @@ -0,0 +1,239 @@ +package com.baiyue.lastleg.relabel.service; + +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper; +import com.baiyue.lastleg.relabel.dto.RelabelRequests; +import com.baiyue.lastleg.relabel.dto.RelabelResponses; +import com.baiyue.lastleg.relabel.entity.RelabelApplication; +import com.baiyue.lastleg.relabel.entity.RelabelItem; +import com.baiyue.lastleg.relabel.entity.RelabelStatusHistory; +import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper; +import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper; +import com.baiyue.lastleg.relabel.mapper.RelabelStatusHistoryMapper; +import com.baiyue.lastleg.service.LastlegOrderNoGenerator; +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.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.assertNotNull; +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.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class RelabelApplicationServiceTest { + @Mock RelabelApplicationMapper applications; + @Mock RelabelItemMapper items; + @Mock RelabelStatusHistoryMapper histories; + @Mock LastlegOrderMapper orders; + @Mock LastlegOrderStatusHistoryMapper orderHistories; + @Mock LastlegOrderNoGenerator orderNoGenerator; + + private RelabelApplicationService service; + + @BeforeEach + void setUp() { + service = new RelabelApplicationService(); + ReflectionTestUtils.setField(service, "applications", applications); + ReflectionTestUtils.setField(service, "items", items); + ReflectionTestUtils.setField(service, "histories", histories); + ReflectionTestUtils.setField(service, "orders", orders); + ReflectionTestUtils.setField(service, "orderHistories", orderHistories); + ReflectionTestUtils.setField(service, "orderNoGenerator", orderNoGenerator); + ReflectionTestUtils.setField(service, "objectMapper", mapper()); + lenient().when(orderNoGenerator.next()).thenReturn("LMO-RELABEL-1"); + } + + @Test + void createBuildsPendingRelabelTailOrderItemsAndBothHistories() { + RelabelRequests.Create request = request("key-1", 10, 6); + RelabelApplication persisted = application(11L, "FBR-1", "TENANT-A", "key-1", null, + RelabelApplicationService.PENDING_PROCESSING); + persisted.setLastlegOrderId(21L); + persisted.setItemCount(1); persisted.setTotalPackageCount(10); persisted.setNotifiedPackageCount(6); + when(applications.findNo(eq("TENANT-A"), any())).thenReturn(persisted); + when(items.selectList(any())).thenReturn(List.of(item(31L, 11L, "SKU-A", 10, 6))); + when(histories.selectList(any())).thenReturn(List.of()); + when(orders.selectById(21L)).thenReturn(order(21L, "TENANT-A", "LMO-RELABEL-1", + RelabelApplicationService.PENDING_PROCESSING)); + + RelabelResponses.Detail result = service.create("TENANT-A", "operator-1", request); + + ArgumentCaptor tailOrder = ArgumentCaptor.forClass(LastlegOrder.class); + ArgumentCaptor application = ArgumentCaptor.forClass(RelabelApplication.class); + ArgumentCaptor relabelItem = ArgumentCaptor.forClass(RelabelItem.class); + ArgumentCaptor relabelHistory = ArgumentCaptor.forClass(RelabelStatusHistory.class); + ArgumentCaptor tailHistory = ArgumentCaptor.forClass(LastlegOrderStatusHistory.class); + verify(orders).insert(tailOrder.capture()); + verify(applications).insert(application.capture()); + verify(items).insert(relabelItem.capture()); + verify(histories).insert(relabelHistory.capture()); + verify(orderHistories).insert(tailHistory.capture()); + + assertEquals("RELABEL_APPLICATION", tailOrder.getValue().getOrderType()); + assertEquals(RelabelApplicationService.PENDING_PROCESSING, tailOrder.getValue().getCurrentStatus()); + assertEquals("CUSTOMER", tailOrder.getValue().getSourceRegion()); + assertEquals("LMO-RELABEL-1", tailOrder.getValue().getOrderNo()); + assertEquals(tailOrder.getValue().getInboundCode(), tailOrder.getValue().getSourceInboundId()); + assertEquals(tailOrder.getValue().getInboundCode(), application.getValue().getRelabelNo()); + assertTrue(tailOrder.getValue().getInboundCode().startsWith("FBR")); + assertEquals(10, tailOrder.getValue().getPackageCount()); + assertEquals(6, application.getValue().getNotifiedPackageCount()); + assertEquals("SKU-A", relabelItem.getValue().getSkuCode()); + assertEquals("PENDING_PROCESSING", relabelHistory.getValue().getToStatus()); + assertEquals("PENDING_PROCESSING", tailHistory.getValue().getToStatus()); + assertEquals("FBR-1", result.application().relabelNo()); + } + + @Test + void createRejectsNotifiedBoxesAboveTotalBeforeAnyWrite() { + ResponseStatusException exception = assertThrows(ResponseStatusException.class, () -> + service.create("TENANT-A", "operator-1", request("key-1", 5, 6))); + + assertEquals(400, exception.getStatusCode().value()); + verify(orders, never()).insert(any(LastlegOrder.class)); + verify(applications, never()).insert(any(RelabelApplication.class)); + verify(items, never()).insert(any(RelabelItem.class)); + } + + @Test + void sameIdempotencyKeyAndPayloadReturnsExistingApplicationWithoutWrites() { + RelabelRequests.Create request = request("key-1", 10, 6); + RelabelApplication existing = application(11L, "FBR-1", "TENANT-A", "key-1", hash(request), + RelabelApplicationService.PENDING_PROCESSING); + existing.setLastlegOrderId(21L); existing.setItemCount(1); existing.setTotalPackageCount(10); + existing.setNotifiedPackageCount(6); + when(applications.findKey("TENANT-A", "key-1")).thenReturn(existing); + stubDetail(existing); + + RelabelResponses.Detail result = service.create("TENANT-A", "operator-1", request); + + assertEquals("FBR-1", result.application().relabelNo()); + verify(orders, never()).insert(any(LastlegOrder.class)); + verify(applications, never()).insert(any(RelabelApplication.class)); + verify(items, never()).insert(any(RelabelItem.class)); + } + + @Test + void idempotencyKeyCannotBeReusedWithDifferentRelabelPayload() { + RelabelRequests.Create request = request("key-1", 10, 6); + RelabelApplication existing = application(11L, "FBR-1", "TENANT-A", "key-1", "different-hash", + RelabelApplicationService.PENDING_PROCESSING); + when(applications.findKey("TENANT-A", "key-1")).thenReturn(existing); + + ResponseStatusException exception = assertThrows(ResponseStatusException.class, () -> + service.create("TENANT-A", "operator-1", request)); + + assertEquals(409, exception.getStatusCode().value()); + verify(orders, never()).insert(any(LastlegOrder.class)); + } + + @Test + void statusChangeIsTenantScopedAndWritesApplicationAndTailOrderHistory() { + RelabelApplication application = application(11L, "FBR-1", "TENANT-A", "key-1", "hash", + RelabelApplicationService.PENDING_PROCESSING); + application.setLastlegOrderId(21L); + when(applications.lockNo("TENANT-A", "FBR-1")).thenReturn(application); + stubDetail(application); + + RelabelResponses.Detail result = service.changeStatus("TENANT-A", "FBR-1", "operator-2", + new RelabelRequests.ChangeStatus("processing", "started")); + + assertEquals("PROCESSING", application.getStatus()); + assertEquals("PROCESSING", result.application().status()); + verify(applications).updateById(application); + ArgumentCaptor updatedOrder = ArgumentCaptor.forClass(LastlegOrder.class); + verify(orders).updateById(updatedOrder.capture()); + assertEquals("PROCESSING", updatedOrder.getValue().getCurrentStatus()); + verify(histories).insert(any(RelabelStatusHistory.class)); + verify(orderHistories).insert(any(LastlegOrderStatusHistory.class)); + } + + @Test + void completedApplicationCannotReturnToProcessingAndOtherTenantCannotReadIt() { + RelabelApplication completed = application(11L, "FBR-1", "TENANT-A", "key-1", "hash", "COMPLETED"); + completed.setLastlegOrderId(21L); + when(applications.lockNo("TENANT-A", "FBR-1")).thenReturn(completed); + + ResponseStatusException transition = assertThrows(ResponseStatusException.class, () -> + service.changeStatus("TENANT-A", "FBR-1", "operator-2", new RelabelRequests.ChangeStatus("PROCESSING", null))); + assertEquals(409, transition.getStatusCode().value()); + + when(applications.findNo("TENANT-B", "FBR-1")).thenReturn(null); + ResponseStatusException boundary = assertThrows(ResponseStatusException.class, () -> + service.detail("TENANT-B", "FBR-1")); + assertEquals(404, boundary.getStatusCode().value()); + verify(applications).findNo("TENANT-B", "FBR-1"); + } + + private void stubDetail(RelabelApplication application) { + when(applications.findNo(application.getTenantId(), application.getRelabelNo())).thenReturn(application); + when(items.selectList(any())).thenReturn(List.of(item(31L, application.getId(), "SKU-A", 10, 6))); + when(histories.selectList(any())).thenReturn(List.of()); + when(orders.selectById(application.getLastlegOrderId())).thenReturn(order(application.getLastlegOrderId(), + application.getTenantId(), "LMO-RELABEL-1", application.getStatus())); + } + + private static RelabelRequests.Create request(String key, int total, int notified) { + return new RelabelRequests.Create("CUSTOMER-1", "WH-1", LocalDateTime.of(2026, 8, 20, 10, 0), + "Li", "13800000000", "remark", key, List.of(new RelabelRequests.Item("SKU-A", "img-1", + "image.png", "https://files/image.png", "Chinese name", total, notified, "SHP-1", "TRACK-1", + "SKU-NEW", "Label on carton", "file-1", "relabel.pdf", "https://files/relabel.pdf"))); + } + + private static RelabelApplication application(long id, String no, String tenantId, String key, String requestHash, + String status) { + RelabelApplication value = new RelabelApplication(); + value.setId(id); value.setTenantId(tenantId); value.setRelabelNo(no); value.setIdempotencyKey(key); + value.setRequestHash(requestHash); value.setStatus(status); value.setCreatedAt(LocalDateTime.now()); + value.setContactName("Li"); value.setContactPhone("13800000000"); value.setCustomerOrderNo("CUSTOMER-1"); + value.setOverseasWarehouseId("WH-1"); value.setLabelDeadline(LocalDateTime.now().plusDays(1)); + value.setItemCount(1); value.setTotalPackageCount(10); value.setNotifiedPackageCount(6); + return value; + } + + private static RelabelItem item(long id, long applicationId, String sku, int total, int notified) { + RelabelItem value = new RelabelItem(); + value.setId(id); value.setApplicationId(applicationId); value.setTenantId("TENANT-A"); value.setSkuCode(sku); + value.setProductNameZh("Chinese name"); value.setTotalPackageCount(total); value.setNotifiedPackageCount(notified); + value.setShipmentNo("SHP-1"); value.setNewSkuCode("SKU-NEW"); + return value; + } + + private static LastlegOrder order(long id, String tenantId, String orderNo, String status) { + LastlegOrder value = new LastlegOrder(); + value.setId(id); value.setTenantId(tenantId); value.setOrderNo(orderNo); value.setCurrentStatus(status); + return value; + } + + private static String hash(RelabelRequests.Create request) { + try { + byte[] digest = java.security.MessageDigest.getInstance("SHA-256") + .digest(mapper().writeValueAsString(request).getBytes(java.nio.charset.StandardCharsets.UTF_8)); + return java.util.HexFormat.of().formatHex(digest); + } catch (Exception exception) { + throw new AssertionError(exception); + } + } + + private static ObjectMapper mapper() { + return new ObjectMapper().findAndRegisterModules(); + } +} diff --git a/scripts/db/incremental.sql b/scripts/db/incremental.sql index c79d55f..ab17948 100644 --- a/scripts/db/incremental.sql +++ b/scripts/db/incremental.sql @@ -659,7 +659,7 @@ CREATE TABLE IF NOT EXISTS ll_inbound_fba_appointment_item ( -- ============================================================================ -- 2026-08-17 / 008 --- Last-leg SKU/package inventory balances, positions and immutable ledger +-- Last-leg inbound-order/package inventory balances, positions and immutable ledger -- ============================================================================ USE baiyue_lastleg; @@ -669,44 +669,40 @@ CREATE TABLE IF NOT EXISTS ll_inventory_balance ( 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, + inbound_order_id BIGINT NOT NULL, inbound_order_no VARCHAR(128) NULL, inbound_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, 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, + procurement_packages 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), + UNIQUE KEY uk_ll_inventory_dimension (tenant_id,overseas_warehouse_id,destination_warehouse_code,inbound_order_id,inbound_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'; + KEY idx_ll_inventory_inbound (tenant_id,inbound_code) +) ENGINE=InnoDB COMMENT='Real-time inbound-order 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, + 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, + receiving_packages 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'; +) ENGINE=InnoDB COMMENT='Inbound-order 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, + destination_warehouse_code VARCHAR(128) NOT NULL DEFAULT '', inbound_order_id BIGINT 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, @@ -714,14 +710,13 @@ CREATE TABLE IF NOT EXISTS ll_inventory_ledger ( 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_inbound (tenant_id,inbound_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'; +) ENGINE=InnoDB COMMENT='Immutable inbound-order package inventory ledger'; CREATE TABLE IF NOT EXISTS ll_inventory_operation ( id BIGINT NOT NULL COMMENT 'Snowflake ID', tenant_id VARCHAR(128) NOT NULL, @@ -784,7 +779,8 @@ 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, + dispatch_no VARCHAR(64) NOT NULL, overseas_warehouse_id VARCHAR(128) NULL, overseas_warehouse_name VARCHAR(255) 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', @@ -801,6 +797,7 @@ 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, + overseas_warehouse_id VARCHAR(128) NULL, overseas_warehouse_name VARCHAR(255) 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, @@ -833,7 +830,8 @@ 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, + operator_name VARCHAR(128) NULL, warehouse_id 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), @@ -867,3 +865,286 @@ 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; + +-- ============================================================================ +-- 2026-08-17 / 013 +-- Partial dispatch completion and cumulative package counters +-- ============================================================================ +USE baiyue_lastleg; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_order' AND column_name='overseas_warehouse_id'), 'SELECT 1', 'ALTER TABLE ll_dispatch_order ADD COLUMN overseas_warehouse_id VARCHAR(128) NULL AFTER dispatch_no')); +PREPARE warehouse_scope_stmt FROM @ddl; EXECUTE warehouse_scope_stmt; DEALLOCATE PREPARE warehouse_scope_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_order' AND column_name='overseas_warehouse_name'), 'SELECT 1', 'ALTER TABLE ll_dispatch_order ADD COLUMN overseas_warehouse_name VARCHAR(255) NULL AFTER overseas_warehouse_id')); +PREPARE warehouse_scope_stmt FROM @ddl; EXECUTE warehouse_scope_stmt; DEALLOCATE PREPARE warehouse_scope_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='overseas_warehouse_id'), 'SELECT 1', 'ALTER TABLE ll_dispatch_item ADD COLUMN overseas_warehouse_id VARCHAR(128) NULL AFTER pallet_no')); +PREPARE warehouse_scope_stmt FROM @ddl; EXECUTE warehouse_scope_stmt; DEALLOCATE PREPARE warehouse_scope_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='overseas_warehouse_name'), 'SELECT 1', 'ALTER TABLE ll_dispatch_item ADD COLUMN overseas_warehouse_name VARCHAR(255) NULL AFTER overseas_warehouse_id')); +PREPARE warehouse_scope_stmt FROM @ddl; EXECUTE warehouse_scope_stmt; DEALLOCATE PREPARE warehouse_scope_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_task' AND column_name='warehouse_id'), 'SELECT 1', 'ALTER TABLE ll_dispatch_pda_task ADD COLUMN warehouse_id VARCHAR(128) NULL AFTER operator_name')); +PREPARE warehouse_scope_stmt FROM @ddl; EXECUTE warehouse_scope_stmt; DEALLOCATE PREPARE warehouse_scope_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_order' AND column_name='picked_package_count'), 'SELECT 1', 'ALTER TABLE ll_dispatch_order ADD COLUMN picked_package_count INT NOT NULL DEFAULT 0 AFTER package_count')); +PREPARE dispatch_partial_stmt FROM @ddl; EXECUTE dispatch_partial_stmt; DEALLOCATE PREPARE dispatch_partial_stmt; + +CREATE TABLE IF NOT EXISTS ll_dispatch_pda_pick_order ( + id BIGINT NOT NULL, tenant_id VARCHAR(128) NOT NULL, pick_id BIGINT NOT NULL, + receipt_id BIGINT NOT NULL, order_id BIGINT NOT NULL, packages BIGINT NOT NULL, + product_quantity BIGINT NOT NULL DEFAULT 0, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), KEY idx_ll_dispatch_pick_order_pick (tenant_id,pick_id), + KEY idx_ll_dispatch_pick_order_receipt (tenant_id,receipt_id) +) ENGINE=InnoDB COMMENT='PDA pick attribution to frozen inbound receipt and tail order'; + +-- Tail order cumulative picking/shipping counters. Original package_count is immutable. +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='picked_package_count'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN picked_package_count INT NOT NULL DEFAULT 0 AFTER package_count')); +PREPARE tail_order_partial_stmt FROM @ddl; EXECUTE tail_order_partial_stmt; DEALLOCATE PREPARE tail_order_partial_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='shipped_package_count'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN shipped_package_count INT NOT NULL DEFAULT 0 AFTER picked_package_count')); +PREPARE tail_order_partial_stmt FROM @ddl; EXECUTE tail_order_partial_stmt; DEALLOCATE PREPARE tail_order_partial_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_order' AND column_name='shipped_package_count'), 'SELECT 1', 'ALTER TABLE ll_dispatch_order ADD COLUMN shipped_package_count INT NOT NULL DEFAULT 0 AFTER picked_package_count')); +PREPARE dispatch_partial_stmt FROM @ddl; EXECUTE dispatch_partial_stmt; DEALLOCATE PREPARE dispatch_partial_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_order' AND column_name='partial_completion_reason'), 'SELECT 1', 'ALTER TABLE ll_dispatch_order ADD COLUMN partial_completion_reason VARCHAR(1000) NULL AFTER shipped_package_count')); +PREPARE dispatch_partial_stmt FROM @ddl; EXECUTE dispatch_partial_stmt; DEALLOCATE PREPARE dispatch_partial_stmt; + +-- ============================================================================ +-- 2026-08-17 / 014 +-- Dispatch transfer to outbound and delivery documents +-- ============================================================================ +USE baiyue_lastleg; + +CREATE TABLE IF NOT EXISTS ll_outbound_order ( + id BIGINT NOT NULL COMMENT 'Snowflake ID', tenant_id VARCHAR(128) NOT NULL, + outbound_no VARCHAR(64) NOT NULL, dispatch_order_id BIGINT NOT NULL, dispatch_no VARCHAR(64) NOT NULL, + overseas_warehouse_id VARCHAR(128) NULL, status VARCHAR(32) NOT NULL DEFAULT 'WAIT_OUTBOUND', + package_count INT NOT NULL DEFAULT 0, pallet_count INT NOT NULL DEFAULT 0, + idempotency_key VARCHAR(255) NOT NULL, 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_outbound_no (tenant_id,outbound_no), + UNIQUE KEY uk_ll_outbound_idempotency (tenant_id,idempotency_key), UNIQUE KEY uk_ll_outbound_dispatch (tenant_id,dispatch_order_id), + KEY idx_ll_outbound_list (tenant_id,status,created_at) +) ENGINE=InnoDB COMMENT='Outbound document generated from a dispatch'; + +CREATE TABLE IF NOT EXISTS ll_outbound_pallet_item ( + id BIGINT NOT NULL COMMENT 'Snowflake ID', tenant_id VARCHAR(128) NOT NULL, outbound_order_id BIGINT NOT NULL, + warehouse_pallet_id BIGINT NOT 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, + package_count INT NOT NULL DEFAULT 0, order_refs VARCHAR(2000) NULL, inbound_code VARCHAR(128) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), PRIMARY KEY (id), + UNIQUE KEY uk_ll_outbound_pallet (tenant_id,outbound_order_id,warehouse_pallet_id), + KEY idx_ll_outbound_pallet_list (tenant_id,outbound_order_id), KEY idx_ll_outbound_pallet_destination (tenant_id,destination_warehouse_code) +) ENGINE=InnoDB COMMENT='Pallet detail snapshot for an outbound document'; + +CREATE TABLE IF NOT EXISTS ll_delivery_order ( + id BIGINT NOT NULL COMMENT 'Snowflake ID', tenant_id VARCHAR(128) NOT NULL, + delivery_no VARCHAR(64) NOT NULL, outbound_order_id BIGINT NOT NULL, outbound_no VARCHAR(64) NOT NULL, + dispatch_order_id BIGINT NOT NULL, dispatch_no VARCHAR(64) NOT NULL, overseas_warehouse_id VARCHAR(128) NULL, + status VARCHAR(32) NOT NULL DEFAULT 'WAIT_DELIVERY', package_count INT NOT NULL DEFAULT 0, pallet_count INT NOT NULL DEFAULT 0, + idempotency_key VARCHAR(255) NOT NULL, 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_delivery_no (tenant_id,delivery_no), + UNIQUE KEY uk_ll_delivery_idempotency (tenant_id,idempotency_key), UNIQUE KEY uk_ll_delivery_outbound (tenant_id,outbound_order_id), + KEY idx_ll_delivery_list (tenant_id,status,created_at) +) ENGINE=InnoDB COMMENT='Delivery document linked to an outbound document'; + +-- ============================================================================ +-- 2026-08-17 / 015 +-- Change inventory ownership from SKU/quantity to inbound-order/package count. +-- Legacy SKU columns become nullable and quantity columns remain untouched so original data is preserved. +-- ============================================================================ +USE baiyue_lastleg; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='inbound_order_id'), 'SELECT 1', 'ALTER TABLE ll_inventory_balance ADD COLUMN inbound_order_id BIGINT NULL AFTER destination_warehouse_name')); +PREPARE inventory_v2_stmt FROM @ddl; EXECUTE inventory_v2_stmt; DEALLOCATE PREPARE inventory_v2_stmt; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='inbound_order_no'), 'SELECT 1', 'ALTER TABLE ll_inventory_balance ADD COLUMN inbound_order_no VARCHAR(128) NULL AFTER inbound_order_id')); +PREPARE inventory_v2_stmt FROM @ddl; EXECUTE inventory_v2_stmt; DEALLOCATE PREPARE inventory_v2_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='inbound_code'), 'SELECT 1', 'ALTER TABLE ll_inventory_balance ADD COLUMN inbound_code VARCHAR(128) NULL AFTER inbound_order_no')); +PREPARE inventory_v2_stmt FROM @ddl; EXECUTE inventory_v2_stmt; DEALLOCATE PREPARE inventory_v2_stmt; + +-- One legacy balance row represented one SKU. Keep each row intact and give it a +-- collision-free migration identity while retaining the best known inbound snapshots. +UPDATE ll_inventory_balance b +LEFT JOIN ( + SELECT balance_id, MAX(inbound_order_no) AS inbound_order_no, MAX(inbound_code) AS inbound_code + FROM ll_inventory_ledger GROUP BY balance_id +) l ON l.balance_id=b.id +SET b.inbound_order_id=COALESCE(b.inbound_order_id,b.id), + b.inbound_order_no=COALESCE(b.inbound_order_no,l.inbound_order_no), + b.inbound_code=COALESCE(NULLIF(b.inbound_code,''),NULLIF(l.inbound_code,''),CONCAT('LEGACY-',b.id)) +WHERE b.inbound_order_id IS NULL OR b.inbound_code IS NULL OR b.inbound_code=''; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='sku_code'), 'ALTER TABLE ll_inventory_balance MODIFY COLUMN sku_code VARCHAR(128) NULL', 'SELECT 1')); +PREPARE inventory_v2_stmt FROM @ddl; EXECUTE inventory_v2_stmt; DEALLOCATE PREPARE inventory_v2_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='inbound_order_id'), 'ALTER TABLE ll_inventory_balance MODIFY COLUMN inbound_order_id BIGINT NOT NULL', 'SELECT 1')); +PREPARE inventory_v2_stmt FROM @ddl; EXECUTE inventory_v2_stmt; DEALLOCATE PREPARE inventory_v2_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='inbound_code'), 'ALTER TABLE ll_inventory_balance MODIFY COLUMN inbound_code VARCHAR(128) NOT NULL', 'SELECT 1')); +PREPARE inventory_v2_stmt FROM @ddl; EXECUTE inventory_v2_stmt; DEALLOCATE PREPARE inventory_v2_stmt; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND index_name='uk_ll_inventory_dimension'), 'ALTER TABLE ll_inventory_balance DROP INDEX uk_ll_inventory_dimension', 'SELECT 1')); +PREPARE inventory_v2_stmt FROM @ddl; EXECUTE inventory_v2_stmt; DEALLOCATE PREPARE inventory_v2_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND index_name='uk_ll_inventory_dimension'), 'SELECT 1', 'ALTER TABLE ll_inventory_balance ADD UNIQUE KEY uk_ll_inventory_dimension (tenant_id,overseas_warehouse_id,destination_warehouse_code,inbound_order_id,inbound_code)')); +PREPARE inventory_v2_stmt FROM @ddl; EXECUTE inventory_v2_stmt; DEALLOCATE PREPARE inventory_v2_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND index_name='idx_ll_inventory_inbound'), 'SELECT 1', 'ALTER TABLE ll_inventory_balance ADD KEY idx_ll_inventory_inbound (tenant_id,inbound_code)')); +PREPARE inventory_v2_stmt FROM @ddl; EXECUTE inventory_v2_stmt; DEALLOCATE PREPARE inventory_v2_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND index_name='idx_ll_inventory_sku'), 'ALTER TABLE ll_inventory_balance DROP INDEX idx_ll_inventory_sku', 'SELECT 1')); +PREPARE inventory_v2_stmt FROM @ddl; EXECUTE inventory_v2_stmt; DEALLOCATE PREPARE inventory_v2_stmt; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_position' AND column_name='sku_code'), 'ALTER TABLE ll_inventory_position MODIFY COLUMN sku_code VARCHAR(128) NULL', 'SELECT 1')); +PREPARE inventory_v2_stmt FROM @ddl; EXECUTE inventory_v2_stmt; DEALLOCATE PREPARE inventory_v2_stmt; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='inbound_order_id'), 'SELECT 1', 'ALTER TABLE ll_inventory_ledger ADD COLUMN inbound_order_id BIGINT NULL AFTER destination_warehouse_code')); +PREPARE inventory_v2_stmt FROM @ddl; EXECUTE inventory_v2_stmt; DEALLOCATE PREPARE inventory_v2_stmt; +UPDATE ll_inventory_ledger SET inbound_order_id=COALESCE(inbound_order_id,business_order_id,balance_id) WHERE inbound_order_id IS NULL; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='sku_code'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN sku_code VARCHAR(128) NULL', 'SELECT 1')); +PREPARE inventory_v2_stmt FROM @ddl; EXECUTE inventory_v2_stmt; DEALLOCATE PREPARE inventory_v2_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='inbound_order_id'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN inbound_order_id BIGINT NOT NULL', 'SELECT 1')); +PREPARE inventory_v2_stmt FROM @ddl; EXECUTE inventory_v2_stmt; DEALLOCATE PREPARE inventory_v2_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND index_name='idx_ll_inventory_ledger_inbound'), 'SELECT 1', 'ALTER TABLE ll_inventory_ledger ADD KEY idx_ll_inventory_ledger_inbound (tenant_id,inbound_code,occurred_at)')); +PREPARE inventory_v2_stmt FROM @ddl; EXECUTE inventory_v2_stmt; DEALLOCATE PREPARE inventory_v2_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND index_name='idx_ll_inventory_ledger_sku'), 'ALTER TABLE ll_inventory_ledger DROP INDEX idx_ll_inventory_ledger_sku', 'SELECT 1')); +PREPARE inventory_v2_stmt FROM @ddl; EXECUTE inventory_v2_stmt; DEALLOCATE PREPARE inventory_v2_stmt; + +-- ============================================================================ +-- 2026-08-17 / 016 +-- PDA stock receipts follow the same inbound-order/package-only inventory model. +-- Legacy SKU and quantity columns remain in place but become nullable/defaulted. +-- ============================================================================ +USE baiyue_lastleg; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_stock_receipt' AND column_name='sku_code'), 'ALTER TABLE ll_inbound_stock_receipt MODIFY COLUMN sku_code VARCHAR(128) NULL', 'SELECT 1')); +PREPARE stock_receipt_v2_stmt FROM @ddl; EXECUTE stock_receipt_v2_stmt; DEALLOCATE PREPARE stock_receipt_v2_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_stock_receipt' AND column_name='received_quantity'), 'ALTER TABLE ll_inbound_stock_receipt MODIFY COLUMN received_quantity BIGINT NULL DEFAULT 0', 'SELECT 1')); +PREPARE stock_receipt_v2_stmt FROM @ddl; EXECUTE stock_receipt_v2_stmt; DEALLOCATE PREPARE stock_receipt_v2_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inbound_stock_receipt' AND index_name='uk_ll_stock_receipt_task_allocation_sku'), 'ALTER TABLE ll_inbound_stock_receipt DROP INDEX uk_ll_stock_receipt_task_allocation_sku', 'SELECT 1')); +PREPARE stock_receipt_v2_stmt FROM @ddl; EXECUTE stock_receipt_v2_stmt; DEALLOCATE PREPARE stock_receipt_v2_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inbound_stock_receipt' AND index_name='uk_ll_stock_receipt_task_allocation'), 'SELECT 1', 'ALTER TABLE ll_inbound_stock_receipt ADD UNIQUE KEY uk_ll_stock_receipt_task_allocation (tenant_id,task_id,allocation_id)')); +PREPARE stock_receipt_v2_stmt FROM @ddl; EXECUTE stock_receipt_v2_stmt; DEALLOCATE PREPARE stock_receipt_v2_stmt; + +-- ============================================================================ +-- 2026-08-17 / 017 +-- Customer relabel applications, cargo snapshots, and process status history +-- ============================================================================ +USE baiyue_lastleg; + +CREATE TABLE IF NOT EXISTS ll_relabel_application ( + id BIGINT NOT NULL COMMENT 'Snowflake ID', tenant_id VARCHAR(128) NOT NULL, + relabel_no VARCHAR(64) NOT NULL, lastleg_order_id BIGINT NOT NULL, + customer_order_no VARCHAR(128) NOT NULL, overseas_warehouse_id VARCHAR(128) NOT NULL, + label_deadline DATETIME(3) NOT NULL, contact_name VARCHAR(128) NOT NULL, + contact_phone VARCHAR(64) NOT NULL, remark VARCHAR(1000) NULL, + status VARCHAR(32) NOT NULL DEFAULT 'PENDING_PROCESSING', item_count INT NOT NULL DEFAULT 0, + total_package_count INT NOT NULL DEFAULT 0, notified_package_count INT NOT NULL DEFAULT 0, + idempotency_key VARCHAR(255) NOT NULL, request_hash CHAR(64) NOT NULL, + 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) +) ENGINE=InnoDB COMMENT='Customer relabel application header'; + +CREATE TABLE IF NOT EXISTS ll_relabel_item ( + id BIGINT NOT NULL COMMENT 'Snowflake ID', application_id BIGINT NOT NULL, tenant_id VARCHAR(128) NOT NULL, + sku_code VARCHAR(128) NOT NULL, product_image_file_id VARCHAR(128) NULL, + product_image_file_name VARCHAR(255) NULL, product_image_url VARCHAR(1000) NULL, + product_name_zh VARCHAR(255) NOT NULL, total_package_count INT NOT NULL, notified_package_count INT NOT NULL, + shipment_no VARCHAR(128) NOT NULL, tracking_code VARCHAR(255) NULL, new_sku_code VARCHAR(128) NOT NULL, + label_requirement VARCHAR(1000) NULL, relabel_file_id VARCHAR(128) NULL, + relabel_file_name VARCHAR(255) NULL, relabel_file_url VARCHAR(1000) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), PRIMARY KEY (id) +) ENGINE=InnoDB COMMENT='Frozen customer relabel cargo items'; + +CREATE TABLE IF NOT EXISTS ll_relabel_status_history ( + id BIGINT NOT NULL COMMENT 'Snowflake ID', tenant_id VARCHAR(128) NOT NULL, application_id BIGINT NOT NULL, + from_status VARCHAR(32) NULL, to_status VARCHAR(32) NOT NULL, operator_id VARCHAR(128) NULL, + remark VARCHAR(1000) NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), PRIMARY KEY (id) +) ENGINE=InnoDB COMMENT='Immutable customer relabel process history'; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND index_name='uk_ll_relabel_application_no'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD UNIQUE KEY uk_ll_relabel_application_no (tenant_id,relabel_no)')); +PREPARE relabel_schema_stmt FROM @ddl; EXECUTE relabel_schema_stmt; DEALLOCATE PREPARE relabel_schema_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND index_name='uk_ll_relabel_application_idempotency'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD UNIQUE KEY uk_ll_relabel_application_idempotency (tenant_id,idempotency_key)')); +PREPARE relabel_schema_stmt FROM @ddl; EXECUTE relabel_schema_stmt; DEALLOCATE PREPARE relabel_schema_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND index_name='uk_ll_relabel_application_order'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD UNIQUE KEY uk_ll_relabel_application_order (lastleg_order_id)')); +PREPARE relabel_schema_stmt FROM @ddl; EXECUTE relabel_schema_stmt; DEALLOCATE PREPARE relabel_schema_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND index_name='idx_ll_relabel_application_list'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD KEY idx_ll_relabel_application_list (tenant_id,status,created_at)')); +PREPARE relabel_schema_stmt FROM @ddl; EXECUTE relabel_schema_stmt; DEALLOCATE PREPARE relabel_schema_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND index_name='idx_ll_relabel_application_customer'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD KEY idx_ll_relabel_application_customer (tenant_id,customer_order_no)')); +PREPARE relabel_schema_stmt FROM @ddl; EXECUTE relabel_schema_stmt; DEALLOCATE PREPARE relabel_schema_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND index_name='idx_ll_relabel_application_warehouse'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD KEY idx_ll_relabel_application_warehouse (tenant_id,overseas_warehouse_id)')); +PREPARE relabel_schema_stmt FROM @ddl; EXECUTE relabel_schema_stmt; DEALLOCATE PREPARE relabel_schema_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND index_name='idx_ll_relabel_item_application'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD KEY idx_ll_relabel_item_application (tenant_id,application_id)')); +PREPARE relabel_schema_stmt FROM @ddl; EXECUTE relabel_schema_stmt; DEALLOCATE PREPARE relabel_schema_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND index_name='idx_ll_relabel_item_sku'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD KEY idx_ll_relabel_item_sku (tenant_id,sku_code)')); +PREPARE relabel_schema_stmt FROM @ddl; EXECUTE relabel_schema_stmt; DEALLOCATE PREPARE relabel_schema_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_relabel_status_history' AND index_name='idx_ll_relabel_status_history_application'), 'SELECT 1', 'ALTER TABLE ll_relabel_status_history ADD KEY idx_ll_relabel_status_history_application (tenant_id,application_id,created_at)')); +PREPARE relabel_schema_stmt FROM @ddl; EXECUTE relabel_schema_stmt; DEALLOCATE PREPARE relabel_schema_stmt; + +-- ============================================================================ +-- 2026-08-17 / 018 +-- Existing-table correction: apply schema changes with ADD/UPDATE/ALTER guards +-- ============================================================================ +-- Sections 008-017 keep CREATE TABLE definitions for fresh databases. This +-- section is the authoritative path for databases that already contain those +-- tables. Legacy SKU/quantity columns are retained and made nullable; no +-- business rows or Outbox rows are deleted. +USE baiyue_lastleg; + +-- Tail orders: cumulative PDA picking/shipping counters. +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='picked_package_count'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN picked_package_count INT NOT NULL DEFAULT 0 COMMENT ''累计已拣箱数'' AFTER package_count')); +PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='shipped_package_count'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN shipped_package_count INT NOT NULL DEFAULT 0 COMMENT ''累计已出库箱数'' AFTER picked_package_count')); +PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018; + +-- Inventory balance: add inbound-order identity without dropping legacy SKU data. +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='inbound_order_id'), 'SELECT 1', 'ALTER TABLE ll_inventory_balance ADD COLUMN inbound_order_id BIGINT NULL COMMENT ''入仓单ID'' AFTER destination_warehouse_name')); +PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='inbound_order_no'), 'SELECT 1', 'ALTER TABLE ll_inventory_balance ADD COLUMN inbound_order_no VARCHAR(128) NULL COMMENT ''入库单号快照'' AFTER inbound_order_id')); +PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='inbound_code'), 'SELECT 1', 'ALTER TABLE ll_inventory_balance ADD COLUMN inbound_code VARCHAR(128) NULL COMMENT ''入仓码快照'' AFTER inbound_order_no')); +PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018; +UPDATE ll_inventory_balance +SET inbound_order_id = COALESCE(inbound_order_id, id), + inbound_code = COALESCE(NULLIF(inbound_code, ''), CONCAT('LEGACY-', id)) +WHERE inbound_order_id IS NULL OR inbound_code IS NULL OR inbound_code = ''; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='inbound_order_id'), 'ALTER TABLE ll_inventory_balance MODIFY COLUMN inbound_order_id BIGINT NOT NULL', 'SELECT 1')); +PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='inbound_code'), 'ALTER TABLE ll_inventory_balance MODIFY COLUMN inbound_code VARCHAR(128) NOT NULL', 'SELECT 1')); +PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND index_name='uk_ll_inventory_dimension'), 'ALTER TABLE ll_inventory_balance DROP INDEX uk_ll_inventory_dimension', 'SELECT 1')); +PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND index_name='uk_ll_inventory_dimension'), 'SELECT 1', 'ALTER TABLE ll_inventory_balance ADD UNIQUE KEY uk_ll_inventory_dimension (tenant_id,overseas_warehouse_id,destination_warehouse_code,inbound_order_id,inbound_code)')); +PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND index_name='idx_ll_inventory_sku'), 'ALTER TABLE ll_inventory_balance DROP INDEX idx_ll_inventory_sku', 'SELECT 1')); +PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND index_name='idx_ll_inventory_inbound'), 'SELECT 1', 'ALTER TABLE ll_inventory_balance ADD KEY idx_ll_inventory_inbound (tenant_id,inbound_code)')); +PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018; + +-- Inventory positions and ledger: preserve SKU columns, add inbound identity. +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_position' AND column_name='sku_code'), 'ALTER TABLE ll_inventory_position MODIFY COLUMN sku_code VARCHAR(128) NULL', 'SELECT 1')); +PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='inbound_order_id'), 'SELECT 1', 'ALTER TABLE ll_inventory_ledger ADD COLUMN inbound_order_id BIGINT NULL COMMENT ''入仓单ID'' AFTER destination_warehouse_code')); +PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018; +UPDATE ll_inventory_ledger +SET inbound_order_id = COALESCE(inbound_order_id, business_order_id, balance_id) +WHERE inbound_order_id IS NULL; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='inbound_order_id'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN inbound_order_id BIGINT NOT NULL', 'SELECT 1')); +PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='sku_code'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN sku_code VARCHAR(128) NULL', 'SELECT 1')); +PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND index_name='idx_ll_inventory_ledger_sku'), 'ALTER TABLE ll_inventory_ledger DROP INDEX idx_ll_inventory_ledger_sku', 'SELECT 1')); +PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND index_name='idx_ll_inventory_ledger_inbound'), 'SELECT 1', 'ALTER TABLE ll_inventory_ledger ADD KEY idx_ll_inventory_ledger_inbound (tenant_id,inbound_code,occurred_at)')); +PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018; + +-- Stock receipts: keep legacy SKU/quantity snapshots but allow inbound-only rows. +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_stock_receipt' AND column_name='sku_code'), 'ALTER TABLE ll_inbound_stock_receipt MODIFY COLUMN sku_code VARCHAR(128) NULL', 'SELECT 1')); +PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_stock_receipt' AND column_name='received_quantity'), 'ALTER TABLE ll_inbound_stock_receipt MODIFY COLUMN received_quantity BIGINT NULL DEFAULT 0', 'SELECT 1')); +PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inbound_stock_receipt' AND index_name='uk_ll_stock_receipt_task_allocation_sku'), 'ALTER TABLE ll_inbound_stock_receipt DROP INDEX uk_ll_stock_receipt_task_allocation_sku', 'SELECT 1')); +PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inbound_stock_receipt' AND index_name='uk_ll_stock_receipt_task_allocation'), 'SELECT 1', 'ALTER TABLE ll_inbound_stock_receipt ADD UNIQUE KEY uk_ll_stock_receipt_task_allocation (tenant_id,task_id,allocation_id)')); +PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018; + +-- Dispatch/PDA fields are also ADD operations for existing installations. +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_order' AND column_name='picked_package_count'), 'SELECT 1', 'ALTER TABLE ll_dispatch_order ADD COLUMN picked_package_count INT NOT NULL DEFAULT 0 COMMENT ''累计已拣箱数'' AFTER package_count')); +PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_order' AND column_name='shipped_package_count'), 'SELECT 1', 'ALTER TABLE ll_dispatch_order ADD COLUMN shipped_package_count INT NOT NULL DEFAULT 0 COMMENT ''累计已出库箱数'' AFTER picked_package_count')); +PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_order' AND column_name='partial_completion_reason'), 'SELECT 1', 'ALTER TABLE ll_dispatch_order ADD COLUMN partial_completion_reason VARCHAR(1000) NULL COMMENT ''部分完成原因'' AFTER shipped_package_count')); +PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018; + +-- No DROP COLUMN is included. Existing columns and business rows remain recoverable.