派车提交
This commit is contained in:
@@ -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 > 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 > 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);
|
||||
}
|
||||
+15
@@ -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;
|
||||
}
|
||||
}
|
||||
+8
@@ -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 {}
|
||||
+51
@@ -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); }
|
||||
}
|
||||
}
|
||||
+23
@@ -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;
|
||||
}
|
||||
+11
@@ -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);
|
||||
}
|
||||
+14
@@ -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);
|
||||
}
|
||||
+14
@@ -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);
|
||||
}
|
||||
+213
@@ -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;
|
||||
}
|
||||
}
|
||||
+23
@@ -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);
|
||||
|
||||
+19
@@ -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) {
|
||||
}
|
||||
|
||||
+25
@@ -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;
|
||||
}
|
||||
+22
@@ -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;
|
||||
|
||||
+9
@@ -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> {
|
||||
}
|
||||
+9
@@ -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> {
|
||||
}
|
||||
+22
@@ -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);
|
||||
}
|
||||
+2
-1
@@ -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,
|
||||
|
||||
+10
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+38
@@ -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;
|
||||
}
|
||||
+13
@@ -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) {}
|
||||
}
|
||||
|
||||
+1
@@ -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);
|
||||
}
|
||||
|
||||
+14
@@ -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);
|
||||
}
|
||||
+7
-2
@@ -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);
|
||||
|
||||
+221
@@ -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) {}
|
||||
}
|
||||
+180
@@ -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();
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -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 {
|
||||
}
|
||||
+9
@@ -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 {
|
||||
}
|
||||
+97
@@ -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());
|
||||
}
|
||||
}
|
||||
+63
@@ -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
|
||||
) {
|
||||
}
|
||||
}
|
||||
+19
@@ -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);
|
||||
}
|
||||
}
|
||||
+22
@@ -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);
|
||||
}
|
||||
+493
@@ -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 >=#{startedAt}</if>" +
|
||||
"<if test='endedAt != null'> AND occurred_at <#{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);
|
||||
}
|
||||
+16
@@ -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);
|
||||
}
|
||||
+27
@@ -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;
|
||||
}
|
||||
+9
@@ -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 {
|
||||
}
|
||||
+112
@@ -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());
|
||||
}
|
||||
}
|
||||
+35
@@ -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
|
||||
) {
|
||||
}
|
||||
+23
@@ -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
|
||||
) {
|
||||
}
|
||||
+27
@@ -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
|
||||
) {
|
||||
}
|
||||
+52
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+124
@@ -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 > 0</when>" +
|
||||
"<when test='q.inventoryStatus == \"HOLD\"'> AND hold_packages > 0</when>" +
|
||||
"<when test='q.inventoryStatus == \"ALLOCATED\"'> AND allocated_packages > 0</when>" +
|
||||
"<when test='q.inventoryStatus == \"PICKED\"'> AND picked_packages > 0</when>" +
|
||||
"<when test='q.inventoryStatus == \"IN_TRANSIT\"'> AND in_transit_packages > 0</when>" +
|
||||
"<when test='q.inventoryStatus == \"RECEIVING\"'> AND receiving_packages > 0</when>" +
|
||||
"<when test='q.inventoryStatus == \"PROCUREMENT\"'> AND procurement_packages > 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 > 0</when>" +
|
||||
"<when test='q.inventoryStatus == \"HOLD\"'> AND b.hold_packages > 0</when>" +
|
||||
"<when test='q.inventoryStatus == \"ALLOCATED\"'> AND b.allocated_packages > 0</when>" +
|
||||
"<when test='q.inventoryStatus == \"PICKED\"'> AND b.picked_packages > 0</when>" +
|
||||
"<when test='q.inventoryStatus == \"IN_TRANSIT\"'> AND b.in_transit_packages > 0</when>" +
|
||||
"<when test='q.inventoryStatus == \"RECEIVING\"'> AND b.receiving_packages > 0</when>" +
|
||||
"<when test='q.inventoryStatus == \"PROCUREMENT\"'> AND b.procurement_packages > 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 > 0</when>" +
|
||||
"<when test='q.inventoryStatus == \"HOLD\"'> AND hold_packages > 0</when>" +
|
||||
"<when test='q.inventoryStatus == \"ALLOCATED\"'> AND allocated_packages > 0</when>" +
|
||||
"<when test='q.inventoryStatus == \"PICKED\"'> AND picked_packages > 0</when>" +
|
||||
"<when test='q.inventoryStatus == \"RECEIVING\"'> AND receiving_packages > 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 >=#{q.occurredFrom}</if>" +
|
||||
"<if test='q.occurredTo != null'> AND occurred_at <=#{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);
|
||||
}
|
||||
+22
@@ -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;
|
||||
}
|
||||
+202
@@ -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("\"", "\"\"") + '"';
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
+83
@@ -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");
|
||||
}
|
||||
}
|
||||
+4
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
+17
@@ -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
|
||||
|
||||
+164
@@ -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) {}
|
||||
}
|
||||
+137
@@ -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);
|
||||
}
|
||||
}
|
||||
+156
@@ -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;
|
||||
}
|
||||
}
|
||||
+58
@@ -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;
|
||||
}
|
||||
}
|
||||
+97
@@ -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);
|
||||
}
|
||||
}
|
||||
+258
-2
@@ -101,7 +101,7 @@ CREATE TABLE IF NOT EXISTS wh_pallet (
|
||||
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
|
||||
warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID',
|
||||
location_id BIGINT UNSIGNED NULL COMMENT '同仓库内绑定的库位ID,可为空',
|
||||
pallet_code VARCHAR(64) NOT NULL COMMENT '仓库内唯一托盘编码',
|
||||
pallet_code VARCHAR(128) NOT NULL COMMENT '仓库内唯一托盘编码',
|
||||
pallet_type VARCHAR(32) NOT NULL COMMENT '托盘类型',
|
||||
capacity_cbm DECIMAL(18,4) NOT NULL DEFAULT 2.0000 COMMENT '托盘容量(立方米)',
|
||||
unit_price DECIMAL(18,2) NOT NULL DEFAULT 0.00 COMMENT '托盘单价',
|
||||
@@ -165,7 +165,7 @@ ALTER TABLE wh_pallet
|
||||
MODIFY COLUMN tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
|
||||
MODIFY COLUMN warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID',
|
||||
MODIFY COLUMN location_id BIGINT UNSIGNED NULL COMMENT '同仓库内绑定的库位ID,可为空',
|
||||
MODIFY COLUMN pallet_code VARCHAR(64) NOT NULL COMMENT '仓库内唯一托盘编码',
|
||||
MODIFY COLUMN pallet_code VARCHAR(128) NOT NULL COMMENT '仓库内唯一托盘编码',
|
||||
MODIFY COLUMN pallet_type VARCHAR(32) NOT NULL COMMENT '托盘类型',
|
||||
MODIFY COLUMN capacity_cbm DECIMAL(18,4) NOT NULL DEFAULT 2.0000 COMMENT '托盘容量(立方米)',
|
||||
MODIFY COLUMN unit_price DECIMAL(18,2) NOT NULL DEFAULT 0.00 COMMENT '托盘单价',
|
||||
@@ -611,3 +611,259 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE
|
||||
PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='wh_service_capability' AND index_name='idx_capability_query'), 'SELECT 1', 'CREATE INDEX idx_capability_query ON wh_service_capability(tenant_id,warehouse_id,enabled,deleted)'));
|
||||
PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt;
|
||||
|
||||
-- ============================================================================
|
||||
-- 2026-08-17 / 007
|
||||
-- Last-leg batch FBA appointments
|
||||
-- ============================================================================
|
||||
|
||||
USE baiyue_lastleg;
|
||||
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='fba_warehouse_code'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_line ADD COLUMN fba_warehouse_code VARCHAR(128) NULL AFTER inbound_code'));
|
||||
PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt;
|
||||
UPDATE ll_inbound_plan_line l JOIN ll_order o ON o.id=l.order_id
|
||||
SET l.fba_warehouse_code=o.fba_warehouse_code
|
||||
WHERE l.fba_warehouse_code IS NULL AND o.fba_warehouse_code IS NOT NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_inbound_fba_appointment (
|
||||
id BIGINT NOT NULL COMMENT 'Snowflake ID',
|
||||
tenant_id VARCHAR(128) NOT NULL,
|
||||
appointment_no VARCHAR(64) NOT NULL,
|
||||
appointment_at DATETIME(3) NOT NULL,
|
||||
appointment_status VARCHAR(32) NOT NULL DEFAULT 'PENDING',
|
||||
appointment_remark VARCHAR(1000) NULL,
|
||||
fba_warehouse_code VARCHAR(128) NOT NULL,
|
||||
selected_count INT NOT NULL DEFAULT 0,
|
||||
created_by VARCHAR(128) NULL,
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_ll_fba_appointment_no (tenant_id, appointment_no),
|
||||
KEY idx_ll_fba_appointment_list (tenant_id, appointment_status, fba_warehouse_code, created_at)
|
||||
) ENGINE=InnoDB COMMENT='Batch FBA appointment';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_inbound_fba_appointment_item (
|
||||
id BIGINT NOT NULL COMMENT 'Snowflake ID',
|
||||
appointment_id BIGINT NOT NULL,
|
||||
plan_id BIGINT NOT NULL,
|
||||
line_id BIGINT NOT NULL,
|
||||
order_id BIGINT NOT NULL,
|
||||
inbound_code VARCHAR(128) NOT NULL,
|
||||
fba_warehouse_code VARCHAR(128) NOT NULL,
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_ll_fba_appointment_item_line (appointment_id, line_id),
|
||||
KEY idx_ll_fba_appointment_item_plan (plan_id),
|
||||
KEY idx_ll_fba_appointment_item_order (order_id)
|
||||
) ENGINE=InnoDB COMMENT='FBA appointment line snapshot';
|
||||
|
||||
-- ============================================================================
|
||||
-- 2026-08-17 / 008
|
||||
-- Last-leg SKU/package inventory balances, positions and immutable ledger
|
||||
-- ============================================================================
|
||||
|
||||
USE baiyue_lastleg;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_inventory_balance (
|
||||
id BIGINT NOT NULL COMMENT 'Snowflake ID', tenant_id VARCHAR(128) NOT NULL, customer_name VARCHAR(255) NULL,
|
||||
overseas_warehouse_id VARCHAR(128) NOT NULL, overseas_warehouse_code VARCHAR(128) NULL,
|
||||
overseas_warehouse_name VARCHAR(255) NULL, destination_warehouse_id VARCHAR(128) NULL,
|
||||
destination_warehouse_code VARCHAR(128) NOT NULL DEFAULT '', destination_warehouse_name VARCHAR(255) NULL,
|
||||
sku_code VARCHAR(128) NOT NULL, sku_name_zh VARCHAR(255) NULL,
|
||||
available_packages BIGINT NOT NULL DEFAULT 0, hold_packages BIGINT NOT NULL DEFAULT 0,
|
||||
allocated_packages BIGINT NOT NULL DEFAULT 0, picked_packages BIGINT NOT NULL DEFAULT 0,
|
||||
in_transit_packages BIGINT NOT NULL DEFAULT 0, receiving_packages BIGINT NOT NULL DEFAULT 0,
|
||||
procurement_packages BIGINT NOT NULL DEFAULT 0,
|
||||
available_quantity BIGINT NOT NULL DEFAULT 0, hold_quantity BIGINT NOT NULL DEFAULT 0,
|
||||
allocated_quantity BIGINT NOT NULL DEFAULT 0, picked_quantity BIGINT NOT NULL DEFAULT 0,
|
||||
in_transit_quantity BIGINT NOT NULL DEFAULT 0, receiving_quantity BIGINT NOT NULL DEFAULT 0,
|
||||
procurement_quantity BIGINT NOT NULL DEFAULT 0, version BIGINT NOT NULL DEFAULT 0,
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_ll_inventory_dimension (tenant_id,overseas_warehouse_id,destination_warehouse_code,sku_code),
|
||||
KEY idx_ll_inventory_list (tenant_id,overseas_warehouse_id,updated_at),
|
||||
KEY idx_ll_inventory_sku (tenant_id,sku_code)
|
||||
) ENGINE=InnoDB COMMENT='Real-time SKU package inventory balance';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_inventory_position (
|
||||
id BIGINT NOT NULL COMMENT 'Snowflake ID', tenant_id VARCHAR(128) NOT NULL, balance_id BIGINT NOT NULL,
|
||||
overseas_warehouse_id VARCHAR(128) NOT NULL, destination_warehouse_code VARCHAR(128) NOT NULL DEFAULT '',
|
||||
sku_code VARCHAR(128) NOT NULL, pallet_id BIGINT NOT NULL, pallet_no VARCHAR(64) NOT NULL,
|
||||
batch_no VARCHAR(128) NOT NULL DEFAULT '', warehouse_location_id BIGINT NULL,
|
||||
warehouse_location_code VARCHAR(128) NOT NULL,
|
||||
available_packages BIGINT NOT NULL DEFAULT 0, hold_packages BIGINT NOT NULL DEFAULT 0,
|
||||
allocated_packages BIGINT NOT NULL DEFAULT 0, picked_packages BIGINT NOT NULL DEFAULT 0,
|
||||
receiving_packages BIGINT NOT NULL DEFAULT 0, product_quantity BIGINT NOT NULL DEFAULT 0,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE', version BIGINT NOT NULL DEFAULT 0,
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY (id), UNIQUE KEY uk_ll_inventory_position (balance_id,pallet_id,batch_no),
|
||||
KEY idx_ll_inventory_position_pallet (tenant_id,pallet_id),
|
||||
KEY idx_ll_inventory_position_location (tenant_id,overseas_warehouse_id,warehouse_location_code)
|
||||
) ENGINE=InnoDB COMMENT='SKU package stock by pallet, default location and batch';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_inventory_ledger (
|
||||
id BIGINT NOT NULL COMMENT 'Snowflake ID', tenant_id VARCHAR(128) NOT NULL,
|
||||
balance_id BIGINT NOT NULL, position_id BIGINT NULL, overseas_warehouse_id VARCHAR(128) NOT NULL,
|
||||
destination_warehouse_code VARCHAR(128) NOT NULL DEFAULT '', sku_code VARCHAR(128) NOT NULL,
|
||||
pallet_id BIGINT NULL, pallet_no VARCHAR(64) NULL, batch_no VARCHAR(128) NULL,
|
||||
warehouse_location_code VARCHAR(128) NULL, business_type VARCHAR(32) NOT NULL,
|
||||
business_order_id BIGINT NULL, business_order_no VARCHAR(128) NULL, inbound_code VARCHAR(128) NULL,
|
||||
inbound_order_no VARCHAR(128) NULL, outbound_order_no VARCHAR(128) NULL,
|
||||
source_event_id VARCHAR(128) NULL, idempotency_key VARCHAR(255) NOT NULL,
|
||||
status_from VARCHAR(32) NULL, status_to VARCHAR(32) NULL,
|
||||
before_packages BIGINT NOT NULL, delta_packages BIGINT NOT NULL, after_packages BIGINT NOT NULL,
|
||||
before_quantity BIGINT NOT NULL, delta_quantity BIGINT NOT NULL, after_quantity BIGINT NOT NULL,
|
||||
operator_id VARCHAR(128) NULL, remark VARCHAR(1000) NULL,
|
||||
occurred_at DATETIME(3) NOT NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY (id), UNIQUE KEY uk_ll_inventory_ledger_idempotency (tenant_id,idempotency_key),
|
||||
KEY idx_ll_inventory_ledger_query (tenant_id,overseas_warehouse_id,occurred_at),
|
||||
KEY idx_ll_inventory_ledger_sku (tenant_id,sku_code,occurred_at),
|
||||
KEY idx_ll_inventory_ledger_business (tenant_id,business_type,business_order_no)
|
||||
) ENGINE=InnoDB COMMENT='Immutable inventory package and quantity ledger';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_inventory_operation (
|
||||
id BIGINT NOT NULL COMMENT 'Snowflake ID', tenant_id VARCHAR(128) NOT NULL,
|
||||
idempotency_key VARCHAR(255) NOT NULL, operation_type VARCHAR(32) NOT NULL,
|
||||
request_hash VARCHAR(128) NOT NULL, status VARCHAR(32) NOT NULL DEFAULT 'PROCESSING',
|
||||
result_json LONGTEXT NULL, last_error VARCHAR(2000) NULL,
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY (id), UNIQUE KEY uk_ll_inventory_operation_key (tenant_id,idempotency_key),
|
||||
KEY idx_ll_inventory_operation_status (tenant_id,status,updated_at)
|
||||
) ENGINE=InnoDB COMMENT='Inventory command idempotency record';
|
||||
|
||||
-- ============================================================================
|
||||
-- 2026-08-17 / 009
|
||||
-- PDA SKU/pallet receipt snapshots for stock-in closure
|
||||
-- ============================================================================
|
||||
|
||||
USE baiyue_lastleg;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_inbound_stock_receipt (
|
||||
id BIGINT NOT NULL COMMENT 'Snowflake ID', tenant_id VARCHAR(128) NOT NULL,
|
||||
plan_id BIGINT NOT NULL, task_id BIGINT NOT NULL, allocation_id BIGINT NOT NULL,
|
||||
line_id BIGINT NOT NULL, order_id BIGINT NOT NULL, inbound_code VARCHAR(128) NOT NULL,
|
||||
sku_code VARCHAR(128) NOT NULL, sku_name_zh VARCHAR(255) NULL, source_detail_ids VARCHAR(2000) NULL,
|
||||
warehouse_pallet_id BIGINT NOT NULL, pallet_no VARCHAR(64) NOT NULL,
|
||||
warehouse_location_id BIGINT NULL, warehouse_location_code VARCHAR(128) NULL,
|
||||
batch_no VARCHAR(128) NOT NULL, received_packages BIGINT NOT NULL, received_quantity BIGINT NOT NULL,
|
||||
inventory_balance_id BIGINT NOT NULL, inventory_position_id BIGINT NOT NULL,
|
||||
inventory_operation_key VARCHAR(255) NOT NULL, received_at DATETIME(3) NOT NULL,
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY (id), UNIQUE KEY uk_ll_stock_receipt_task_allocation_sku (tenant_id,task_id,allocation_id,sku_code),
|
||||
KEY idx_ll_stock_receipt_plan (tenant_id,plan_id,line_id), KEY idx_ll_stock_receipt_inventory (inventory_balance_id,inventory_position_id)
|
||||
) ENGINE=InnoDB COMMENT='Immutable SKU-pallet PDA stock-in receipt snapshot';
|
||||
|
||||
-- 2026-08-17 warehouse virtual location and generated pallet code support.
|
||||
USE baiyue_lastlag_warehouse;
|
||||
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
|
||||
AND table_name='wh_pallet' AND column_name='pallet_code'),
|
||||
'ALTER TABLE wh_pallet MODIFY COLUMN pallet_code VARCHAR(128) NOT NULL COMMENT ''Warehouse pallet code''', 'SELECT 1'));
|
||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
INSERT INTO wh_location (id, tenant_id, warehouse_id, zone_code, location_code, location_type,
|
||||
max_load_kg, status, remark)
|
||||
SELECT UUID_SHORT(), w.tenant_id, w.id, 'DEFAULT', CONCAT('LOC-', w.warehouse_code, '-DEFAULT'),
|
||||
'VIRTUAL', 999999999.000, 'ACTIVE', 'System default virtual location'
|
||||
FROM wh_warehouse w
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM wh_location l
|
||||
WHERE l.tenant_id = w.tenant_id AND l.warehouse_id = w.id
|
||||
AND l.location_code = CONCAT('LOC-', w.warehouse_code, '-DEFAULT') AND l.deleted = 0
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- 2026-08-17 / 010
|
||||
-- Last-leg pre-dispatch orders and pallet locks
|
||||
-- ============================================================================
|
||||
|
||||
USE baiyue_lastleg;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_dispatch_order (
|
||||
id BIGINT NOT NULL COMMENT 'Snowflake ID', tenant_id VARCHAR(128) NOT NULL,
|
||||
dispatch_no VARCHAR(64) NOT NULL, vehicle_plate VARCHAR(64) NULL, driver_name VARCHAR(128) NULL,
|
||||
driver_phone VARCHAR(64) NULL, carrier_name VARCHAR(255) NULL, route VARCHAR(255) NULL,
|
||||
depart_at DATETIME(3) NULL, fba_appointment_no VARCHAR(64) NULL,
|
||||
fba_appointment_status VARCHAR(32) NOT NULL DEFAULT 'PENDING',
|
||||
vehicle_appointment_status VARCHAR(32) NOT NULL DEFAULT 'PENDING',
|
||||
estimated_arrival_at DATETIME(3) NULL, status VARCHAR(32) NOT NULL DEFAULT 'PENDING_DEPARTURE',
|
||||
item_count INT NOT NULL DEFAULT 0, pallet_count INT NOT NULL DEFAULT 0, package_count INT NOT NULL DEFAULT 0,
|
||||
created_by VARCHAR(128) NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY (id), UNIQUE KEY uk_ll_dispatch_order_no (tenant_id,dispatch_no),
|
||||
KEY idx_ll_dispatch_order_list (tenant_id,status,depart_at), KEY idx_ll_dispatch_order_vehicle (tenant_id,vehicle_plate)
|
||||
) ENGINE=InnoDB COMMENT='Last-leg pre-dispatch order';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_dispatch_item (
|
||||
id BIGINT NOT NULL COMMENT 'Snowflake ID', dispatch_order_id BIGINT NOT NULL, tenant_id VARCHAR(128) NOT NULL,
|
||||
plan_id BIGINT NULL, plan_pallet_id BIGINT NULL, warehouse_pallet_id BIGINT NULL,
|
||||
order_id BIGINT NULL, order_no VARCHAR(64) NULL, order_refs VARCHAR(2000) NULL, inbound_code VARCHAR(128) NULL, pallet_no VARCHAR(128) NOT NULL,
|
||||
destination_warehouse_id VARCHAR(128) NULL, destination_warehouse_code VARCHAR(128) NULL,
|
||||
destination_warehouse_name VARCHAR(255) NULL, fba_warehouse_code VARCHAR(128) NULL,
|
||||
package_count INT NOT NULL DEFAULT 0, volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0,
|
||||
source_stage VARCHAR(32) NOT NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY (id), UNIQUE KEY uk_ll_dispatch_item_source (dispatch_order_id,plan_pallet_id,warehouse_pallet_id),
|
||||
KEY idx_ll_dispatch_item_order (tenant_id,order_id), KEY idx_ll_dispatch_item_inbound (tenant_id,inbound_code),
|
||||
KEY idx_ll_dispatch_item_pallet (tenant_id,plan_pallet_id,warehouse_pallet_id)
|
||||
) ENGINE=InnoDB COMMENT='Frozen pallet and cargo snapshot for a dispatch order';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_dispatch_pallet_lock (
|
||||
id BIGINT NOT NULL COMMENT 'Snowflake ID', tenant_id VARCHAR(128) NOT NULL,
|
||||
source_pallet_key VARCHAR(160) NOT NULL, active_key VARCHAR(160) NULL,
|
||||
plan_id BIGINT NULL, plan_pallet_id BIGINT NULL, warehouse_pallet_id BIGINT NULL,
|
||||
dispatch_order_id BIGINT NOT NULL, status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE',
|
||||
locked_at DATETIME(3) NOT NULL, released_at DATETIME(3) NULL,
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY (id), UNIQUE KEY uk_ll_dispatch_active_pallet (tenant_id,active_key),
|
||||
KEY idx_ll_dispatch_pallet_lock_order (tenant_id,dispatch_order_id,status),
|
||||
KEY idx_ll_dispatch_pallet_lock_source (tenant_id,source_pallet_key,status)
|
||||
) ENGINE=InnoDB COMMENT='Active dispatch pallet exclusion lock';
|
||||
|
||||
-- ============================================================================
|
||||
-- 2026-08-17 / 011
|
||||
-- Dispatch PDA task and immutable pallet pick operations
|
||||
-- ============================================================================
|
||||
|
||||
USE baiyue_lastleg;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_dispatch_pda_task (
|
||||
id BIGINT NOT NULL, tenant_id VARCHAR(128) NOT NULL, dispatch_order_id BIGINT NOT NULL,
|
||||
task_no VARCHAR(64) NOT NULL, status VARCHAR(32) NOT NULL, operator_id VARCHAR(128) NULL,
|
||||
operator_name VARCHAR(128) NULL, pushed_at DATETIME(3) NOT NULL, completed_at DATETIME(3) NULL,
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY (id), UNIQUE KEY uk_ll_dispatch_pda_task_dispatch (tenant_id,dispatch_order_id),
|
||||
UNIQUE KEY uk_ll_dispatch_pda_task_no (tenant_id,task_no)
|
||||
) ENGINE=InnoDB COMMENT='PDA task for a pre-arranged dispatch';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_dispatch_pda_pick (
|
||||
id BIGINT NOT NULL, tenant_id VARCHAR(128) NOT NULL, task_id BIGINT NOT NULL,
|
||||
dispatch_item_id BIGINT NOT NULL, inventory_position_id BIGINT NOT NULL,
|
||||
packages BIGINT NOT NULL, product_quantity BIGINT NOT NULL DEFAULT 0, idempotency_key VARCHAR(255) NOT NULL,
|
||||
picked_at DATETIME(3) NOT NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY (id), UNIQUE KEY uk_ll_dispatch_pda_pick_key (tenant_id,idempotency_key),
|
||||
KEY idx_ll_dispatch_pda_pick_item (tenant_id,task_id,dispatch_item_id)
|
||||
) ENGINE=InnoDB COMMENT='Immutable dispatch PDA pallet package picks';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_dispatch_pda_operation (
|
||||
id BIGINT NOT NULL, tenant_id VARCHAR(128) NOT NULL, task_id BIGINT NULL,
|
||||
operation_type VARCHAR(32) NOT NULL, idempotency_key VARCHAR(255) NOT NULL, request_hash CHAR(64) NOT NULL,
|
||||
result_json LONGTEXT NULL, status VARCHAR(32) NOT NULL,
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY (id), UNIQUE KEY uk_ll_dispatch_pda_operation_key (tenant_id,idempotency_key)
|
||||
) ENGINE=InnoDB COMMENT='Idempotent dispatch PDA operations';
|
||||
|
||||
-- ============================================================================
|
||||
-- 2026-08-17 / 012
|
||||
-- Preserve all tail-leg orders included in one merged dispatch pallet
|
||||
-- ============================================================================
|
||||
|
||||
USE baiyue_lastleg;
|
||||
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='order_refs'), 'SELECT 1', 'ALTER TABLE ll_dispatch_item ADD COLUMN order_refs VARCHAR(2000) NULL AFTER order_no'));
|
||||
PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt;
|
||||
|
||||
+22
@@ -3,8 +3,11 @@ package com.baiyue.warehouse.controller;
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.warehouse.dto.WarehouseResourceLockRequests;
|
||||
import com.baiyue.warehouse.dto.WarehouseResourceLockResponse;
|
||||
import com.baiyue.warehouse.dto.WarehousePalletScrapResponse;
|
||||
import com.baiyue.warehouse.entity.WarehouseLocationEntity;
|
||||
import com.baiyue.warehouse.security.InternalServiceTokenGuard;
|
||||
import com.baiyue.warehouse.service.WarehouseResourceLockService;
|
||||
import com.baiyue.warehouse.service.WarehouseResourceService;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
@@ -18,6 +21,7 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
@RequestMapping("/internal/v1/warehouse-resource-locks")
|
||||
public class InternalWarehouseResourceLockController {
|
||||
@Resource private WarehouseResourceLockService service;
|
||||
@Resource private WarehouseResourceService resourceService;
|
||||
@Resource private InternalServiceTokenGuard tokenGuard;
|
||||
|
||||
@PostMapping
|
||||
@@ -45,4 +49,22 @@ public class InternalWarehouseResourceLockController {
|
||||
tokenGuard.verify(token);
|
||||
return ApiResponse.success(service.scrap(request.tenantId(), lockId));
|
||||
}
|
||||
|
||||
@PostMapping("/pallets/{palletId}/scrap")
|
||||
public ApiResponse<WarehousePalletScrapResponse> scrapPallet(
|
||||
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||
@PathVariable long palletId,
|
||||
@Valid @RequestBody WarehouseResourceLockRequests.CloseRequest request) {
|
||||
tokenGuard.verify(token);
|
||||
return ApiResponse.success(service.scrapByPallet(request.tenantId(), palletId));
|
||||
}
|
||||
|
||||
@PostMapping("/default-locations/ensure")
|
||||
public ApiResponse<WarehouseLocationEntity> ensureDefaultLocation(
|
||||
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||
@Valid @RequestBody WarehouseResourceLockRequests.DefaultLocationRequest request) {
|
||||
tokenGuard.verify(token);
|
||||
return ApiResponse.success(resourceService.ensureDefaultVirtualLocation(
|
||||
request.tenantId(), request.warehouseId()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ public final class PalletRequests {
|
||||
}
|
||||
|
||||
public record SaveRequest(
|
||||
@NotBlank @Size(max = 64) String palletCode,
|
||||
@Size(max = 128) String palletCode,
|
||||
@NotBlank @Size(max = 32) String palletType,
|
||||
@DecimalMin(value = "0.0000", inclusive = false) BigDecimal capacityCbm,
|
||||
@NotNull @DecimalMin("0.00") BigDecimal unitPrice,
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
package com.baiyue.warehouse.dto;
|
||||
|
||||
public record WarehousePalletScrapResponse(long palletId, long warehouseId, Long locationId, String status) {
|
||||
}
|
||||
@@ -17,4 +17,9 @@ public final class WarehouseResourceLockRequests {
|
||||
) {}
|
||||
|
||||
public record CloseRequest(@Positive long tenantId) {}
|
||||
|
||||
public record DefaultLocationRequest(
|
||||
@Positive long tenantId,
|
||||
@Positive long warehouseId
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,21 @@ package com.baiyue.warehouse.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.warehouse.entity.WarehouseLocationEntity;
|
||||
import org.apache.ibatis.annotations.Insert;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
public interface WarehouseLocationMapper extends BaseMapper<WarehouseLocationEntity> {
|
||||
/**
|
||||
* The unique location-code index makes this safe for concurrent first use of an older warehouse.
|
||||
*/
|
||||
@Insert("""
|
||||
INSERT IGNORE INTO wh_location
|
||||
(id, tenant_id, warehouse_id, zone_code, location_code, location_type,
|
||||
max_load_kg, status, remark, deleted, version)
|
||||
VALUES
|
||||
(#{location.id}, #{location.tenantId}, #{location.warehouseId}, #{location.zoneCode},
|
||||
#{location.locationCode}, #{location.locationType}, #{location.maxLoadKg},
|
||||
#{location.status}, #{location.remark}, 0, 0)
|
||||
""")
|
||||
int insertDefaultVirtualLocationIgnore(@Param("location") WarehouseLocationEntity location);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,11 @@ public interface WarehouseResourceLockMapper extends BaseMapper<WarehouseResourc
|
||||
WarehouseResourceLockEntity findByReservationKey(@Param("tenantId") long tenantId,
|
||||
@Param("reservationKey") String reservationKey);
|
||||
|
||||
@Select("SELECT * FROM wh_resource_lock WHERE tenant_id=#{tenantId} AND pallet_id=#{palletId} " +
|
||||
"AND status='ACTIVE' ORDER BY locked_at DESC LIMIT 1")
|
||||
WarehouseResourceLockEntity findActiveByPallet(@Param("tenantId") long tenantId,
|
||||
@Param("palletId") long palletId);
|
||||
|
||||
@Update("""
|
||||
UPDATE wh_pallet
|
||||
SET status = 'OCCUPIED', location_id = #{locationId}, updated_at = CURRENT_TIMESTAMP(3), version = version + 1
|
||||
@@ -42,6 +47,17 @@ public interface WarehouseResourceLockMapper extends BaseMapper<WarehouseResourc
|
||||
int releasePallet(@Param("tenantId") long tenantId, @Param("warehouseId") long warehouseId,
|
||||
@Param("palletId") long palletId, @Param("locationId") long locationId);
|
||||
|
||||
@Update("""
|
||||
UPDATE wh_pallet
|
||||
SET status = 'IDLE', updated_at = CURRENT_TIMESTAMP(3), version = version + 1
|
||||
WHERE id = #{palletId} AND tenant_id = #{tenantId} AND warehouse_id = #{warehouseId}
|
||||
AND location_id = #{locationId} AND status = 'OCCUPIED' AND deleted = 0
|
||||
""")
|
||||
int releasePalletAtVirtualLocation(@Param("tenantId") long tenantId,
|
||||
@Param("warehouseId") long warehouseId,
|
||||
@Param("palletId") long palletId,
|
||||
@Param("locationId") long locationId);
|
||||
|
||||
@Update("""
|
||||
UPDATE wh_location
|
||||
SET status = 'IDLE', updated_at = CURRENT_TIMESTAMP(3), version = version + 1
|
||||
@@ -60,6 +76,21 @@ public interface WarehouseResourceLockMapper extends BaseMapper<WarehouseResourc
|
||||
int scrapPallet(@Param("tenantId") long tenantId, @Param("warehouseId") long warehouseId,
|
||||
@Param("palletId") long palletId, @Param("locationId") long locationId);
|
||||
|
||||
@Update("""
|
||||
UPDATE wh_pallet
|
||||
SET status = 'SCRAPPED', updated_at = CURRENT_TIMESTAMP(3), version = version + 1
|
||||
WHERE id = #{palletId} AND tenant_id = #{tenantId} AND warehouse_id = #{warehouseId}
|
||||
AND location_id = #{locationId} AND status = 'OCCUPIED' AND deleted = 0
|
||||
""")
|
||||
int scrapPalletAtVirtualLocation(@Param("tenantId") long tenantId,
|
||||
@Param("warehouseId") long warehouseId,
|
||||
@Param("palletId") long palletId,
|
||||
@Param("locationId") long locationId);
|
||||
|
||||
@Update("UPDATE wh_pallet SET status='SCRAPPED',updated_at=CURRENT_TIMESTAMP(3),version=version+1 " +
|
||||
"WHERE tenant_id=#{tenantId} AND id=#{palletId} AND status='OCCUPIED' AND deleted=0")
|
||||
int scrapPalletWithoutLock(@Param("tenantId") long tenantId, @Param("palletId") long palletId);
|
||||
|
||||
@Update("""
|
||||
UPDATE wh_resource_lock
|
||||
SET status = #{status}, released_at = CURRENT_TIMESTAMP(3), updated_at = CURRENT_TIMESTAMP(3)
|
||||
|
||||
@@ -7,9 +7,11 @@ import com.baiyue.warehouse.dto.WarehouseRequests;
|
||||
import com.baiyue.warehouse.entity.ServiceCapabilityEntity;
|
||||
import com.baiyue.warehouse.entity.WarehouseAddressEntity;
|
||||
import com.baiyue.warehouse.entity.WarehouseEntity;
|
||||
import com.baiyue.warehouse.entity.WarehouseLocationEntity;
|
||||
import com.baiyue.warehouse.mapper.ServiceCapabilityMapper;
|
||||
import com.baiyue.warehouse.mapper.WarehouseAddressMapper;
|
||||
import com.baiyue.warehouse.mapper.WarehouseMapper;
|
||||
import com.baiyue.warehouse.mapper.WarehouseLocationMapper;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import org.springframework.util.StringUtils;
|
||||
import jakarta.annotation.Resource;
|
||||
@@ -22,6 +24,7 @@ import java.util.List;
|
||||
@Repository
|
||||
public class WarehouseRepository {
|
||||
@Resource private WarehouseMapper warehouses;
|
||||
@Resource private WarehouseLocationMapper locations;
|
||||
@Resource private WarehouseAddressMapper addresses;
|
||||
@Resource private ServiceCapabilityMapper capabilities;
|
||||
|
||||
@@ -29,6 +32,17 @@ public class WarehouseRepository {
|
||||
warehouses.insert(warehouse);
|
||||
}
|
||||
|
||||
public void insertLocation(WarehouseLocationEntity location) {
|
||||
locations.insert(location);
|
||||
}
|
||||
|
||||
public boolean existsLocationCode(long tenantId, long warehouseId, String locationCode) {
|
||||
return locations.selectCount(new LambdaQueryWrapper<WarehouseLocationEntity>()
|
||||
.eq(WarehouseLocationEntity::getTenantId, tenantId)
|
||||
.eq(WarehouseLocationEntity::getWarehouseId, warehouseId)
|
||||
.eq(WarehouseLocationEntity::getLocationCode, locationCode)) > 0;
|
||||
}
|
||||
|
||||
public void saveWarehouse(WarehouseEntity warehouse) {
|
||||
warehouses.updateById(warehouse);
|
||||
}
|
||||
|
||||
+24
@@ -26,6 +26,16 @@ public class WarehouseResourceLockRepository {
|
||||
.eq(WarehouseResourceLockEntity::getId, lockId));
|
||||
}
|
||||
|
||||
public WarehouseResourceLockEntity findActiveByPallet(long tenantId, long palletId) {
|
||||
return locks.findActiveByPallet(tenantId, palletId);
|
||||
}
|
||||
|
||||
public WarehousePalletEntity findPallet(long tenantId, long palletId) {
|
||||
return pallets.selectOne(new LambdaQueryWrapper<WarehousePalletEntity>()
|
||||
.eq(WarehousePalletEntity::getTenantId, tenantId)
|
||||
.eq(WarehousePalletEntity::getId, palletId));
|
||||
}
|
||||
|
||||
public WarehousePalletEntity findPallet(long tenantId, long warehouseId, long palletId) {
|
||||
return pallets.selectOne(new LambdaQueryWrapper<WarehousePalletEntity>()
|
||||
.eq(WarehousePalletEntity::getTenantId, tenantId)
|
||||
@@ -54,10 +64,24 @@ public class WarehouseResourceLockRepository {
|
||||
return locks.releasePallet(lock.getTenantId(), lock.getWarehouseId(), lock.getPalletId(), lock.getLocationId());
|
||||
}
|
||||
|
||||
public int releasePalletAtVirtualLocation(WarehouseResourceLockEntity lock) {
|
||||
return locks.releasePalletAtVirtualLocation(lock.getTenantId(), lock.getWarehouseId(),
|
||||
lock.getPalletId(), lock.getLocationId());
|
||||
}
|
||||
|
||||
public int scrapPallet(WarehouseResourceLockEntity lock) {
|
||||
return locks.scrapPallet(lock.getTenantId(), lock.getWarehouseId(), lock.getPalletId(), lock.getLocationId());
|
||||
}
|
||||
|
||||
public int scrapPalletWithoutLock(long tenantId, long palletId) {
|
||||
return locks.scrapPalletWithoutLock(tenantId, palletId);
|
||||
}
|
||||
|
||||
public int scrapPalletAtVirtualLocation(WarehouseResourceLockEntity lock) {
|
||||
return locks.scrapPalletAtVirtualLocation(lock.getTenantId(), lock.getWarehouseId(),
|
||||
lock.getPalletId(), lock.getLocationId());
|
||||
}
|
||||
|
||||
public int releaseLocation(WarehouseResourceLockEntity lock) {
|
||||
return locks.releaseLocation(lock.getTenantId(), lock.getWarehouseId(), lock.getLocationId());
|
||||
}
|
||||
|
||||
+27
@@ -27,10 +27,37 @@ public class WarehouseResourceRepository {
|
||||
.eq(WarehouseEntity::getId, warehouseId)) > 0;
|
||||
}
|
||||
|
||||
public WarehouseEntity findWarehouse(long tenantId, long warehouseId) {
|
||||
return warehouses.selectOne(new LambdaQueryWrapper<WarehouseEntity>()
|
||||
.eq(WarehouseEntity::getTenantId, tenantId)
|
||||
.eq(WarehouseEntity::getId, warehouseId));
|
||||
}
|
||||
|
||||
public WarehouseLocationEntity findDefaultVirtualLocation(long tenantId, long warehouseId) {
|
||||
return locations.selectOne(new LambdaQueryWrapper<WarehouseLocationEntity>()
|
||||
.eq(WarehouseLocationEntity::getTenantId, tenantId)
|
||||
.eq(WarehouseLocationEntity::getWarehouseId, warehouseId)
|
||||
.eq(WarehouseLocationEntity::getLocationType, "VIRTUAL")
|
||||
.eq(WarehouseLocationEntity::getStatus, "ACTIVE")
|
||||
.orderByAsc(WarehouseLocationEntity::getCreatedAt)
|
||||
.last("LIMIT 1"));
|
||||
}
|
||||
|
||||
public WarehouseLocationEntity findLocationByCode(long tenantId, long warehouseId, String locationCode) {
|
||||
return locations.selectOne(new LambdaQueryWrapper<WarehouseLocationEntity>()
|
||||
.eq(WarehouseLocationEntity::getTenantId, tenantId)
|
||||
.eq(WarehouseLocationEntity::getWarehouseId, warehouseId)
|
||||
.eq(WarehouseLocationEntity::getLocationCode, locationCode));
|
||||
}
|
||||
|
||||
public void insertLocation(WarehouseLocationEntity entity) {
|
||||
locations.insert(entity);
|
||||
}
|
||||
|
||||
public int insertDefaultVirtualLocationIgnore(WarehouseLocationEntity entity) {
|
||||
return locations.insertDefaultVirtualLocationIgnore(entity);
|
||||
}
|
||||
|
||||
public void updateLocation(WarehouseLocationEntity entity) {
|
||||
locations.updateById(entity);
|
||||
}
|
||||
|
||||
@@ -10,11 +10,13 @@ import com.baiyue.warehouse.dto.WarehouseRequests;
|
||||
import com.baiyue.warehouse.entity.ServiceCapabilityEntity;
|
||||
import com.baiyue.warehouse.entity.WarehouseAddressEntity;
|
||||
import com.baiyue.warehouse.entity.WarehouseEntity;
|
||||
import com.baiyue.warehouse.entity.WarehouseLocationEntity;
|
||||
import com.baiyue.warehouse.repository.WarehouseRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/**
|
||||
* 仓库应用服务,负责仓库主数据、地址和服务能力的租户级用例编排、事务控制与业务校验。
|
||||
@@ -37,10 +39,28 @@ public class WarehouseApplicationService {
|
||||
ensureCodeUnique(request.tenantId(), request.warehouseCode(), null);
|
||||
WarehouseEntity warehouse = toEntity(request);
|
||||
repository.insertWarehouse(warehouse);
|
||||
ensureDefaultVirtualLocation(warehouse);
|
||||
insertChildren(warehouse.getTenantId(), warehouse.getId(), request.address(), request.capabilities());
|
||||
return warehouse.getId();
|
||||
}
|
||||
|
||||
private void ensureDefaultVirtualLocation(WarehouseEntity warehouse) {
|
||||
String locationCode = "LOC-" + warehouse.getWarehouseCode() + "-DEFAULT";
|
||||
if (repository.existsLocationCode(warehouse.getTenantId(), warehouse.getId(), locationCode)) {
|
||||
return;
|
||||
}
|
||||
WarehouseLocationEntity location = new WarehouseLocationEntity();
|
||||
location.setTenantId(warehouse.getTenantId());
|
||||
location.setWarehouseId(warehouse.getId());
|
||||
location.setZoneCode("DEFAULT");
|
||||
location.setLocationCode(locationCode);
|
||||
location.setLocationType("VIRTUAL");
|
||||
location.setMaxLoadKg(new BigDecimal("999999999.000"));
|
||||
location.setStatus("ACTIVE");
|
||||
location.setRemark("System default virtual location");
|
||||
repository.insertLocation(location);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@BusinessAudit(
|
||||
event = "WAREHOUSE_UPDATE",
|
||||
|
||||
+41
-4
@@ -4,6 +4,7 @@ import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.warehouse.dto.WarehouseResourceLockRequests;
|
||||
import com.baiyue.warehouse.dto.WarehouseResourceLockResponse;
|
||||
import com.baiyue.warehouse.dto.WarehousePalletScrapResponse;
|
||||
import com.baiyue.warehouse.entity.WarehouseLocationEntity;
|
||||
import com.baiyue.warehouse.entity.WarehousePalletEntity;
|
||||
import com.baiyue.warehouse.entity.WarehouseResourceLockEntity;
|
||||
@@ -33,7 +34,9 @@ public class WarehouseResourceLockService {
|
||||
if (concurrent != null) return idempotentResult(concurrent, request);
|
||||
throw new BizException(ResultCode.NOT_FOUND, "Pallet or location does not belong to the tenant warehouse");
|
||||
}
|
||||
if (!"IDLE".equals(pallet.getStatus()) || !"IDLE".equals(location.getStatus())) {
|
||||
boolean virtualLocation = isVirtual(location);
|
||||
boolean locationAvailable = virtualLocation ? "ACTIVE".equals(location.getStatus()) : "IDLE".equals(location.getStatus());
|
||||
if (!"IDLE".equals(pallet.getStatus()) || !locationAvailable) {
|
||||
WarehouseResourceLockEntity concurrent =
|
||||
repository.findByReservationKey(request.tenantId(), request.reservationKey());
|
||||
if (concurrent != null) return idempotentResult(concurrent, request);
|
||||
@@ -42,7 +45,7 @@ public class WarehouseResourceLockService {
|
||||
if (repository.occupyPallet(request.tenantId(), request.warehouseId(), request.palletId(), request.locationId()) != 1) {
|
||||
return concurrentIdempotentOrConflict(request);
|
||||
}
|
||||
if (repository.occupyLocation(request.tenantId(), request.warehouseId(), request.locationId()) != 1) {
|
||||
if (!virtualLocation && repository.occupyLocation(request.tenantId(), request.warehouseId(), request.locationId()) != 1) {
|
||||
throw new BizException(ResultCode.CONFLICT, "Location is no longer IDLE");
|
||||
}
|
||||
|
||||
@@ -65,7 +68,9 @@ public class WarehouseResourceLockService {
|
||||
WarehouseResourceLockEntity lock = requireLock(tenantId, lockId);
|
||||
if ("RELEASED".equals(lock.getStatus())) return WarehouseResourceLockResponse.from(lock);
|
||||
requireActive(lock);
|
||||
if (repository.releasePallet(lock) != 1 || repository.releaseLocation(lock) != 1
|
||||
boolean virtualLocation = isVirtualLockLocation(lock);
|
||||
int palletResult = virtualLocation ? repository.releasePalletAtVirtualLocation(lock) : repository.releasePallet(lock);
|
||||
if (palletResult != 1 || (!virtualLocation && repository.releaseLocation(lock) != 1)
|
||||
|| repository.close(lock, "RELEASED") != 1) {
|
||||
throw new BizException(ResultCode.CONFLICT, "Resource lock state changed concurrently");
|
||||
}
|
||||
@@ -79,7 +84,9 @@ public class WarehouseResourceLockService {
|
||||
WarehouseResourceLockEntity lock = requireLock(tenantId, lockId);
|
||||
if ("SCRAPPED".equals(lock.getStatus())) return WarehouseResourceLockResponse.from(lock);
|
||||
requireActive(lock);
|
||||
if (repository.scrapPallet(lock) != 1 || repository.releaseLocation(lock) != 1
|
||||
boolean virtualLocation = isVirtualLockLocation(lock);
|
||||
int palletResult = virtualLocation ? repository.scrapPalletAtVirtualLocation(lock) : repository.scrapPallet(lock);
|
||||
if (palletResult != 1 || (!virtualLocation && repository.releaseLocation(lock) != 1)
|
||||
|| repository.close(lock, "SCRAPPED") != 1) {
|
||||
throw new BizException(ResultCode.CONFLICT, "Resource lock state changed concurrently");
|
||||
}
|
||||
@@ -88,6 +95,25 @@ public class WarehouseResourceLockService {
|
||||
return WarehouseResourceLockResponse.from(lock);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public WarehousePalletScrapResponse scrapByPallet(long tenantId, long palletId) {
|
||||
WarehousePalletEntity pallet = repository.findPallet(tenantId, palletId);
|
||||
if (pallet == null) throw new BizException(ResultCode.NOT_FOUND, "Pallet not found");
|
||||
if ("SCRAPPED".equals(pallet.getStatus())) {
|
||||
return new WarehousePalletScrapResponse(palletId, pallet.getWarehouseId(), pallet.getLocationId(), "SCRAPPED");
|
||||
}
|
||||
if (!"OCCUPIED".equals(pallet.getStatus())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "Only an OCCUPIED pallet can be scrapped after shipment");
|
||||
}
|
||||
WarehouseResourceLockEntity lock = repository.findActiveByPallet(tenantId, palletId);
|
||||
if (lock != null) {
|
||||
scrap(tenantId, lock.getId());
|
||||
} else if (repository.scrapPalletWithoutLock(tenantId, palletId) != 1) {
|
||||
throw new BizException(ResultCode.CONFLICT, "Pallet state changed concurrently");
|
||||
}
|
||||
return new WarehousePalletScrapResponse(palletId, pallet.getWarehouseId(), pallet.getLocationId(), "SCRAPPED");
|
||||
}
|
||||
|
||||
private WarehouseResourceLockResponse concurrentIdempotentOrConflict(
|
||||
WarehouseResourceLockRequests.ReserveRequest request) {
|
||||
WarehouseResourceLockEntity existing = repository.findByReservationKey(request.tenantId(), request.reservationKey());
|
||||
@@ -118,4 +144,15 @@ public class WarehouseResourceLockService {
|
||||
throw new BizException(ResultCode.CONFLICT, "Resource lock is not ACTIVE");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private boolean isVirtualLockLocation(WarehouseResourceLockEntity lock) {
|
||||
WarehouseLocationEntity location = repository.findLocation(lock.getTenantId(), lock.getWarehouseId(), lock.getLocationId());
|
||||
if (location == null) throw new BizException(ResultCode.NOT_FOUND, "Resource lock location not found");
|
||||
return isVirtual(location);
|
||||
}
|
||||
|
||||
private static boolean isVirtual(WarehouseLocationEntity location) {
|
||||
return "VIRTUAL".equals(location.getLocationType());
|
||||
}
|
||||
}
|
||||
|
||||
+106
-17
@@ -7,12 +7,17 @@ import com.baiyue.warehouse.dto.LocationRequests;
|
||||
import com.baiyue.warehouse.dto.PalletRequests;
|
||||
import com.baiyue.warehouse.entity.WarehouseLocationEntity;
|
||||
import com.baiyue.warehouse.entity.WarehousePalletEntity;
|
||||
import com.baiyue.warehouse.entity.WarehouseEntity;
|
||||
import com.baiyue.warehouse.repository.WarehouseResourceRepository;
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import jakarta.annotation.Resource;
|
||||
|
||||
import java.util.Set;
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
@Service
|
||||
public class WarehouseResourceService {
|
||||
@@ -33,6 +38,11 @@ public class WarehouseResourceService {
|
||||
@Transactional
|
||||
public void updateLocation(long tenantId, long warehouseId, long id, LocationRequests.SaveRequest request) {
|
||||
WarehouseLocationEntity existing = requireLocation(tenantId, warehouseId, id);
|
||||
if ("VIRTUAL".equals(existing.getLocationType())
|
||||
&& (!"VIRTUAL".equals(request.locationType()) || !"ACTIVE".equals(request.status())
|
||||
|| !existing.getLocationCode().equals(request.locationCode()))) {
|
||||
throw new BizException(ResultCode.CONFLICT, "Default virtual location must remain VIRTUAL and ACTIVE");
|
||||
}
|
||||
validateLocation(request, tenantId, warehouseId, id);
|
||||
WarehouseLocationEntity entity = locationEntity(tenantId, warehouseId, request);
|
||||
entity.setId(existing.getId());
|
||||
@@ -52,7 +62,10 @@ public class WarehouseResourceService {
|
||||
|
||||
@Transactional
|
||||
public void deleteLocation(long tenantId, long warehouseId, long id) {
|
||||
requireLocation(tenantId, warehouseId, id);
|
||||
WarehouseLocationEntity location = requireLocation(tenantId, warehouseId, id);
|
||||
if ("VIRTUAL".equals(location.getLocationType())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "Default virtual location cannot be deleted");
|
||||
}
|
||||
if (repository.locationHasPallets(tenantId, warehouseId, id)) {
|
||||
throw new BizException(ResultCode.CONFLICT, "库位仍绑定托盘,不能删除");
|
||||
}
|
||||
@@ -61,18 +74,69 @@ public class WarehouseResourceService {
|
||||
|
||||
@Transactional
|
||||
public Long createPallet(long tenantId, long warehouseId, PalletRequests.SaveRequest request) {
|
||||
requireWarehouse(tenantId, warehouseId);
|
||||
validatePallet(request, tenantId, warehouseId, null);
|
||||
WarehousePalletEntity entity = palletEntity(tenantId, warehouseId, request);
|
||||
WarehouseEntity warehouse = requireWarehouseEntity(tenantId, warehouseId);
|
||||
validatePalletStatus(request.status());
|
||||
Long palletId = IdWorker.getId();
|
||||
String palletCode = StringUtils.hasText(request.palletCode()) ? request.palletCode().trim()
|
||||
: generatedPalletCode(warehouse.getWarehouseCode(), palletId);
|
||||
Long locationId = request.locationId();
|
||||
if (locationId == null) {
|
||||
locationId = ensureDefaultVirtualLocation(tenantId, warehouseId).getId();
|
||||
}
|
||||
validatePallet(request, palletCode, locationId, tenantId, warehouseId, null);
|
||||
WarehousePalletEntity entity = palletEntity(tenantId, warehouseId, request, palletCode, locationId);
|
||||
entity.setId(palletId);
|
||||
repository.insertPallet(entity);
|
||||
return entity.getId();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the warehouse-scoped virtual location used only as a pallet/inventory reference.
|
||||
* It is deliberately never made IDLE/OCCUPIED by resource locking.
|
||||
*/
|
||||
@Transactional
|
||||
public WarehouseLocationEntity ensureDefaultVirtualLocation(long tenantId, long warehouseId) {
|
||||
WarehouseEntity warehouse = requireWarehouseEntity(tenantId, warehouseId);
|
||||
String locationCode = defaultLocationCode(warehouse.getWarehouseCode());
|
||||
WarehouseLocationEntity existing = repository.findLocationByCode(tenantId, warehouseId, locationCode);
|
||||
if (existing != null) {
|
||||
if (!"VIRTUAL".equals(existing.getLocationType()) || !"ACTIVE".equals(existing.getStatus())) {
|
||||
throw new BizException(ResultCode.CONFLICT,
|
||||
"Default virtual location code is occupied by a non-active virtual location");
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
|
||||
WarehouseLocationEntity location = new WarehouseLocationEntity();
|
||||
location.setId(IdWorker.getId());
|
||||
location.setTenantId(tenantId);
|
||||
location.setWarehouseId(warehouseId);
|
||||
location.setZoneCode("DEFAULT");
|
||||
location.setLocationCode(locationCode);
|
||||
location.setLocationType("VIRTUAL");
|
||||
location.setMaxLoadKg(new java.math.BigDecimal("999999999.000"));
|
||||
location.setStatus("ACTIVE");
|
||||
location.setRemark("System default virtual location");
|
||||
repository.insertDefaultVirtualLocationIgnore(location);
|
||||
|
||||
WarehouseLocationEntity resolved = repository.findLocationByCode(tenantId, warehouseId, locationCode);
|
||||
if (resolved == null) {
|
||||
throw new BizException(ResultCode.CONFLICT, "Default virtual location could not be created");
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void updatePallet(long tenantId, long warehouseId, long id, PalletRequests.SaveRequest request) {
|
||||
WarehousePalletEntity existing = requirePallet(tenantId, warehouseId, id);
|
||||
validatePallet(request, tenantId, warehouseId, id);
|
||||
WarehousePalletEntity entity = palletEntity(tenantId, warehouseId, request);
|
||||
if ("SCRAPPED".equals(existing.getStatus()) && !"SCRAPPED".equals(request.status())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "SCRAPPED pallet cannot be reused");
|
||||
}
|
||||
String palletCode = StringUtils.hasText(request.palletCode()) ? request.palletCode().trim()
|
||||
: existing.getPalletCode();
|
||||
Long locationId = request.locationId() == null ? existing.getLocationId() : request.locationId();
|
||||
validatePallet(request, palletCode, locationId, tenantId, warehouseId, id);
|
||||
WarehousePalletEntity entity = palletEntity(tenantId, warehouseId, request, palletCode, locationId);
|
||||
entity.setId(existing.getId());
|
||||
repository.updatePallet(entity);
|
||||
}
|
||||
@@ -94,6 +158,9 @@ public class WarehouseResourceService {
|
||||
if ("OCCUPIED".equals(pallet.getStatus())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "占用中的托盘不能删除");
|
||||
}
|
||||
if ("SCRAPPED".equals(pallet.getStatus())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "Scrapped pallet must be retained and cannot be reused");
|
||||
}
|
||||
repository.deletePallet(id);
|
||||
}
|
||||
|
||||
@@ -103,6 +170,14 @@ public class WarehouseResourceService {
|
||||
}
|
||||
}
|
||||
|
||||
private WarehouseEntity requireWarehouseEntity(long tenantId, long warehouseId) {
|
||||
WarehouseEntity warehouse = repository.findWarehouse(tenantId, warehouseId);
|
||||
if (warehouse == null) {
|
||||
throw new BizException(ResultCode.NOT_FOUND, "Tenant warehouse not found");
|
||||
}
|
||||
return warehouse;
|
||||
}
|
||||
|
||||
private WarehouseLocationEntity requireLocation(long tenantId, long warehouseId, long id) {
|
||||
requireWarehouse(tenantId, warehouseId);
|
||||
WarehouseLocationEntity entity = repository.findLocation(tenantId, warehouseId, id);
|
||||
@@ -127,16 +202,20 @@ public class WarehouseResourceService {
|
||||
}
|
||||
}
|
||||
|
||||
private void validatePallet(PalletRequests.SaveRequest request, long tenantId,
|
||||
long warehouseId, Long excludedId) {
|
||||
if (!PALLET_STATUSES.contains(request.status())) {
|
||||
throw new BizException("托盘状态仅支持 IDLE、OCCUPIED、SCRAPPED");
|
||||
}
|
||||
if (repository.palletCodeExists(tenantId, warehouseId, request.palletCode(), excludedId)) {
|
||||
private void validatePallet(PalletRequests.SaveRequest request, String palletCode, Long locationId,
|
||||
long tenantId, long warehouseId, Long excludedId) {
|
||||
validatePalletStatus(request.status());
|
||||
if (repository.palletCodeExists(tenantId, warehouseId, palletCode, excludedId)) {
|
||||
throw new BizException(ResultCode.CONFLICT, "当前仓库下托盘编码已存在");
|
||||
}
|
||||
if (request.locationId() != null) {
|
||||
requireLocation(tenantId, warehouseId, request.locationId());
|
||||
if (locationId != null) {
|
||||
requireLocation(tenantId, warehouseId, locationId);
|
||||
}
|
||||
}
|
||||
|
||||
private void validatePalletStatus(String status) {
|
||||
if (!PALLET_STATUSES.contains(status)) {
|
||||
throw new BizException("托盘状态仅支持 IDLE、OCCUPIED、SCRAPPED");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,12 +234,13 @@ public class WarehouseResourceService {
|
||||
}
|
||||
|
||||
private WarehousePalletEntity palletEntity(long tenantId, long warehouseId,
|
||||
PalletRequests.SaveRequest request) {
|
||||
PalletRequests.SaveRequest request,
|
||||
String palletCode, Long locationId) {
|
||||
WarehousePalletEntity entity = new WarehousePalletEntity();
|
||||
entity.setTenantId(tenantId);
|
||||
entity.setWarehouseId(warehouseId);
|
||||
entity.setLocationId(request.locationId());
|
||||
entity.setPalletCode(request.palletCode());
|
||||
entity.setLocationId(locationId);
|
||||
entity.setPalletCode(palletCode);
|
||||
entity.setPalletType(request.palletType());
|
||||
entity.setCapacityCbm(request.capacityCbm());
|
||||
entity.setUnitPrice(request.unitPrice());
|
||||
@@ -168,4 +248,13 @@ public class WarehouseResourceService {
|
||||
entity.setRemark(request.remark());
|
||||
return entity;
|
||||
}
|
||||
|
||||
private static String generatedPalletCode(String warehouseCode, long palletId) {
|
||||
return "PLT-" + warehouseCode + "-" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE)
|
||||
+ "-" + Long.toUnsignedString(palletId);
|
||||
}
|
||||
|
||||
private static String defaultLocationCode(String warehouseCode) {
|
||||
return "LOC-" + warehouseCode + "-DEFAULT";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ CREATE TABLE IF NOT EXISTS wh_pallet (
|
||||
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
|
||||
warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID',
|
||||
location_id BIGINT UNSIGNED DEFAULT NULL COMMENT '同仓库内绑定的库位ID,可为空',
|
||||
pallet_code VARCHAR(64) NOT NULL COMMENT '仓库内唯一托盘编码',
|
||||
pallet_code VARCHAR(128) NOT NULL COMMENT '仓库内唯一托盘编码',
|
||||
pallet_type VARCHAR(32) NOT NULL COMMENT '托盘类型',
|
||||
capacity_cbm DECIMAL(18,4) NOT NULL DEFAULT 2.0000 COMMENT '托盘容量(立方米)',
|
||||
unit_price DECIMAL(18,2) NOT NULL DEFAULT 0.00 COMMENT '托盘单价',
|
||||
@@ -159,13 +159,25 @@ ALTER TABLE wh_location
|
||||
MODIFY COLUMN deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记',
|
||||
MODIFY COLUMN version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号';
|
||||
|
||||
-- Every warehouse has one non-occupiable virtual location used for inventory history.
|
||||
INSERT INTO wh_location (id, tenant_id, warehouse_id, zone_code, location_code, location_type,
|
||||
max_load_kg, status, remark)
|
||||
SELECT UUID_SHORT(), w.tenant_id, w.id, 'DEFAULT', CONCAT('LOC-', w.warehouse_code, '-DEFAULT'),
|
||||
'VIRTUAL', 999999999.000, 'ACTIVE', 'System default virtual location'
|
||||
FROM wh_warehouse w
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM wh_location l
|
||||
WHERE l.tenant_id = w.tenant_id AND l.warehouse_id = w.id
|
||||
AND l.location_code = CONCAT('LOC-', w.warehouse_code, '-DEFAULT') AND l.deleted = 0
|
||||
);
|
||||
|
||||
ALTER TABLE wh_pallet COMMENT='仓库托盘表:按租户和仓库维护托盘、容量、单价及状态';
|
||||
ALTER TABLE wh_pallet
|
||||
MODIFY COLUMN id BIGINT UNSIGNED NOT NULL COMMENT '托盘ID(雪花ID)',
|
||||
MODIFY COLUMN tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
|
||||
MODIFY COLUMN warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID',
|
||||
MODIFY COLUMN location_id BIGINT UNSIGNED NULL COMMENT '同仓库内绑定的库位ID,可为空',
|
||||
MODIFY COLUMN pallet_code VARCHAR(64) NOT NULL COMMENT '仓库内唯一托盘编码',
|
||||
MODIFY COLUMN pallet_code VARCHAR(128) NOT NULL COMMENT '仓库内唯一托盘编码',
|
||||
MODIFY COLUMN pallet_type VARCHAR(32) NOT NULL COMMENT '托盘类型',
|
||||
MODIFY COLUMN capacity_cbm DECIMAL(18,4) NOT NULL DEFAULT 2.0000 COMMENT '托盘容量(立方米)',
|
||||
MODIFY COLUMN unit_price DECIMAL(18,2) NOT NULL DEFAULT 0.00 COMMENT '托盘单价',
|
||||
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
package com.baiyue.warehouse.service;
|
||||
|
||||
import com.baiyue.warehouse.dto.WarehouseRequests;
|
||||
import com.baiyue.warehouse.entity.WarehouseEntity;
|
||||
import com.baiyue.warehouse.entity.WarehouseLocationEntity;
|
||||
import com.baiyue.warehouse.repository.WarehouseRepository;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class WarehouseApplicationServiceTest {
|
||||
@Mock WarehouseRepository repository;
|
||||
private WarehouseApplicationService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new WarehouseApplicationService();
|
||||
ReflectionTestUtils.setField(service, "repository", repository);
|
||||
}
|
||||
|
||||
@Test
|
||||
void warehouseCreationAlsoCreatesActiveDefaultVirtualLocation() {
|
||||
doAnswer(call -> {
|
||||
((WarehouseEntity) call.getArgument(0)).setId(20001L);
|
||||
return null;
|
||||
}).when(repository).insertWarehouse(any());
|
||||
|
||||
service.create(request());
|
||||
|
||||
ArgumentCaptor<WarehouseLocationEntity> location = ArgumentCaptor.forClass(WarehouseLocationEntity.class);
|
||||
verify(repository).insertLocation(location.capture());
|
||||
assertThat(location.getValue().getTenantId()).isEqualTo(10001L);
|
||||
assertThat(location.getValue().getWarehouseId()).isEqualTo(20001L);
|
||||
assertThat(location.getValue().getLocationCode()).isEqualTo("LOC-US-LAX-01-DEFAULT");
|
||||
assertThat(location.getValue().getLocationType()).isEqualTo("VIRTUAL");
|
||||
assertThat(location.getValue().getStatus()).isEqualTo("ACTIVE");
|
||||
}
|
||||
|
||||
private static WarehouseRequests.SaveRequest request() {
|
||||
WarehouseRequests.AddressRequest address = new WarehouseRequests.AddressRequest(
|
||||
"WAREHOUSE", "US", "CA", "Los Angeles", null, "90001",
|
||||
"1 Logistics Ave", null, null, null);
|
||||
return new WarehouseRequests.SaveRequest(10001L, "US-LAX-01", "LAX Warehouse",
|
||||
"OVERSEAS", "US", "America/Los_Angeles", null, null, null,
|
||||
"ACTIVE", null, address, List.of());
|
||||
}
|
||||
}
|
||||
+69
@@ -122,6 +122,7 @@ class WarehouseResourceLockServiceTest {
|
||||
void releaseRestoresBothResourcesAndClosesLock() {
|
||||
WarehouseResourceLockEntity lock = activeLock();
|
||||
when(repository.find(TENANT, lock.getId())).thenReturn(lock);
|
||||
when(repository.findLocation(TENANT, WAREHOUSE, LOCATION)).thenReturn(location("OCCUPIED"));
|
||||
when(repository.releasePallet(lock)).thenReturn(1);
|
||||
when(repository.releaseLocation(lock)).thenReturn(1);
|
||||
when(repository.close(lock, "RELEASED")).thenReturn(1);
|
||||
@@ -137,6 +138,7 @@ class WarehouseResourceLockServiceTest {
|
||||
void scrapDiscardsPalletAndReleasesLocation() {
|
||||
WarehouseResourceLockEntity lock = activeLock();
|
||||
when(repository.find(TENANT, lock.getId())).thenReturn(lock);
|
||||
when(repository.findLocation(TENANT, WAREHOUSE, LOCATION)).thenReturn(location("OCCUPIED"));
|
||||
when(repository.scrapPallet(lock)).thenReturn(1);
|
||||
when(repository.releaseLocation(lock)).thenReturn(1);
|
||||
when(repository.close(lock, "SCRAPPED")).thenReturn(1);
|
||||
@@ -161,6 +163,73 @@ class WarehouseResourceLockServiceTest {
|
||||
verify(repository, never()).insert(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void virtualLocationIsNeverOccupiedOrReleased() {
|
||||
WarehouseLocationEntity virtual = location("ACTIVE");
|
||||
virtual.setLocationType("VIRTUAL");
|
||||
when(repository.findPallet(TENANT, WAREHOUSE, PALLET)).thenReturn(pallet("IDLE"));
|
||||
when(repository.findLocation(TENANT, WAREHOUSE, LOCATION)).thenReturn(virtual);
|
||||
when(repository.occupyPallet(TENANT, WAREHOUSE, PALLET, LOCATION)).thenReturn(1);
|
||||
doAnswer(call -> {
|
||||
((WarehouseResourceLockEntity) call.getArgument(0)).setId(502L);
|
||||
return null;
|
||||
}).when(repository).insert(any());
|
||||
|
||||
service.reserve(request("virtual-reserve"));
|
||||
|
||||
verify(repository, never()).occupyLocation(TENANT, WAREHOUSE, LOCATION);
|
||||
}
|
||||
|
||||
@Test
|
||||
void scrappingAtVirtualLocationKeepsLocationUntouched() {
|
||||
WarehouseResourceLockEntity lock = activeLock();
|
||||
WarehouseLocationEntity virtual = location("ACTIVE");
|
||||
virtual.setLocationType("VIRTUAL");
|
||||
when(repository.find(TENANT, lock.getId())).thenReturn(lock);
|
||||
when(repository.findLocation(TENANT, WAREHOUSE, LOCATION)).thenReturn(virtual);
|
||||
when(repository.scrapPalletAtVirtualLocation(lock)).thenReturn(1);
|
||||
when(repository.close(lock, "SCRAPPED")).thenReturn(1);
|
||||
|
||||
var result = service.scrap(TENANT, lock.getId());
|
||||
|
||||
assertThat(result.status()).isEqualTo("SCRAPPED");
|
||||
verify(repository).scrapPalletAtVirtualLocation(lock);
|
||||
verify(repository, never()).releaseLocation(lock);
|
||||
}
|
||||
|
||||
@Test
|
||||
void scrapByPalletClosesItsActiveLockAndKeepsLocationReference() {
|
||||
WarehousePalletEntity pallet = pallet("OCCUPIED");
|
||||
pallet.setWarehouseId(WAREHOUSE);
|
||||
pallet.setLocationId(LOCATION);
|
||||
WarehouseResourceLockEntity lock = activeLock();
|
||||
when(repository.findPallet(TENANT, PALLET)).thenReturn(pallet);
|
||||
when(repository.findActiveByPallet(TENANT, PALLET)).thenReturn(lock);
|
||||
when(repository.find(TENANT, lock.getId())).thenReturn(lock);
|
||||
WarehouseLocationEntity virtual = location("ACTIVE");
|
||||
virtual.setLocationType("VIRTUAL");
|
||||
when(repository.findLocation(TENANT, WAREHOUSE, LOCATION)).thenReturn(virtual);
|
||||
when(repository.scrapPalletAtVirtualLocation(lock)).thenReturn(1);
|
||||
when(repository.close(lock, "SCRAPPED")).thenReturn(1);
|
||||
|
||||
var result = service.scrapByPallet(TENANT, PALLET);
|
||||
|
||||
assertThat(result.status()).isEqualTo("SCRAPPED");
|
||||
assertThat(result.locationId()).isEqualTo(LOCATION);
|
||||
verify(repository).scrapPalletAtVirtualLocation(lock);
|
||||
}
|
||||
|
||||
@Test
|
||||
void scrapByPalletIsIdempotentWhenAlreadyScrapped() {
|
||||
WarehousePalletEntity pallet = pallet("SCRAPPED");
|
||||
pallet.setWarehouseId(WAREHOUSE);
|
||||
pallet.setLocationId(LOCATION);
|
||||
when(repository.findPallet(TENANT, PALLET)).thenReturn(pallet);
|
||||
|
||||
assertThat(service.scrapByPallet(TENANT, PALLET).status()).isEqualTo("SCRAPPED");
|
||||
verify(repository, never()).findActiveByPallet(TENANT, PALLET);
|
||||
}
|
||||
|
||||
private static WarehouseResourceLockRequests.ReserveRequest request(String key) {
|
||||
return new WarehouseResourceLockRequests.ReserveRequest(TENANT, WAREHOUSE, PALLET, LOCATION,
|
||||
"INBOUND_PLAN_PALLET", "plan-pallet-1", key);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user