派车提交

This commit is contained in:
2026-08-17 13:38:48 +08:00
parent d6fe79b0f2
commit 3082167411
101 changed files with 5353 additions and 32 deletions
@@ -0,0 +1,9 @@
package com.baiyue.lastleg.dispatch;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.context.annotation.Configuration;
@Configuration
@MapperScan("com.baiyue.lastleg.dispatch.mapper")
public class DispatchMapperConfiguration {
}
@@ -0,0 +1,124 @@
package com.baiyue.lastleg.dispatch.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.dispatch.dto.DispatchRequests;
import com.baiyue.lastleg.dispatch.dto.DispatchResponse;
import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate;
import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaRequests;
import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaResponses;
import com.baiyue.lastleg.dispatch.pda.service.DispatchPdaWorkflowService;
import com.baiyue.lastleg.dispatch.service.DispatchService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
@RestController
@RequestMapping("/api/platform/v1/last-leg/dispatches")
@Tag(name = "Tail-leg vehicle dispatch")
public class DispatchController {
@Resource private DispatchService service;
@Resource private DispatchPdaWorkflowService pdaWorkflow;
@GetMapping("/candidates")
@Operation(summary = "List unlocked planned or received pallets for intelligent pre-dispatch")
public ApiResponse<List<DispatchPalletCandidate>> candidates(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String tenantId,
@RequestParam(required = false) String orderNo,
@RequestParam(required = false) String fbaWarehouseCode,
@RequestParam(required = false) String sourceStage) {
return ApiResponse.success(service.candidates(scope(user, tenantId), orderNo, fbaWarehouseCode, sourceStage));
}
@PostMapping("/pre-arrange")
@Operation(summary = "Create a pending-departure dispatch and lock all selected pallets")
public ApiResponse<DispatchResponse> preArrange(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String tenantId,
@Valid @RequestBody DispatchRequests.PreArrange request) {
String scopedTenant = scope(user, tenantId);
String operatorId = user.userId() == null ? user.username() : String.valueOf(user.userId());
return ApiResponse.success(service.preArrange(scopedTenant, operatorId, request));
}
@GetMapping
@Operation(summary = "Page dispatch orders")
public ApiResponse<PageResult<DispatchResponse>> page(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String tenantId,
@Valid DispatchRequests.Query query) {
return ApiResponse.success(service.page(scope(user, tenantId), query));
}
@GetMapping("/{dispatchNo}")
@Operation(summary = "Get dispatch details and all pallet destinations")
public ApiResponse<DispatchResponse> detail(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String tenantId,
@PathVariable String dispatchNo) {
return ApiResponse.success(service.detail(scope(user, tenantId), dispatchNo));
}
@PutMapping("/{dispatchNo}")
@Operation(summary = "Edit vehicle, driver, appointment, route and departure data")
public ApiResponse<DispatchResponse> update(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String tenantId,
@PathVariable String dispatchNo,
@Valid @RequestBody DispatchRequests.Update request) {
return ApiResponse.success(service.update(scope(user, tenantId), dispatchNo, request));
}
@DeleteMapping("/{dispatchNo}")
@Operation(summary = "Cancel a pending dispatch and release its pallet locks")
public ApiResponse<Void> cancel(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String tenantId,
@PathVariable String dispatchNo) {
service.cancel(scope(user, tenantId), dispatchNo);
return ApiResponse.success(null);
}
@PostMapping("/{dispatchNo}/depart")
@Operation(summary = "Ship all PDA-picked pallets and mark the dispatch departed")
public ApiResponse<DispatchPdaResponses.Departed> depart(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String tenantId,
@PathVariable String dispatchNo,
@Valid @RequestBody DispatchPdaRequests.Depart request) {
String scopedTenant = scope(user, tenantId);
String operatorId = user.userId() == null ? user.username() : String.valueOf(user.userId());
return ApiResponse.success(pdaWorkflow.depart(scopedTenant, dispatchNo, operatorId, request));
}
private String scope(AuthenticatedUser user, String requestedTenant) {
if (user == null || user.tenantId() == null) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
}
if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) {
if (requestedTenant == null || requestedTenant.isBlank() || "0".equals(requestedTenant)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"Platform request must specify target tenant");
}
return requestedTenant;
}
return String.valueOf(user.tenantId());
}
}
@@ -0,0 +1,35 @@
package com.baiyue.lastleg.dispatch.dto;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.time.LocalDateTime;
import java.util.List;
public final class DispatchRequests {
private DispatchRequests() {
}
public record Query(@Min(1) long page, @Min(1) @Max(200) long size, String dispatchNo,
String vehiclePlate, String status, String fbaAppointmentNo) {
public Query { if (page == 0) page = 1; if (size == 0) size = 20; }
}
public record PreArrange(@NotEmpty @Size(max = 500) List<@NotNull Long> planPalletIds,
@Size(max = 255) String route, @Size(max = 64) String fbaAppointmentNo,
@Size(max = 32) String fbaAppointmentStatus,
@Size(max = 32) String vehicleAppointmentStatus) {
}
public record Update(@Size(max = 64) String vehiclePlate, @Size(max = 128) String driverName,
@Size(max = 64) String driverPhone, @Size(max = 255) String carrierName,
@Size(max = 255) String route, LocalDateTime departAt,
@Size(max = 64) String fbaAppointmentNo,
@Size(max = 32) String fbaAppointmentStatus,
@Size(max = 32) String vehicleAppointmentStatus,
LocalDateTime estimatedArrivalAt, @Size(max = 32) String status) {
}
}
@@ -0,0 +1,18 @@
package com.baiyue.lastleg.dispatch.dto;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
public record DispatchResponse(Long id, String dispatchNo, String vehiclePlate, String driverName,
String driverPhone, String carrierName, String orderNos, String route,
LocalDateTime departAt, String fbaAppointmentNo, String fbaAppointmentStatus,
String vehicleAppointmentStatus, LocalDateTime estimatedArrivalAt, String status,
int itemCount, int palletCount, int packageCount, LocalDateTime createdAt,
List<Item> items) {
public record Item(Long id, Long planPalletId, Long warehousePalletId, String palletNo,
String orderNo, String orderRefs, String inboundCode, String destinationWarehouseCode,
String destinationWarehouseName, String fbaWarehouseCode, int packageCount,
BigDecimal volumeCbm, String sourceStage) {
}
}
@@ -0,0 +1,36 @@
package com.baiyue.lastleg.dispatch.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/** Immutable pallet/cargo snapshot selected into a dispatch order. */
@Data
@TableName("ll_dispatch_item")
public class DispatchItem {
@TableId(type = IdType.INPUT)
private Long id;
private Long dispatchOrderId;
private String tenantId;
private Long planId;
private Long planPalletId;
private Long warehousePalletId;
private Long orderId;
private String orderNo;
private String orderRefs;
private String inboundCode;
private String palletNo;
private String destinationWarehouseId;
private String destinationWarehouseCode;
private String destinationWarehouseName;
private String fbaWarehouseCode;
private Integer packageCount;
private BigDecimal volumeCbm;
/** PLAN for planned pallets; RECEIVED for warehouse/entity pallets. */
private String sourceStage;
private LocalDateTime createdAt;
}
@@ -0,0 +1,35 @@
package com.baiyue.lastleg.dispatch.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
/** Header for a pre-dispatched vehicle load. Vehicle information remains editable before departure. */
@Data
@TableName("ll_dispatch_order")
public class DispatchOrder {
@TableId(type = IdType.INPUT)
private Long id;
private String tenantId;
private String dispatchNo;
private String vehiclePlate;
private String driverName;
private String driverPhone;
private String carrierName;
private String route;
private LocalDateTime departAt;
private String fbaAppointmentNo;
private String fbaAppointmentStatus;
private String vehicleAppointmentStatus;
private LocalDateTime estimatedArrivalAt;
private String status;
private Integer itemCount;
private Integer palletCount;
private Integer packageCount;
private String createdBy;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,29 @@
package com.baiyue.lastleg.dispatch.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
/** Prevents a source pallet from being added to more than one active dispatch order. */
@Data
@TableName("ll_dispatch_pallet_lock")
public class DispatchPalletLock {
@TableId(type = IdType.INPUT)
private Long id;
private String tenantId;
private String sourcePalletKey;
/** Equals sourcePalletKey while ACTIVE and is null after release; unique index enforces exclusivity. */
private String activeKey;
private Long planId;
private Long planPalletId;
private Long warehousePalletId;
private Long dispatchOrderId;
private String status;
private LocalDateTime lockedAt;
private LocalDateTime releasedAt;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,66 @@
package com.baiyue.lastleg.dispatch.mapper;
import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface DispatchCandidateMapper {
@Select("<script>" +
"SELECT p.plan_id AS planId,p.id AS planPalletId,p.warehouse_pallet_id AS warehousePalletId," +
"MIN(l.order_id) AS orderId,MIN(o.order_no) AS orderNo," +
"GROUP_CONCAT(DISTINCT o.order_no ORDER BY o.order_no SEPARATOR ',') AS orderRefs,MIN(l.inbound_code) AS inboundCode," +
"p.pallet_no AS palletNo,p.destination_warehouse_id AS destinationWarehouseId," +
"p.destination_warehouse_code AS destinationWarehouseCode,p.destination_warehouse_name AS destinationWarehouseName," +
"MIN(l.fba_warehouse_code) AS fbaWarehouseCode," +
"COALESCE(SUM(CASE WHEN a.picked_package_count &gt; 0 THEN a.picked_package_count " +
"ELSE a.planned_package_count END),0) AS packageCount,p.allocated_volume_cbm AS volumeCbm," +
"CASE WHEN p.warehouse_pallet_id IS NULL THEN 'PLAN' ELSE 'RECEIVED' END AS sourceStage " +
"FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id " +
"LEFT JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id " +
"LEFT JOIN ll_inbound_plan_line l ON l.id=a.line_id " +
"LEFT JOIN ll_order o ON o.id=l.order_id " +
"WHERE ip.tenant_id=#{tenantId} " +
"<if test='orderNo != null and orderNo != \"\"'> AND o.order_no LIKE CONCAT('%',#{orderNo},'%')</if> " +
"<if test='fbaWarehouseCode != null and fbaWarehouseCode != \"\"'> AND l.fba_warehouse_code=#{fbaWarehouseCode}</if> " +
"<if test='sourceStage != null and sourceStage != \"\"'> AND " +
"CASE WHEN p.warehouse_pallet_id IS NULL THEN 'PLAN' ELSE 'RECEIVED' END=#{sourceStage}</if> " +
"AND NOT EXISTS (SELECT 1 FROM ll_dispatch_pallet_lock d " +
"WHERE d.tenant_id=ip.tenant_id AND d.status='ACTIVE' AND (" +
"d.source_pallet_key=CONCAT('PLAN:',p.id) OR (p.warehouse_pallet_id IS NOT NULL AND " +
"d.source_pallet_key=CONCAT('RECEIVED:',p.warehouse_pallet_id)))) " +
"GROUP BY p.plan_id,p.id,p.warehouse_pallet_id,p.pallet_no,p.destination_warehouse_id," +
"p.destination_warehouse_code,p.destination_warehouse_name,p.allocated_volume_cbm " +
"ORDER BY p.plan_id,p.sequence_no" +
"</script>")
List<DispatchPalletCandidate> available(@Param("tenantId") String tenantId,
@Param("orderNo") String orderNo,
@Param("fbaWarehouseCode") String fbaWarehouseCode,
@Param("sourceStage") String sourceStage);
@Select("<script>" +
"SELECT p.plan_id AS planId,p.id AS planPalletId,p.warehouse_pallet_id AS warehousePalletId," +
"MIN(l.order_id) AS orderId,MIN(o.order_no) AS orderNo," +
"GROUP_CONCAT(DISTINCT o.order_no ORDER BY o.order_no SEPARATOR ',') AS orderRefs,MIN(l.inbound_code) AS inboundCode," +
"p.pallet_no AS palletNo,p.destination_warehouse_id AS destinationWarehouseId," +
"p.destination_warehouse_code AS destinationWarehouseCode,p.destination_warehouse_name AS destinationWarehouseName," +
"MIN(l.fba_warehouse_code) AS fbaWarehouseCode," +
"COALESCE(SUM(CASE WHEN a.picked_package_count &gt; 0 THEN a.picked_package_count " +
"ELSE a.planned_package_count END),0) AS packageCount,p.allocated_volume_cbm AS volumeCbm," +
"CASE WHEN p.warehouse_pallet_id IS NULL THEN 'PLAN' ELSE 'RECEIVED' END AS sourceStage " +
"FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id " +
"LEFT JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id " +
"LEFT JOIN ll_inbound_plan_line l ON l.id=a.line_id " +
"LEFT JOIN ll_order o ON o.id=l.order_id " +
"WHERE ip.tenant_id=#{tenantId} AND p.id IN " +
"<foreach collection='planPalletIds' item='id' open='(' separator=',' close=')'>#{id}</foreach> " +
"GROUP BY p.plan_id,p.id,p.warehouse_pallet_id,p.pallet_no,p.destination_warehouse_id," +
"p.destination_warehouse_code,p.destination_warehouse_name,p.allocated_volume_cbm " +
"ORDER BY p.id FOR UPDATE" +
"</script>")
List<DispatchPalletCandidate> lockSelected(@Param("tenantId") String tenantId,
@Param("planPalletIds") List<Long> planPalletIds);
}
@@ -0,0 +1,25 @@
package com.baiyue.lastleg.dispatch.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.dispatch.entity.DispatchItem;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.util.List;
@Mapper
public interface DispatchItemMapper extends BaseMapper<DispatchItem> {
@Select("SELECT * FROM ll_dispatch_item WHERE tenant_id=#{tenantId} AND dispatch_order_id=#{dispatchOrderId} ORDER BY id")
List<DispatchItem> findByDispatchOrder(@Param("tenantId") String tenantId,
@Param("dispatchOrderId") Long dispatchOrderId);
@Update("UPDATE ll_dispatch_item i JOIN ll_inbound_plan_pallet p ON p.id=i.plan_pallet_id " +
"JOIN ll_inbound_plan ip ON ip.id=p.plan_id AND ip.tenant_id=i.tenant_id " +
"SET i.warehouse_pallet_id=p.warehouse_pallet_id,i.source_stage='RECEIVED' " +
"WHERE i.tenant_id=#{tenantId} AND i.dispatch_order_id=#{dispatchOrderId} " +
"AND i.warehouse_pallet_id IS NULL AND p.warehouse_pallet_id IS NOT NULL")
int bindReceivedPallets(@Param("tenantId") String tenantId,
@Param("dispatchOrderId") Long dispatchOrderId);
}
@@ -0,0 +1,16 @@
package com.baiyue.lastleg.dispatch.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.dispatch.entity.DispatchOrder;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface DispatchOrderMapper extends BaseMapper<DispatchOrder> {
@Select("SELECT * FROM ll_dispatch_order WHERE tenant_id=#{tenantId} AND dispatch_no=#{dispatchNo} LIMIT 1")
DispatchOrder findByDispatchNo(@Param("tenantId") String tenantId, @Param("dispatchNo") String dispatchNo);
@Select("SELECT * FROM ll_dispatch_order WHERE tenant_id=#{tenantId} AND id=#{id} LIMIT 1 FOR UPDATE")
DispatchOrder lockById(@Param("tenantId") String tenantId, @Param("id") Long id);
}
@@ -0,0 +1,15 @@
package com.baiyue.lastleg.dispatch.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.dispatch.entity.DispatchPalletLock;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface DispatchPalletLockMapper extends BaseMapper<DispatchPalletLock> {
@Select("SELECT * FROM ll_dispatch_pallet_lock WHERE tenant_id=#{tenantId} AND source_pallet_key=#{sourcePalletKey} " +
"AND status='ACTIVE' LIMIT 1 FOR UPDATE")
DispatchPalletLock lockActive(@Param("tenantId") String tenantId,
@Param("sourcePalletKey") String sourcePalletKey);
}
@@ -0,0 +1,26 @@
package com.baiyue.lastleg.dispatch.model;
import java.math.BigDecimal;
public record DispatchPalletCandidate(
Long planId,
Long planPalletId,
Long warehousePalletId,
Long orderId,
String orderNo,
String orderRefs,
String inboundCode,
String palletNo,
String destinationWarehouseId,
String destinationWarehouseCode,
String destinationWarehouseName,
String fbaWarehouseCode,
Integer packageCount,
BigDecimal volumeCbm,
String sourceStage
) {
public String sourcePalletKey() {
// A plan pallet keeps the same lock identity before and after the physical pallet is received.
return planPalletId != null ? "PLAN:" + planPalletId : "RECEIVED:" + warehousePalletId;
}
}
@@ -0,0 +1,8 @@
package com.baiyue.lastleg.dispatch.pda;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.context.annotation.Configuration;
@Configuration
@MapperScan("com.baiyue.lastleg.dispatch.pda.mapper")
public class DispatchPdaMapperConfiguration {}
@@ -0,0 +1,51 @@
package com.baiyue.lastleg.dispatch.pda.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaRequests;
import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaResponses;
import com.baiyue.lastleg.dispatch.pda.service.DispatchPdaWorkflowService;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
@RestController
@RequestMapping("/api/platform/v1/last-leg/dispatches/{dispatchNo}/pda")
public class DispatchPdaController {
@Resource private DispatchPdaWorkflowService service;
@PostMapping("/push")
public ApiResponse<DispatchPdaResponses.Task> push(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String dispatchNo,
@RequestParam(required = false) String tenantId,
@Valid @RequestBody DispatchPdaRequests.Push request) {
return ApiResponse.success(service.push(scope(user, tenantId), dispatchNo, request));
}
@GetMapping
public ApiResponse<DispatchPdaResponses.Task> task(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String dispatchNo,
@RequestParam(required = false) String tenantId) {
return ApiResponse.success(service.task(scope(user, tenantId), dispatchNo));
}
@PostMapping("/items/{itemId}/pick")
public ApiResponse<DispatchPdaResponses.Picked> pick(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String dispatchNo, @PathVariable long itemId,
@RequestParam(required = false) String tenantId,
@Valid @RequestBody DispatchPdaRequests.Pick request) {
return ApiResponse.success(service.pick(scope(user, tenantId), dispatchNo, itemId, operator(user), request));
}
private String scope(AuthenticatedUser user, String requestedTenant) {
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) {
if (requestedTenant == null || requestedTenant.isBlank() || "0".equals(requestedTenant)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Platform request must specify target tenant");
}
return requestedTenant.trim();
}
return String.valueOf(user.tenantId());
}
private String operator(AuthenticatedUser user) { return user.userId() == null ? user.username() : String.valueOf(user.userId()); }
}
@@ -0,0 +1,14 @@
package com.baiyue.lastleg.dispatch.pda.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.PositiveOrZero;
public final class DispatchPdaRequests {
private DispatchPdaRequests() {}
public record Push(@NotBlank String idempotencyKey, String operatorId, String operatorName) {}
public record Pick(@NotNull Long positionId, @Positive long packages, @PositiveOrZero long productQuantity,
@NotBlank String idempotencyKey, String remark) {}
public record Depart(@NotBlank String idempotencyKey, String remark) {}
}
@@ -0,0 +1,19 @@
package com.baiyue.lastleg.dispatch.pda.dto;
import java.util.List;
public final class DispatchPdaResponses {
private DispatchPdaResponses() {}
public record Task(Long id, String taskNo, String dispatchNo, String vehiclePlate, String status,
List<Item> items) {}
public record Item(Long dispatchItemId, String palletNo, String destinationWarehouseCode,
String destinationWarehouseName, String fbaWarehouseCode, int scheduledPackages,
long pickedPackages, List<Position> positions) {}
public record Position(Long id, String skuCode, long availablePackages, long allocatedPackages,
long pickedPackages, long productQuantity) {}
public record Picked(Long dispatchItemId, Long positionId, long packages, long pickedPackages,
boolean palletScrapped, boolean replayed) {}
public record Departed(String dispatchNo, String status, long shippedPackages, boolean replayed) {
public Departed withReplay() { return new Departed(dispatchNo, status, shippedPackages, true); }
}
}
@@ -0,0 +1,23 @@
package com.baiyue.lastleg.dispatch.pda.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("ll_dispatch_pda_operation")
public class DispatchPdaOperation {
@TableId(type = IdType.INPUT) private Long id;
private String tenantId;
private Long taskId;
private String operationType;
private String idempotencyKey;
private String requestHash;
private String resultJson;
private String status;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,23 @@
package com.baiyue.lastleg.dispatch.pda.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("ll_dispatch_pda_pick")
public class DispatchPdaPick {
@TableId(type = IdType.INPUT) private Long id;
private String tenantId;
private Long taskId;
private Long dispatchItemId;
private Long inventoryPositionId;
private Long packages;
private Long productQuantity;
private String idempotencyKey;
private LocalDateTime pickedAt;
private LocalDateTime createdAt;
}
@@ -0,0 +1,24 @@
package com.baiyue.lastleg.dispatch.pda.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("ll_dispatch_pda_task")
public class DispatchPdaTask {
@TableId(type = IdType.INPUT) private Long id;
private String tenantId;
private Long dispatchOrderId;
private String taskNo;
private String status;
private String operatorId;
private String operatorName;
private LocalDateTime pushedAt;
private LocalDateTime completedAt;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,11 @@
package com.baiyue.lastleg.dispatch.pda.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaOperation;
import org.apache.ibatis.annotations.*;
@Mapper
public interface DispatchPdaOperationMapper extends BaseMapper<DispatchPdaOperation> {
@Select("SELECT * FROM ll_dispatch_pda_operation WHERE tenant_id=#{tenantId} AND idempotency_key=#{key} LIMIT 1")
DispatchPdaOperation findKey(@Param("tenantId") String tenantId, @Param("key") String key);
}
@@ -0,0 +1,14 @@
package com.baiyue.lastleg.dispatch.pda.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaPick;
import org.apache.ibatis.annotations.*;
import java.util.List;
@Mapper
public interface DispatchPdaPickMapper extends BaseMapper<DispatchPdaPick> {
@Select("SELECT * FROM ll_dispatch_pda_pick WHERE tenant_id=#{tenantId} AND task_id=#{taskId} ORDER BY id")
List<DispatchPdaPick> listTask(@Param("tenantId") String tenantId, @Param("taskId") Long taskId);
@Select("SELECT COALESCE(SUM(packages),0) FROM ll_dispatch_pda_pick WHERE tenant_id=#{tenantId} AND task_id=#{taskId} AND dispatch_item_id=#{itemId}")
long totalItem(@Param("tenantId") String tenantId, @Param("taskId") Long taskId, @Param("itemId") Long itemId);
}
@@ -0,0 +1,14 @@
package com.baiyue.lastleg.dispatch.pda.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaTask;
import org.apache.ibatis.annotations.*;
@Mapper
public interface DispatchPdaTaskMapper extends BaseMapper<DispatchPdaTask> {
@Select("SELECT * FROM ll_dispatch_pda_task WHERE tenant_id=#{tenantId} AND dispatch_order_id=#{dispatchOrderId} LIMIT 1 FOR UPDATE")
DispatchPdaTask lockByDispatch(@Param("tenantId") String tenantId, @Param("dispatchOrderId") Long dispatchOrderId);
@Select("SELECT * FROM ll_dispatch_pda_task WHERE tenant_id=#{tenantId} AND dispatch_order_id=#{dispatchOrderId} LIMIT 1")
DispatchPdaTask findByDispatch(@Param("tenantId") String tenantId, @Param("dispatchOrderId") Long dispatchOrderId);
}
@@ -0,0 +1,213 @@
package com.baiyue.lastleg.dispatch.pda.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.dispatch.entity.DispatchItem;
import com.baiyue.lastleg.dispatch.entity.DispatchOrder;
import com.baiyue.lastleg.dispatch.entity.DispatchPalletLock;
import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper;
import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper;
import com.baiyue.lastleg.dispatch.mapper.DispatchPalletLockMapper;
import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaRequests;
import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaResponses;
import com.baiyue.lastleg.dispatch.pda.entity.*;
import com.baiyue.lastleg.dispatch.pda.mapper.*;
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests;
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.LocalDateTime;
import java.util.*;
import java.util.stream.Collectors;
@Service
public class DispatchPdaWorkflowService {
@Resource private DispatchOrderMapper orders;
@Resource private DispatchItemMapper items;
@Resource private DispatchPdaTaskMapper tasks;
@Resource private DispatchPdaPickMapper picks;
@Resource private DispatchPdaOperationMapper operations;
@Resource private DispatchPalletLockMapper palletLocks;
@Resource private InventoryPositionMapper positions;
@Resource private InventoryCommandService inventory;
@Resource private ObjectMapper objectMapper;
@Transactional
public DispatchPdaResponses.Task push(String tenantId, String dispatchNo, DispatchPdaRequests.Push request) {
DispatchOrder order = requireOrder(tenantId, dispatchNo);
requirePdaStatus(order);
requireVehicle(order);
DispatchPdaOperation replay = replay(tenantId, "PUSH", request.idempotencyKey(), request);
if (replay != null) return read(replay, DispatchPdaResponses.Task.class);
items.bindReceivedPallets(tenantId, order.getId());
List<DispatchItem> dispatchItems = items.findByDispatchOrder(tenantId, order.getId());
if (dispatchItems.isEmpty()) throw conflict("Dispatch has no pallets to push");
if (dispatchItems.stream().anyMatch(item -> item.getWarehousePalletId() == null)) {
throw conflict("All pre-arranged pallets must be received before PDA push");
}
DispatchPdaTask task = tasks.lockByDispatch(tenantId, order.getId());
LocalDateTime now = LocalDateTime.now();
if (task == null) {
task = new DispatchPdaTask();
task.setId(IdWorker.getId()); task.setTenantId(tenantId); task.setDispatchOrderId(order.getId());
task.setTaskNo("DPDA" + task.getId()); task.setStatus("PDA_PENDING");
task.setOperatorId(trim(request.operatorId())); task.setOperatorName(trim(request.operatorName()));
task.setPushedAt(now); task.setCreatedAt(now); task.setUpdatedAt(now); tasks.insert(task);
}
if ("PENDING_DEPARTURE".equals(order.getStatus())) { order.setStatus("PICKING"); order.setUpdatedAt(now); orders.updateById(order); }
DispatchPdaResponses.Task result = task(tenantId, order, task, dispatchItems);
save(tenantId, task.getId(), "PUSH", request.idempotencyKey(), request, result);
return result;
}
@Transactional(readOnly = true)
public DispatchPdaResponses.Task task(String tenantId, String dispatchNo) {
DispatchOrder order = requireOrder(tenantId, dispatchNo);
DispatchPdaTask task = tasks.findByDispatch(tenantId, order.getId());
if (task == null) throw notFound("Dispatch PDA task not found");
return task(tenantId, order, task, items.findByDispatchOrder(tenantId, order.getId()));
}
@Transactional
public DispatchPdaResponses.Picked pick(String tenantId, String dispatchNo, long itemId,
String operatorId, DispatchPdaRequests.Pick request) {
DispatchOrder order = requireOrder(tenantId, dispatchNo);
requirePdaStatus(order);
requireVehicle(order);
DispatchPdaTask task = tasks.lockByDispatch(tenantId, order.getId());
if (task == null || !("PDA_PENDING".equals(task.getStatus()) || "PICKING".equals(task.getStatus()))) {
throw conflict("Dispatch PDA task is not available for picking");
}
DispatchItem item = items.selectById(itemId);
if (item == null || !tenantId.equals(item.getTenantId()) || !order.getId().equals(item.getDispatchOrderId())) {
throw notFound("Dispatch pallet item not found");
}
DispatchPdaOperation replay = replay(tenantId, "PICK", request.idempotencyKey(), request);
if (replay != null) return read(replay, DispatchPdaResponses.Picked.class);
InventoryPosition position = positions.selectById(request.positionId());
if (position == null || !tenantId.equals(position.getTenantId())
|| item.getWarehousePalletId() == null || !item.getWarehousePalletId().equals(position.getPalletId())) {
throw conflict("Inventory position does not belong to this dispatch pallet");
}
long picked = picks.totalItem(tenantId, task.getId(), itemId);
if (picked + request.packages() > item.getPackageCount()) throw conflict("Picked packages exceed dispatch pallet packages");
String keyBase = "DISPATCH:" + order.getDispatchNo() + ":" + itemId + ":" + request.idempotencyKey();
InventoryCommandRequests.Move move = new InventoryCommandRequests.Move(tenantId, request.positionId(),
request.packages(), request.productQuantity(), order.getDispatchNo(), order.getDispatchNo(), null,
keyBase + ":ALLOCATE", request.remark());
inventory.allocate(tenantId, operatorId, move);
inventory.pick(tenantId, operatorId, new InventoryCommandRequests.Move(tenantId, request.positionId(),
request.packages(), request.productQuantity(), order.getDispatchNo(), order.getDispatchNo(), null,
keyBase + ":PICK", request.remark()));
DispatchPdaPick pick = new DispatchPdaPick();
pick.setId(IdWorker.getId()); pick.setTenantId(tenantId); pick.setTaskId(task.getId());
pick.setDispatchItemId(itemId); pick.setInventoryPositionId(request.positionId()); pick.setPackages(request.packages());
pick.setProductQuantity(request.productQuantity()); pick.setIdempotencyKey(request.idempotencyKey());
pick.setPickedAt(LocalDateTime.now()); pick.setCreatedAt(pick.getPickedAt()); picks.insert(pick);
long total = picked + request.packages();
task.setStatus("PICKING"); task.setUpdatedAt(LocalDateTime.now());
if (allPicked(tenantId, task.getId(), items.findByDispatchOrder(tenantId, order.getId()))) {
task.setStatus("COMPLETED"); task.setCompletedAt(LocalDateTime.now()); order.setStatus("READY_TO_DEPART");
order.setUpdatedAt(LocalDateTime.now()); orders.updateById(order);
}
tasks.updateById(task);
DispatchPdaResponses.Picked result = new DispatchPdaResponses.Picked(itemId, request.positionId(), request.packages(), total, false, false);
save(tenantId, task.getId(), "PICK", request.idempotencyKey(), request, result);
return result;
}
/** Final warehouse exit: only fully PDA-picked dispatches can consume picked inventory. */
@Transactional
public DispatchPdaResponses.Departed depart(String tenantId, String dispatchNo, String operatorId,
DispatchPdaRequests.Depart request) {
DispatchOrder found = requireOrder(tenantId, dispatchNo);
DispatchOrder order = orders.lockById(tenantId, found.getId());
if (order == null) throw notFound("Dispatch order not found");
DispatchPdaOperation replay = replay(tenantId, "DEPART", request.idempotencyKey(), request);
if (replay != null) return read(replay, DispatchPdaResponses.Departed.class).withReplay();
requireVehicle(order);
DispatchPdaTask task = tasks.lockByDispatch(tenantId, order.getId());
if (task == null || !"COMPLETED".equals(task.getStatus()) || !"READY_TO_DEPART".equals(order.getStatus())) {
throw conflict("Dispatch must complete PDA picking before departure");
}
List<DispatchItem> dispatchItems = items.findByDispatchOrder(tenantId, order.getId());
if (!allPicked(tenantId, task.getId(), dispatchItems)) {
throw conflict("Every dispatch pallet must be fully picked before departure");
}
List<DispatchPdaPick> dispatchPicks = picks.listTask(tenantId, task.getId());
if (dispatchPicks.isEmpty()) throw conflict("Dispatch has no PDA picks to ship");
long shipped = 0;
for (DispatchPdaPick pick : dispatchPicks) {
String key = "DISPATCH:" + order.getDispatchNo() + ":PICK:" + pick.getId() + ":SHIP";
inventory.ship(tenantId, operatorId, new InventoryCommandRequests.Move(tenantId,
pick.getInventoryPositionId(), n(pick.getPackages()), n(pick.getProductQuantity()),
order.getDispatchNo(), order.getDispatchNo(), null, key, request.remark()));
shipped += n(pick.getPackages());
}
LocalDateTime now = LocalDateTime.now();
order.setStatus("DEPARTED");
order.setUpdatedAt(now);
orders.updateById(order);
releasePalletLocks(tenantId, order.getId(), now);
DispatchPdaResponses.Departed result = new DispatchPdaResponses.Departed(order.getDispatchNo(),
order.getStatus(), shipped, false);
save(tenantId, task.getId(), "DEPART", request.idempotencyKey(), request, result);
return result;
}
private DispatchPdaResponses.Task task(String tenantId, DispatchOrder order, DispatchPdaTask task, List<DispatchItem> source) {
List<DispatchPdaResponses.Item> values = new ArrayList<>();
for (DispatchItem item : source) {
List<InventoryPosition> palletPositions = item.getWarehousePalletId() == null ? List.of()
: positions.listPallet(tenantId, item.getWarehousePalletId());
values.add(new DispatchPdaResponses.Item(item.getId(), item.getPalletNo(), item.getDestinationWarehouseCode(),
item.getDestinationWarehouseName(), item.getFbaWarehouseCode(), item.getPackageCount(),
picks.totalItem(tenantId, task.getId(), item.getId()), palletPositions.stream().map(p ->
new DispatchPdaResponses.Position(p.getId(), p.getSkuCode(), n(p.getAvailablePackages()), n(p.getAllocatedPackages()),
n(p.getPickedPackages()), n(p.getProductQuantity()))).toList()));
}
return new DispatchPdaResponses.Task(task.getId(), task.getTaskNo(), order.getDispatchNo(), order.getVehiclePlate(), task.getStatus(), values);
}
private boolean allPicked(String tenantId, Long taskId, List<DispatchItem> source) {
return source.stream().allMatch(item -> picks.totalItem(tenantId, taskId, item.getId()) == item.getPackageCount());
}
private void releasePalletLocks(String tenantId, Long dispatchOrderId, LocalDateTime now) {
List<DispatchPalletLock> activeLocks = palletLocks.selectList(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<DispatchPalletLock>()
.eq(DispatchPalletLock::getTenantId, tenantId)
.eq(DispatchPalletLock::getDispatchOrderId, dispatchOrderId)
.eq(DispatchPalletLock::getStatus, "ACTIVE"));
for (DispatchPalletLock lock : activeLocks) {
lock.setStatus("RELEASED");
lock.setActiveKey(null);
lock.setReleasedAt(now);
lock.setUpdatedAt(now);
palletLocks.updateById(lock);
}
}
private DispatchOrder requireOrder(String tenantId, String dispatchNo) { DispatchOrder order = orders.findByDispatchNo(tenantId, dispatchNo); if (order == null) throw notFound("Dispatch order not found"); return order; }
private void requirePdaStatus(DispatchOrder order) {
if (!("PENDING_DEPARTURE".equals(order.getStatus()) || "PICKING".equals(order.getStatus())
|| "READY_TO_DEPART".equals(order.getStatus()))) throw conflict("Dispatch has already departed and cannot be changed by PDA");
}
private void requireVehicle(DispatchOrder order) { if (trim(order.getVehiclePlate()) == null) throw conflict("Vehicle plate is required before PDA push or picking"); }
private DispatchPdaOperation replay(String tenantId, String type, String key, Object request) { DispatchPdaOperation op = operations.findKey(tenantId, required(key, "Idempotency key is required")); if (op == null) return null; if (!type.equals(op.getOperationType()) || !hash(request).equals(op.getRequestHash())) throw conflict("Idempotency key was reused with a different request"); if (!"COMPLETED".equals(op.getStatus())) throw conflict("PDA operation is still processing"); return op; }
private void save(String tenantId, Long taskId, String type, String key, Object request, Object result) { DispatchPdaOperation op = new DispatchPdaOperation(); op.setId(IdWorker.getId()); op.setTenantId(tenantId); op.setTaskId(taskId); op.setOperationType(type); op.setIdempotencyKey(required(key,"Idempotency key is required")); op.setRequestHash(hash(request)); op.setResultJson(json(result)); op.setStatus("COMPLETED"); op.setCreatedAt(LocalDateTime.now()); op.setUpdatedAt(op.getCreatedAt()); operations.insert(op); }
private <T> T read(DispatchPdaOperation op, Class<T> type) { try { return objectMapper.readValue(op.getResultJson(), type); } catch (Exception e) { throw new IllegalStateException("Cannot read PDA replay", e); } }
private String hash(Object value) { try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json(value).getBytes(StandardCharsets.UTF_8))); } catch (Exception e) { throw new IllegalStateException(e); } }
private String json(Object value) { try { return objectMapper.writeValueAsString(value); } catch (Exception e) { throw new IllegalStateException(e); } }
private static long n(Long value) { return value == null ? 0 : value; }
private static String trim(String value) { return value == null || value.trim().isEmpty() ? null : value.trim(); }
private static String required(String value, String message) { String result = trim(value); if (result == null) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, message); return result; }
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
private static ResponseStatusException notFound(String message) { return new ResponseStatusException(HttpStatus.NOT_FOUND, message); }
}
@@ -0,0 +1,246 @@
package com.baiyue.lastleg.dispatch.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.lastleg.dispatch.dto.DispatchRequests;
import com.baiyue.lastleg.dispatch.dto.DispatchResponse;
import com.baiyue.lastleg.dispatch.entity.DispatchItem;
import com.baiyue.lastleg.dispatch.entity.DispatchOrder;
import com.baiyue.lastleg.dispatch.entity.DispatchPalletLock;
import com.baiyue.lastleg.dispatch.mapper.DispatchCandidateMapper;
import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper;
import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper;
import com.baiyue.lastleg.dispatch.mapper.DispatchPalletLockMapper;
import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate;
import jakarta.annotation.Resource;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import org.springframework.web.server.ResponseStatusException;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
@Service
public class DispatchService {
public static final String PENDING_DEPARTURE = "PENDING_DEPARTURE";
public static final String CANCELLED = "CANCELLED";
private static final Set<String> STATUSES = Set.of(PENDING_DEPARTURE, "PICKING", "READY_TO_DEPART",
"DEPARTED", "COMPLETED", CANCELLED);
@Resource private DispatchOrderMapper orders;
@Resource private DispatchItemMapper items;
@Resource private DispatchPalletLockMapper locks;
@Resource private DispatchCandidateMapper candidates;
public List<DispatchPalletCandidate> candidates(String tenantId, String orderNo,
String fbaWarehouseCode, String sourceStage) {
if (StringUtils.hasText(sourceStage) && !Set.of("PLAN", "RECEIVED").contains(sourceStage)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "sourceStage must be PLAN or RECEIVED");
}
return candidates.available(tenantId, trimToNull(orderNo), trimToNull(fbaWarehouseCode), trimToNull(sourceStage));
}
@Transactional
public DispatchResponse preArrange(String tenantId, String operatorId, DispatchRequests.PreArrange request) {
List<Long> palletIds = request.planPalletIds().stream().filter(Objects::nonNull).distinct().sorted().toList();
if (palletIds.isEmpty() || palletIds.size() != request.planPalletIds().size()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Plan pallet IDs must be unique and non-empty");
}
List<DispatchPalletCandidate> selected = candidates.lockSelected(tenantId, palletIds);
if (selected.size() != palletIds.size()) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "One or more selected pallets are not in this tenant");
}
for (DispatchPalletCandidate candidate : selected) {
boolean planLocked = candidate.planPalletId() != null
&& locks.lockActive(tenantId, "PLAN:" + candidate.planPalletId()) != null;
boolean receivedLocked = candidate.warehousePalletId() != null
&& locks.lockActive(tenantId, "RECEIVED:" + candidate.warehousePalletId()) != null;
if (planLocked || receivedLocked) {
throw new ResponseStatusException(HttpStatus.CONFLICT,
"Pallet is already locked by another active dispatch: " + candidate.palletNo());
}
}
LocalDateTime now = LocalDateTime.now();
DispatchOrder dispatch = new DispatchOrder();
dispatch.setId(IdWorker.getId());
dispatch.setTenantId(tenantId);
dispatch.setDispatchNo("PC" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + dispatch.getId());
dispatch.setRoute(StringUtils.hasText(request.route()) ? request.route().trim() : route(selected));
dispatch.setFbaAppointmentNo(trimToNull(request.fbaAppointmentNo()));
dispatch.setFbaAppointmentStatus(defaultStatus(request.fbaAppointmentStatus()));
dispatch.setVehicleAppointmentStatus(defaultStatus(request.vehicleAppointmentStatus()));
dispatch.setStatus(PENDING_DEPARTURE);
dispatch.setItemCount(selected.size());
dispatch.setPalletCount(selected.size());
dispatch.setPackageCount(selected.stream().map(DispatchPalletCandidate::packageCount)
.filter(Objects::nonNull).mapToInt(Integer::intValue).sum());
dispatch.setCreatedBy(trimToNull(operatorId));
dispatch.setCreatedAt(now);
dispatch.setUpdatedAt(now);
orders.insert(dispatch);
for (DispatchPalletCandidate candidate : selected) {
DispatchPalletLock lock = new DispatchPalletLock();
lock.setId(IdWorker.getId());
lock.setTenantId(tenantId);
lock.setSourcePalletKey(candidate.sourcePalletKey());
lock.setActiveKey(candidate.sourcePalletKey());
lock.setPlanId(candidate.planId());
lock.setPlanPalletId(candidate.planPalletId());
lock.setWarehousePalletId(candidate.warehousePalletId());
lock.setDispatchOrderId(dispatch.getId());
lock.setStatus("ACTIVE");
lock.setLockedAt(now);
lock.setCreatedAt(now);
lock.setUpdatedAt(now);
try {
locks.insert(lock);
} catch (DuplicateKeyException exception) {
throw new ResponseStatusException(HttpStatus.CONFLICT,
"Pallet is already locked by another active dispatch: " + candidate.palletNo(), exception);
}
DispatchItem item = new DispatchItem();
item.setId(IdWorker.getId());
item.setDispatchOrderId(dispatch.getId());
item.setTenantId(tenantId);
item.setPlanId(candidate.planId());
item.setPlanPalletId(candidate.planPalletId());
item.setWarehousePalletId(candidate.warehousePalletId());
item.setOrderId(candidate.orderId());
item.setOrderNo(candidate.orderNo());
item.setOrderRefs(candidate.orderRefs());
item.setInboundCode(candidate.inboundCode());
item.setPalletNo(candidate.palletNo());
item.setDestinationWarehouseId(candidate.destinationWarehouseId());
item.setDestinationWarehouseCode(candidate.destinationWarehouseCode());
item.setDestinationWarehouseName(candidate.destinationWarehouseName());
item.setFbaWarehouseCode(candidate.fbaWarehouseCode());
item.setPackageCount(Objects.requireNonNullElse(candidate.packageCount(), 0));
item.setVolumeCbm(Objects.requireNonNullElse(candidate.volumeCbm(), BigDecimal.ZERO));
item.setSourceStage(candidate.sourceStage());
item.setCreatedAt(now);
items.insert(item);
}
return response(dispatch, items.findByDispatchOrder(tenantId, dispatch.getId()));
}
public PageResult<DispatchResponse> page(String tenantId, DispatchRequests.Query query) {
Page<DispatchOrder> page = orders.selectPage(new Page<>(query.page(), query.size()),
new LambdaQueryWrapper<DispatchOrder>().eq(DispatchOrder::getTenantId, tenantId)
.like(StringUtils.hasText(query.dispatchNo()), DispatchOrder::getDispatchNo, query.dispatchNo())
.like(StringUtils.hasText(query.vehiclePlate()), DispatchOrder::getVehiclePlate, query.vehiclePlate())
.eq(StringUtils.hasText(query.status()), DispatchOrder::getStatus, query.status())
.like(StringUtils.hasText(query.fbaAppointmentNo()), DispatchOrder::getFbaAppointmentNo,
query.fbaAppointmentNo())
.orderByDesc(DispatchOrder::getCreatedAt));
List<DispatchResponse> records = page.getRecords().stream()
.map(order -> response(order, items.findByDispatchOrder(tenantId, order.getId()))).toList();
return new PageResult<>(records, page.getCurrent(), page.getSize(), page.getTotal(), page.getPages());
}
public DispatchResponse detail(String tenantId, String dispatchNo) {
DispatchOrder dispatch = find(tenantId, dispatchNo);
return response(dispatch, items.findByDispatchOrder(tenantId, dispatch.getId()));
}
@Transactional
public DispatchResponse update(String tenantId, String dispatchNo, DispatchRequests.Update request) {
DispatchOrder dispatch = orders.lockById(tenantId, find(tenantId, dispatchNo).getId());
if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found");
// Vehicle plate is intentionally editable through all dispatch states.
dispatch.setVehiclePlate(trimToNull(request.vehiclePlate()));
dispatch.setDriverName(trimToNull(request.driverName()));
dispatch.setDriverPhone(trimToNull(request.driverPhone()));
dispatch.setCarrierName(trimToNull(request.carrierName()));
dispatch.setRoute(trimToNull(request.route()));
dispatch.setDepartAt(request.departAt());
dispatch.setFbaAppointmentNo(trimToNull(request.fbaAppointmentNo()));
dispatch.setFbaAppointmentStatus(defaultStatus(request.fbaAppointmentStatus()));
dispatch.setVehicleAppointmentStatus(defaultStatus(request.vehicleAppointmentStatus()));
dispatch.setEstimatedArrivalAt(request.estimatedArrivalAt());
if (StringUtils.hasText(request.status())) {
String status = request.status().trim().toUpperCase();
if (!STATUSES.contains(status) || CANCELLED.equals(status)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Use cancel endpoint or a valid dispatch status");
}
dispatch.setStatus(status);
}
dispatch.setUpdatedAt(LocalDateTime.now());
orders.updateById(dispatch);
return response(dispatch, items.findByDispatchOrder(tenantId, dispatch.getId()));
}
@Transactional
public void cancel(String tenantId, String dispatchNo) {
DispatchOrder dispatch = orders.lockById(tenantId, find(tenantId, dispatchNo).getId());
if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found");
if (!(PENDING_DEPARTURE.equals(dispatch.getStatus()))) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "Dispatched order cannot be cancelled");
}
LocalDateTime now = LocalDateTime.now();
dispatch.setStatus(CANCELLED);
dispatch.setUpdatedAt(now);
orders.updateById(dispatch);
List<DispatchPalletLock> activeLocks = locks.selectList(new LambdaQueryWrapper<DispatchPalletLock>()
.eq(DispatchPalletLock::getTenantId, tenantId)
.eq(DispatchPalletLock::getDispatchOrderId, dispatch.getId())
.eq(DispatchPalletLock::getStatus, "ACTIVE"));
for (DispatchPalletLock lock : activeLocks) {
lock.setStatus("RELEASED");
lock.setActiveKey(null);
lock.setReleasedAt(now);
lock.setUpdatedAt(now);
locks.updateById(lock);
}
}
private DispatchOrder find(String tenantId, String dispatchNo) {
DispatchOrder dispatch = orders.findByDispatchNo(tenantId, dispatchNo);
if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found");
return dispatch;
}
private DispatchResponse response(DispatchOrder order, List<DispatchItem> itemList) {
String orderNos = itemList.stream().flatMap(item -> splitOrderRefs(item.getOrderRefs(), item.getOrderNo()).stream())
.distinct().collect(Collectors.joining(","));
return new DispatchResponse(order.getId(), order.getDispatchNo(), order.getVehiclePlate(), order.getDriverName(),
order.getDriverPhone(), order.getCarrierName(), orderNos, order.getRoute(), order.getDepartAt(),
order.getFbaAppointmentNo(), order.getFbaAppointmentStatus(), order.getVehicleAppointmentStatus(),
order.getEstimatedArrivalAt(), order.getStatus(), Objects.requireNonNullElse(order.getItemCount(), 0),
Objects.requireNonNullElse(order.getPalletCount(), 0), Objects.requireNonNullElse(order.getPackageCount(), 0),
order.getCreatedAt(), itemList.stream().map(item -> new DispatchResponse.Item(item.getId(), item.getPlanPalletId(),
item.getWarehousePalletId(), item.getPalletNo(), item.getOrderNo(), item.getOrderRefs(), item.getInboundCode(),
item.getDestinationWarehouseCode(), item.getDestinationWarehouseName(), item.getFbaWarehouseCode(),
Objects.requireNonNullElse(item.getPackageCount(), 0), item.getVolumeCbm(), item.getSourceStage())).toList());
}
private static String route(List<DispatchPalletCandidate> selected) {
return selected.stream().map(DispatchPalletCandidate::destinationWarehouseCode).filter(StringUtils::hasText)
.distinct().collect(Collectors.joining(" -> "));
}
private static List<String> splitOrderRefs(String refs, String fallback) {
String value = StringUtils.hasText(refs) ? refs : fallback;
return StringUtils.hasText(value) ? List.of(value.split(",")) : List.of();
}
private static String defaultStatus(String status) {
return StringUtils.hasText(status) ? status.trim().toUpperCase() : "PENDING";
}
private static String trimToNull(String value) {
return StringUtils.hasText(value) ? value.trim() : null;
}
}
@@ -3,7 +3,10 @@ package com.baiyue.lastleg.inboundplan.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.inboundplan.dto.ConvertInboundPlanRequest;
import com.baiyue.lastleg.inboundplan.dto.BatchFbaAppointmentRequest;
import com.baiyue.lastleg.inboundplan.dto.FbaAppointmentResponse;
import com.baiyue.lastleg.inboundplan.dto.InboundPlanResponse;
import com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService;
import com.baiyue.lastleg.inboundplan.service.InboundPlanService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -19,6 +22,7 @@ import org.springframework.web.server.ResponseStatusException;
@Tag(name = "Tail-leg inbound plans")
public class InboundPlanController {
@Resource private InboundPlanService service;
@Resource private InboundFbaAppointmentService appointmentService;
@PostMapping("/convert")
@Operation(summary = "Convert the entire container to one inbound plan")
@@ -35,6 +39,25 @@ public class InboundPlanController {
return ApiResponse.success(service.detailByPlanNo(scope(user, tenantId), planNo));
}
@PostMapping("/fba-appointments/batch")
@Operation(summary = "Create one FBA appointment for selected inbound lines")
public ApiResponse<FbaAppointmentResponse> batchFbaAppointment(
@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody BatchFbaAppointmentRequest request) {
String tenantId = scope(user, request.tenantId());
String operatorId = user.userId() == null ? user.username() : String.valueOf(user.userId());
return ApiResponse.success(appointmentService.batchCreate(tenantId, operatorId, request));
}
@GetMapping("/fba-appointments/{appointmentNo}")
@Operation(summary = "Get an FBA appointment and its selected inbound lines")
public ApiResponse<FbaAppointmentResponse> fbaAppointmentDetail(
@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String appointmentNo,
@RequestParam(required = false) String tenantId) {
return ApiResponse.success(appointmentService.detail(scope(user, tenantId), appointmentNo));
}
private String scope(AuthenticatedUser user, String requestedTenant) {
if (user == null || user.tenantId() == null) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
@@ -0,0 +1,19 @@
package com.baiyue.lastleg.inboundplan.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.time.LocalDateTime;
import java.util.List;
public record BatchFbaAppointmentRequest(
String tenantId,
@NotEmpty @Size(max = 500) List<@NotNull Long> lineIds,
@NotBlank @Size(max = 64) String appointmentNo,
@NotNull LocalDateTime appointmentAt,
@NotBlank @Size(max = 32) String appointmentStatus,
@Size(max = 1000) String appointmentRemark
) {
}
@@ -0,0 +1,23 @@
package com.baiyue.lastleg.inboundplan.dto;
import java.time.LocalDateTime;
import java.util.List;
public record FbaAppointmentResponse(
Long id,
String tenantId,
String appointmentNo,
LocalDateTime appointmentAt,
String appointmentStatus,
String appointmentRemark,
String fbaWarehouseCode,
int selectedCount,
String createdBy,
LocalDateTime createdAt,
LocalDateTime updatedAt,
List<Item> items
) {
public record Item(Long id, Long planId, Long lineId, Long orderId, String inboundCode,
String fbaWarehouseCode) {
}
}
@@ -17,7 +17,7 @@ public record InboundPlanResponse(
List<Pallet> pallets,
List<Allocation> allocations
) {
public record Line(Long id, Long orderId, String inboundCode, String destinationWarehouseId,
public record Line(Long id, Long orderId, String inboundCode, String fbaWarehouseCode, String destinationWarehouseId,
String destinationWarehouseCode, String destinationWarehouseName,
BigDecimal volumeCbm, int requiredPackageCount, int plannedPalletCount) {
}
@@ -0,0 +1,25 @@
package com.baiyue.lastleg.inboundplan.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("ll_inbound_fba_appointment")
public class InboundFbaAppointment {
@TableId(type = IdType.INPUT)
private Long id;
private String tenantId;
private String appointmentNo;
private LocalDateTime appointmentAt;
private String appointmentStatus;
private String appointmentRemark;
private String fbaWarehouseCode;
private Integer selectedCount;
private String createdBy;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,22 @@
package com.baiyue.lastleg.inboundplan.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("ll_inbound_fba_appointment_item")
public class InboundFbaAppointmentItem {
@TableId(type = IdType.INPUT)
private Long id;
private Long appointmentId;
private Long planId;
private Long lineId;
private Long orderId;
private String inboundCode;
private String fbaWarehouseCode;
private LocalDateTime createdAt;
}
@@ -15,6 +15,7 @@ public class InboundPlanLine {
private Long planId;
private Long orderId;
private String inboundCode;
private String fbaWarehouseCode;
private String destinationWarehouseId;
private String destinationWarehouseCode;
private String destinationWarehouseName;
@@ -0,0 +1,9 @@
package com.baiyue.lastleg.inboundplan.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.inboundplan.entity.InboundFbaAppointmentItem;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface InboundFbaAppointmentItemMapper extends BaseMapper<InboundFbaAppointmentItem> {
}
@@ -0,0 +1,9 @@
package com.baiyue.lastleg.inboundplan.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.inboundplan.entity.InboundFbaAppointment;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface InboundFbaAppointmentMapper extends BaseMapper<InboundFbaAppointment> {
}
@@ -0,0 +1,22 @@
package com.baiyue.lastleg.inboundplan.mapper;
import com.baiyue.lastleg.inboundplan.model.FbaAppointmentLineSnapshot;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface InboundFbaAppointmentQueryMapper {
@Select("<script>" +
"SELECT l.id AS lineId,l.plan_id AS planId,l.order_id AS orderId,l.inbound_code AS inboundCode," +
"l.fba_warehouse_code AS fbaWarehouseCode " +
"FROM ll_inbound_plan_line l JOIN ll_inbound_plan p ON p.id=l.plan_id " +
"WHERE p.tenant_id=#{tenantId} AND l.id IN " +
"<foreach collection='lineIds' item='id' open='(' separator=',' close=')'>#{id}</foreach> " +
"ORDER BY l.id FOR UPDATE" +
"</script>")
List<FbaAppointmentLineSnapshot> lockLines(@Param("tenantId") String tenantId,
@Param("lineIds") List<Long> lineIds);
}
@@ -18,6 +18,7 @@ public interface InboundPlanOrderMapper {
@Select("SELECT o.id AS orderId,o.tenant_id AS tenantId,o.container_no AS containerNo," +
"o.inbound_code AS inboundCode,o.current_status AS currentStatus,o.inbound_order_no AS inboundOrderNo," +
"COALESCE(o.volume_cbm,0) AS volumeCbm,COALESCE(o.package_count,0) AS packageCount," +
"NULLIF(o.fba_warehouse_code,'') AS fbaWarehouseCode," +
"COALESCE(NULLIF(o.transfer_warehouse_id,''),MIN(NULLIF(d.destination_warehouse_id,''))) AS destinationWarehouseId," +
"COALESCE(NULLIF(o.transfer_warehouse_code,''),MIN(NULLIF(d.destination_warehouse_code,''))) AS destinationWarehouseCode," +
"COALESCE(NULLIF(o.transfer_warehouse_name,''),MIN(NULLIF(d.destination_warehouse_name,''))) AS destinationWarehouseName," +
@@ -29,7 +30,7 @@ public interface InboundPlanOrderMapper {
"AS destinationMismatchCount " +
"FROM ll_order o LEFT JOIN ll_order_detail d ON d.order_id=o.id " +
"WHERE o.tenant_id=#{tenantId} AND o.container_no=#{containerNo} " +
"GROUP BY o.id,o.tenant_id,o.container_no,o.inbound_code,o.current_status,o.inbound_order_no,o.volume_cbm,o.package_count," +
"GROUP BY o.id,o.tenant_id,o.container_no,o.inbound_code,o.current_status,o.inbound_order_no,o.volume_cbm,o.package_count,o.fba_warehouse_code," +
"o.transfer_warehouse_id,o.transfer_warehouse_code,o.transfer_warehouse_name " +
"ORDER BY o.id FOR UPDATE")
List<SourceOrderSnapshot> lockContainerOrders(@Param("tenantId") String tenantId,
@@ -0,0 +1,10 @@
package com.baiyue.lastleg.inboundplan.model;
public record FbaAppointmentLineSnapshot(
Long lineId,
Long planId,
Long orderId,
String inboundCode,
String fbaWarehouseCode
) {
}
@@ -0,0 +1,8 @@
package com.baiyue.lastleg.inboundplan.model;
public enum FbaAppointmentStatus {
PENDING,
SUCCESS,
FAILED,
CANCELLED
}
@@ -11,10 +11,20 @@ public record SourceOrderSnapshot(
String inboundOrderNo,
BigDecimal volumeCbm,
int packageCount,
String fbaWarehouseCode,
String destinationWarehouseId,
String destinationWarehouseCode,
String destinationWarehouseName,
int destinationCount,
int destinationMismatchCount
) {
public SourceOrderSnapshot(Long orderId, String tenantId, String containerNo, String inboundCode,
String currentStatus, String inboundOrderNo, BigDecimal volumeCbm,
int packageCount, String destinationWarehouseId,
String destinationWarehouseCode, String destinationWarehouseName,
int destinationCount, int destinationMismatchCount) {
this(orderId, tenantId, containerNo, inboundCode, currentStatus, inboundOrderNo, volumeCbm,
packageCount, null, destinationWarehouseId, destinationWarehouseCode,
destinationWarehouseName, destinationCount, destinationMismatchCount);
}
}
@@ -0,0 +1,38 @@
package com.baiyue.lastleg.inboundplan.pda.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
/** Immutable SKU/pallet receipt created when a PDA inbound task completes. */
@Data
@TableName("ll_inbound_stock_receipt")
public class InboundStockReceipt {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private String tenantId;
private Long planId;
private Long taskId;
private Long allocationId;
private Long lineId;
private Long orderId;
private String inboundCode;
private String skuCode;
private String skuNameZh;
private String sourceDetailIds;
private Long warehousePalletId;
private String palletNo;
private Long warehouseLocationId;
private String warehouseLocationCode;
private String batchNo;
private Long receivedPackages;
private Long receivedQuantity;
private Long inventoryBalanceId;
private Long inventoryPositionId;
private String inventoryOperationKey;
private LocalDateTime receivedAt;
private LocalDateTime createdAt;
}
@@ -57,6 +57,19 @@ public class HttpWarehouseResourceGateway implements WarehouseResourceGateway {
.toBodilessEntity();
}
@Override
public void scrapPallet(String tenantId, long palletId) {
client.post()
.uri("/internal/v1/warehouse-resource-locks/pallets/{palletId}/scrap", palletId)
.header("X-Internal-Service-Token", internalToken)
.body(Map.of("tenantId", Long.parseLong(tenantId)))
.retrieve()
.onStatus(HttpStatusCode::isError, (request, response) -> {
throw new IllegalStateException("Warehouse rejected pallet scrap: HTTP " + response.getStatusCode());
})
.toBodilessEntity();
}
private record ReserveEnvelope(ReserveData data) {}
private record ReserveData(long lockId) {}
}
@@ -4,4 +4,5 @@ public interface WarehouseResourceGateway {
long reserve(String tenantId, long warehouseId, long palletId, long locationId,
String businessType, String businessId, String reservationKey);
void scrap(String tenantId, long resourceLockId);
void scrapPallet(String tenantId, long palletId);
}
@@ -0,0 +1,14 @@
package com.baiyue.lastleg.inboundplan.pda.mapper;
import com.baiyue.lastleg.inboundplan.pda.entity.InboundStockReceipt;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
public interface InboundStockReceiptMapper extends BaseMapper<InboundStockReceipt> {
@Select("SELECT * FROM ll_inbound_stock_receipt WHERE tenant_id=#{tenantId} AND task_id=#{taskId} " +
"ORDER BY allocation_id,sku_code")
List<InboundStockReceipt> listTask(@Param("tenantId") String tenantId, @Param("taskId") Long taskId);
}
@@ -42,6 +42,7 @@ public class InboundPdaWorkflowService {
@Resource private PdaWorkflowRepository repository;
@Resource private WarehouseResourceGateway warehouse;
@Resource private InboundStockReceiptService stockReceipts;
@Resource private ObjectMapper sourceObjectMapper;
private ObjectMapper objectMapper;
@@ -276,9 +277,12 @@ public class InboundPdaWorkflowService {
@Transactional
public PdaResponses.Task complete(String tenantId, String planNo) {
InboundPlan plan = requirePlan(tenantId, planNo);
InboundPdaTask task = requireActiveTask(tenantId, plan);
InboundPdaTask task = repository.lockTask(tenantId, plan.getId());
if (task == null) throw conflict("Push the inbound plan to PDA before operating");
if ("COMPLETED".equals(task.getStatus())) return task(task, planNo);
Map<Long, Integer> pickedByLine = new HashMap<>();
for (InboundPlanPalletAllocation allocation : repository.allocations(plan.getId())) {
List<InboundPlanPalletAllocation> allocations = repository.allocations(plan.getId());
for (InboundPlanPalletAllocation allocation : allocations) {
pickedByLine.merge(allocation.getLineId(), value(allocation.getPickedPackageCount()), Integer::sum);
}
List<InboundPlanLine> lines = repository.lines(plan.getId());
@@ -289,6 +293,7 @@ public class InboundPdaWorkflowService {
}
}
LocalDateTime now = LocalDateTime.now();
stockReceipts.stockIn(tenantId, plan, task, lines, allocations, repository.pallets(plan.getId()), now);
task.setStatus("COMPLETED");
task.setCompletedAt(now);
task.setUpdatedAt(now);
@@ -0,0 +1,221 @@
package com.baiyue.lastleg.inboundplan.pda.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.entity.LastlegOrderDetail;
import com.baiyue.lastleg.inboundplan.entity.InboundPlan;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation;
import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask;
import com.baiyue.lastleg.inboundplan.pda.entity.InboundStockReceipt;
import com.baiyue.lastleg.inboundplan.pda.mapper.InboundStockReceiptMapper;
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests;
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandResponse;
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import jakarta.annotation.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;
@Service
public class InboundStockReceiptService {
@Resource private LastlegOrderMapper orders;
@Resource private LastlegOrderDetailMapper details;
@Resource private InboundStockReceiptMapper receipts;
@Resource private InventoryCommandService inventory;
@Transactional
public void stockIn(String tenantId, InboundPlan plan, InboundPdaTask task,
List<InboundPlanLine> lines, List<InboundPlanPalletAllocation> allocations,
List<InboundPlanPallet> pallets, LocalDateTime receivedAt) {
if (!receipts.listTask(tenantId, task.getId()).isEmpty()) return;
var requestedOrderIds = lines.stream().map(InboundPlanLine::getOrderId).collect(Collectors.toSet());
Map<Long, LastlegOrder> orderById = orders.selectBatchIds(requestedOrderIds).stream()
.filter(order -> tenantId.equals(order.getTenantId()))
.collect(Collectors.toMap(LastlegOrder::getId, order -> order));
if (orderById.size() != requestedOrderIds.size()) {
throw conflict("Inbound line tail order is missing or outside the tenant");
}
Map<Long, List<LastlegOrderDetail>> detailsByOrder = details.selectList(
new LambdaQueryWrapper<LastlegOrderDetail>().in(LastlegOrderDetail::getOrderId, orderById.keySet()))
.stream().collect(Collectors.groupingBy(LastlegOrderDetail::getOrderId));
Map<Long, InboundPlanPallet> palletById = pallets.stream()
.collect(Collectors.toMap(InboundPlanPallet::getId, pallet -> pallet));
Map<Long, List<InboundPlanPalletAllocation>> allocationsByLine = allocations.stream()
.filter(allocation -> value(allocation.getPickedPackageCount()) > 0)
.sorted(Comparator.comparing(InboundPlanPalletAllocation::getId))
.collect(Collectors.groupingBy(InboundPlanPalletAllocation::getLineId,
LinkedHashMap::new, Collectors.toList()));
for (InboundPlanLine line : lines) {
LastlegOrder order = orderById.get(line.getOrderId());
if (order == null) throw conflict("Inbound line tail order is missing or outside the tenant");
List<InboundPlanPalletAllocation> lineAllocations = allocationsByLine
.getOrDefault(line.getId(), List.of());
List<SkuReceipt> skuReceipts = skuReceipts(line, detailsByOrder.getOrDefault(order.getId(), List.of()));
if (lineAllocations.stream().map(InboundPlanPalletAllocation::getPalletId).distinct().count() > 1
&& skuReceipts.size() > 1) {
throw conflict("Multi-SKU inbound lines split across pallets require SKU-level PDA receipt counts");
}
if (skuReceipts.size() > 1) {
if (lineAllocations.size() != 1) {
throw conflict("Multi-SKU inbound lines require one unambiguous pallet allocation");
}
InboundPlanPalletAllocation allocation = lineAllocations.get(0);
InboundPlanPallet pallet = requirePallet(palletById, allocation);
for (SkuReceipt sku : skuReceipts) {
persist(tenantId, plan, task, line, allocation, pallet, order, sku,
sku.packages(), sku.quantity(), receivedAt);
}
continue;
}
SkuReceipt sku = skuReceipts.get(0);
long cumulativePackages = 0;
for (InboundPlanPalletAllocation allocation : lineAllocations) {
long packages = value(allocation.getPickedPackageCount());
long nextPackages = cumulativePackages + packages;
long quantity = proportionalCumulative(sku.quantity(), nextPackages, sku.packages())
- proportionalCumulative(sku.quantity(), cumulativePackages, sku.packages());
persist(tenantId, plan, task, line, allocation, requirePallet(palletById, allocation),
order, sku, packages, quantity, receivedAt);
cumulativePackages = nextPackages;
}
if (cumulativePackages != sku.packages()) {
throw conflict("PDA picked packages do not conserve the frozen SKU package total");
}
}
}
private List<SkuReceipt> skuReceipts(InboundPlanLine line, List<LastlegOrderDetail> source) {
if (source.isEmpty()) throw conflict("Inbound line has no frozen SKU details");
Map<String, MutableSku> grouped = new LinkedHashMap<>();
long frozenPackages = 0;
for (LastlegOrderDetail detail : source) {
String skuCode = required(detail.getSkuCode(), "Frozen SKU code is required");
long packages = value(detail.getPackageCount());
if (packages < 0) throw conflict("Frozen SKU package count cannot be negative");
frozenPackages += packages;
if (packages == 0) continue;
MutableSku sku = grouped.computeIfAbsent(skuCode, ignored -> new MutableSku(skuCode));
sku.packages += packages;
sku.quantity += value(detail.getProductQuantity());
sku.nameZh = first(sku.nameZh, detail.getProductNameZh());
sku.detailIds.add(detail.getId());
}
if (frozenPackages != value(line.getRequiredPackageCount())) {
throw conflict("Frozen SKU package totals must equal the inbound line required packages");
}
if (grouped.isEmpty()) throw conflict("Inbound line has no positive frozen SKU packages");
return grouped.values().stream().map(MutableSku::snapshot).toList();
}
private void persist(String tenantId, InboundPlan plan, InboundPdaTask task, InboundPlanLine line,
InboundPlanPalletAllocation allocation, InboundPlanPallet pallet,
LastlegOrder order, SkuReceipt sku, long packages, long quantity,
LocalDateTime receivedAt) {
if (packages <= 0 || pallet.getWarehousePalletId() == null) {
throw conflict("Stock-in requires a positive package count and an assigned warehouse pallet");
}
String destinationCode = required(first(line.getDestinationWarehouseCode(), order.getFbaWarehouseCode()),
"Destination warehouse code is required for stock-in");
String overseasWarehouseId = required(order.getOverseasWarehouseId(),
"Actual overseas warehouse ID is required for stock-in");
String palletNo = required(pallet.getPalletNo(), "Pallet number is required for stock-in");
String locationCode = "LOC-" + overseasWarehouseId + "-DEFAULT";
String operationKey = "PDA_STOCK_IN:" + task.getId() + ":" + allocation.getId() + ":" + sku.skuCode();
InventoryCommandResponse result = inventory.stockIn(tenantId, task.getOperatorId(),
new InventoryCommandRequests.StockIn(null, order.getCustomerName(), overseasWarehouseId,
null, order.getOverseasWarehouseName(), line.getDestinationWarehouseId(), destinationCode,
line.getDestinationWarehouseName(), sku.skuCode(), sku.nameZh(), pallet.getWarehousePalletId(),
palletNo, plan.getPlanNo(), pallet.getWarehouseLocationId(), locationCode,
packages, quantity, order.getOrderNo(), plan.getPlanNo(), order.getSourceEventId(), operationKey,
"PDA inbound completion for " + line.getInboundCode()));
InboundStockReceipt receipt = new InboundStockReceipt();
receipt.setId(IdWorker.getId());
receipt.setTenantId(tenantId);
receipt.setPlanId(plan.getId());
receipt.setTaskId(task.getId());
receipt.setAllocationId(allocation.getId());
receipt.setLineId(line.getId());
receipt.setOrderId(order.getId());
receipt.setInboundCode(line.getInboundCode());
receipt.setSkuCode(sku.skuCode());
receipt.setSkuNameZh(sku.nameZh());
receipt.setSourceDetailIds(sku.detailIds());
receipt.setWarehousePalletId(pallet.getWarehousePalletId());
receipt.setPalletNo(palletNo);
receipt.setWarehouseLocationId(pallet.getWarehouseLocationId());
receipt.setWarehouseLocationCode(locationCode);
receipt.setBatchNo(plan.getPlanNo());
receipt.setReceivedPackages(packages);
receipt.setReceivedQuantity(quantity);
receipt.setInventoryBalanceId(result.balanceId());
receipt.setInventoryPositionId(result.positionId());
receipt.setInventoryOperationKey(operationKey);
receipt.setReceivedAt(receivedAt);
receipt.setCreatedAt(receivedAt);
receipts.insert(receipt);
}
private static InboundPlanPallet requirePallet(Map<Long, InboundPlanPallet> pallets,
InboundPlanPalletAllocation allocation) {
InboundPlanPallet pallet = pallets.get(allocation.getPalletId());
if (pallet == null) throw conflict("PDA allocation pallet is missing");
return pallet;
}
private static long proportionalCumulative(long quantity, long packages, long totalPackages) {
return Math.floorDiv(Math.multiplyExact(quantity, packages), totalPackages);
}
private static String required(String value, String message) {
String normalized = trim(value);
if (normalized == null) throw conflict(message);
return normalized;
}
private static String first(String first, String second) {
String normalized = trim(first);
return normalized == null ? trim(second) : normalized;
}
private static String trim(String value) { return value == null || value.isBlank() ? null : value.trim(); }
private static long value(Integer value) { return value == null ? 0L : value.longValue(); }
private static ResponseStatusException conflict(String message) {
return new ResponseStatusException(HttpStatus.CONFLICT, message);
}
private static final class MutableSku {
private final String skuCode;
private String nameZh;
private long packages;
private long quantity;
private final List<Long> detailIds = new ArrayList<>();
private MutableSku(String skuCode) { this.skuCode = skuCode; }
private SkuReceipt snapshot() {
return new SkuReceipt(skuCode, nameZh, packages, quantity,
detailIds.stream().filter(Objects::nonNull).map(String::valueOf).collect(Collectors.joining(",")));
}
}
private record SkuReceipt(String skuCode, String nameZh, long packages, long quantity, String detailIds) {}
}
@@ -0,0 +1,180 @@
package com.baiyue.lastleg.inboundplan.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.inboundplan.dto.BatchFbaAppointmentRequest;
import com.baiyue.lastleg.inboundplan.dto.FbaAppointmentResponse;
import com.baiyue.lastleg.inboundplan.entity.InboundFbaAppointment;
import com.baiyue.lastleg.inboundplan.entity.InboundFbaAppointmentItem;
import com.baiyue.lastleg.inboundplan.mapper.InboundFbaAppointmentItemMapper;
import com.baiyue.lastleg.inboundplan.mapper.InboundFbaAppointmentMapper;
import com.baiyue.lastleg.inboundplan.mapper.InboundFbaAppointmentQueryMapper;
import com.baiyue.lastleg.inboundplan.model.FbaAppointmentLineSnapshot;
import com.baiyue.lastleg.inboundplan.model.FbaAppointmentStatus;
import jakarta.annotation.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDateTime;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Set;
@Service
public class InboundFbaAppointmentService {
@Resource private InboundFbaAppointmentMapper appointmentMapper;
@Resource private InboundFbaAppointmentItemMapper itemMapper;
@Resource private InboundFbaAppointmentQueryMapper queryMapper;
@Transactional
public FbaAppointmentResponse batchCreate(String tenantId, String operatorId,
BatchFbaAppointmentRequest request) {
List<Long> lineIds = List.copyOf(request.lineIds());
if (new HashSet<>(lineIds).size() != lineIds.size()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Selected inbound lines contain duplicates");
}
String appointmentNo = requiredTrimmed(request.appointmentNo(), "Appointment number is required");
String status = normalizeStatus(request.appointmentStatus());
String remark = trimToNull(request.appointmentRemark());
InboundFbaAppointment existing = find(tenantId, appointmentNo);
if (existing != null) {
return verifyReplay(existing, lineIds, request.appointmentAt(), status, remark);
}
List<FbaAppointmentLineSnapshot> lines = queryMapper.lockLines(tenantId, lineIds);
if (lines.size() != lineIds.size()) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND,
"One or more selected inbound lines do not exist in this tenant");
}
String fbaWarehouseCode = commonFbaWarehouse(lines);
// The row locks serialize concurrent submissions selecting the same inbound lines.
existing = find(tenantId, appointmentNo);
if (existing != null) {
return verifyReplay(existing, lineIds, request.appointmentAt(), status, remark);
}
LocalDateTime now = LocalDateTime.now();
InboundFbaAppointment appointment = new InboundFbaAppointment();
appointment.setId(IdWorker.getId());
appointment.setTenantId(tenantId);
appointment.setAppointmentNo(appointmentNo);
appointment.setAppointmentAt(request.appointmentAt());
appointment.setAppointmentStatus(status);
appointment.setAppointmentRemark(remark);
appointment.setFbaWarehouseCode(fbaWarehouseCode);
appointment.setSelectedCount(lines.size());
appointment.setCreatedBy(trimToNull(operatorId));
appointment.setCreatedAt(now);
appointment.setUpdatedAt(now);
try {
appointmentMapper.insert(appointment);
} catch (DuplicateKeyException exception) {
InboundFbaAppointment concurrent = find(tenantId, appointmentNo);
if (concurrent != null) {
return verifyReplay(concurrent, lineIds, request.appointmentAt(), status, remark);
}
throw exception;
}
for (FbaAppointmentLineSnapshot line : lines) {
InboundFbaAppointmentItem item = new InboundFbaAppointmentItem();
item.setId(IdWorker.getId());
item.setAppointmentId(appointment.getId());
item.setPlanId(line.planId());
item.setLineId(line.lineId());
item.setOrderId(line.orderId());
item.setInboundCode(line.inboundCode());
item.setFbaWarehouseCode(line.fbaWarehouseCode());
item.setCreatedAt(now);
itemMapper.insert(item);
}
return response(appointment, items(appointment.getId()));
}
@Transactional(readOnly = true)
public FbaAppointmentResponse detail(String tenantId, String appointmentNo) {
InboundFbaAppointment appointment = find(tenantId,
requiredTrimmed(appointmentNo, "Appointment number is required"));
if (appointment == null) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "FBA appointment not found");
}
return response(appointment, items(appointment.getId()));
}
private FbaAppointmentResponse verifyReplay(InboundFbaAppointment existing, List<Long> requestedLineIds,
LocalDateTime appointmentAt, String status, String remark) {
List<InboundFbaAppointmentItem> existingItems = items(existing.getId());
Set<Long> existingLineIds = existingItems.stream()
.map(InboundFbaAppointmentItem::getLineId).collect(java.util.stream.Collectors.toSet());
if (!Objects.equals(existing.getAppointmentAt(), appointmentAt)
|| !Objects.equals(existing.getAppointmentStatus(), status)
|| !Objects.equals(existing.getAppointmentRemark(), remark)
|| existingLineIds.size() != requestedLineIds.size()
|| !existingLineIds.equals(new HashSet<>(requestedLineIds))) {
throw new ResponseStatusException(HttpStatus.CONFLICT,
"Appointment number already exists with different appointment data");
}
return response(existing, existingItems);
}
private String commonFbaWarehouse(List<FbaAppointmentLineSnapshot> lines) {
List<String> warehouses = lines.stream().map(FbaAppointmentLineSnapshot::fbaWarehouseCode)
.map(InboundFbaAppointmentService::trimToNull).toList();
if (warehouses.stream().anyMatch(Objects::isNull) || new HashSet<>(warehouses).size() != 1) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"Selected inbound lines must belong to the same non-empty FBA warehouse");
}
return warehouses.get(0);
}
private InboundFbaAppointment find(String tenantId, String appointmentNo) {
return appointmentMapper.selectOne(new LambdaQueryWrapper<InboundFbaAppointment>()
.eq(InboundFbaAppointment::getTenantId, tenantId)
.eq(InboundFbaAppointment::getAppointmentNo, appointmentNo));
}
private List<InboundFbaAppointmentItem> items(Long appointmentId) {
return itemMapper.selectList(new LambdaQueryWrapper<InboundFbaAppointmentItem>()
.eq(InboundFbaAppointmentItem::getAppointmentId, appointmentId)
.orderByAsc(InboundFbaAppointmentItem::getLineId));
}
private FbaAppointmentResponse response(InboundFbaAppointment appointment,
List<InboundFbaAppointmentItem> items) {
return new FbaAppointmentResponse(appointment.getId(), appointment.getTenantId(),
appointment.getAppointmentNo(), appointment.getAppointmentAt(), appointment.getAppointmentStatus(),
appointment.getAppointmentRemark(), appointment.getFbaWarehouseCode(), appointment.getSelectedCount(),
appointment.getCreatedBy(), appointment.getCreatedAt(), appointment.getUpdatedAt(),
items.stream().map(item -> new FbaAppointmentResponse.Item(item.getId(), item.getPlanId(),
item.getLineId(), item.getOrderId(), item.getInboundCode(), item.getFbaWarehouseCode())).toList());
}
private static String normalizeStatus(String value) {
String normalized = requiredTrimmed(value, "Appointment status is required").toUpperCase(Locale.ROOT);
try {
return FbaAppointmentStatus.valueOf(normalized).name();
} catch (IllegalArgumentException exception) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"Appointment status must be PENDING, SUCCESS, FAILED or CANCELLED");
}
}
private static String requiredTrimmed(String value, String message) {
String normalized = trimToNull(value);
if (normalized == null) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, message);
}
return normalized;
}
private static String trimToNull(String value) {
return value == null || value.isBlank() ? null : value.trim();
}
}
@@ -115,6 +115,7 @@ public class InboundPlanService {
line.setPlanId(plan.getId());
line.setOrderId(order.orderId());
line.setInboundCode(order.inboundCode());
line.setFbaWarehouseCode(order.fbaWarehouseCode());
line.setDestinationWarehouseId(order.destinationWarehouseId());
line.setDestinationWarehouseCode(order.destinationWarehouseCode());
line.setDestinationWarehouseName(order.destinationWarehouseName());
@@ -208,7 +209,7 @@ public class InboundPlanService {
.orderByAsc(InboundPlanPalletAllocation::getId));
return new InboundPlanResponse(plan.getId(), plan.getPlanNo(), plan.getTenantId(), plan.getContainerNo(),
plan.getStatus(), plan.getLineCount(), plan.getPalletCount(), plan.getTotalVolumeCbm(),
lines.stream().map(line -> new InboundPlanResponse.Line(line.getId(), line.getOrderId(), line.getInboundCode(),
lines.stream().map(line -> new InboundPlanResponse.Line(line.getId(), line.getOrderId(), line.getInboundCode(), line.getFbaWarehouseCode(),
line.getDestinationWarehouseId(), line.getDestinationWarehouseCode(), line.getDestinationWarehouseName(),
line.getVolumeCbm(), line.getRequiredPackageCount(), line.getPlannedPalletCount())).toList(),
pallets.stream().map(pallet -> new InboundPlanResponse.Pallet(pallet.getId(), pallet.getPalletNo(),
@@ -0,0 +1,9 @@
package com.baiyue.lastleg.inventory;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.context.annotation.Configuration;
@Configuration
@MapperScan("com.baiyue.lastleg.inventory.mapper")
public class InventoryMapperConfiguration {
}
@@ -0,0 +1,9 @@
package com.baiyue.lastleg.inventory.command;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.context.annotation.Configuration;
@Configuration
@MapperScan("com.baiyue.lastleg.inventory.command.mapper")
public class InventoryCommandMapperConfiguration {
}
@@ -0,0 +1,97 @@
package com.baiyue.lastleg.inventory.command.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests;
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandResponse;
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
@RestController
@RequestMapping("/api/platform/v1/last-leg/inventory")
@Tag(name = "Tail-leg SKU package inventory commands")
public class InventoryCommandController {
@Resource private InventoryCommandService service;
@PostMapping("/stock-in")
@Operation(summary = "Put SKU packages into an overseas warehouse pallet")
public ApiResponse<InventoryCommandResponse> stockIn(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody InventoryCommandRequests.StockIn request) {
return ApiResponse.success(service.stockIn(scope(user, request.tenantId()), operator(user), request));
}
@PostMapping("/hold")
@Operation(summary = "Move available SKU packages to hold")
public ApiResponse<InventoryCommandResponse> hold(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody InventoryCommandRequests.Move request) {
return ApiResponse.success(service.hold(scope(user, request.tenantId()), operator(user), request));
}
@PostMapping("/release")
@Operation(summary = "Release held SKU packages")
public ApiResponse<InventoryCommandResponse> release(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody InventoryCommandRequests.Move request) {
return ApiResponse.success(service.release(scope(user, request.tenantId()), operator(user), request));
}
@PostMapping("/allocate")
@Operation(summary = "Allocate available SKU packages to an outbound order")
public ApiResponse<InventoryCommandResponse> allocate(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody InventoryCommandRequests.Move request) {
return ApiResponse.success(service.allocate(scope(user, request.tenantId()), operator(user), request));
}
@PostMapping("/unallocate")
@Operation(summary = "Cancel an outbound allocation")
public ApiResponse<InventoryCommandResponse> unallocate(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody InventoryCommandRequests.Move request) {
return ApiResponse.success(service.unallocate(scope(user, request.tenantId()), operator(user), request));
}
@PostMapping("/pick")
@Operation(summary = "Confirm picked SKU packages")
public ApiResponse<InventoryCommandResponse> pick(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody InventoryCommandRequests.Move request) {
return ApiResponse.success(service.pick(scope(user, request.tenantId()), operator(user), request));
}
@PostMapping("/ship")
@Operation(summary = "Ship picked SKU packages and report whether the pallet is empty")
public ApiResponse<InventoryCommandResponse> ship(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody InventoryCommandRequests.Move request) {
return ApiResponse.success(service.ship(scope(user, request.tenantId()), operator(user), request));
}
@PostMapping("/adjust")
@Operation(summary = "Adjust SKU package inventory after a physical count")
public ApiResponse<InventoryCommandResponse> adjust(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody InventoryCommandRequests.Adjustment request) {
return ApiResponse.success(service.adjust(scope(user, request.tenantId()), operator(user), request));
}
private String scope(AuthenticatedUser user, String requestedTenant) {
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) {
if (requestedTenant == null || requestedTenant.isBlank() || "0".equals(requestedTenant)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"Platform request must specify target tenant");
}
return requestedTenant.trim();
}
return String.valueOf(user.tenantId());
}
private String operator(AuthenticatedUser user) {
return user.userId() == null ? user.username() : String.valueOf(user.userId());
}
}
@@ -0,0 +1,63 @@
package com.baiyue.lastleg.inventory.command.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.PositiveOrZero;
import jakarta.validation.constraints.Size;
public final class InventoryCommandRequests {
private InventoryCommandRequests() {
}
public record StockIn(
String tenantId,
String customerName,
@NotBlank String overseasWarehouseId,
String overseasWarehouseCode,
String overseasWarehouseName,
String destinationWarehouseId,
@NotBlank String destinationWarehouseCode,
String destinationWarehouseName,
@NotBlank String skuCode,
String skuNameZh,
@NotNull Long palletId,
@NotBlank String palletNo,
String batchNo,
Long warehouseLocationId,
String warehouseLocationCode,
@Positive long packages,
@PositiveOrZero long productQuantity,
String businessOrderNo,
String inboundOrderNo,
String sourceEventId,
@NotBlank @Size(max = 255) String idempotencyKey,
@Size(max = 1000) String remark
) {
}
public record Move(
String tenantId,
@NotNull Long positionId,
@Positive long packages,
@PositiveOrZero long productQuantity,
String businessOrderNo,
String outboundOrderNo,
String sourceEventId,
@NotBlank @Size(max = 255) String idempotencyKey,
@Size(max = 1000) String remark
) {
}
public record Adjustment(
String tenantId,
@NotNull Long positionId,
long packageDelta,
long quantityDelta,
@NotBlank String reason,
String businessOrderNo,
@NotBlank @Size(max = 255) String idempotencyKey,
@Size(max = 1000) String remark
) {
}
}
@@ -0,0 +1,19 @@
package com.baiyue.lastleg.inventory.command.dto;
public record InventoryCommandResponse(
Long operationId,
String operationType,
Long balanceId,
Long positionId,
long availablePackages,
long holdPackages,
long allocatedPackages,
long pickedPackages,
boolean palletEmpty,
boolean replayed
) {
public InventoryCommandResponse asReplay() {
return new InventoryCommandResponse(operationId, operationType, balanceId, positionId,
availablePackages, holdPackages, allocatedPackages, pickedPackages, palletEmpty, true);
}
}
@@ -0,0 +1,22 @@
package com.baiyue.lastleg.inventory.command.mapper;
import com.baiyue.lastleg.inventory.entity.InventoryBalance;
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface InventoryCommandMapper {
@Select("SELECT * FROM ll_inventory_position WHERE tenant_id=#{tenantId} AND id=#{positionId} LIMIT 1 FOR UPDATE")
InventoryPosition lockPosition(@Param("tenantId") String tenantId,
@Param("positionId") Long positionId);
@Select("SELECT * FROM ll_inventory_balance WHERE tenant_id=#{tenantId} AND id=#{balanceId} LIMIT 1 FOR UPDATE")
InventoryBalance lockBalance(@Param("tenantId") String tenantId,
@Param("balanceId") Long balanceId);
@Select("SELECT COALESCE(SUM(available_packages+hold_packages+allocated_packages+picked_packages+receiving_packages),0) " +
"FROM ll_inventory_position WHERE tenant_id=#{tenantId} AND pallet_id=#{palletId}")
long palletPackages(@Param("tenantId") String tenantId, @Param("palletId") Long palletId);
}
@@ -0,0 +1,493 @@
package com.baiyue.lastleg.inventory.command.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests;
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandResponse;
import com.baiyue.lastleg.inventory.command.mapper.InventoryCommandMapper;
import com.baiyue.lastleg.inventory.entity.InventoryBalance;
import com.baiyue.lastleg.inventory.entity.InventoryLedger;
import com.baiyue.lastleg.inventory.entity.InventoryOperation;
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper;
import com.baiyue.lastleg.inventory.mapper.InventoryLedgerMapper;
import com.baiyue.lastleg.inventory.mapper.InventoryOperationMapper;
import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper;
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.LocalDateTime;
import java.util.HexFormat;
@Service
public class InventoryCommandService {
@Resource private InventoryBalanceMapper balances;
@Resource private InventoryPositionMapper positions;
@Resource private InventoryLedgerMapper ledgers;
@Resource private InventoryOperationMapper operations;
@Resource private InventoryCommandMapper commands;
@Resource private WarehouseResourceGateway warehouseGateway;
@Resource private ObjectMapper objectMapper;
@Transactional
public InventoryCommandResponse stockIn(String tenantId, String operatorId,
InventoryCommandRequests.StockIn request) {
return execute(tenantId, operatorId, "STOCK_IN", request.idempotencyKey(), request, operation -> {
InventoryBalance balance = balance(tenantId, request);
InventoryPosition position = position(tenantId, balance, request);
long before = value(position.getAvailablePackages());
position.setAvailablePackages(before + request.packages());
position.setProductQuantity(value(position.getProductQuantity()) + request.productQuantity());
position.setStatus("OCCUPIED");
position.setVersion(value(position.getVersion()) + 1);
position.setUpdatedAt(LocalDateTime.now());
positions.updateById(position);
balance.setAvailablePackages(value(balance.getAvailablePackages()) + request.packages());
balance.setAvailableQuantity(value(balance.getAvailableQuantity()) + request.productQuantity());
touch(balance);
balances.updateById(balance);
ledger(tenantId, operatorId, operation.getId(), balance, position, "STOCK_IN",
"OUTSIDE", "AVAILABLE", before, request.packages(), before + request.packages(),
request.businessOrderNo(), request.inboundOrderNo(), null, request.sourceEventId(),
request.idempotencyKey(), value(balance.getAvailableQuantity()) - request.productQuantity(),
request.productQuantity(), value(balance.getAvailableQuantity()), request.remark());
return response(operation, balance, position, false);
});
}
@Transactional
public InventoryCommandResponse hold(String tenantId, String operatorId, InventoryCommandRequests.Move request) {
return move(tenantId, operatorId, "HOLD", Bucket.AVAILABLE, Bucket.HOLD, request);
}
@Transactional
public InventoryCommandResponse release(String tenantId, String operatorId, InventoryCommandRequests.Move request) {
return move(tenantId, operatorId, "RELEASE", Bucket.HOLD, Bucket.AVAILABLE, request);
}
@Transactional
public InventoryCommandResponse allocate(String tenantId, String operatorId, InventoryCommandRequests.Move request) {
return move(tenantId, operatorId, "ALLOCATE", Bucket.AVAILABLE, Bucket.ALLOCATED, request);
}
@Transactional
public InventoryCommandResponse unallocate(String tenantId, String operatorId, InventoryCommandRequests.Move request) {
return move(tenantId, operatorId, "UNALLOCATE", Bucket.ALLOCATED, Bucket.AVAILABLE, request);
}
@Transactional
public InventoryCommandResponse pick(String tenantId, String operatorId, InventoryCommandRequests.Move request) {
return move(tenantId, operatorId, "PICK", Bucket.ALLOCATED, Bucket.PICKED, request);
}
@Transactional
public InventoryCommandResponse ship(String tenantId, String operatorId, InventoryCommandRequests.Move request) {
return execute(tenantId, operatorId, "SHIP", request.idempotencyKey(), request, operation -> {
Locked locked = locked(tenantId, request.positionId());
InventoryPosition position = locked.position();
InventoryBalance balance = locked.balance();
requireAtLeast(value(position.getPickedPackages()), request.packages(), "picked");
requireAtLeast(value(position.getProductQuantity()), request.productQuantity(), "product quantity");
requireAtLeast(value(balance.getPickedPackages()), request.packages(), "balance picked");
requireAtLeast(value(balance.getPickedQuantity()), request.productQuantity(), "balance picked quantity");
long before = value(position.getPickedPackages());
long beforeQuantity = value(balance.getPickedQuantity());
position.setPickedPackages(before - request.packages());
position.setProductQuantity(value(position.getProductQuantity()) - request.productQuantity());
position.setStatus(positionPackages(position) == 0 ? "EMPTY" : "OCCUPIED");
position.setVersion(value(position.getVersion()) + 1);
position.setUpdatedAt(LocalDateTime.now());
positions.updateById(position);
balance.setPickedPackages(value(balance.getPickedPackages()) - request.packages());
balance.setPickedQuantity(value(balance.getPickedQuantity()) - request.productQuantity());
touch(balance);
balances.updateById(balance);
boolean palletEmpty = commands.palletPackages(tenantId, position.getPalletId()) == 0;
if (palletEmpty) warehouseGateway.scrapPallet(tenantId, position.getPalletId());
ledger(tenantId, operatorId, operation.getId(), balance, position, "SHIP",
"PICKED", "SHIPPED", before, -request.packages(), before - request.packages(),
request.businessOrderNo(), null, request.outboundOrderNo(), request.sourceEventId(),
request.idempotencyKey(), beforeQuantity, -request.productQuantity(),
value(balance.getPickedQuantity()), request.remark());
return response(operation, balance, position, palletEmpty);
});
}
@Transactional
public InventoryCommandResponse adjust(String tenantId, String operatorId,
InventoryCommandRequests.Adjustment request) {
if (request.packageDelta() == 0 && request.quantityDelta() == 0) {
throw badRequest("Adjustment package and quantity deltas cannot both be zero");
}
return execute(tenantId, operatorId, "ADJUST", request.idempotencyKey(), request, operation -> {
Locked locked = locked(tenantId, request.positionId());
InventoryPosition position = locked.position();
InventoryBalance balance = locked.balance();
long before = value(position.getAvailablePackages());
long after = before + request.packageDelta();
long quantityAfter = value(position.getProductQuantity()) + request.quantityDelta();
long balanceAfter = value(balance.getAvailablePackages()) + request.packageDelta();
long balanceQuantityBefore = value(balance.getAvailableQuantity());
long balanceQuantityAfter = balanceQuantityBefore + request.quantityDelta();
if (after < 0 || quantityAfter < 0 || balanceAfter < 0 || balanceQuantityAfter < 0) {
throw conflict("Adjustment would make inventory negative");
}
position.setAvailablePackages(after);
position.setProductQuantity(quantityAfter);
position.setStatus(positionPackages(position) == 0 ? "EMPTY" : "OCCUPIED");
position.setVersion(value(position.getVersion()) + 1);
position.setUpdatedAt(LocalDateTime.now());
positions.updateById(position);
balance.setAvailablePackages(balanceAfter);
balance.setAvailableQuantity(balanceQuantityAfter);
touch(balance);
balances.updateById(balance);
ledger(tenantId, operatorId, operation.getId(), balance, position, "ADJUST",
"AVAILABLE", "AVAILABLE", before, request.packageDelta(), after,
request.businessOrderNo(), null, null, null, request.idempotencyKey(),
balanceQuantityBefore, request.quantityDelta(), balanceQuantityAfter,
request.reason() + remarkSuffix(request.remark()));
return response(operation, balance, position, false);
});
}
private InventoryCommandResponse move(String tenantId, String operatorId, String operationType,
Bucket from, Bucket to, InventoryCommandRequests.Move request) {
return execute(tenantId, operatorId, operationType, request.idempotencyKey(), request, operation -> {
Locked locked = locked(tenantId, request.positionId());
InventoryPosition position = locked.position();
InventoryBalance balance = locked.balance();
requireAtLeast(packages(position, from), request.packages(), from.name().toLowerCase());
requireAtLeast(packages(balance, from), request.packages(), "balance " + from.name().toLowerCase());
requireAtLeast(quantity(balance, from), request.productQuantity(),
"balance " + from.name().toLowerCase() + " quantity");
long beforeTarget = packages(position, to);
long beforeTargetQuantity = quantity(balance, to);
packages(position, from, packages(position, from) - request.packages());
packages(position, to, beforeTarget + request.packages());
position.setVersion(value(position.getVersion()) + 1);
position.setUpdatedAt(LocalDateTime.now());
positions.updateById(position);
packages(balance, from, packages(balance, from) - request.packages());
packages(balance, to, packages(balance, to) + request.packages());
quantity(balance, from, quantity(balance, from) - request.productQuantity());
quantity(balance, to, quantity(balance, to) + request.productQuantity());
touch(balance);
balances.updateById(balance);
ledger(tenantId, operatorId, operation.getId(), balance, position, operationType,
from.name(), to.name(), beforeTarget, request.packages(), beforeTarget + request.packages(),
request.businessOrderNo(), null, request.outboundOrderNo(), request.sourceEventId(),
request.idempotencyKey(), beforeTargetQuantity, request.productQuantity(),
quantity(balance, to), request.remark());
return response(operation, balance, position, false);
});
}
private InventoryBalance balance(String tenantId, InventoryCommandRequests.StockIn request) {
InventoryBalance balance = balances.lockDimension(tenantId, request.overseasWarehouseId(),
request.destinationWarehouseCode(), request.skuCode());
if (balance != null) return balance;
balance = new InventoryBalance();
balance.setId(IdWorker.getId());
balance.setTenantId(tenantId);
balance.setCustomerName(trim(request.customerName()));
balance.setOverseasWarehouseId(request.overseasWarehouseId().trim());
balance.setOverseasWarehouseCode(trim(request.overseasWarehouseCode()));
balance.setOverseasWarehouseName(trim(request.overseasWarehouseName()));
balance.setDestinationWarehouseId(trim(request.destinationWarehouseId()));
balance.setDestinationWarehouseCode(request.destinationWarehouseCode().trim());
balance.setDestinationWarehouseName(trim(request.destinationWarehouseName()));
balance.setSkuCode(request.skuCode().trim());
balance.setSkuNameZh(trim(request.skuNameZh()));
zero(balance);
balance.setVersion(0L);
balance.setCreatedAt(LocalDateTime.now());
balance.setUpdatedAt(balance.getCreatedAt());
try {
balances.insert(balance);
return balance;
} catch (DuplicateKeyException exception) {
InventoryBalance concurrent = balances.lockDimension(tenantId, request.overseasWarehouseId(),
request.destinationWarehouseCode(), request.skuCode());
if (concurrent != null) return concurrent;
throw exception;
}
}
private InventoryPosition position(String tenantId, InventoryBalance balance,
InventoryCommandRequests.StockIn request) {
String batchNo = trim(request.batchNo()) == null ? "DEFAULT" : request.batchNo().trim();
InventoryPosition position = positions.lockPosition(tenantId, balance.getId(), request.palletId(), batchNo);
if (position != null) return position;
position = new InventoryPosition();
position.setId(IdWorker.getId());
position.setTenantId(tenantId);
position.setBalanceId(balance.getId());
position.setOverseasWarehouseId(balance.getOverseasWarehouseId());
position.setDestinationWarehouseCode(balance.getDestinationWarehouseCode());
position.setSkuCode(balance.getSkuCode());
position.setPalletId(request.palletId());
position.setPalletNo(request.palletNo().trim());
position.setBatchNo(batchNo);
position.setWarehouseLocationId(request.warehouseLocationId());
String locationCode = trim(request.warehouseLocationCode());
position.setWarehouseLocationCode(locationCode == null
? "LOC-" + defaultCode(balance) + "-DEFAULT" : locationCode);
position.setAvailablePackages(0L);
position.setHoldPackages(0L);
position.setAllocatedPackages(0L);
position.setPickedPackages(0L);
position.setReceivingPackages(0L);
position.setProductQuantity(0L);
position.setStatus("OCCUPIED");
position.setVersion(0L);
position.setCreatedAt(LocalDateTime.now());
position.setUpdatedAt(position.getCreatedAt());
try {
positions.insert(position);
return position;
} catch (DuplicateKeyException exception) {
InventoryPosition concurrent = positions.lockPosition(tenantId, balance.getId(), request.palletId(), batchNo);
if (concurrent != null) return concurrent;
throw exception;
}
}
private Locked locked(String tenantId, Long positionId) {
InventoryPosition position = commands.lockPosition(tenantId, positionId);
if (position == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Inventory position not found");
InventoryBalance balance = commands.lockBalance(tenantId, position.getBalanceId());
if (balance == null) throw new IllegalStateException("Inventory position balance is missing");
return new Locked(balance, position);
}
private InventoryCommandResponse execute(String tenantId, String operatorId, String operationType,
String key, Object request, Work work) {
String normalizedKey = required(key, "Idempotency key is required");
String requestHash = hash(request);
InventoryOperation existing = operations.findIdempotency(tenantId, normalizedKey);
if (existing != null) return replay(existing, operationType, requestHash);
InventoryOperation operation = new InventoryOperation();
operation.setId(IdWorker.getId());
operation.setTenantId(tenantId);
operation.setIdempotencyKey(normalizedKey);
operation.setOperationType(operationType);
operation.setRequestHash(requestHash);
operation.setStatus("PROCESSING");
operation.setCreatedAt(LocalDateTime.now());
operation.setUpdatedAt(operation.getCreatedAt());
try {
operations.insert(operation);
} catch (DuplicateKeyException exception) {
InventoryOperation concurrent = operations.findIdempotency(tenantId, normalizedKey);
if (concurrent != null) return replay(concurrent, operationType, requestHash);
throw exception;
}
InventoryCommandResponse response = work.run(operation);
operation.setStatus("COMPLETED");
operation.setResultJson(write(response));
operation.setUpdatedAt(LocalDateTime.now());
operations.updateById(operation);
return response;
}
private InventoryCommandResponse replay(InventoryOperation operation, String type, String requestHash) {
if (!type.equals(operation.getOperationType()) || !requestHash.equals(operation.getRequestHash())) {
throw conflict("Idempotency key was already used with a different inventory request");
}
if (!"COMPLETED".equals(operation.getStatus()) || trim(operation.getResultJson()) == null) {
throw conflict("Inventory operation is still processing");
}
try {
return objectMapper.readValue(operation.getResultJson(), InventoryCommandResponse.class).asReplay();
} catch (JsonProcessingException exception) {
throw new IllegalStateException("Cannot read inventory idempotency result", exception);
}
}
private void ledger(String tenantId, String operatorId, Long operationId, InventoryBalance balance,
InventoryPosition position, String businessType, String from, String to,
long beforePackages, long deltaPackages, long afterPackages,
String businessOrderNo, String inboundOrderNo, String outboundOrderNo,
String sourceEventId, String idempotencyKey, long beforeQuantity,
long deltaQuantity, long afterQuantity, String remark) {
InventoryLedger ledger = new InventoryLedger();
ledger.setId(IdWorker.getId());
ledger.setTenantId(tenantId);
ledger.setBalanceId(balance.getId());
ledger.setPositionId(position.getId());
ledger.setOverseasWarehouseId(balance.getOverseasWarehouseId());
ledger.setDestinationWarehouseCode(balance.getDestinationWarehouseCode());
ledger.setSkuCode(balance.getSkuCode());
ledger.setPalletId(position.getPalletId());
ledger.setPalletNo(position.getPalletNo());
ledger.setBatchNo(position.getBatchNo());
ledger.setWarehouseLocationCode(position.getWarehouseLocationCode());
ledger.setBusinessType(businessType);
ledger.setBusinessOrderNo(trim(businessOrderNo));
ledger.setInboundOrderNo(trim(inboundOrderNo));
ledger.setOutboundOrderNo(trim(outboundOrderNo));
ledger.setSourceEventId(trim(sourceEventId));
ledger.setIdempotencyKey(idempotencyKey + ":" + operationId);
ledger.setStatusFrom(from);
ledger.setStatusTo(to);
ledger.setBeforePackages(beforePackages);
ledger.setDeltaPackages(deltaPackages);
ledger.setAfterPackages(afterPackages);
ledger.setBeforeQuantity(beforeQuantity);
ledger.setDeltaQuantity(deltaQuantity);
ledger.setAfterQuantity(afterQuantity);
ledger.setOperatorId(trim(operatorId));
ledger.setRemark(trim(remark));
ledger.setOccurredAt(LocalDateTime.now());
ledger.setCreatedAt(ledger.getOccurredAt());
ledgers.insert(ledger);
}
private InventoryCommandResponse response(InventoryOperation operation, InventoryBalance balance,
InventoryPosition position, boolean palletEmpty) {
return new InventoryCommandResponse(operation.getId(), operation.getOperationType(), balance.getId(),
position.getId(), value(balance.getAvailablePackages()), value(balance.getHoldPackages()),
value(balance.getAllocatedPackages()), value(balance.getPickedPackages()), palletEmpty, false);
}
private static void touch(InventoryBalance balance) {
balance.setVersion(value(balance.getVersion()) + 1);
balance.setUpdatedAt(LocalDateTime.now());
}
private static void zero(InventoryBalance balance) {
balance.setAvailablePackages(0L); balance.setHoldPackages(0L); balance.setAllocatedPackages(0L);
balance.setPickedPackages(0L); balance.setInTransitPackages(0L); balance.setReceivingPackages(0L);
balance.setProcurementPackages(0L); balance.setAvailableQuantity(0L); balance.setHoldQuantity(0L);
balance.setAllocatedQuantity(0L); balance.setPickedQuantity(0L); balance.setInTransitQuantity(0L);
balance.setReceivingQuantity(0L); balance.setProcurementQuantity(0L);
}
private static long positionPackages(InventoryPosition position) {
return value(position.getAvailablePackages()) + value(position.getHoldPackages())
+ value(position.getAllocatedPackages()) + value(position.getPickedPackages())
+ value(position.getReceivingPackages());
}
private static long packages(InventoryPosition position, Bucket bucket) {
return switch (bucket) {
case AVAILABLE -> value(position.getAvailablePackages());
case HOLD -> value(position.getHoldPackages());
case ALLOCATED -> value(position.getAllocatedPackages());
case PICKED -> value(position.getPickedPackages());
};
}
private static void packages(InventoryPosition position, Bucket bucket, long value) {
switch (bucket) {
case AVAILABLE -> position.setAvailablePackages(value);
case HOLD -> position.setHoldPackages(value);
case ALLOCATED -> position.setAllocatedPackages(value);
case PICKED -> position.setPickedPackages(value);
}
}
private static long packages(InventoryBalance balance, Bucket bucket) {
return switch (bucket) {
case AVAILABLE -> value(balance.getAvailablePackages());
case HOLD -> value(balance.getHoldPackages());
case ALLOCATED -> value(balance.getAllocatedPackages());
case PICKED -> value(balance.getPickedPackages());
};
}
private static void packages(InventoryBalance balance, Bucket bucket, long value) {
switch (bucket) {
case AVAILABLE -> balance.setAvailablePackages(value);
case HOLD -> balance.setHoldPackages(value);
case ALLOCATED -> balance.setAllocatedPackages(value);
case PICKED -> balance.setPickedPackages(value);
}
}
private static long quantity(InventoryBalance balance, Bucket bucket) {
return switch (bucket) {
case AVAILABLE -> value(balance.getAvailableQuantity());
case HOLD -> value(balance.getHoldQuantity());
case ALLOCATED -> value(balance.getAllocatedQuantity());
case PICKED -> value(balance.getPickedQuantity());
};
}
private static void quantity(InventoryBalance balance, Bucket bucket, long value) {
switch (bucket) {
case AVAILABLE -> balance.setAvailableQuantity(value);
case HOLD -> balance.setHoldQuantity(value);
case ALLOCATED -> balance.setAllocatedQuantity(value);
case PICKED -> balance.setPickedQuantity(value);
}
}
private String hash(Object value) {
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(write(value).getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException(exception);
}
}
private String write(Object value) {
try {
return objectMapper.writeValueAsString(value);
} catch (JsonProcessingException exception) {
throw new IllegalStateException("Cannot serialize inventory operation", exception);
}
}
private static String defaultCode(InventoryBalance balance) {
String code = trim(balance.getOverseasWarehouseCode());
return code == null ? balance.getOverseasWarehouseId() : code;
}
private static void requireAtLeast(long actual, long requested, String field) {
if (actual < requested) throw conflict("Insufficient " + field + " packages or quantity");
}
private static String required(String value, String message) {
String normalized = trim(value);
if (normalized == null) throw badRequest(message);
return normalized;
}
private static String trim(String value) {
return value == null || value.isBlank() ? null : value.trim();
}
private static String remarkSuffix(String remark) {
String value = trim(remark);
return value == null ? "" : "; " + value;
}
private static long value(Long value) { return value == null ? 0L : value; }
private static ResponseStatusException conflict(String message) {
return new ResponseStatusException(HttpStatus.CONFLICT, message);
}
private static ResponseStatusException badRequest(String message) {
return new ResponseStatusException(HttpStatus.BAD_REQUEST, message);
}
private enum Bucket { AVAILABLE, HOLD, ALLOCATED, PICKED }
private record Locked(InventoryBalance balance, InventoryPosition position) {}
@FunctionalInterface private interface Work { InventoryCommandResponse run(InventoryOperation operation); }
}
@@ -0,0 +1,43 @@
package com.baiyue.lastleg.inventory.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
/** Real-time SKU/package balance for one tenant, warehouse, destination and SKU. */
@Data
@TableName("ll_inventory_balance")
public class InventoryBalance {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private String tenantId;
private String customerName;
private String overseasWarehouseId;
private String overseasWarehouseCode;
private String overseasWarehouseName;
private String destinationWarehouseId;
private String destinationWarehouseCode;
private String destinationWarehouseName;
private String skuCode;
private String skuNameZh;
private Long availablePackages;
private Long holdPackages;
private Long allocatedPackages;
private Long pickedPackages;
private Long inTransitPackages;
private Long receivingPackages;
private Long procurementPackages;
private Long availableQuantity;
private Long holdQuantity;
private Long allocatedQuantity;
private Long pickedQuantity;
private Long inTransitQuantity;
private Long receivingQuantity;
private Long procurementQuantity;
private Long version;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,46 @@
package com.baiyue.lastleg.inventory.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
/** Append-only inventory fact. Never update or delete this entity. */
@Data
@TableName("ll_inventory_ledger")
public class InventoryLedger {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private String tenantId;
private Long balanceId;
private Long positionId;
private String overseasWarehouseId;
private String destinationWarehouseCode;
private String skuCode;
private Long palletId;
private String palletNo;
private String batchNo;
private String warehouseLocationCode;
private String businessType;
private Long businessOrderId;
private String businessOrderNo;
private String inboundCode;
private String inboundOrderNo;
private String outboundOrderNo;
private String sourceEventId;
private String idempotencyKey;
private String statusFrom;
private String statusTo;
private Long beforePackages;
private Long deltaPackages;
private Long afterPackages;
private Long beforeQuantity;
private Long deltaQuantity;
private Long afterQuantity;
private String operatorId;
private String remark;
private LocalDateTime occurredAt;
private LocalDateTime createdAt;
}
@@ -0,0 +1,25 @@
package com.baiyue.lastleg.inventory.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
/** Command-level idempotency record for inventory mutations. */
@Data
@TableName("ll_inventory_operation")
public class InventoryOperation {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private String tenantId;
private String idempotencyKey;
private String operationType;
private String requestHash;
private String status;
private String resultJson;
private String lastError;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,36 @@
package com.baiyue.lastleg.inventory.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
/** Physical stock snapshot. A location is currently informational only. */
@Data
@TableName("ll_inventory_position")
public class InventoryPosition {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private String tenantId;
private Long balanceId;
private String overseasWarehouseId;
private String destinationWarehouseCode;
private String skuCode;
private Long palletId;
private String palletNo;
private String batchNo;
private Long warehouseLocationId;
private String warehouseLocationCode;
private Long availablePackages;
private Long holdPackages;
private Long allocatedPackages;
private Long pickedPackages;
private Long receivingPackages;
private Long productQuantity;
private String status;
private Long version;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,52 @@
package com.baiyue.lastleg.inventory.mapper;
import com.baiyue.lastleg.inventory.entity.InventoryBalance;
import com.baiyue.lastleg.inventory.model.InventorySummary;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
public interface InventoryBalanceMapper extends BaseMapper<InventoryBalance> {
@Select("SELECT * FROM ll_inventory_balance WHERE tenant_id=#{tenantId} " +
"AND overseas_warehouse_id=#{overseasWarehouseId} " +
"AND destination_warehouse_code=#{destinationWarehouseCode} AND sku_code=#{skuCode} LIMIT 1")
InventoryBalance findDimension(@Param("tenantId") String tenantId,
@Param("overseasWarehouseId") String overseasWarehouseId,
@Param("destinationWarehouseCode") String destinationWarehouseCode,
@Param("skuCode") String skuCode);
@Select("SELECT * FROM ll_inventory_balance WHERE tenant_id=#{tenantId} " +
"AND overseas_warehouse_id=#{overseasWarehouseId} " +
"AND destination_warehouse_code=#{destinationWarehouseCode} AND sku_code=#{skuCode} LIMIT 1 FOR UPDATE")
InventoryBalance lockDimension(@Param("tenantId") String tenantId,
@Param("overseasWarehouseId") String overseasWarehouseId,
@Param("destinationWarehouseCode") String destinationWarehouseCode,
@Param("skuCode") String skuCode);
@Select("<script>SELECT * FROM ll_inventory_balance WHERE tenant_id=#{tenantId}" +
"<if test='overseasWarehouseId != null and overseasWarehouseId != \"\"'> AND overseas_warehouse_id=#{overseasWarehouseId}</if>" +
"<if test='destinationWarehouseCode != null and destinationWarehouseCode != \"\"'> AND destination_warehouse_code=#{destinationWarehouseCode}</if>" +
"<if test='skuCode != null and skuCode != \"\"'> AND sku_code=#{skuCode}</if>" +
" ORDER BY updated_at DESC,id DESC LIMIT #{limit} OFFSET #{offset}</script>")
List<InventoryBalance> listTenant(@Param("tenantId") String tenantId,
@Param("overseasWarehouseId") String overseasWarehouseId,
@Param("destinationWarehouseCode") String destinationWarehouseCode,
@Param("skuCode") String skuCode,
@Param("offset") long offset,
@Param("limit") int limit);
@Select("<script>SELECT COUNT(*) balance_count," +
"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}" +
"<if test='overseasWarehouseId != null and overseasWarehouseId != \"\"'> AND overseas_warehouse_id=#{overseasWarehouseId}</if></script>")
InventorySummary summarize(@Param("tenantId") String tenantId,
@Param("overseasWarehouseId") String overseasWarehouseId);
}
@@ -0,0 +1,33 @@
package com.baiyue.lastleg.inventory.mapper;
import com.baiyue.lastleg.inventory.entity.InventoryLedger;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.time.LocalDateTime;
import java.util.List;
public interface InventoryLedgerMapper extends BaseMapper<InventoryLedger> {
@Select("SELECT * FROM ll_inventory_ledger WHERE tenant_id=#{tenantId} AND idempotency_key=#{idempotencyKey} LIMIT 1")
InventoryLedger findIdempotency(@Param("tenantId") String tenantId,
@Param("idempotencyKey") String idempotencyKey);
@Select("<script>SELECT * FROM ll_inventory_ledger WHERE tenant_id=#{tenantId}" +
"<if test='overseasWarehouseId != null and overseasWarehouseId != \"\"'> AND overseas_warehouse_id=#{overseasWarehouseId}</if>" +
"<if test='skuCode != null and skuCode != \"\"'> AND sku_code=#{skuCode}</if>" +
"<if test='businessType != null and businessType != \"\"'> AND business_type=#{businessType}</if>" +
"<if test='businessOrderNo != null and businessOrderNo != \"\"'> AND business_order_no=#{businessOrderNo}</if>" +
"<if test='startedAt != null'> AND occurred_at &gt;=#{startedAt}</if>" +
"<if test='endedAt != null'> AND occurred_at &lt;#{endedAt}</if>" +
" ORDER BY occurred_at DESC,id DESC LIMIT #{limit} OFFSET #{offset}</script>")
List<InventoryLedger> listTenant(@Param("tenantId") String tenantId,
@Param("overseasWarehouseId") String overseasWarehouseId,
@Param("skuCode") String skuCode,
@Param("businessType") String businessType,
@Param("businessOrderNo") String businessOrderNo,
@Param("startedAt") LocalDateTime startedAt,
@Param("endedAt") LocalDateTime endedAt,
@Param("offset") long offset,
@Param("limit") int limit);
}
@@ -0,0 +1,16 @@
package com.baiyue.lastleg.inventory.mapper;
import com.baiyue.lastleg.inventory.entity.InventoryOperation;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
public interface InventoryOperationMapper extends BaseMapper<InventoryOperation> {
@Select("SELECT * FROM ll_inventory_operation WHERE tenant_id=#{tenantId} AND idempotency_key=#{idempotencyKey} LIMIT 1")
InventoryOperation findIdempotency(@Param("tenantId") String tenantId,
@Param("idempotencyKey") String idempotencyKey);
@Select("SELECT * FROM ll_inventory_operation WHERE tenant_id=#{tenantId} AND idempotency_key=#{idempotencyKey} LIMIT 1 FOR UPDATE")
InventoryOperation lockIdempotency(@Param("tenantId") String tenantId,
@Param("idempotencyKey") String idempotencyKey);
}
@@ -0,0 +1,27 @@
package com.baiyue.lastleg.inventory.mapper;
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
public interface InventoryPositionMapper extends BaseMapper<InventoryPosition> {
@Select("SELECT p.* FROM ll_inventory_position p JOIN ll_inventory_balance b ON b.id=p.balance_id " +
"WHERE b.tenant_id=#{tenantId} AND p.balance_id=#{balanceId} ORDER BY p.pallet_no,p.batch_no")
List<InventoryPosition> listBalance(@Param("tenantId") String tenantId,
@Param("balanceId") Long balanceId);
@Select("SELECT * FROM ll_inventory_position WHERE tenant_id=#{tenantId} AND pallet_id=#{palletId} " +
"ORDER BY sku_code,batch_no")
List<InventoryPosition> listPallet(@Param("tenantId") String tenantId,
@Param("palletId") Long palletId);
@Select("SELECT * FROM ll_inventory_position WHERE tenant_id=#{tenantId} AND balance_id=#{balanceId} " +
"AND pallet_id=#{palletId} AND batch_no=#{batchNo} LIMIT 1 FOR UPDATE")
InventoryPosition lockPosition(@Param("tenantId") String tenantId,
@Param("balanceId") Long balanceId,
@Param("palletId") Long palletId,
@Param("batchNo") String batchNo);
}
@@ -0,0 +1,22 @@
package com.baiyue.lastleg.inventory.model;
import lombok.Data;
@Data
public class InventorySummary {
private Long balanceCount;
private Long availablePackages;
private Long holdPackages;
private Long allocatedPackages;
private Long pickedPackages;
private Long inTransitPackages;
private Long receivingPackages;
private Long procurementPackages;
private Long availableQuantity;
private Long holdQuantity;
private Long allocatedQuantity;
private Long pickedQuantity;
private Long inTransitQuantity;
private Long receivingQuantity;
private Long procurementQuantity;
}
@@ -0,0 +1,9 @@
package com.baiyue.lastleg.inventory.query;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.context.annotation.Configuration;
@Configuration
@MapperScan("com.baiyue.lastleg.inventory.query.mapper")
public class InventoryQueryMapperConfiguration {
}
@@ -0,0 +1,112 @@
package com.baiyue.lastleg.inventory.query.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.inventory.query.dto.InventoryBalanceView;
import com.baiyue.lastleg.inventory.query.dto.InventoryDashboardResponse;
import com.baiyue.lastleg.inventory.query.dto.InventoryLedgerView;
import com.baiyue.lastleg.inventory.query.dto.InventoryPositionView;
import com.baiyue.lastleg.inventory.query.dto.InventoryQueryRequests;
import com.baiyue.lastleg.inventory.query.service.InventoryQueryService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets;
import java.util.List;
@RestController
@Tag(name = "Inventory queries")
public class InventoryQueryController {
private static final String BASE_PATH = "/api/platform/v1/last-leg/inventory";
@Resource private InventoryQueryService service;
@GetMapping(BASE_PATH + "/summary")
@Operation(summary = "Get SKU/package inventory totals")
public ApiResponse<InventoryDashboardResponse> summary(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String tenantId,
@Valid InventoryQueryRequests.BalanceQuery query) {
return ApiResponse.success(service.dashboard(scope(user, tenantId), query));
}
@GetMapping(BASE_PATH + "/balances")
@Operation(summary = "Page inventory by customer, destination warehouse and SKU")
public ApiResponse<PageResult<InventoryBalanceView>> balances(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String tenantId,
@Valid InventoryQueryRequests.BalanceQuery query) {
return ApiResponse.success(service.balances(scope(user, tenantId), query));
}
@GetMapping(BASE_PATH + "/balances/{balanceId}/positions")
@Operation(summary = "List pallet and batch positions for one SKU balance")
public ApiResponse<List<InventoryPositionView>> positions(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String tenantId,
@PathVariable long balanceId,
@Valid InventoryQueryRequests.PositionQuery query) {
return ApiResponse.success(service.positions(scope(user, tenantId), balanceId, query));
}
@GetMapping(BASE_PATH + "/ledger")
@Operation(summary = "Page append-only inventory movements")
public ApiResponse<PageResult<InventoryLedgerView>> ledgers(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String tenantId,
@Valid InventoryQueryRequests.LedgerQuery query) {
return ApiResponse.success(service.ledgers(scope(user, tenantId), query));
}
@GetMapping(value = BASE_PATH + "/balances/export", produces = "text/csv")
@Operation(summary = "Export filtered SKU inventory as UTF-8 CSV")
public ResponseEntity<byte[]> exportBalances(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String tenantId,
@Valid InventoryQueryRequests.BalanceQuery query) {
return csv(service.exportBalances(scope(user, tenantId), query), "inventory-balances.csv");
}
@GetMapping(value = BASE_PATH + "/ledger/export", produces = "text/csv")
@Operation(summary = "Export filtered inventory movements as UTF-8 CSV")
public ResponseEntity<byte[]> exportLedgers(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String tenantId,
@Valid InventoryQueryRequests.LedgerQuery query) {
return csv(service.exportLedgers(scope(user, tenantId), query), "inventory-ledger.csv");
}
private static ResponseEntity<byte[]> csv(byte[] body, String filename) {
return ResponseEntity.ok()
.contentType(new MediaType("text", "csv", StandardCharsets.UTF_8))
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=" + filename)
.body(body);
}
private String scope(AuthenticatedUser user, String requestedTenant) {
if (user == null || user.tenantId() == null) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
}
if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) {
if (requestedTenant == null || requestedTenant.isBlank() || "0".equals(requestedTenant)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"Platform queries must specify a customer tenant");
}
return requestedTenant;
}
return String.valueOf(user.tenantId());
}
}
@@ -0,0 +1,35 @@
package com.baiyue.lastleg.inventory.query.dto;
import java.time.LocalDateTime;
public record InventoryBalanceView(
Long id,
String customerId,
String customerName,
String overseasWarehouseId,
String overseasWarehouseCode,
String overseasWarehouseName,
String destinationWarehouseId,
String destinationWarehouseCode,
String destinationWarehouseName,
String skuCode,
String skuName,
long totalPackages,
long availablePackages,
long holdPackages,
long allocatedPackages,
long pickedPackages,
long inTransitPackages,
long receivingPackages,
long procurementPackages,
long totalQuantity,
long availableQuantity,
long holdQuantity,
long allocatedQuantity,
long pickedQuantity,
long inTransitQuantity,
long receivingQuantity,
long procurementQuantity,
LocalDateTime updatedAt
) {
}
@@ -0,0 +1,23 @@
package com.baiyue.lastleg.inventory.query.dto;
public record InventoryDashboardResponse(
long skuDimensionCount,
long palletCount,
long totalPackages,
long availablePackages,
long holdPackages,
long allocatedPackages,
long pickedPackages,
long inTransitPackages,
long receivingPackages,
long procurementPackages,
long totalQuantity,
long availableQuantity,
long holdQuantity,
long allocatedQuantity,
long pickedQuantity,
long inTransitQuantity,
long receivingQuantity,
long procurementQuantity
) {
}
@@ -0,0 +1,30 @@
package com.baiyue.lastleg.inventory.query.dto;
import java.time.LocalDateTime;
public record InventoryLedgerView(
Long id,
String customerId,
String overseasWarehouseId,
String destinationWarehouseCode,
String skuCode,
String palletNo,
String batchNo,
String warehouseLocationCode,
String businessType,
String businessOrderNo,
String inboundOrderNo,
String outboundOrderNo,
String statusFrom,
String statusTo,
long beforePackages,
long deltaPackages,
long afterPackages,
long beforeQuantity,
long deltaQuantity,
long afterQuantity,
String operatorId,
String remark,
LocalDateTime occurredAt
) {
}
@@ -0,0 +1,27 @@
package com.baiyue.lastleg.inventory.query.dto;
import java.time.LocalDateTime;
public record InventoryPositionView(
Long id,
Long balanceId,
String customerId,
String overseasWarehouseId,
String destinationWarehouseCode,
String skuCode,
Long palletId,
String palletNo,
String batchNo,
Long warehouseLocationId,
String warehouseLocationCode,
long totalPackages,
long availablePackages,
long holdPackages,
long allocatedPackages,
long pickedPackages,
long receivingPackages,
long productQuantity,
String status,
LocalDateTime updatedAt
) {
}
@@ -0,0 +1,52 @@
package com.baiyue.lastleg.inventory.query.dto;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import java.time.LocalDateTime;
public final class InventoryQueryRequests {
private InventoryQueryRequests() {
}
public record BalanceQuery(
@Min(1) long page,
@Min(1) @Max(200) long size,
String customerName,
String overseasWarehouseId,
String overseasWarehouseCode,
String destinationWarehouseCode,
String skuCode,
String skuName,
String inventoryStatus
) {
public BalanceQuery {
if (page == 0) page = 1;
if (size == 0) size = 20;
}
}
public record PositionQuery(String palletNo, String batchNo, String inventoryStatus) {
}
public record LedgerQuery(
@Min(1) long page,
@Min(1) @Max(200) long size,
String overseasWarehouseId,
String destinationWarehouseCode,
String skuCode,
String businessType,
String businessOrderNo,
String inboundOrderNo,
String outboundOrderNo,
String palletNo,
String batchNo,
LocalDateTime occurredFrom,
LocalDateTime occurredTo
) {
public LedgerQuery {
if (page == 0) page = 1;
if (size == 0) size = 20;
}
}
}
@@ -0,0 +1,124 @@
package com.baiyue.lastleg.inventory.query.mapper;
import com.baiyue.lastleg.inventory.entity.InventoryBalance;
import com.baiyue.lastleg.inventory.entity.InventoryLedger;
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
import com.baiyue.lastleg.inventory.query.dto.InventoryQueryRequests;
import com.baiyue.lastleg.inventory.query.model.InventoryAggregateProjection;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
public interface InventoryQueryMapper {
String BALANCE_FILTER = " WHERE tenant_id=#{tenantId}" +
"<if test='q.customerName != null and q.customerName != \"\"'> AND customer_name LIKE CONCAT('%',#{q.customerName},'%')</if>" +
"<if test='q.overseasWarehouseId != null and q.overseasWarehouseId != \"\"'> AND overseas_warehouse_id=#{q.overseasWarehouseId}</if>" +
"<if test='q.overseasWarehouseCode != null and q.overseasWarehouseCode != \"\"'> AND overseas_warehouse_code=#{q.overseasWarehouseCode}</if>" +
"<if test='q.destinationWarehouseCode != null and q.destinationWarehouseCode != \"\"'> AND destination_warehouse_code=#{q.destinationWarehouseCode}</if>" +
"<if test='q.skuCode != null and q.skuCode != \"\"'> AND sku_code LIKE CONCAT('%',#{q.skuCode},'%')</if>" +
"<if test='q.skuName != null and q.skuName != \"\"'> AND sku_name_zh LIKE CONCAT('%',#{q.skuName},'%')</if>" +
"<choose>" +
"<when test='q.inventoryStatus == \"AVAILABLE\"'> AND available_packages &gt; 0</when>" +
"<when test='q.inventoryStatus == \"HOLD\"'> AND hold_packages &gt; 0</when>" +
"<when test='q.inventoryStatus == \"ALLOCATED\"'> AND allocated_packages &gt; 0</when>" +
"<when test='q.inventoryStatus == \"PICKED\"'> AND picked_packages &gt; 0</when>" +
"<when test='q.inventoryStatus == \"IN_TRANSIT\"'> AND in_transit_packages &gt; 0</when>" +
"<when test='q.inventoryStatus == \"RECEIVING\"'> AND receiving_packages &gt; 0</when>" +
"<when test='q.inventoryStatus == \"PROCUREMENT\"'> AND procurement_packages &gt; 0</when>" +
"</choose>";
String JOINED_BALANCE_FILTER = " WHERE b.tenant_id=#{tenantId}" +
"<if test='q.customerName != null and q.customerName != \"\"'> AND b.customer_name LIKE CONCAT('%',#{q.customerName},'%')</if>" +
"<if test='q.overseasWarehouseId != null and q.overseasWarehouseId != \"\"'> AND b.overseas_warehouse_id=#{q.overseasWarehouseId}</if>" +
"<if test='q.overseasWarehouseCode != null and q.overseasWarehouseCode != \"\"'> AND b.overseas_warehouse_code=#{q.overseasWarehouseCode}</if>" +
"<if test='q.destinationWarehouseCode != null and q.destinationWarehouseCode != \"\"'> AND b.destination_warehouse_code=#{q.destinationWarehouseCode}</if>" +
"<if test='q.skuCode != null and q.skuCode != \"\"'> AND b.sku_code LIKE CONCAT('%',#{q.skuCode},'%')</if>" +
"<if test='q.skuName != null and q.skuName != \"\"'> AND b.sku_name_zh LIKE CONCAT('%',#{q.skuName},'%')</if>" +
"<choose>" +
"<when test='q.inventoryStatus == \"AVAILABLE\"'> AND b.available_packages &gt; 0</when>" +
"<when test='q.inventoryStatus == \"HOLD\"'> AND b.hold_packages &gt; 0</when>" +
"<when test='q.inventoryStatus == \"ALLOCATED\"'> AND b.allocated_packages &gt; 0</when>" +
"<when test='q.inventoryStatus == \"PICKED\"'> AND b.picked_packages &gt; 0</when>" +
"<when test='q.inventoryStatus == \"IN_TRANSIT\"'> AND b.in_transit_packages &gt; 0</when>" +
"<when test='q.inventoryStatus == \"RECEIVING\"'> AND b.receiving_packages &gt; 0</when>" +
"<when test='q.inventoryStatus == \"PROCUREMENT\"'> AND b.procurement_packages &gt; 0</when>" +
"</choose>";
@Select("<script>SELECT * FROM ll_inventory_balance" + BALANCE_FILTER +
" ORDER BY updated_at DESC,id DESC LIMIT #{limit} OFFSET #{offset}</script>")
List<InventoryBalance> balances(@Param("tenantId") String tenantId,
@Param("q") InventoryQueryRequests.BalanceQuery query,
@Param("offset") long offset,
@Param("limit") long limit);
@Select("<script>SELECT COUNT(*) FROM ll_inventory_balance" + BALANCE_FILTER + "</script>")
long countBalances(@Param("tenantId") String tenantId,
@Param("q") InventoryQueryRequests.BalanceQuery query);
@Select("<script>SELECT COUNT(*) dimension_count," +
"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" + BALANCE_FILTER + "</script>")
InventoryAggregateProjection aggregate(@Param("tenantId") String tenantId,
@Param("q") InventoryQueryRequests.BalanceQuery query);
@Select("<script>SELECT COUNT(DISTINCT p.pallet_id) FROM ll_inventory_position p " +
"JOIN ll_inventory_balance b ON b.id=p.balance_id" +
JOINED_BALANCE_FILTER + "</script>")
long countPallets(@Param("tenantId") String tenantId,
@Param("q") InventoryQueryRequests.BalanceQuery query);
@Select("<script>SELECT * FROM ll_inventory_position WHERE tenant_id=#{tenantId} AND balance_id=#{balanceId}" +
"<if test='q.palletNo != null and q.palletNo != \"\"'> AND pallet_no LIKE CONCAT('%',#{q.palletNo},'%')</if>" +
"<if test='q.batchNo != null and q.batchNo != \"\"'> AND batch_no LIKE CONCAT('%',#{q.batchNo},'%')</if>" +
"<choose>" +
"<when test='q.inventoryStatus == \"AVAILABLE\"'> AND available_packages &gt; 0</when>" +
"<when test='q.inventoryStatus == \"HOLD\"'> AND hold_packages &gt; 0</when>" +
"<when test='q.inventoryStatus == \"ALLOCATED\"'> AND allocated_packages &gt; 0</when>" +
"<when test='q.inventoryStatus == \"PICKED\"'> AND picked_packages &gt; 0</when>" +
"<when test='q.inventoryStatus == \"RECEIVING\"'> AND receiving_packages &gt; 0</when>" +
"</choose> ORDER BY pallet_no,batch_no,id</script>")
List<InventoryPosition> positions(@Param("tenantId") String tenantId,
@Param("balanceId") long balanceId,
@Param("q") InventoryQueryRequests.PositionQuery query);
@Select("SELECT COUNT(*) FROM ll_inventory_balance WHERE tenant_id=#{tenantId} AND id=#{balanceId}")
long countOwnedBalance(@Param("tenantId") String tenantId, @Param("balanceId") long balanceId);
String LEDGER_FILTER = " WHERE tenant_id=#{tenantId}" +
"<if test='q.overseasWarehouseId != null and q.overseasWarehouseId != \"\"'> AND overseas_warehouse_id=#{q.overseasWarehouseId}</if>" +
"<if test='q.destinationWarehouseCode != null and q.destinationWarehouseCode != \"\"'> AND destination_warehouse_code=#{q.destinationWarehouseCode}</if>" +
"<if test='q.skuCode != null and q.skuCode != \"\"'> AND sku_code LIKE CONCAT('%',#{q.skuCode},'%')</if>" +
"<if test='q.businessType != null and q.businessType != \"\"'> AND business_type=#{q.businessType}</if>" +
"<if test='q.businessOrderNo != null and q.businessOrderNo != \"\"'> AND business_order_no LIKE CONCAT('%',#{q.businessOrderNo},'%')</if>" +
"<if test='q.inboundOrderNo != null and q.inboundOrderNo != \"\"'> AND inbound_order_no LIKE CONCAT('%',#{q.inboundOrderNo},'%')</if>" +
"<if test='q.outboundOrderNo != null and q.outboundOrderNo != \"\"'> AND outbound_order_no LIKE CONCAT('%',#{q.outboundOrderNo},'%')</if>" +
"<if test='q.palletNo != null and q.palletNo != \"\"'> AND pallet_no LIKE CONCAT('%',#{q.palletNo},'%')</if>" +
"<if test='q.batchNo != null and q.batchNo != \"\"'> AND batch_no LIKE CONCAT('%',#{q.batchNo},'%')</if>" +
"<if test='q.occurredFrom != null'> AND occurred_at &gt;=#{q.occurredFrom}</if>" +
"<if test='q.occurredTo != null'> AND occurred_at &lt;=#{q.occurredTo}</if>";
@Select("<script>SELECT * FROM ll_inventory_ledger" + LEDGER_FILTER +
" ORDER BY occurred_at DESC,id DESC LIMIT #{limit} OFFSET #{offset}</script>")
List<InventoryLedger> ledgers(@Param("tenantId") String tenantId,
@Param("q") InventoryQueryRequests.LedgerQuery query,
@Param("offset") long offset,
@Param("limit") long limit);
@Select("<script>SELECT COUNT(*) FROM ll_inventory_ledger" + LEDGER_FILTER + "</script>")
long countLedgers(@Param("tenantId") String tenantId,
@Param("q") InventoryQueryRequests.LedgerQuery query);
}
@@ -0,0 +1,22 @@
package com.baiyue.lastleg.inventory.query.model;
import lombok.Data;
@Data
public class InventoryAggregateProjection {
private Long dimensionCount;
private Long availablePackages;
private Long holdPackages;
private Long allocatedPackages;
private Long pickedPackages;
private Long inTransitPackages;
private Long receivingPackages;
private Long procurementPackages;
private Long availableQuantity;
private Long holdQuantity;
private Long allocatedQuantity;
private Long pickedQuantity;
private Long inTransitQuantity;
private Long receivingQuantity;
private Long procurementQuantity;
}
@@ -0,0 +1,202 @@
package com.baiyue.lastleg.inventory.query.service;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.lastleg.inventory.entity.InventoryBalance;
import com.baiyue.lastleg.inventory.entity.InventoryLedger;
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
import com.baiyue.lastleg.inventory.query.dto.InventoryBalanceView;
import com.baiyue.lastleg.inventory.query.dto.InventoryDashboardResponse;
import com.baiyue.lastleg.inventory.query.dto.InventoryLedgerView;
import com.baiyue.lastleg.inventory.query.dto.InventoryPositionView;
import com.baiyue.lastleg.inventory.query.dto.InventoryQueryRequests;
import com.baiyue.lastleg.inventory.query.mapper.InventoryQueryMapper;
import com.baiyue.lastleg.inventory.query.model.InventoryAggregateProjection;
import jakarta.annotation.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
@Service
public class InventoryQueryService {
private static final int EXPORT_LIMIT = 10_000;
private static final Set<String> BALANCE_STATUSES = Set.of(
"AVAILABLE", "HOLD", "ALLOCATED", "PICKED", "IN_TRANSIT", "RECEIVING", "PROCUREMENT");
private static final Set<String> POSITION_STATUSES = Set.of(
"AVAILABLE", "HOLD", "ALLOCATED", "PICKED", "RECEIVING");
@Resource private InventoryQueryMapper mapper;
public InventoryDashboardResponse dashboard(String tenantId, InventoryQueryRequests.BalanceQuery query) {
validateStatus(query.inventoryStatus(), BALANCE_STATUSES);
InventoryAggregateProjection value = mapper.aggregate(tenantId, query);
long availablePackages = number(value.getAvailablePackages());
long holdPackages = number(value.getHoldPackages());
long allocatedPackages = number(value.getAllocatedPackages());
long pickedPackages = number(value.getPickedPackages());
long inTransitPackages = number(value.getInTransitPackages());
long receivingPackages = number(value.getReceivingPackages());
long procurementPackages = number(value.getProcurementPackages());
long availableQuantity = number(value.getAvailableQuantity());
long holdQuantity = number(value.getHoldQuantity());
long allocatedQuantity = number(value.getAllocatedQuantity());
long pickedQuantity = number(value.getPickedQuantity());
long inTransitQuantity = number(value.getInTransitQuantity());
long receivingQuantity = number(value.getReceivingQuantity());
long procurementQuantity = number(value.getProcurementQuantity());
return new InventoryDashboardResponse(number(value.getDimensionCount()), mapper.countPallets(tenantId, query),
availablePackages + holdPackages + allocatedPackages + pickedPackages + inTransitPackages
+ receivingPackages + procurementPackages,
availablePackages, holdPackages, allocatedPackages, pickedPackages, inTransitPackages,
receivingPackages, procurementPackages,
availableQuantity + holdQuantity + allocatedQuantity + pickedQuantity + inTransitQuantity
+ receivingQuantity + procurementQuantity,
availableQuantity, holdQuantity, allocatedQuantity, pickedQuantity, inTransitQuantity,
receivingQuantity, procurementQuantity);
}
public PageResult<InventoryBalanceView> balances(String tenantId, InventoryQueryRequests.BalanceQuery query) {
validateStatus(query.inventoryStatus(), BALANCE_STATUSES);
long total = mapper.countBalances(tenantId, query);
List<InventoryBalanceView> records = total == 0 ? List.of() : mapper.balances(
tenantId, query, offset(query.page(), query.size()), query.size()).stream().map(this::balanceView).toList();
return page(records, query.page(), query.size(), total);
}
public List<InventoryPositionView> positions(String tenantId, long balanceId,
InventoryQueryRequests.PositionQuery query) {
validateStatus(query.inventoryStatus(), POSITION_STATUSES);
if (mapper.countOwnedBalance(tenantId, balanceId) == 0) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Inventory balance not found");
}
return mapper.positions(tenantId, balanceId, query).stream().map(this::positionView).toList();
}
public PageResult<InventoryLedgerView> ledgers(String tenantId, InventoryQueryRequests.LedgerQuery query) {
validateDateRange(query);
long total = mapper.countLedgers(tenantId, query);
List<InventoryLedgerView> records = total == 0 ? List.of() : mapper.ledgers(
tenantId, query, offset(query.page(), query.size()), query.size()).stream().map(this::ledgerView).toList();
return page(records, query.page(), query.size(), total);
}
public byte[] exportBalances(String tenantId, InventoryQueryRequests.BalanceQuery query) {
validateStatus(query.inventoryStatus(), BALANCE_STATUSES);
List<String> rows = new ArrayList<>();
rows.add("customerId,customerName,overseasWarehouseCode,overseasWarehouseName,destinationWarehouseCode," +
"destinationWarehouseName,skuCode,skuName,totalPackages,availablePackages,holdPackages," +
"allocatedPackages,pickedPackages,inTransitPackages,receivingPackages,procurementPackages,totalQuantity");
mapper.balances(tenantId, query, 0, EXPORT_LIMIT).stream().map(this::balanceView).forEach(v -> rows.add(
String.join(",", csv(v.customerId()), csv(v.customerName()), csv(v.overseasWarehouseCode()), csv(v.overseasWarehouseName()),
csv(v.destinationWarehouseCode()), csv(v.destinationWarehouseName()), csv(v.skuCode()), csv(v.skuName()),
csv(v.totalPackages()), csv(v.availablePackages()), csv(v.holdPackages()), csv(v.allocatedPackages()),
csv(v.pickedPackages()), csv(v.inTransitPackages()), csv(v.receivingPackages()),
csv(v.procurementPackages()), csv(v.totalQuantity()))));
return ("\uFEFF" + String.join("\r\n", rows)).getBytes(StandardCharsets.UTF_8);
}
public byte[] exportLedgers(String tenantId, InventoryQueryRequests.LedgerQuery query) {
validateDateRange(query);
List<String> rows = new ArrayList<>();
rows.add("occurredAt,customerId,overseasWarehouseId,destinationWarehouseCode,skuCode,businessType," +
"businessOrderNo,inboundOrderNo,outboundOrderNo,palletNo,batchNo,statusFrom,statusTo," +
"beforePackages,deltaPackages,afterPackages,beforeQuantity,deltaQuantity,afterQuantity,operatorId,remark");
mapper.ledgers(tenantId, query, 0, EXPORT_LIMIT).stream().map(this::ledgerView).forEach(v -> rows.add(
String.join(",", csv(v.occurredAt()), csv(v.customerId()), csv(v.overseasWarehouseId()),
csv(v.destinationWarehouseCode()), csv(v.skuCode()), csv(v.businessType()), csv(v.businessOrderNo()),
csv(v.inboundOrderNo()), csv(v.outboundOrderNo()), csv(v.palletNo()), csv(v.batchNo()),
csv(v.statusFrom()), csv(v.statusTo()), csv(v.beforePackages()), csv(v.deltaPackages()),
csv(v.afterPackages()), csv(v.beforeQuantity()), csv(v.deltaQuantity()), csv(v.afterQuantity()),
csv(v.operatorId()), csv(v.remark()))));
return ("\uFEFF" + String.join("\r\n", rows)).getBytes(StandardCharsets.UTF_8);
}
private InventoryBalanceView balanceView(InventoryBalance value) {
long availablePackages = number(value.getAvailablePackages());
long holdPackages = number(value.getHoldPackages());
long allocatedPackages = number(value.getAllocatedPackages());
long pickedPackages = number(value.getPickedPackages());
long inTransitPackages = number(value.getInTransitPackages());
long receivingPackages = number(value.getReceivingPackages());
long procurementPackages = number(value.getProcurementPackages());
long availableQuantity = number(value.getAvailableQuantity());
long holdQuantity = number(value.getHoldQuantity());
long allocatedQuantity = number(value.getAllocatedQuantity());
long pickedQuantity = number(value.getPickedQuantity());
long inTransitQuantity = number(value.getInTransitQuantity());
long receivingQuantity = number(value.getReceivingQuantity());
long procurementQuantity = number(value.getProcurementQuantity());
return new InventoryBalanceView(value.getId(), value.getTenantId(), value.getCustomerName(), value.getOverseasWarehouseId(),
value.getOverseasWarehouseCode(), value.getOverseasWarehouseName(), value.getDestinationWarehouseId(),
value.getDestinationWarehouseCode(), value.getDestinationWarehouseName(), value.getSkuCode(),
value.getSkuNameZh(), availablePackages + holdPackages + allocatedPackages + pickedPackages
+ inTransitPackages + receivingPackages + procurementPackages,
availablePackages, holdPackages, allocatedPackages, pickedPackages, inTransitPackages,
receivingPackages, procurementPackages,
availableQuantity + holdQuantity + allocatedQuantity + pickedQuantity + inTransitQuantity
+ receivingQuantity + procurementQuantity,
availableQuantity, holdQuantity, allocatedQuantity, pickedQuantity, inTransitQuantity,
receivingQuantity, procurementQuantity, value.getUpdatedAt());
}
private InventoryPositionView positionView(InventoryPosition value) {
long available = number(value.getAvailablePackages());
long hold = number(value.getHoldPackages());
long allocated = number(value.getAllocatedPackages());
long picked = number(value.getPickedPackages());
long receiving = number(value.getReceivingPackages());
return new InventoryPositionView(value.getId(), value.getBalanceId(), value.getTenantId(),
value.getOverseasWarehouseId(), value.getDestinationWarehouseCode(), value.getSkuCode(),
value.getPalletId(), value.getPalletNo(), value.getBatchNo(), value.getWarehouseLocationId(),
value.getWarehouseLocationCode(), available + hold + allocated + picked + receiving,
available, hold, allocated, picked, receiving, number(value.getProductQuantity()), value.getStatus(),
value.getUpdatedAt());
}
private InventoryLedgerView ledgerView(InventoryLedger value) {
return new InventoryLedgerView(value.getId(), value.getTenantId(), value.getOverseasWarehouseId(),
value.getDestinationWarehouseCode(), value.getSkuCode(), value.getPalletNo(), value.getBatchNo(),
value.getWarehouseLocationCode(), value.getBusinessType(), value.getBusinessOrderNo(),
value.getInboundOrderNo(), value.getOutboundOrderNo(), value.getStatusFrom(), value.getStatusTo(),
number(value.getBeforePackages()), number(value.getDeltaPackages()), number(value.getAfterPackages()),
number(value.getBeforeQuantity()), number(value.getDeltaQuantity()), number(value.getAfterQuantity()),
value.getOperatorId(), value.getRemark(), value.getOccurredAt());
}
private static void validateStatus(String status, Set<String> accepted) {
if (status != null && !status.isBlank() && !accepted.contains(status)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Unsupported inventory status: " + status);
}
}
private static void validateDateRange(InventoryQueryRequests.LedgerQuery query) {
if (query.occurredFrom() != null && query.occurredTo() != null
&& query.occurredFrom().isAfter(query.occurredTo())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "occurredFrom must not be after occurredTo");
}
}
private static long offset(long page, long size) {
return Math.multiplyExact(page - 1, size);
}
private static long number(Long value) {
return value == null ? 0 : value;
}
private static <T> PageResult<T> page(List<T> records, long page, long size, long total) {
long pages = total == 0 ? 0 : (total + size - 1) / size;
return new PageResult<>(records, page, size, total, pages);
}
private static String csv(Object value) {
if (value == null) return "";
String text = String.valueOf(value);
if (!text.isEmpty() && "=+-@".indexOf(text.charAt(0)) >= 0) text = "'" + text;
return '"' + text.replace("\"", "\"\"") + '"';
}
}
+233
View File
@@ -495,6 +495,44 @@ CREATE TABLE IF NOT EXISTS ll_inbound_pallet_merge (
KEY idx_ll_inbound_merge_task (tenant_id, task_id), KEY idx_ll_inbound_merge_group (merge_group_no)
) ENGINE=InnoDB;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='fba_warehouse_code'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_line ADD COLUMN fba_warehouse_code VARCHAR(128) NULL AFTER inbound_code'));
PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt;
UPDATE ll_inbound_plan_line l JOIN ll_order o ON o.id=l.order_id
SET l.fba_warehouse_code=o.fba_warehouse_code
WHERE l.fba_warehouse_code IS NULL AND o.fba_warehouse_code IS NOT NULL;
CREATE TABLE IF NOT EXISTS ll_inbound_fba_appointment (
id BIGINT NOT NULL COMMENT 'Snowflake ID',
tenant_id VARCHAR(128) NOT NULL,
appointment_no VARCHAR(64) NOT NULL,
appointment_at DATETIME(3) NOT NULL,
appointment_status VARCHAR(32) NOT NULL DEFAULT 'PENDING',
appointment_remark VARCHAR(1000) NULL,
fba_warehouse_code VARCHAR(128) NOT NULL,
selected_count INT NOT NULL DEFAULT 0,
created_by VARCHAR(128) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_ll_fba_appointment_no (tenant_id, appointment_no),
KEY idx_ll_fba_appointment_list (tenant_id, appointment_status, fba_warehouse_code, created_at)
) ENGINE=InnoDB COMMENT='Batch FBA appointment';
CREATE TABLE IF NOT EXISTS ll_inbound_fba_appointment_item (
id BIGINT NOT NULL COMMENT 'Snowflake ID',
appointment_id BIGINT NOT NULL,
plan_id BIGINT NOT NULL,
line_id BIGINT NOT NULL,
order_id BIGINT NOT NULL,
inbound_code VARCHAR(128) NOT NULL,
fba_warehouse_code VARCHAR(128) NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_ll_fba_appointment_item_line (appointment_id, line_id),
KEY idx_ll_fba_appointment_item_plan (plan_id),
KEY idx_ll_fba_appointment_item_order (order_id)
) ENGINE=InnoDB COMMENT='FBA appointment line snapshot';
ALTER TABLE ll_loading_order_status_history
MODIFY COLUMN id BIGINT NOT NULL AUTO_INCREMENT COMMENT '状态历史主键',
MODIFY COLUMN order_id BIGINT NOT NULL COMMENT '尾程订单 ID',
@@ -503,3 +541,198 @@ ALTER TABLE ll_loading_order_status_history
MODIFY COLUMN event_id VARCHAR(128) NULL COMMENT '触发事件 ID',
MODIFY COLUMN remark VARCHAR(500) NULL COMMENT '状态变更备注',
MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录时间';
CREATE TABLE IF NOT EXISTS ll_inventory_balance (
id BIGINT NOT NULL COMMENT 'Snowflake ID',
tenant_id VARCHAR(128) NOT NULL COMMENT 'Customer tenant', customer_name VARCHAR(255) NULL COMMENT 'Customer name snapshot',
overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT 'Actual overseas warehouse ID',
overseas_warehouse_code VARCHAR(128) NULL COMMENT 'Actual overseas warehouse code snapshot',
overseas_warehouse_name VARCHAR(255) NULL COMMENT 'Actual overseas warehouse name snapshot',
destination_warehouse_id VARCHAR(128) NULL COMMENT 'Customer destination warehouse ID snapshot',
destination_warehouse_code VARCHAR(128) NOT NULL DEFAULT '' COMMENT 'Customer destination warehouse code snapshot',
destination_warehouse_name VARCHAR(255) NULL COMMENT 'Customer destination warehouse name snapshot',
sku_code VARCHAR(128) NOT NULL COMMENT 'SKU code', sku_name_zh VARCHAR(255) NULL COMMENT 'Chinese product name',
available_packages BIGINT NOT NULL DEFAULT 0, hold_packages BIGINT NOT NULL DEFAULT 0,
allocated_packages BIGINT NOT NULL DEFAULT 0, picked_packages BIGINT NOT NULL DEFAULT 0,
in_transit_packages BIGINT NOT NULL DEFAULT 0, receiving_packages BIGINT NOT NULL DEFAULT 0,
procurement_packages BIGINT NOT NULL DEFAULT 0,
available_quantity BIGINT NOT NULL DEFAULT 0, hold_quantity BIGINT NOT NULL DEFAULT 0,
allocated_quantity BIGINT NOT NULL DEFAULT 0, picked_quantity BIGINT NOT NULL DEFAULT 0,
in_transit_quantity BIGINT NOT NULL DEFAULT 0, receiving_quantity BIGINT NOT NULL DEFAULT 0,
procurement_quantity BIGINT NOT NULL DEFAULT 0,
version BIGINT NOT NULL DEFAULT 0 COMMENT 'Optimistic lock version',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_ll_inventory_dimension (tenant_id,overseas_warehouse_id,destination_warehouse_code,sku_code),
KEY idx_ll_inventory_list (tenant_id,overseas_warehouse_id,updated_at),
KEY idx_ll_inventory_sku (tenant_id,sku_code)
) ENGINE=InnoDB COMMENT='Real-time SKU package inventory balance';
CREATE TABLE IF NOT EXISTS ll_inventory_position (
id BIGINT NOT NULL COMMENT 'Snowflake ID', tenant_id VARCHAR(128) NOT NULL, balance_id BIGINT NOT NULL,
overseas_warehouse_id VARCHAR(128) NOT NULL, destination_warehouse_code VARCHAR(128) NOT NULL DEFAULT '',
sku_code VARCHAR(128) NOT NULL, pallet_id BIGINT NOT NULL, pallet_no VARCHAR(64) NOT NULL,
batch_no VARCHAR(128) NOT NULL DEFAULT '', warehouse_location_id BIGINT NULL,
warehouse_location_code VARCHAR(128) NOT NULL COMMENT 'Informational default location code',
available_packages BIGINT NOT NULL DEFAULT 0, hold_packages BIGINT NOT NULL DEFAULT 0,
allocated_packages BIGINT NOT NULL DEFAULT 0, picked_packages BIGINT NOT NULL DEFAULT 0,
receiving_packages BIGINT NOT NULL DEFAULT 0, product_quantity BIGINT NOT NULL DEFAULT 0,
status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE', version BIGINT NOT NULL DEFAULT 0,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id), UNIQUE KEY uk_ll_inventory_position (balance_id,pallet_id,batch_no),
KEY idx_ll_inventory_position_pallet (tenant_id,pallet_id),
KEY idx_ll_inventory_position_location (tenant_id,overseas_warehouse_id,warehouse_location_code)
) ENGINE=InnoDB COMMENT='SKU package stock by pallet, default location and batch';
CREATE TABLE IF NOT EXISTS ll_inventory_ledger (
id BIGINT NOT NULL COMMENT 'Snowflake ID', tenant_id VARCHAR(128) NOT NULL,
balance_id BIGINT NOT NULL, position_id BIGINT NULL, overseas_warehouse_id VARCHAR(128) NOT NULL,
destination_warehouse_code VARCHAR(128) NOT NULL DEFAULT '', sku_code VARCHAR(128) NOT NULL,
pallet_id BIGINT NULL, pallet_no VARCHAR(64) NULL, batch_no VARCHAR(128) NULL,
warehouse_location_code VARCHAR(128) NULL, business_type VARCHAR(32) NOT NULL,
business_order_id BIGINT NULL, business_order_no VARCHAR(128) NULL, inbound_code VARCHAR(128) NULL,
inbound_order_no VARCHAR(128) NULL, outbound_order_no VARCHAR(128) NULL,
source_event_id VARCHAR(128) NULL, idempotency_key VARCHAR(255) NOT NULL,
status_from VARCHAR(32) NULL, status_to VARCHAR(32) NULL,
before_packages BIGINT NOT NULL, delta_packages BIGINT NOT NULL, after_packages BIGINT NOT NULL,
before_quantity BIGINT NOT NULL, delta_quantity BIGINT NOT NULL, after_quantity BIGINT NOT NULL,
operator_id VARCHAR(128) NULL, remark VARCHAR(1000) NULL,
occurred_at DATETIME(3) NOT NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id), UNIQUE KEY uk_ll_inventory_ledger_idempotency (tenant_id,idempotency_key),
KEY idx_ll_inventory_ledger_query (tenant_id,overseas_warehouse_id,occurred_at),
KEY idx_ll_inventory_ledger_sku (tenant_id,sku_code,occurred_at),
KEY idx_ll_inventory_ledger_business (tenant_id,business_type,business_order_no)
) ENGINE=InnoDB COMMENT='Immutable inventory package and quantity ledger';
CREATE TABLE IF NOT EXISTS ll_inventory_operation (
id BIGINT NOT NULL COMMENT 'Snowflake ID', tenant_id VARCHAR(128) NOT NULL,
idempotency_key VARCHAR(255) NOT NULL, operation_type VARCHAR(32) NOT NULL,
request_hash VARCHAR(128) NOT NULL, status VARCHAR(32) NOT NULL DEFAULT 'PROCESSING',
result_json LONGTEXT NULL, last_error VARCHAR(2000) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id), UNIQUE KEY uk_ll_inventory_operation_key (tenant_id,idempotency_key),
KEY idx_ll_inventory_operation_status (tenant_id,status,updated_at)
) ENGINE=InnoDB COMMENT='Inventory command idempotency record';
CREATE TABLE IF NOT EXISTS ll_inbound_stock_receipt (
id BIGINT NOT NULL COMMENT 'Snowflake ID', tenant_id VARCHAR(128) NOT NULL,
plan_id BIGINT NOT NULL, task_id BIGINT NOT NULL, allocation_id BIGINT NOT NULL,
line_id BIGINT NOT NULL, order_id BIGINT NOT NULL, inbound_code VARCHAR(128) NOT NULL,
sku_code VARCHAR(128) NOT NULL, sku_name_zh VARCHAR(255) NULL, source_detail_ids VARCHAR(2000) NULL,
warehouse_pallet_id BIGINT NOT NULL, pallet_no VARCHAR(64) NOT NULL,
warehouse_location_id BIGINT NULL, warehouse_location_code VARCHAR(128) NULL,
batch_no VARCHAR(128) NOT NULL, received_packages BIGINT NOT NULL, received_quantity BIGINT NOT NULL,
inventory_balance_id BIGINT NOT NULL, inventory_position_id BIGINT NOT NULL,
inventory_operation_key VARCHAR(255) NOT NULL, received_at DATETIME(3) NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id), UNIQUE KEY uk_ll_stock_receipt_task_allocation_sku (tenant_id,task_id,allocation_id,sku_code),
KEY idx_ll_stock_receipt_plan (tenant_id,plan_id,line_id), KEY idx_ll_stock_receipt_inventory (inventory_balance_id,inventory_position_id)
) ENGINE=InnoDB COMMENT='Immutable SKU-pallet PDA stock-in receipt snapshot';
CREATE TABLE IF NOT EXISTS ll_dispatch_order (
id BIGINT NOT NULL COMMENT 'Snowflake ID',
tenant_id VARCHAR(128) NOT NULL,
dispatch_no VARCHAR(64) NOT NULL COMMENT 'Dispatch order number',
vehicle_plate VARCHAR(64) NULL COMMENT 'Editable vehicle plate; required before PDA picking',
driver_name VARCHAR(128) NULL,
driver_phone VARCHAR(64) NULL,
carrier_name VARCHAR(255) NULL,
route VARCHAR(255) NULL,
depart_at DATETIME(3) NULL,
fba_appointment_no VARCHAR(64) NULL,
fba_appointment_status VARCHAR(32) NOT NULL DEFAULT 'PENDING',
vehicle_appointment_status VARCHAR(32) NOT NULL DEFAULT 'PENDING',
estimated_arrival_at DATETIME(3) NULL,
status VARCHAR(32) NOT NULL DEFAULT 'PENDING_DEPARTURE',
item_count INT NOT NULL DEFAULT 0,
pallet_count INT NOT NULL DEFAULT 0,
package_count INT NOT NULL DEFAULT 0,
created_by VARCHAR(128) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_ll_dispatch_order_no (tenant_id,dispatch_no),
KEY idx_ll_dispatch_order_list (tenant_id,status,depart_at),
KEY idx_ll_dispatch_order_vehicle (tenant_id,vehicle_plate)
) ENGINE=InnoDB COMMENT='Last-leg pre-dispatch order';
CREATE TABLE IF NOT EXISTS ll_dispatch_item (
id BIGINT NOT NULL COMMENT 'Snowflake ID',
dispatch_order_id BIGINT NOT NULL,
tenant_id VARCHAR(128) NOT NULL,
plan_id BIGINT NULL,
plan_pallet_id BIGINT NULL,
warehouse_pallet_id BIGINT NULL,
order_id BIGINT NULL,
order_no VARCHAR(64) NULL,
order_refs VARCHAR(2000) NULL,
inbound_code VARCHAR(128) NULL,
pallet_no VARCHAR(128) NOT NULL,
destination_warehouse_id VARCHAR(128) NULL,
destination_warehouse_code VARCHAR(128) NULL,
destination_warehouse_name VARCHAR(255) NULL,
fba_warehouse_code VARCHAR(128) NULL,
package_count INT NOT NULL DEFAULT 0,
volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0,
source_stage VARCHAR(32) NOT NULL COMMENT 'PLAN or RECEIVED',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_ll_dispatch_item_source (dispatch_order_id,plan_pallet_id,warehouse_pallet_id),
KEY idx_ll_dispatch_item_order (tenant_id,order_id),
KEY idx_ll_dispatch_item_inbound (tenant_id,inbound_code),
KEY idx_ll_dispatch_item_pallet (tenant_id,plan_pallet_id,warehouse_pallet_id)
) ENGINE=InnoDB COMMENT='Frozen pallet and cargo snapshot for a dispatch order';
CREATE TABLE IF NOT EXISTS ll_dispatch_pallet_lock (
id BIGINT NOT NULL COMMENT 'Snowflake ID',
tenant_id VARCHAR(128) NOT NULL,
source_pallet_key VARCHAR(160) NOT NULL COMMENT 'PLAN:{planPalletId} or RECEIVED:{warehousePalletId}',
active_key VARCHAR(160) NULL COMMENT 'Equals source key only while lock is active',
plan_id BIGINT NULL,
plan_pallet_id BIGINT NULL,
warehouse_pallet_id BIGINT NULL,
dispatch_order_id BIGINT NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE',
locked_at DATETIME(3) NOT NULL,
released_at DATETIME(3) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_ll_dispatch_active_pallet (tenant_id,active_key),
KEY idx_ll_dispatch_pallet_lock_order (tenant_id,dispatch_order_id,status),
KEY idx_ll_dispatch_pallet_lock_source (tenant_id,source_pallet_key,status)
) ENGINE=InnoDB COMMENT='Active dispatch pallet exclusion lock';
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='order_refs'), 'SELECT 1', 'ALTER TABLE ll_dispatch_item ADD COLUMN order_refs VARCHAR(2000) NULL AFTER order_no'));
PREPARE dispatch_schema_stmt FROM @ddl; EXECUTE dispatch_schema_stmt; DEALLOCATE PREPARE dispatch_schema_stmt;
CREATE TABLE IF NOT EXISTS ll_dispatch_pda_task (
id BIGINT NOT NULL, tenant_id VARCHAR(128) NOT NULL, dispatch_order_id BIGINT NOT NULL,
task_no VARCHAR(64) NOT NULL, status VARCHAR(32) NOT NULL, operator_id VARCHAR(128) NULL,
operator_name VARCHAR(128) NULL, pushed_at DATETIME(3) NOT NULL, completed_at DATETIME(3) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id), UNIQUE KEY uk_ll_dispatch_pda_task_dispatch (tenant_id,dispatch_order_id),
UNIQUE KEY uk_ll_dispatch_pda_task_no (tenant_id,task_no)
) ENGINE=InnoDB COMMENT='PDA task for a pre-arranged dispatch';
CREATE TABLE IF NOT EXISTS ll_dispatch_pda_pick (
id BIGINT NOT NULL, tenant_id VARCHAR(128) NOT NULL, task_id BIGINT NOT NULL,
dispatch_item_id BIGINT NOT NULL, inventory_position_id BIGINT NOT NULL,
packages BIGINT NOT NULL, product_quantity BIGINT NOT NULL DEFAULT 0, idempotency_key VARCHAR(255) NOT NULL,
picked_at DATETIME(3) NOT NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id), UNIQUE KEY uk_ll_dispatch_pda_pick_key (tenant_id,idempotency_key),
KEY idx_ll_dispatch_pda_pick_item (tenant_id,task_id,dispatch_item_id)
) ENGINE=InnoDB COMMENT='Immutable dispatch PDA pallet package picks';
CREATE TABLE IF NOT EXISTS ll_dispatch_pda_operation (
id BIGINT NOT NULL, tenant_id VARCHAR(128) NOT NULL, task_id BIGINT NULL,
operation_type VARCHAR(32) NOT NULL, idempotency_key VARCHAR(255) NOT NULL, request_hash CHAR(64) NOT NULL,
result_json LONGTEXT NULL, status VARCHAR(32) NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id), UNIQUE KEY uk_ll_dispatch_pda_operation_key (tenant_id,idempotency_key)
) ENGINE=InnoDB COMMENT='Idempotent dispatch PDA operations';
@@ -0,0 +1,24 @@
package com.baiyue.lastleg.dispatch;
import org.junit.jupiter.api.Test;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.assertTrue;
class DispatchSchemaContractTest {
@Test
void baselineDefinesDispatchHeadersItemsAndActivePalletLock() throws Exception {
String sql = Files.readString(Path.of("src/main/resources/db/lastleg.sql"));
assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_dispatch_order"));
assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_dispatch_item"));
assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_dispatch_pallet_lock"));
assertTrue(sql.contains("uk_ll_dispatch_active_pallet"));
assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_dispatch_pda_task"));
assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_dispatch_pda_pick"));
assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_dispatch_pda_operation"));
assertTrue(sql.contains("active_key"));
assertTrue(sql.contains("order_refs"));
}
}
@@ -0,0 +1,83 @@
package com.baiyue.lastleg.dispatch.pda.service;
import com.baiyue.lastleg.dispatch.entity.DispatchOrder;
import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper;
import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaRequests;
import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaResponses;
import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaOperation;
import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaOperationMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class DispatchPdaWorkflowServiceTest {
@Mock private DispatchOrderMapper orders;
@Mock private DispatchPdaOperationMapper operations;
@Test
void pushRequiresVehiclePlateBeforeCreatingPdaTask() {
DispatchPdaWorkflowService service = new DispatchPdaWorkflowService();
ReflectionTestUtils.setField(service, "orders", orders);
DispatchOrder order = new DispatchOrder();
order.setId(1L); order.setTenantId("100"); order.setDispatchNo("PC1"); order.setVehiclePlate(" ");
when(orders.findByDispatchNo("100", "PC1")).thenReturn(order);
ResponseStatusException exception = assertThrows(ResponseStatusException.class,
() -> service.push("100", "PC1", new DispatchPdaRequests.Push("push-1", null, null)));
assertEquals(409, exception.getStatusCode().value());
}
@Test
void departRequiresVehiclePlate() {
DispatchPdaWorkflowService service = new DispatchPdaWorkflowService();
ReflectionTestUtils.setField(service, "orders", orders);
ReflectionTestUtils.setField(service, "operations", operations);
DispatchOrder order = new DispatchOrder();
order.setId(1L); order.setTenantId("100"); order.setDispatchNo("PC1");
order.setStatus("READY_TO_DEPART"); order.setVehiclePlate(" ");
when(orders.findByDispatchNo("100", "PC1")).thenReturn(order);
when(orders.lockById("100", 1L)).thenReturn(order);
when(operations.findKey("100", "depart-1")).thenReturn(null);
ResponseStatusException exception = assertThrows(ResponseStatusException.class,
() -> service.depart("100", "PC1", "operator", new DispatchPdaRequests.Depart("depart-1", null)));
assertEquals(409, exception.getStatusCode().value());
}
@Test
void departedRequestReplaysAfterStatusChanged() throws Exception {
DispatchPdaWorkflowService service = new DispatchPdaWorkflowService();
ObjectMapper objectMapper = new ObjectMapper();
ReflectionTestUtils.setField(service, "orders", orders);
ReflectionTestUtils.setField(service, "operations", operations);
ReflectionTestUtils.setField(service, "objectMapper", objectMapper);
DispatchOrder order = new DispatchOrder();
order.setId(1L); order.setTenantId("100"); order.setDispatchNo("PC1"); order.setStatus("DEPARTED");
when(orders.findByDispatchNo("100", "PC1")).thenReturn(order);
when(orders.lockById("100", 1L)).thenReturn(order);
DispatchPdaOperation operation = new DispatchPdaOperation();
operation.setOperationType("DEPART"); operation.setStatus("COMPLETED");
operation.setResultJson(objectMapper.writeValueAsString(new DispatchPdaResponses.Departed("PC1", "DEPARTED", 12, false)));
when(operations.findKey("100", "depart-1")).thenReturn(operation);
// This exact key/hash pair models the persisted first execution result.
DispatchPdaRequests.Depart request = new DispatchPdaRequests.Depart("depart-1", null);
operation.setRequestHash(java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256")
.digest(objectMapper.writeValueAsString(request).getBytes(java.nio.charset.StandardCharsets.UTF_8))));
DispatchPdaResponses.Departed result = service.depart("100", "PC1", "operator", request);
assertEquals("DEPARTED", result.status());
assertEquals(true, result.replayed());
}
}
@@ -0,0 +1,93 @@
package com.baiyue.lastleg.dispatch.service;
import com.baiyue.lastleg.dispatch.dto.DispatchRequests;
import com.baiyue.lastleg.dispatch.entity.DispatchItem;
import com.baiyue.lastleg.dispatch.entity.DispatchOrder;
import com.baiyue.lastleg.dispatch.entity.DispatchPalletLock;
import com.baiyue.lastleg.dispatch.mapper.DispatchCandidateMapper;
import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper;
import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper;
import com.baiyue.lastleg.dispatch.mapper.DispatchPalletLockMapper;
import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.math.BigDecimal;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class DispatchServiceTest {
@Mock DispatchOrderMapper orders;
@Mock DispatchItemMapper items;
@Mock DispatchPalletLockMapper locks;
@Mock DispatchCandidateMapper candidates;
private DispatchService service;
@BeforeEach
void setUp() {
service = new DispatchService();
ReflectionTestUtils.setField(service, "orders", orders);
ReflectionTestUtils.setField(service, "items", items);
ReflectionTestUtils.setField(service, "locks", locks);
ReflectionTestUtils.setField(service, "candidates", candidates);
lenient().when(items.findByDispatchOrder(any(), any())).thenReturn(List.of());
}
@Test
void preArrangeLocksPalletWithoutVehicleCapacityValidation() throws Exception {
assertNotNull(DispatchService.class.getMethod("preArrange", String.class, String.class,
DispatchRequests.PreArrange.class).getAnnotation(Transactional.class));
when(candidates.lockSelected("100", List.of(10L))).thenReturn(List.of(candidate(10L, null, "ONT8")));
var result = service.preArrange("100", "7", new DispatchRequests.PreArrange(List.of(10L), null,
null, null, null));
assertEquals(DispatchService.PENDING_DEPARTURE, result.status());
assertEquals("ONT8", result.route());
ArgumentCaptor<DispatchOrder> dispatch = ArgumentCaptor.forClass(DispatchOrder.class);
verify(orders).insert(dispatch.capture());
assertEquals(1, dispatch.getValue().getPalletCount());
verify(locks).insert(any(DispatchPalletLock.class));
verify(items).insert(any(DispatchItem.class));
}
@Test
void rejectsPalletAlreadyLockedByAnotherDispatch() {
when(candidates.lockSelected("100", List.of(10L))).thenReturn(List.of(candidate(10L, null, "ONT8")));
DispatchPalletLock locked = new DispatchPalletLock();
when(locks.lockActive(eq("100"), eq("PLAN:10"))).thenReturn(locked);
assertThrows(ResponseStatusException.class, () -> service.preArrange("100", "7",
new DispatchRequests.PreArrange(List.of(10L), null, null, null, null)));
verify(orders, never()).insert(any(DispatchOrder.class));
}
@Test
void invalidCandidateStageIsRejected() {
assertThrows(ResponseStatusException.class, () -> service.candidates("100", null, null, "INVALID"));
verify(candidates, never()).available(any(), any(), any(), any());
}
private DispatchPalletCandidate candidate(long planPalletId, Long warehousePalletId, String destination) {
return new DispatchPalletCandidate(1L, planPalletId, warehousePalletId, 2L, "LMO1", "LMO1", "RK1",
"LIP1-P001", destination, destination, destination, destination, 12,
new BigDecimal("1.2000"), warehousePalletId == null ? "PLAN" : "RECEIVED");
}
}
@@ -22,6 +22,10 @@ class InboundPlanSchemaContractTest {
assertTrue(schema.contains("create table if not exists ll_inbound_pda_task"));
assertTrue(schema.contains("create table if not exists ll_inbound_pda_operation"));
assertTrue(schema.contains("create table if not exists ll_inbound_pallet_merge"));
assertTrue(schema.contains("create table if not exists ll_inbound_fba_appointment"));
assertTrue(schema.contains("unique key uk_ll_fba_appointment_no (tenant_id, appointment_no)"));
assertTrue(schema.contains("create table if not exists ll_inbound_fba_appointment_item"));
assertTrue(schema.contains("fba_warehouse_code"));
assertFalse(schema.contains("cabinet_no"));
}
}
@@ -29,6 +29,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.atLeastOnce;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
@@ -39,6 +40,7 @@ import static org.mockito.Mockito.when;
class InboundPdaWorkflowServiceTest {
@Mock PdaWorkflowRepository repository;
@Mock WarehouseResourceGateway warehouse;
@Mock InboundStockReceiptService stockReceipts;
private InboundPdaWorkflowService service;
private InboundPlan plan;
@@ -57,6 +59,7 @@ class InboundPdaWorkflowServiceTest {
service = new InboundPdaWorkflowService();
ReflectionTestUtils.setField(service, "repository", repository);
ReflectionTestUtils.setField(service, "warehouse", warehouse);
ReflectionTestUtils.setField(service, "stockReceipts", stockReceipts);
ReflectionTestUtils.setField(service, "sourceObjectMapper", new ObjectMapper().findAndRegisterModules());
service.initializeObjectMapper();
plan = plan();
@@ -249,6 +252,20 @@ class InboundPdaWorkflowServiceTest {
assertEquals("COMPLETED", plan.getStatus());
verify(repository).update(task);
verify(repository).update(plan);
verify(stockReceipts).stockIn(eq("100"), eq(plan), eq(task), eq(List.of(targetLine)),
eq(List.of(target, moved)), any(), any());
}
@Test
void completedTaskReplayDoesNotRunStockInAgain() {
task.setStatus("COMPLETED");
activeTask();
var result = service.complete("100", "LIP1");
assertEquals("COMPLETED", result.status());
verify(stockReceipts, never()).stockIn(any(), any(), any(), any(), any(), any(), any());
verify(repository, never()).allocations(plan.getId());
}
@Test
@@ -0,0 +1,164 @@
package com.baiyue.lastleg.inboundplan.pda.service;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.entity.LastlegOrderDetail;
import com.baiyue.lastleg.inboundplan.entity.InboundPlan;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation;
import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask;
import com.baiyue.lastleg.inboundplan.pda.entity.InboundStockReceipt;
import com.baiyue.lastleg.inboundplan.pda.mapper.InboundStockReceiptMapper;
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests;
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandResponse;
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class InboundStockReceiptServiceTest {
@Mock LastlegOrderMapper orders;
@Mock LastlegOrderDetailMapper details;
@Mock InboundStockReceiptMapper receipts;
@Mock InventoryCommandService inventory;
private InboundStockReceiptService service;
@BeforeEach
void setUp() {
service = new InboundStockReceiptService();
ReflectionTestUtils.setField(service, "orders", orders);
ReflectionTestUtils.setField(service, "details", details);
ReflectionTestUtils.setField(service, "receipts", receipts);
ReflectionTestUtils.setField(service, "inventory", inventory);
when(receipts.listTask("100", 2L)).thenReturn(List.of());
}
@Test
void writesOneReceiptPerSkuAndPalletAndUsesPdaPackageCount() {
Fixture fixture = fixture(5, 5, 5, 10);
when(orders.selectBatchIds(any())).thenReturn(List.of(fixture.order));
when(details.selectList(any())).thenReturn(List.of(fixture.detail));
when(inventory.stockIn(eq("100"), eq("operator"), any(InventoryCommandRequests.StockIn.class)))
.thenReturn(new InventoryCommandResponse(8L, "STOCK_IN", 9L, 10L, 5, 0, 0, 0, false, false));
service.stockIn("100", fixture.plan, fixture.task, List.of(fixture.line),
List.of(fixture.allocation), List.of(fixture.pallet), LocalDateTime.now());
ArgumentCaptor<InventoryCommandRequests.StockIn> request = ArgumentCaptor.forClass(InventoryCommandRequests.StockIn.class);
verify(inventory).stockIn(eq("100"), eq("operator"), request.capture());
assertEquals(5L, request.getValue().packages());
assertEquals(10L, request.getValue().productQuantity());
assertEquals("SKU-1", request.getValue().skuCode());
ArgumentCaptor<InboundStockReceipt> receipt = ArgumentCaptor.forClass(InboundStockReceipt.class);
verify(receipts).insert(receipt.capture());
assertEquals(5L, receipt.getValue().getReceivedPackages());
assertEquals(10L, receipt.getValue().getReceivedQuantity());
assertEquals(10L, receipt.getValue().getInventoryPositionId());
}
@Test
void rejectsMultiSkuLineSplitAcrossPalletsInsteadOfProportionalMisallocation() {
Fixture fixture = fixture(10, 5, 5, 10);
fixture.detail.setPackageCount(5);
LastlegOrderDetail second = detail(202L, "SKU-2", 5, 7);
InboundPlanPallet secondPallet = pallet(22L, 2002L);
InboundPlanPalletAllocation secondAllocation = allocation(32L, 12L, 22L, 5);
when(orders.selectBatchIds(any())).thenReturn(List.of(fixture.order));
when(details.selectList(any())).thenReturn(List.of(fixture.detail, second));
assertThrows(ResponseStatusException.class, () -> service.stockIn("100", fixture.plan, fixture.task,
List.of(fixture.line), List.of(fixture.allocation, secondAllocation),
List.of(fixture.pallet, secondPallet), LocalDateTime.now()));
verify(inventory, never()).stockIn(any(), any(), any());
verify(receipts, never()).insert(any(InboundStockReceipt.class));
}
@Test
void conservesSingleSkuPackagesAndQuantityAcrossPallets() {
Fixture fixture = fixture(5, 2, 2, 11);
InboundPlanPallet secondPallet = pallet(22L, 2002L);
InboundPlanPalletAllocation secondAllocation = allocation(32L, 12L, 22L, 3);
when(orders.selectBatchIds(any())).thenReturn(List.of(fixture.order));
when(details.selectList(any())).thenReturn(List.of(fixture.detail));
when(inventory.stockIn(eq("100"), eq("operator"), any(InventoryCommandRequests.StockIn.class)))
.thenReturn(new InventoryCommandResponse(8L, "STOCK_IN", 9L, 10L, 5, 0, 0, 0, false, false));
service.stockIn("100", fixture.plan, fixture.task, List.of(fixture.line),
List.of(fixture.allocation, secondAllocation), List.of(fixture.pallet, secondPallet), LocalDateTime.now());
ArgumentCaptor<InventoryCommandRequests.StockIn> requests = ArgumentCaptor.forClass(InventoryCommandRequests.StockIn.class);
verify(inventory, times(2)).stockIn(eq("100"), eq("operator"), requests.capture());
assertEquals(5L, requests.getAllValues().stream().mapToLong(InventoryCommandRequests.StockIn::packages).sum());
assertEquals(11L, requests.getAllValues().stream().mapToLong(InventoryCommandRequests.StockIn::productQuantity).sum());
}
@Test
void existingTaskReceiptMakesStockInReplayANoOp() {
Fixture fixture = fixture(5, 5, 5, 10);
when(receipts.listTask("100", fixture.task.getId())).thenReturn(List.of(new InboundStockReceipt()));
service.stockIn("100", fixture.plan, fixture.task, List.of(fixture.line),
List.of(fixture.allocation), List.of(fixture.pallet), LocalDateTime.now());
verify(inventory, never()).stockIn(any(), any(), any());
verify(receipts, never()).insert(any(InboundStockReceipt.class));
}
private Fixture fixture(int required, int planned, int picked, int quantity) {
InboundPlan plan = new InboundPlan(); plan.setId(1L); plan.setPlanNo("IP-1");
InboundPdaTask task = new InboundPdaTask(); task.setId(2L); task.setOperatorId("operator");
InboundPlanLine line = new InboundPlanLine(); line.setId(12L); line.setPlanId(1L); line.setOrderId(101L);
line.setInboundCode("IN-1"); line.setDestinationWarehouseId("WH-DEST");
line.setDestinationWarehouseCode("ONT8"); line.setDestinationWarehouseName("ONT8");
line.setRequiredPackageCount(required); line.setVolumeCbm(new BigDecimal("1.0"));
LastlegOrder order = new LastlegOrder(); order.setId(101L); order.setTenantId("100");
order.setOrderNo("LMO-1"); order.setCustomerName("Customer"); order.setOverseasWarehouseId("OW-1");
order.setOverseasWarehouseName("Warehouse"); order.setSourceEventId("evt-1"); order.setFbaWarehouseCode("ONT8");
LastlegOrderDetail detail = detail(201L, "SKU-1", required, quantity);
return new Fixture(plan, task, line, order, detail, allocation(31L, 12L, 21L, picked), pallet(21L, 2001L));
}
private LastlegOrderDetail detail(long id, String sku, int packages, int quantity) {
LastlegOrderDetail detail = new LastlegOrderDetail(); detail.setId(id); detail.setOrderId(101L);
detail.setSkuCode(sku); detail.setPackageCount(packages); detail.setProductQuantity(quantity);
detail.setProductNameZh(sku + " name"); return detail;
}
private InboundPlanPallet pallet(long id, long warehousePalletId) {
InboundPlanPallet pallet = new InboundPlanPallet(); pallet.setId(id); pallet.setPlanId(1L);
pallet.setPalletNo("PLT-" + id); pallet.setWarehousePalletId(warehousePalletId);
pallet.setWarehouseLocationId(3001L); return pallet;
}
private InboundPlanPalletAllocation allocation(long id, long lineId, long palletId, int picked) {
InboundPlanPalletAllocation allocation = new InboundPlanPalletAllocation(); allocation.setId(id);
allocation.setPlanId(1L); allocation.setLineId(lineId); allocation.setPalletId(palletId);
allocation.setPlannedPackageCount(picked); allocation.setPickedPackageCount(picked); return allocation;
}
private record Fixture(InboundPlan plan, InboundPdaTask task, InboundPlanLine line, LastlegOrder order,
LastlegOrderDetail detail, InboundPlanPalletAllocation allocation,
InboundPlanPallet pallet) {}
}
@@ -0,0 +1,137 @@
package com.baiyue.lastleg.inboundplan.service;
import com.baiyue.lastleg.inboundplan.dto.BatchFbaAppointmentRequest;
import com.baiyue.lastleg.inboundplan.entity.InboundFbaAppointment;
import com.baiyue.lastleg.inboundplan.entity.InboundFbaAppointmentItem;
import com.baiyue.lastleg.inboundplan.mapper.InboundFbaAppointmentItemMapper;
import com.baiyue.lastleg.inboundplan.mapper.InboundFbaAppointmentMapper;
import com.baiyue.lastleg.inboundplan.mapper.InboundFbaAppointmentQueryMapper;
import com.baiyue.lastleg.inboundplan.model.FbaAppointmentLineSnapshot;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDateTime;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class InboundFbaAppointmentServiceTest {
@Mock InboundFbaAppointmentMapper appointmentMapper;
@Mock InboundFbaAppointmentItemMapper itemMapper;
@Mock InboundFbaAppointmentQueryMapper queryMapper;
private InboundFbaAppointmentService service;
private final LocalDateTime appointmentAt = LocalDateTime.of(2026, 8, 20, 10, 30);
@BeforeEach
void setUp() {
service = new InboundFbaAppointmentService();
ReflectionTestUtils.setField(service, "appointmentMapper", appointmentMapper);
ReflectionTestUtils.setField(service, "itemMapper", itemMapper);
ReflectionTestUtils.setField(service, "queryMapper", queryMapper);
lenient().when(itemMapper.selectList(any())).thenReturn(List.of());
}
@Test
void createsOneBatchForSelectedLinesAndFreezesFbaWarehouse() throws Exception {
assertEquals(true, InboundFbaAppointmentService.class
.getMethod("batchCreate", String.class, String.class, BatchFbaAppointmentRequest.class)
.getAnnotation(Transactional.class) != null);
when(queryMapper.lockLines("100", List.of(11L, 12L))).thenReturn(List.of(
line(11L, "IN-11", "ONT8"), line(12L, "IN-12", "ONT8")));
var result = service.batchCreate("100", "7", request(List.of(11L, 12L), "APT-1"));
assertEquals("APT-1", result.appointmentNo());
assertEquals("ONT8", result.fbaWarehouseCode());
assertEquals(2, result.selectedCount());
verify(appointmentMapper).insert(any(InboundFbaAppointment.class));
verify(itemMapper, org.mockito.Mockito.times(2)).insert(any(InboundFbaAppointmentItem.class));
}
@Test
void rejectsMixedFbaWarehouses() {
when(queryMapper.lockLines("100", List.of(11L, 12L))).thenReturn(List.of(
line(11L, "IN-11", "ONT8"), line(12L, "IN-12", "LAX9")));
assertThrows(ResponseStatusException.class,
() -> service.batchCreate("100", "7", request(List.of(11L, 12L), "APT-1")));
verify(appointmentMapper, never()).insert(any(InboundFbaAppointment.class));
}
@Test
void rejectsMissingLineFromAnotherTenant() {
when(queryMapper.lockLines("100", List.of(11L, 12L))).thenReturn(List.of(line(11L, "IN-11", "ONT8")));
assertThrows(ResponseStatusException.class,
() -> service.batchCreate("100", "7", request(List.of(11L, 12L), "APT-1")));
}
@Test
void duplicateLineIdsAreRejectedBeforeDatabaseAccess() {
assertThrows(ResponseStatusException.class,
() -> service.batchCreate("100", "7", request(List.of(11L, 11L), "APT-1")));
verify(queryMapper, never()).lockLines(any(), any());
}
@Test
void sameAppointmentReplayReturnsExistingResult() {
InboundFbaAppointment existing = appointment(99L, "APT-1", "ONT8");
InboundFbaAppointmentItem item = new InboundFbaAppointmentItem();
item.setLineId(11L);
item.setInboundCode("IN-11");
item.setFbaWarehouseCode("ONT8");
when(appointmentMapper.selectOne(any())).thenReturn(existing);
when(itemMapper.selectList(any())).thenReturn(List.of(item));
var result = service.batchCreate("100", "7", request(List.of(11L), "APT-1"));
assertEquals(99L, result.id());
verify(appointmentMapper, never()).insert(any(InboundFbaAppointment.class));
}
@Test
void sameAppointmentNumberWithDifferentSelectionConflicts() {
when(appointmentMapper.selectOne(any())).thenReturn(appointment(99L, "APT-1", "ONT8"));
InboundFbaAppointmentItem item = new InboundFbaAppointmentItem();
item.setLineId(11L);
when(itemMapper.selectList(any())).thenReturn(List.of(item));
assertThrows(ResponseStatusException.class,
() -> service.batchCreate("100", "7", request(List.of(12L), "APT-1")));
}
private BatchFbaAppointmentRequest request(List<Long> lineIds, String number) {
return new BatchFbaAppointmentRequest("100", lineIds, number, appointmentAt, "SUCCESS", "dock");
}
private FbaAppointmentLineSnapshot line(Long id, String inboundCode, String fba) {
return new FbaAppointmentLineSnapshot(id, 1000L, id + 100L, inboundCode, fba);
}
private InboundFbaAppointment appointment(Long id, String number, String fba) {
InboundFbaAppointment value = new InboundFbaAppointment();
value.setId(id);
value.setTenantId("100");
value.setAppointmentNo(number);
value.setAppointmentAt(appointmentAt);
value.setAppointmentStatus("SUCCESS");
value.setAppointmentRemark("dock");
value.setFbaWarehouseCode(fba);
value.setSelectedCount(1);
return value;
}
}
@@ -0,0 +1,78 @@
package com.baiyue.lastleg.inventory;
import com.baiyue.lastleg.inventory.entity.InventoryBalance;
import com.baiyue.lastleg.inventory.entity.InventoryLedger;
import com.baiyue.lastleg.inventory.entity.InventoryOperation;
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
class InventorySchemaContractTest {
@Test
void baselineDefinesInventoryDimensionsStatesAndIdempotency() throws IOException {
String schema;
try (var stream = getClass().getClassLoader().getResourceAsStream("db/lastleg.sql")) {
assertNotNull(stream, "lastleg schema must be available");
schema = new String(stream.readAllBytes(), StandardCharsets.UTF_8).toLowerCase();
}
assertTrue(schema.contains("create table if not exists ll_inventory_balance"));
assertTrue(schema.contains("unique key uk_ll_inventory_dimension (tenant_id,overseas_warehouse_id,destination_warehouse_code,sku_code)"));
assertTrue(schema.contains("available_packages bigint not null default 0"));
assertTrue(schema.contains("in_transit_packages bigint not null default 0"));
assertTrue(schema.contains("procurement_quantity bigint not null default 0"));
assertTrue(schema.contains("create table if not exists ll_inventory_position"));
assertTrue(schema.contains("unique key uk_ll_inventory_position (balance_id,pallet_id,batch_no)"));
assertTrue(schema.contains("warehouse_location_code varchar(128) not null"));
assertTrue(schema.contains("create table if not exists ll_inventory_ledger"));
assertTrue(schema.contains("business_order_id bigint null"));
assertTrue(schema.contains("inbound_code varchar(128) null"));
assertTrue(schema.contains("before_packages bigint not null"));
assertTrue(schema.contains("delta_packages bigint not null"));
assertTrue(schema.contains("after_packages bigint not null"));
assertTrue(schema.contains("unique key uk_ll_inventory_ledger_idempotency (tenant_id,idempotency_key)"));
assertTrue(schema.contains("create table if not exists ll_inventory_operation"));
assertTrue(schema.contains("create table if not exists ll_inbound_stock_receipt"));
assertTrue(schema.contains("unique key uk_ll_stock_receipt_task_allocation_sku (tenant_id,task_id,allocation_id,sku_code)"));
assertFalse(tableDefinition(schema, "ll_inventory_ledger").contains("updated_at"));
}
@Test
void inventoryEntitiesUseSnowflakeIds() throws NoSuchFieldException {
for (Class<?> type : new Class<?>[] {
InventoryBalance.class, InventoryPosition.class, InventoryLedger.class, InventoryOperation.class
}) {
TableId tableId = type.getDeclaredField("id").getAnnotation(TableId.class);
assertNotNull(tableId, type.getSimpleName());
assertEquals(IdType.ASSIGN_ID, tableId.type(), type.getSimpleName());
}
}
@Test
void incrementalMigrationIsAdditiveAndRerunnable() throws IOException {
String migration = Files.readString(Path.of("..", "scripts", "db", "incremental.sql"), StandardCharsets.UTF_8)
.toLowerCase();
assertTrue(migration.contains("2026-08-17 / 008"));
assertTrue(migration.contains("create table if not exists ll_inventory_balance"));
assertTrue(migration.contains("create table if not exists ll_inventory_position"));
assertTrue(migration.contains("create table if not exists ll_inventory_ledger"));
assertTrue(migration.contains("create table if not exists ll_inventory_operation"));
}
private static String tableDefinition(String schema, String tableName) {
int start = schema.indexOf("create table if not exists " + tableName);
int end = schema.indexOf("engine=innodb", start);
return schema.substring(start, end);
}
}
@@ -0,0 +1,156 @@
package com.baiyue.lastleg.inventory.command;
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests;
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandResponse;
import com.baiyue.lastleg.inventory.command.mapper.InventoryCommandMapper;
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
import com.baiyue.lastleg.inventory.entity.InventoryBalance;
import com.baiyue.lastleg.inventory.entity.InventoryLedger;
import com.baiyue.lastleg.inventory.entity.InventoryOperation;
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper;
import com.baiyue.lastleg.inventory.mapper.InventoryLedgerMapper;
import com.baiyue.lastleg.inventory.mapper.InventoryOperationMapper;
import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper;
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class InventoryCommandServiceTest {
@Mock InventoryBalanceMapper balances;
@Mock InventoryPositionMapper positions;
@Mock InventoryLedgerMapper ledgers;
@Mock InventoryOperationMapper operations;
@Mock InventoryCommandMapper commands;
@Mock WarehouseResourceGateway warehouseGateway;
private InventoryCommandService service;
@BeforeEach
void setUp() {
service = new InventoryCommandService();
ReflectionTestUtils.setField(service, "balances", balances);
ReflectionTestUtils.setField(service, "positions", positions);
ReflectionTestUtils.setField(service, "ledgers", ledgers);
ReflectionTestUtils.setField(service, "operations", operations);
ReflectionTestUtils.setField(service, "commands", commands);
ReflectionTestUtils.setField(service, "warehouseGateway", warehouseGateway);
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper());
lenient().when(operations.findIdempotency(any(), any())).thenReturn(null);
}
@Test
void stockInUpdatesBalancePositionAndLedgerInOneTransaction() throws Exception {
assertTrue(InventoryCommandService.class.getMethod("stockIn", String.class, String.class,
InventoryCommandRequests.StockIn.class).getAnnotation(Transactional.class) != null);
when(balances.lockDimension("100", "WH-1", "ONT8", "SKU-A")).thenReturn(null);
var result = service.stockIn("100", "7", stockIn("IN-1", "stock-in-1", 12, 24));
assertEquals(12, result.availablePackages());
assertFalse(result.replayed());
ArgumentCaptor<InventoryBalance> balance = ArgumentCaptor.forClass(InventoryBalance.class);
verify(balances).updateById(balance.capture());
assertEquals(12L, balance.getValue().getAvailablePackages());
verify(positions).insert(any(InventoryPosition.class));
verify(ledgers).insert(any(InventoryLedger.class));
verify(operations).updateById(any(InventoryOperation.class));
}
@Test
void holdRejectsWhenAvailablePackagesAreInsufficient() {
InventoryBalance balance = balance(200L, 5);
InventoryPosition position = position(201L, 200L, 5);
when(commands.lockPosition("100", 201L)).thenReturn(position);
when(commands.lockBalance("100", 200L)).thenReturn(balance);
assertThrows(ResponseStatusException.class,
() -> service.hold("100", "7", new InventoryCommandRequests.Move(
"100", 201L, 6, 0, "ORD-1", null, null, "hold-1", null)));
verify(positions, never()).updateById(any(InventoryPosition.class));
verify(balances, never()).updateById(any(InventoryBalance.class));
}
@Test
void replayUsesPersistedResultWithoutMutatingInventoryAgain() throws Exception {
var request = stockIn("IN-1", "stock-in-1", 12, 24);
when(balances.lockDimension("100", "WH-1", "ONT8", "SKU-A")).thenReturn(null);
service.stockIn("100", "7", request);
ArgumentCaptor<InventoryOperation> operation = ArgumentCaptor.forClass(InventoryOperation.class);
verify(operations).insert(operation.capture());
when(operations.findIdempotency("100", "stock-in-1")).thenReturn(operation.getValue());
InventoryCommandResponse replay = service.stockIn("100", "7", request);
assertTrue(replay.replayed());
verify(balances, times(1)).updateById(any(InventoryBalance.class));
verify(ledgers, times(1)).insert(any(InventoryLedger.class));
}
@Test
void shippingLastPackagesScrapsPalletWithoutReleasingItsVirtualLocation() {
InventoryBalance balance = balance(200L, 0);
balance.setPickedPackages(5L);
balance.setPickedQuantity(5L);
InventoryPosition position = position(201L, 200L, 0);
position.setPickedPackages(5L);
position.setProductQuantity(5L);
when(commands.lockPosition("100", 201L)).thenReturn(position);
when(commands.lockBalance("100", 200L)).thenReturn(balance);
when(commands.palletPackages("100", 300L)).thenReturn(0L);
var result = service.ship("100", "7", new InventoryCommandRequests.Move(
"100", 201L, 5, 5, "OUT-1", "OUT-1", null, "ship-1", null));
assertTrue(result.palletEmpty());
assertEquals("EMPTY", position.getStatus());
verify(warehouseGateway).scrapPallet("100", 300L);
verify(ledgers).insert(any(InventoryLedger.class));
}
private InventoryCommandRequests.StockIn stockIn(String inbound, String key, long packages, long quantity) {
return new InventoryCommandRequests.StockIn("100", "Customer 100", "WH-1", "WH1", "Warehouse 1",
"DEST-1", "ONT8", "ONT8", "SKU-A", "Widget", 300L, "PLT-1", null, null, null,
packages, quantity, "ORD-1", inbound, "EVT-1", key, null);
}
private InventoryBalance balance(Long id, long available) {
InventoryBalance value = new InventoryBalance();
value.setId(id); value.setTenantId("100"); value.setOverseasWarehouseId("WH-1");
value.setDestinationWarehouseCode("ONT8"); value.setSkuCode("SKU-A");
value.setAvailablePackages(available); value.setAvailableQuantity(available);
value.setHoldPackages(0L); value.setAllocatedPackages(0L); value.setPickedPackages(0L);
value.setHoldQuantity(0L); value.setAllocatedQuantity(0L); value.setPickedQuantity(0L);
value.setVersion(0L);
return value;
}
private InventoryPosition position(Long id, Long balanceId, long available) {
InventoryPosition value = new InventoryPosition();
value.setId(id); value.setTenantId("100"); value.setBalanceId(balanceId);
value.setPalletId(300L); value.setPalletNo("PLT-1"); value.setBatchNo("DEFAULT");
value.setAvailablePackages(available); value.setHoldPackages(0L);
value.setAllocatedPackages(0L); value.setPickedPackages(0L); value.setReceivingPackages(0L);
value.setProductQuantity(available); value.setVersion(0L);
return value;
}
}
@@ -0,0 +1,58 @@
package com.baiyue.lastleg.inventory.query.controller;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.inventory.query.dto.InventoryQueryRequests;
import com.baiyue.lastleg.inventory.query.service.InventoryQueryService;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
class InventoryQueryControllerTest {
private final InventoryQueryService service = mock(InventoryQueryService.class);
private final InventoryQueryController controller = controller();
private final InventoryQueryRequests.BalanceQuery query = new InventoryQueryRequests.BalanceQuery(
1, 20, null, null, null, null, null, null, null);
@Test
void merchantCannotOverrideCustomerScope() {
controller.balances(user(100L, "MERCHANT"), "200", query);
verify(service).balances(eq("100"), eq(query));
}
@Test
void platformMustSelectCustomer() {
assertThrows(ResponseStatusException.class,
() -> controller.summary(user(0L, "PLATFORM"), null, query));
}
@Test
void platformCanQuerySelectedCustomer() {
controller.summary(user(0L, "PLATFORM"), "200", query);
verify(service).dashboard(eq("200"), eq(query));
}
@Test
void unauthenticatedPositionDetailIsRejected() {
InventoryQueryRequests.PositionQuery positionQuery =
new InventoryQueryRequests.PositionQuery(null, null, null);
assertThrows(ResponseStatusException.class,
() -> controller.positions(null, null, 1L, positionQuery));
}
private static AuthenticatedUser user(long tenantId, String appCode) {
return new AuthenticatedUser(1L, tenantId, tenantId, 1L, "tester", appCode, "WEB", Set.of());
}
private InventoryQueryController controller() {
InventoryQueryController controller = new InventoryQueryController();
ReflectionTestUtils.setField(controller, "service", service);
return controller;
}
}
@@ -0,0 +1,97 @@
package com.baiyue.lastleg.inventory.query.service;
import com.baiyue.lastleg.inventory.entity.InventoryBalance;
import com.baiyue.lastleg.inventory.query.dto.InventoryDashboardResponse;
import com.baiyue.lastleg.inventory.query.dto.InventoryQueryRequests;
import com.baiyue.lastleg.inventory.query.mapper.InventoryQueryMapper;
import com.baiyue.lastleg.inventory.query.model.InventoryAggregateProjection;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class InventoryQueryServiceTest {
private final InventoryQueryMapper mapper = mock(InventoryQueryMapper.class);
private final InventoryQueryService service = new InventoryQueryService();
@BeforeEach
void setUp() {
ReflectionTestUtils.setField(service, "mapper", mapper);
}
@Test
void dashboardUsesSameTenantAndFiltersForTotalsAndPalletCount() {
InventoryQueryRequests.BalanceQuery query = balanceQuery("AVAILABLE");
InventoryAggregateProjection aggregate = new InventoryAggregateProjection();
aggregate.setDimensionCount(2L);
aggregate.setAvailablePackages(12L);
aggregate.setAvailableQuantity(120L);
when(mapper.aggregate("100", query)).thenReturn(aggregate);
when(mapper.countPallets("100", query)).thenReturn(3L);
InventoryDashboardResponse result = service.dashboard("100", query);
assertEquals(2, result.skuDimensionCount());
assertEquals(3, result.palletCount());
assertEquals(12, result.totalPackages());
assertEquals(120, result.totalQuantity());
}
@Test
void balancePageIsBoundToRequestedTenant() {
InventoryQueryRequests.BalanceQuery query = balanceQuery(null);
InventoryBalance balance = new InventoryBalance();
balance.setId(1L);
balance.setTenantId("100");
when(mapper.countBalances("100", query)).thenReturn(1L);
when(mapper.balances("100", query, 0, 20)).thenReturn(List.of(balance));
service.balances("100", query);
verify(mapper).countBalances("100", query);
verify(mapper).balances("100", query, 0, 20);
}
@Test
void unknownStatusIsRejectedBeforeDatabaseQuery() {
InventoryQueryRequests.BalanceQuery query = balanceQuery("DAMAGED");
assertThrows(ResponseStatusException.class, () -> service.balances("100", query));
verify(mapper, never()).countBalances(any(), any());
}
@Test
void positionDetailDoesNotLeakAnotherTenantBalance() {
InventoryQueryRequests.PositionQuery query = new InventoryQueryRequests.PositionQuery(null, null, null);
when(mapper.countOwnedBalance("100", 99L)).thenReturn(0L);
assertThrows(ResponseStatusException.class, () -> service.positions("100", 99L, query));
verify(mapper, never()).positions(eq("100"), eq(99L), any());
}
@Test
void reversedLedgerDateRangeIsRejected() {
InventoryQueryRequests.LedgerQuery query = new InventoryQueryRequests.LedgerQuery(
1, 20, null, null, null, null, null, null, null, null, null,
java.time.LocalDateTime.of(2026, 8, 18, 0, 0),
java.time.LocalDateTime.of(2026, 8, 17, 0, 0));
assertThrows(ResponseStatusException.class, () -> service.ledgers("100", query));
verify(mapper, never()).countLedgers(any(), any());
}
private static InventoryQueryRequests.BalanceQuery balanceQuery(String status) {
return new InventoryQueryRequests.BalanceQuery(1, 20, null, null, null, null, null, null, status);
}
}