暂存提交

This commit is contained in:
2026-08-19 16:24:02 +08:00
parent f5c4bf130a
commit 6acb6178da
354 changed files with 25000 additions and 198 deletions
@@ -0,0 +1,9 @@
package com.baiyue.lastleg.inboundplan;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.context.annotation.Configuration;
@Configuration
@MapperScan({"com.baiyue.lastleg.inboundplan.mapper", "com.baiyue.lastleg.inboundplan.pda.mapper"})
public class InboundPlanMapperConfiguration {
}
@@ -0,0 +1,74 @@
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;
import jakarta.validation.Valid;
import jakarta.annotation.Resource;
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/inbound-plans")
@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")
public ApiResponse<InboundPlanResponse> convert(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody ConvertInboundPlanRequest request) {
return ApiResponse.success(service.convert(scope(user, request.customerId()), request.orderId()));
}
@GetMapping("/{planNo}")
@Operation(summary = "Get an inbound plan with lines, pallets and allocations")
public ApiResponse<InboundPlanResponse> detail(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String planNo,
@RequestParam(required = false) String customerId) {
return ApiResponse.success(service.detailByPlanNo(scope(user, customerId), 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 customerId = scope(user, request.customerId());
String operatorId = user.userId() == null ? user.username() : String.valueOf(user.userId());
return ApiResponse.success(appointmentService.batchCreate(customerId, 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 customerId) {
return ApiResponse.success(appointmentService.detail(scope(user, customerId), appointmentNo));
}
private String scope(AuthenticatedUser user, String requestedCustomer) {
if (user == null || user.tenantId() == null) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
}
if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) {
if (requestedCustomer == null || requestedCustomer.isBlank() || "0".equals(requestedCustomer)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"Platform request must specify target tenant");
}
return requestedCustomer;
}
return String.valueOf(user.tenantId());
}
}
@@ -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 customerId,
@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,9 @@
package com.baiyue.lastleg.inboundplan.dto;
import jakarta.validation.constraints.NotNull;
public record ConvertInboundPlanRequest(
@NotNull Long orderId,
String customerId
) {
}
@@ -0,0 +1,23 @@
package com.baiyue.lastleg.inboundplan.dto;
import java.time.LocalDateTime;
import java.util.List;
public record FbaAppointmentResponse(
Long id,
String customerId,
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) {
}
}
@@ -0,0 +1,37 @@
package com.baiyue.lastleg.inboundplan.dto;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
public record InboundPlanResponse(
Long id,
String planNo,
String customerId,
String containerNo,
String status,
int lineCount,
int palletCount,
BigDecimal totalVolumeCbm,
List<Line> lines,
List<Pallet> pallets,
List<Allocation> allocations
) {
public record Line(Long id, Long orderId, String inboundCode, String fbaWarehouseCode, String destinationWarehouseId,
String destinationWarehouseCode, String destinationWarehouseName,
BigDecimal volumeCbm, int requiredPackageCount, int plannedPalletCount) {
}
public record Pallet(Long id, String palletNo, String destinationWarehouseId,
String destinationWarehouseCode, String destinationWarehouseName,
BigDecimal capacityCbm, BigDecimal allocatedVolumeCbm, int sequenceNo,
Long warehousePalletId, Long warehouseLocationId, Long warehouseResourceLockId,
String assignmentStatus, LocalDateTime lockedAt, BigDecimal actualVolumeCbm,
int actualPackageCount) {
}
public record Allocation(Long lineId, Long palletId, BigDecimal allocatedVolumeCbm,
int plannedPackageCount, int countedPackageCount, int pickedPackageCount,
BigDecimal actualVolumeCbm, String status, int version) {
}
}
@@ -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 customerId;
private String appointmentNo;
private LocalDateTime appointmentAt;
private String appointmentStatus;
private String appointmentRemark;
private String fbaWarehouseCode;
private Integer selectedCount;
private String createdBy;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,22 @@
package com.baiyue.lastleg.inboundplan.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("ll_inbound_fba_appointment_item")
public class InboundFbaAppointmentItem {
@TableId(type = IdType.INPUT)
private Long id;
private Long appointmentId;
private Long planId;
private Long lineId;
private Long orderId;
private String inboundCode;
private String fbaWarehouseCode;
private LocalDateTime createdAt;
}
@@ -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.math.BigDecimal;
import java.time.LocalDateTime;
@Data
@TableName("ll_inbound_plan")
public class InboundPlan {
@TableId(type = IdType.INPUT)
private Long id;
private String planNo;
private String customerId;
private String containerNo;
private String status;
private Integer lineCount;
private Integer palletCount;
private BigDecimal totalVolumeCbm;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -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.math.BigDecimal;
@Data
@TableName("ll_inbound_plan_line")
public class InboundPlanLine {
@TableId(type = IdType.INPUT)
private Long id;
private Long planId;
private Long orderId;
private String inboundCode;
private String fbaWarehouseCode;
private String destinationWarehouseId;
private String destinationWarehouseCode;
private String destinationWarehouseName;
private BigDecimal volumeCbm;
private Integer requiredPackageCount;
private Integer plannedPalletCount;
}
@@ -0,0 +1,31 @@
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.math.BigDecimal;
import java.time.LocalDateTime;
@Data
@TableName("ll_inbound_plan_pallet")
public class InboundPlanPallet {
@TableId(type = IdType.INPUT)
private Long id;
private Long planId;
private String palletNo;
private String destinationWarehouseId;
private String destinationWarehouseCode;
private String destinationWarehouseName;
private BigDecimal capacityCbm;
private BigDecimal allocatedVolumeCbm;
private Integer sequenceNo;
private Long warehousePalletId;
private Long warehouseLocationId;
private Long warehouseResourceLockId;
private String assignmentStatus;
private LocalDateTime lockedAt;
private BigDecimal actualVolumeCbm;
private Integer actualPackageCount;
}
@@ -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.math.BigDecimal;
@Data
@TableName("ll_inbound_plan_pallet_allocation")
public class InboundPlanPalletAllocation {
@TableId(type = IdType.INPUT)
private Long id;
private Long planId;
private Long lineId;
private Long palletId;
private BigDecimal allocatedVolumeCbm;
private Integer plannedPackageCount;
private Integer countedPackageCount;
private Integer pickedPackageCount;
private BigDecimal actualVolumeCbm;
private String status;
private Integer version;
}
@@ -0,0 +1,9 @@
package com.baiyue.lastleg.inboundplan.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.inboundplan.entity.InboundFbaAppointmentItem;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface InboundFbaAppointmentItemMapper extends BaseMapper<InboundFbaAppointmentItem> {
}
@@ -0,0 +1,9 @@
package com.baiyue.lastleg.inboundplan.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.inboundplan.entity.InboundFbaAppointment;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface InboundFbaAppointmentMapper extends BaseMapper<InboundFbaAppointment> {
}
@@ -0,0 +1,22 @@
package com.baiyue.lastleg.inboundplan.mapper;
import com.baiyue.lastleg.inboundplan.model.FbaAppointmentLineSnapshot;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface InboundFbaAppointmentQueryMapper {
@Select("<script>" +
"SELECT l.id AS lineId,l.plan_id AS planId,l.order_id AS orderId,l.inbound_code AS inboundCode," +
"l.fba_warehouse_code AS fbaWarehouseCode " +
"FROM ll_inbound_plan_line l JOIN ll_inbound_plan p ON p.id=l.plan_id " +
"WHERE p.customer_id=#{customerId} 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("customerId") String customerId,
@Param("lineIds") List<Long> lineIds);
}
@@ -0,0 +1,9 @@
package com.baiyue.lastleg.inboundplan.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface InboundPlanLineMapper extends BaseMapper<InboundPlanLine> {
}
@@ -0,0 +1,13 @@
package com.baiyue.lastleg.inboundplan.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.inboundplan.entity.InboundPlan;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface InboundPlanMapper extends BaseMapper<InboundPlan> {
@Select("SELECT * FROM ll_inbound_plan WHERE customer_id=#{customerId} AND container_no=#{containerNo} LIMIT 1")
InboundPlan findByContainer(@Param("customerId") String customerId, @Param("containerNo") String containerNo);
}
@@ -0,0 +1,50 @@
package com.baiyue.lastleg.inboundplan.mapper;
import com.baiyue.lastleg.inboundplan.model.SourceOrderSnapshot;
import org.apache.ibatis.annotations.Insert;
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.time.LocalDateTime;
import java.util.List;
@Mapper
public interface InboundPlanOrderMapper {
@Select("SELECT container_no FROM ll_order WHERE customer_id=#{customerId} AND id=#{orderId} LIMIT 1")
String findContainerNo(@Param("customerId") String customerId, @Param("orderId") Long orderId);
@Select("SELECT o.id AS orderId,o.customer_id AS customerId,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," +
"COUNT(DISTINCT COALESCE(NULLIF(d.destination_warehouse_id,''),NULLIF(d.destination_warehouse_code,''),'#EMPTY#')) AS destinationCount," +
"SUM(CASE WHEN d.id IS NOT NULL " +
"AND COALESCE(NULLIF(o.transfer_warehouse_id,''),NULLIF(o.transfer_warehouse_code,'')) IS NOT NULL " +
"AND COALESCE(NULLIF(d.destination_warehouse_id,''),NULLIF(d.destination_warehouse_code,''),'#EMPTY#') " +
"<> COALESCE(NULLIF(o.transfer_warehouse_id,''),NULLIF(o.transfer_warehouse_code,'')) THEN 1 ELSE 0 END) " +
"AS destinationMismatchCount " +
"FROM ll_order o LEFT JOIN ll_order_detail d ON d.order_id=o.id " +
"WHERE o.customer_id=#{customerId} AND o.container_no=#{containerNo} " +
"GROUP BY o.id,o.customer_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("customerId") String customerId,
@Param("containerNo") String containerNo);
@Update("UPDATE ll_order SET inbound_order_no=#{planNo},current_status='INBOUND_PLANNED'," +
"pallet_merge_flag='MERGED',pallet_count=#{palletCount},updated_at=#{updatedAt} " +
"WHERE customer_id=#{customerId} AND id=#{orderId}")
int markPlanned(@Param("customerId") String customerId, @Param("orderId") Long orderId,
@Param("planNo") String planNo, @Param("palletCount") int palletCount,
@Param("updatedAt") LocalDateTime updatedAt);
@Insert("INSERT INTO ll_loading_order_status_history(order_id,from_status,to_status,remark,created_at) " +
"VALUES(#{orderId},#{fromStatus},'INBOUND_PLANNED',#{remark},#{createdAt})")
int insertHistory(@Param("orderId") Long orderId, @Param("fromStatus") String fromStatus,
@Param("remark") String remark, @Param("createdAt") LocalDateTime createdAt);
}
@@ -0,0 +1,9 @@
package com.baiyue.lastleg.inboundplan.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface InboundPlanPalletAllocationMapper extends BaseMapper<InboundPlanPalletAllocation> {
}
@@ -0,0 +1,9 @@
package com.baiyue.lastleg.inboundplan.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface InboundPlanPalletMapper extends BaseMapper<InboundPlanPallet> {
}
@@ -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
}
@@ -0,0 +1,30 @@
package com.baiyue.lastleg.inboundplan.model;
import java.math.BigDecimal;
public record SourceOrderSnapshot(
Long orderId,
String customerId,
String containerNo,
String inboundCode,
String currentStatus,
String inboundOrderNo,
BigDecimal volumeCbm,
int packageCount,
String fbaWarehouseCode,
String destinationWarehouseId,
String destinationWarehouseCode,
String destinationWarehouseName,
int destinationCount,
int destinationMismatchCount
) {
public SourceOrderSnapshot(Long orderId, String customerId, String containerNo, String inboundCode,
String currentStatus, String inboundOrderNo, BigDecimal volumeCbm,
int packageCount, String destinationWarehouseId,
String destinationWarehouseCode, String destinationWarehouseName,
int destinationCount, int destinationMismatchCount) {
this(orderId, customerId, containerNo, inboundCode, currentStatus, inboundOrderNo, volumeCbm,
packageCount, null, destinationWarehouseId, destinationWarehouseCode,
destinationWarehouseName, destinationCount, destinationMismatchCount);
}
}
@@ -0,0 +1,43 @@
package com.baiyue.lastleg.inboundplan.pda.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.inboundplan.pda.dto.PdaRequests;
import com.baiyue.lastleg.inboundplan.pda.service.InboundPdaWorkflowService;
import jakarta.validation.Valid;
import jakarta.annotation.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.PathVariable;
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.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
@RestController
@RequestMapping("/api/platform/v1/last-leg/inbound-plans/{planNo}/pallets")
public class InboundPalletAssignmentController {
@Resource private InboundPdaWorkflowService service;
@PostMapping("/{planPalletId}/assign")
public ApiResponse<Void> assign(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String planNo, @PathVariable long planPalletId,
@RequestParam(required = false) String customerId,
@Valid @RequestBody PdaRequests.Assign request) {
service.assign(scope(user, customerId), planNo, planPalletId, request);
return ApiResponse.success(null);
}
private String scope(AuthenticatedUser user, String requestedCustomer) {
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) {
if (requestedCustomer == null || requestedCustomer.isBlank() || "0".equals(requestedCustomer)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Platform request must specify target tenant");
}
return requestedCustomer;
}
return String.valueOf(user.tenantId());
}
}
@@ -0,0 +1,107 @@
package com.baiyue.lastleg.inboundplan.pda.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.inboundplan.pda.dto.PdaRequests;
import com.baiyue.lastleg.inboundplan.pda.dto.PdaResponses;
import com.baiyue.lastleg.inboundplan.pda.service.InboundPdaWorkflowService;
import jakarta.validation.Valid;
import jakarta.annotation.Resource;
import org.springframework.http.HttpStatus;
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.PostMapping;
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/inbound-plans/{planNo}/pda")
public class InboundPdaController {
public static final String OPERATE_PERMISSION = "lastleg:pda:operate";
@Resource private InboundPdaWorkflowService service;
@PostMapping("/push")
public ApiResponse<PdaResponses.Task> push(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String planNo,
@RequestParam(required = false) String customerId,
@Valid @RequestBody PdaRequests.Push request) {
requireOperate(user);
return ApiResponse.success(service.push(scope(user, customerId), planNo, request));
}
@GetMapping
public ApiResponse<PdaResponses.Task> task(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String planNo,
@RequestParam(required = false) String customerId) {
return ApiResponse.success(service.task(scope(user, customerId), planNo));
}
@PostMapping("/allocations/{allocationId}/count")
public ApiResponse<PdaResponses.Operation> count(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String planNo,
@PathVariable long allocationId,
@RequestParam(required = false) String customerId,
@Valid @RequestBody PdaRequests.PackageOperation request) {
requireOperate(user);
return ApiResponse.success(service.count(scope(user, customerId), planNo, allocationId, request));
}
@PostMapping("/allocations/{allocationId}/pick")
public ApiResponse<PdaResponses.Operation> pick(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String planNo,
@PathVariable long allocationId,
@RequestParam(required = false) String customerId,
@Valid @RequestBody PdaRequests.PackageOperation request) {
requireOperate(user);
return ApiResponse.success(service.pick(scope(user, customerId), planNo, allocationId, request));
}
@GetMapping("/allocations/{allocationId}/merge-candidates")
public ApiResponse<List<PdaResponses.MergeCandidate>> candidates(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String planNo,
@PathVariable long allocationId, @RequestParam(required = false) String customerId) {
return ApiResponse.success(service.mergeCandidates(scope(user, customerId), planNo, allocationId));
}
@PostMapping("/allocations/{allocationId}/merge")
public ApiResponse<PdaResponses.Merge> merge(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String planNo,
@PathVariable long allocationId,
@RequestParam(required = false) String customerId,
@Valid @RequestBody PdaRequests.ConfirmMerge request) {
requireOperate(user);
return ApiResponse.success(service.merge(scope(user, customerId), planNo, allocationId, request));
}
@PostMapping("/complete")
public ApiResponse<PdaResponses.Task> complete(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String planNo,
@RequestParam(required = false) String customerId) {
requireOperate(user);
return ApiResponse.success(service.complete(scope(user, customerId), planNo));
}
private String scope(AuthenticatedUser user, String requestedCustomer) {
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) {
if (requestedCustomer == null || requestedCustomer.isBlank() || "0".equals(requestedCustomer)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Platform request must specify target tenant");
}
return requestedCustomer;
}
return String.valueOf(user.tenantId());
}
private void requireOperate(AuthenticatedUser user) {
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if (user.permissions() == null || !user.permissions().contains(OPERATE_PERMISSION)) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, OPERATE_PERMISSION + " permission is required");
}
}
}
@@ -0,0 +1,19 @@
package com.baiyue.lastleg.inboundplan.pda.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Positive;
public final class PdaRequests {
private PdaRequests() {}
public record Push(@NotBlank String idempotencyKey, String operatorId, String operatorName) {}
public record Assign(@Positive long warehouseId, @Positive long palletId, @Positive long locationId,
@NotBlank String idempotencyKey) {}
public record PackageOperation(@Positive int packageCount, @NotBlank String idempotencyKey,
String operatorId, String operatorName) {}
public record ConfirmMerge(@Positive long sourceAllocationId, @Positive int packageCount,
@NotBlank String idempotencyKey, String operatorId, String operatorName) {}
}
@@ -0,0 +1,26 @@
package com.baiyue.lastleg.inboundplan.pda.dto;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
public final class PdaResponses {
private PdaResponses() {}
public record Task(long taskId, String taskNo, String planNo, String status,
LocalDateTime pushedAt, LocalDateTime startedAt, LocalDateTime completedAt) {}
public record Operation(String result, long allocationId, int requiredPackageCount,
int countedPackageCount, int pickedPackageCount,
BigDecimal actualVolumeCbm, Integer remainingPackageCount,
Integer requestedPackageCount, Integer mergePackageCount,
List<MergeCandidate> candidates) {}
public record MergeCandidate(long allocationId, long palletId, String palletNo, long lineId,
long orderId, String inboundCode, int availablePackageCount,
BigDecimal availableVolumeCbm) {}
public record Merge(String result, String mergeGroupNo, long sourceOrderId, long targetOrderId,
int movedPackageCount, BigDecimal movedVolumeCbm,
boolean sourcePalletScrapped) {}
}
@@ -0,0 +1,32 @@
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.math.BigDecimal;
import java.time.LocalDateTime;
@Data
@TableName("ll_inbound_pallet_merge")
public class InboundPalletMerge {
@TableId(type = IdType.INPUT)
private Long id;
private String customerId;
private Long taskId;
private String mergeGroupNo;
private Long sourcePalletId;
private Long targetPalletId;
private Long sourceAllocationId;
private Long targetAllocationId;
private Long sourceLineId;
private Long targetLineId;
private Long sourceOrderId;
private Long targetOrderId;
private Integer movedPackageCount;
private BigDecimal movedVolumeCbm;
private Boolean sourcePalletScrapped;
private String status;
private LocalDateTime createdAt;
}
@@ -0,0 +1,24 @@
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;
@Data
@TableName("ll_inbound_pda_operation")
public class InboundPdaOperation {
@TableId(type = IdType.INPUT)
private Long id;
private String customerId;
private Long taskId;
private Long allocationId;
private String action;
private String idempotencyKey;
private String requestHash;
private String requestJson;
private String resultJson;
private LocalDateTime createdAt;
}
@@ -0,0 +1,27 @@
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;
@Data
@TableName("ll_inbound_pda_task")
public class InboundPdaTask {
@TableId(type = IdType.INPUT)
private Long id;
private Long planId;
private String customerId;
private String taskNo;
private String status;
private String operatorId;
private String operatorName;
private LocalDateTime pushedAt;
private LocalDateTime startedAt;
private LocalDateTime completedAt;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private Integer version;
}
@@ -0,0 +1,34 @@
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 inbound-order/pallet package receipt created when a PDA task completes. */
@Data
@TableName("ll_inbound_stock_receipt")
public class InboundStockReceipt {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private String customerId;
private Long planId;
private Long taskId;
private Long allocationId;
private Long lineId;
private Long orderId;
private String inboundCode;
private Long warehousePalletId;
private String palletNo;
private Long warehouseLocationId;
private String warehouseLocationCode;
private String batchNo;
private Long receivedPackages;
private Long inventoryBalanceId;
private Long inventoryPositionId;
private String inventoryOperationKey;
private LocalDateTime receivedAt;
private LocalDateTime createdAt;
}
@@ -0,0 +1,89 @@
package com.baiyue.lastleg.inboundplan.pda.gateway;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatusCode;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.Resource;
import java.util.Map;
@Component
public class HttpWarehouseResourceGateway implements WarehouseResourceGateway {
@Resource private RestClient.Builder restClientBuilder;
@Value("${baiyue.warehouse.base-url:http://127.0.0.1:9305}")
private String baseUrl;
@Value("${baiyue.security.internal-service-token:}")
private String internalToken;
private RestClient client;
@PostConstruct
void initializeClient() {
this.client = restClientBuilder.baseUrl(baseUrl).build();
}
@Override
public WarehousePalletReference ensureDropshipPallet(String customerId, long warehouseId, String businessId) {
WarehousePalletResponse response = client.post().uri("/internal/v1/warehouse-pallets/ensure-dropship")
.header("X-Internal-Service-Token", internalToken)
.body(Map.of("customerId", Long.parseLong(customerId), "warehouseId", warehouseId, "businessId", businessId))
.retrieve().onStatus(HttpStatusCode::isError, (request, result) -> {
throw new IllegalStateException("Warehouse rejected dropship pallet: HTTP " + result.getStatusCode());
}).body(WarehousePalletResponse.class);
if (response == null || response.data() == null) throw new IllegalStateException("Warehouse returned an empty pallet response");
return new WarehousePalletReference(response.data().id(), response.data().palletCode(), response.data().locationId());
}
@Override
public long reserve(String customerId, long warehouseId, long palletId, long locationId,
String businessType, String businessId, String reservationKey) {
ReserveEnvelope response = client.post()
.uri("/internal/v1/warehouse-resource-locks")
.header("X-Internal-Service-Token", internalToken)
.body(Map.of("customerId", Long.parseLong(customerId), "warehouseId", warehouseId,
"palletId", palletId, "locationId", locationId, "businessType", businessType,
"businessId", businessId, "reservationKey", reservationKey))
.retrieve()
.onStatus(HttpStatusCode::isError, (request, result) -> {
throw new IllegalStateException("Warehouse rejected pallet reservation: HTTP " + result.getStatusCode());
})
.body(ReserveEnvelope.class);
if (response == null || response.data() == null) throw new IllegalStateException("Warehouse returned an empty lock response");
return response.data().lockId();
}
@Override
public void scrap(String customerId, long resourceLockId) {
client.post()
.uri("/internal/v1/warehouse-resource-locks/{lockId}/scrap", resourceLockId)
.header("X-Internal-Service-Token", internalToken)
.body(Map.of("customerId", Long.parseLong(customerId)))
.retrieve()
.onStatus(HttpStatusCode::isError, (request, response) -> {
throw new IllegalStateException("Warehouse rejected pallet scrap: HTTP " + response.getStatusCode());
})
.toBodilessEntity();
}
@Override
public void scrapPallet(String customerId, long palletId) {
client.post()
.uri("/internal/v1/warehouse-resource-locks/pallets/{palletId}/scrap", palletId)
.header("X-Internal-Service-Token", internalToken)
.body(Map.of("customerId", Long.parseLong(customerId)))
.retrieve()
.onStatus(HttpStatusCode::isError, (request, response) -> {
throw new IllegalStateException("Warehouse rejected pallet scrap: HTTP " + response.getStatusCode());
})
.toBodilessEntity();
}
private record WarehousePalletResponse(WarehousePalletData data) {}
private record WarehousePalletData(long id, String palletCode, long locationId) {}
private record ReserveEnvelope(ReserveData data) {}
private record ReserveData(long lockId) {}
}
@@ -0,0 +1,11 @@
package com.baiyue.lastleg.inboundplan.pda.gateway;
public interface WarehouseResourceGateway {
WarehousePalletReference ensureDropshipPallet(String customerId, long warehouseId, String businessId);
long reserve(String customerId, long warehouseId, long palletId, long locationId,
String businessType, String businessId, String reservationKey);
void scrap(String customerId, long resourceLockId);
void scrapPallet(String customerId, long palletId);
record WarehousePalletReference(long palletId, String palletNo, long locationId) {}
}
@@ -0,0 +1,9 @@
package com.baiyue.lastleg.inboundplan.pda.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.inboundplan.pda.entity.InboundPalletMerge;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface InboundPalletMergeMapper extends BaseMapper<InboundPalletMerge> {
}
@@ -0,0 +1,13 @@
package com.baiyue.lastleg.inboundplan.pda.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaOperation;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface InboundPdaOperationMapper extends BaseMapper<InboundPdaOperation> {
@Select("SELECT * FROM ll_inbound_pda_operation WHERE customer_id=#{customerId} AND idempotency_key=#{key} LIMIT 1")
InboundPdaOperation findByKey(@Param("customerId") String customerId, @Param("key") String key);
}
@@ -0,0 +1,16 @@
package com.baiyue.lastleg.inboundplan.pda.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface InboundPdaTaskMapper extends BaseMapper<InboundPdaTask> {
@Select("SELECT * FROM ll_inbound_pda_task WHERE customer_id=#{customerId} AND plan_id=#{planId} LIMIT 1 FOR UPDATE")
InboundPdaTask lockByPlan(@Param("customerId") String customerId, @Param("planId") Long planId);
@Select("SELECT * FROM ll_inbound_pda_task WHERE customer_id=#{customerId} AND plan_id=#{planId} LIMIT 1")
InboundPdaTask findByPlan(@Param("customerId") String customerId, @Param("planId") Long planId);
}
@@ -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 customer_id=#{customerId} AND task_id=#{taskId} " +
"ORDER BY allocation_id")
List<InboundStockReceipt> listTask(@Param("customerId") String customerId, @Param("taskId") Long taskId);
}
@@ -0,0 +1,79 @@
package com.baiyue.lastleg.inboundplan.pda.repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
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.mapper.InboundPlanLineMapper;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanMapper;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletAllocationMapper;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletMapper;
import com.baiyue.lastleg.inboundplan.pda.entity.InboundPalletMerge;
import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaOperation;
import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask;
import com.baiyue.lastleg.inboundplan.pda.mapper.InboundPalletMergeMapper;
import com.baiyue.lastleg.inboundplan.pda.mapper.InboundPdaOperationMapper;
import com.baiyue.lastleg.inboundplan.pda.mapper.InboundPdaTaskMapper;
import org.springframework.stereotype.Repository;
import jakarta.annotation.Resource;
import java.util.List;
@Repository
public class PdaWorkflowRepository {
@Resource private InboundPlanMapper plans;
@Resource private InboundPlanLineMapper lines;
@Resource private InboundPlanPalletMapper pallets;
@Resource private InboundPlanPalletAllocationMapper allocations;
@Resource private InboundPdaTaskMapper tasks;
@Resource private InboundPdaOperationMapper operations;
@Resource private InboundPalletMergeMapper merges;
public InboundPlan lockPlan(String customerId, String planNo) {
return plans.selectOne(new LambdaQueryWrapper<InboundPlan>()
.eq(InboundPlan::getCustomerId, customerId).eq(InboundPlan::getPlanNo, planNo).last("FOR UPDATE"));
}
public InboundPdaTask lockTask(String customerId, Long planId) { return tasks.lockByPlan(customerId, planId); }
public InboundPdaTask task(String customerId, Long planId) { return tasks.findByPlan(customerId, planId); }
public InboundPdaOperation operation(String customerId, String key) { return operations.findByKey(customerId, key); }
public InboundPlanPalletAllocation lockAllocation(Long planId, Long allocationId) {
return allocations.selectOne(new LambdaQueryWrapper<InboundPlanPalletAllocation>()
.eq(InboundPlanPalletAllocation::getPlanId, planId)
.eq(InboundPlanPalletAllocation::getId, allocationId).last("FOR UPDATE"));
}
public InboundPlanPallet lockPallet(Long planId, Long palletId) {
return pallets.selectOne(new LambdaQueryWrapper<InboundPlanPallet>()
.eq(InboundPlanPallet::getPlanId, planId).eq(InboundPlanPallet::getId, palletId).last("FOR UPDATE"));
}
public InboundPlanLine line(Long planId, Long lineId) {
return lines.selectOne(new LambdaQueryWrapper<InboundPlanLine>()
.eq(InboundPlanLine::getPlanId, planId).eq(InboundPlanLine::getId, lineId));
}
public List<InboundPlanLine> lines(Long planId) {
return lines.selectList(new LambdaQueryWrapper<InboundPlanLine>().eq(InboundPlanLine::getPlanId, planId));
}
public List<InboundPlanPallet> pallets(Long planId) {
return pallets.selectList(new LambdaQueryWrapper<InboundPlanPallet>().eq(InboundPlanPallet::getPlanId, planId));
}
public List<InboundPlanPalletAllocation> allocations(Long planId) {
return allocations.selectList(new LambdaQueryWrapper<InboundPlanPalletAllocation>()
.eq(InboundPlanPalletAllocation::getPlanId, planId));
}
public void insert(InboundPdaTask value) { tasks.insert(value); }
public void insert(InboundPdaOperation value) { operations.insert(value); }
public void insert(InboundPalletMerge value) { merges.insert(value); }
public void insert(InboundPlanPalletAllocation value) { allocations.insert(value); }
public void update(InboundPlan value) { plans.updateById(value); }
public void update(InboundPdaTask value) { tasks.updateById(value); }
public void update(InboundPlanPallet value) { pallets.updateById(value); }
public void update(InboundPlanPalletAllocation value) { allocations.updateById(value); }
}
@@ -0,0 +1,532 @@
package com.baiyue.lastleg.inboundplan.pda.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
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.dto.PdaRequests;
import com.baiyue.lastleg.inboundplan.pda.dto.PdaResponses;
import com.baiyue.lastleg.inboundplan.pda.entity.InboundPalletMerge;
import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaOperation;
import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask;
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
import com.baiyue.lastleg.inboundplan.pda.repository.PdaWorkflowRepository;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.MapperFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.Resource;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@Service
public class InboundPdaWorkflowService {
private static final BigDecimal ZERO_VOLUME = new BigDecimal("0.0000");
@Resource private PdaWorkflowRepository repository;
@Resource private WarehouseResourceGateway warehouse;
@Resource private InboundStockReceiptService stockReceipts;
@Resource private ObjectMapper sourceObjectMapper;
private ObjectMapper objectMapper;
@PostConstruct
void initializeObjectMapper() {
this.objectMapper = sourceObjectMapper.copy()
.configure(MapperFeature.SORT_PROPERTIES_ALPHABETICALLY, true)
.configure(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true);
}
@Transactional
public PdaResponses.Task push(String customerId, String planNo, PdaRequests.Push request) {
InboundPlan plan = requirePlan(customerId, planNo);
InboundPdaOperation replay = replay(customerId, "PUSH", request.idempotencyKey(), request);
if (replay != null) return read(replay, PdaResponses.Task.class);
List<InboundPlanPallet> pallets = repository.pallets(plan.getId());
if (pallets.isEmpty() || pallets.stream().anyMatch(this::isNotAssigned)) {
throw conflict("Every planned pallet must have an assigned and locked warehouse pallet/location");
}
InboundPdaTask task = repository.lockTask(customerId, plan.getId());
LocalDateTime now = LocalDateTime.now();
if (task == null) {
task = new InboundPdaTask();
task.setId(IdWorker.getId());
task.setPlanId(plan.getId());
task.setCustomerId(customerId);
task.setTaskNo("PDA" + task.getId());
task.setStatus("PDA_PENDING");
task.setOperatorId(request.operatorId());
task.setOperatorName(request.operatorName());
task.setPushedAt(now);
task.setCreatedAt(now);
task.setUpdatedAt(now);
task.setVersion(0);
repository.insert(task);
}
plan.setStatus("PDA_PENDING");
plan.setUpdatedAt(now);
repository.update(plan);
PdaResponses.Task result = task(task, planNo);
saveOperation(customerId, task.getId(), null, "PUSH", request.idempotencyKey(), request, result);
return result;
}
@Transactional
public void assign(String customerId, String planNo, long planPalletId, PdaRequests.Assign request) {
InboundPlan plan = requirePlan(customerId, planNo);
InboundPlanPallet pallet = requirePallet(plan.getId(), planPalletId);
validateDestinationWarehouse(pallet, request.warehouseId());
if (pallet.getWarehouseResourceLockId() != null) {
if (Objects.equals(pallet.getWarehousePalletId(), request.palletId())
&& Objects.equals(pallet.getWarehouseLocationId(), request.locationId())) return;
throw conflict("Planned pallet is already assigned to another warehouse resource");
}
String businessId = planNo + ":" + planPalletId;
long lockId = warehouse.reserve(customerId, request.warehouseId(), request.palletId(), request.locationId(),
"INBOUND_PLAN_PALLET", businessId, request.idempotencyKey());
pallet.setWarehousePalletId(request.palletId());
pallet.setWarehouseLocationId(request.locationId());
pallet.setWarehouseResourceLockId(lockId);
pallet.setAssignmentStatus("ASSIGNED");
pallet.setLockedAt(LocalDateTime.now());
repository.update(pallet);
List<InboundPlanPallet> pallets = repository.pallets(plan.getId());
if (!pallets.isEmpty() && pallets.stream().allMatch(value -> !isNotAssigned(value))) {
plan.setStatus("ASSIGNED");
plan.setUpdatedAt(LocalDateTime.now());
repository.update(plan);
}
}
@Transactional
public PdaResponses.Task task(String customerId, String planNo) {
InboundPlan plan = requirePlan(customerId, planNo);
InboundPdaTask task = repository.task(customerId, plan.getId());
if (task == null) throw notFound("PDA task not found");
return task(task, planNo);
}
@Transactional
public PdaResponses.Operation count(String customerId, String planNo, long allocationId,
PdaRequests.PackageOperation request) {
InboundPlan plan = requirePlan(customerId, planNo);
InboundPdaOperation replay = replay(customerId, "COUNT", request.idempotencyKey(), request);
if (replay != null) return read(replay, PdaResponses.Operation.class);
InboundPdaTask task = requireActiveTask(customerId, plan);
InboundPlanPalletAllocation allocation = requireAllocation(plan.getId(), allocationId);
int picked = value(allocation.getPickedPackageCount());
if (request.packageCount() < picked) {
throw conflict("Counted package count cannot be less than already picked packages");
}
if (request.packageCount() > value(allocation.getPlannedPackageCount())) {
throw conflict("Counted package count cannot exceed this pallet allocation");
}
allocation.setCountedPackageCount(request.packageCount());
allocation.setStatus(request.packageCount() == value(allocation.getPlannedPackageCount())
? "COUNTED" : "COUNTING");
bump(allocation);
repository.update(allocation);
startPicking(task, plan, request.operatorId(), request.operatorName());
InboundPlanLine line = requireLine(plan.getId(), allocation.getLineId());
PdaResponses.Operation result = operation("COUNTED", allocation, line, List.of(), null, null, null);
saveOperation(customerId, task.getId(), allocationId, "COUNT", request.idempotencyKey(), request, result);
return result;
}
@Transactional
public PdaResponses.Operation pick(String customerId, String planNo, long allocationId,
PdaRequests.PackageOperation request) {
InboundPlan plan = requirePlan(customerId, planNo);
InboundPdaOperation replay = replay(customerId, "PICK", request.idempotencyKey(), request);
if (replay != null) return read(replay, PdaResponses.Operation.class);
InboundPdaTask task = requireActiveTask(customerId, plan);
InboundPlanPalletAllocation allocation = requireAllocation(plan.getId(), allocationId);
InboundPlanLine line = requireLine(plan.getId(), allocation.getLineId());
int picked = value(allocation.getPickedPackageCount());
int countedRemaining = value(allocation.getCountedPackageCount()) - picked;
if (countedRemaining <= 0) throw conflict("Count this pallet allocation before picking");
int plannedRemaining = value(allocation.getPlannedPackageCount()) - picked;
if (request.packageCount() > plannedRemaining) {
int mergeCount = request.packageCount() - Math.max(0, plannedRemaining);
List<PdaResponses.MergeCandidate> candidates = candidates(plan, allocation, mergeCount);
PdaResponses.Operation result = operation("MERGE_CONFIRMATION_REQUIRED", allocation, line,
candidates, plannedRemaining, request.packageCount(), mergeCount);
saveOperation(customerId, task.getId(), allocationId, "PICK", request.idempotencyKey(), request, result);
return result;
}
if (request.packageCount() > countedRemaining) {
throw conflict("Picked package count cannot exceed the counted remaining packages");
}
BigDecimal perPackage = perPackageVolume(line);
int newPicked = picked + request.packageCount();
allocation.setPickedPackageCount(newPicked);
allocation.setActualVolumeCbm(perPackage.multiply(BigDecimal.valueOf(newPicked)).setScale(4, RoundingMode.HALF_UP));
allocation.setStatus(newPicked == value(allocation.getPlannedPackageCount()) ? "PICKED" : "PICKING");
bump(allocation);
repository.update(allocation);
InboundPlanPallet pallet = requirePallet(plan.getId(), allocation.getPalletId());
pallet.setActualPackageCount(value(pallet.getActualPackageCount()) + request.packageCount());
pallet.setActualVolumeCbm(volume(pallet.getActualVolumeCbm()).add(
perPackage.multiply(BigDecimal.valueOf(request.packageCount()))).setScale(4, RoundingMode.HALF_UP));
repository.update(pallet);
startPicking(task, plan, request.operatorId(), request.operatorName());
PdaResponses.Operation result = operation("PICKED", allocation, line, List.of(), null, null, null);
saveOperation(customerId, task.getId(), allocationId, "PICK", request.idempotencyKey(), request, result);
return result;
}
@Transactional
public List<PdaResponses.MergeCandidate> mergeCandidates(String customerId, String planNo,
long allocationId) {
InboundPlan plan = requirePlan(customerId, planNo);
InboundPlanPalletAllocation allocation = requireAllocation(plan.getId(), allocationId);
return candidates(plan, allocation, 1);
}
@Transactional
public PdaResponses.Merge merge(String customerId, String planNo, long targetAllocationId,
PdaRequests.ConfirmMerge request) {
InboundPlan plan = requirePlan(customerId, planNo);
InboundPdaOperation replay = replay(customerId, "MERGE", request.idempotencyKey(), request);
if (replay != null) return read(replay, PdaResponses.Merge.class);
InboundPdaTask task = requireActiveTask(customerId, plan);
if (request.sourceAllocationId() == targetAllocationId) throw conflict("Source and target allocations differ");
InboundPlanPalletAllocation target = requireAllocation(plan.getId(), targetAllocationId);
InboundPlanPalletAllocation source = requireAllocation(plan.getId(), request.sourceAllocationId());
InboundPlanPallet targetPallet = requirePallet(plan.getId(), target.getPalletId());
InboundPlanPallet sourcePallet = requirePallet(plan.getId(), source.getPalletId());
if (Objects.equals(targetPallet.getId(), sourcePallet.getId())) throw conflict("Source and target pallets differ");
if (!sameWarehouse(targetPallet, sourcePallet)) throw conflict("Pallet merge requires the same destination warehouse");
if (value(source.getPickedPackageCount()) < request.packageCount()) {
throw conflict("Source pallet does not contain enough picked packages");
}
InboundPlanLine sourceLine = requireLine(plan.getId(), source.getLineId());
InboundPlanLine targetLine = requireLine(plan.getId(), target.getLineId());
BigDecimal movedVolume = perPackageVolume(sourceLine)
.multiply(BigDecimal.valueOf(request.packageCount())).setScale(4, RoundingMode.HALF_UP);
if (value(sourcePallet.getActualPackageCount()) < request.packageCount()) {
throw conflict("Source pallet actual package count is insufficient");
}
if (targetPallet.getCapacityCbm() != null
&& volume(targetPallet.getActualVolumeCbm()).add(movedVolume).compareTo(targetPallet.getCapacityCbm()) > 0) {
throw conflict("Merged volume exceeds target pallet capacity");
}
moveSourceAllocation(plan.getId(), source, targetPallet.getId(), request.packageCount(), movedVolume);
sourcePallet.setActualPackageCount(value(sourcePallet.getActualPackageCount()) - request.packageCount());
sourcePallet.setActualVolumeCbm(nonNegative(volume(sourcePallet.getActualVolumeCbm()).subtract(movedVolume)));
targetPallet.setActualPackageCount(value(targetPallet.getActualPackageCount()) + request.packageCount());
targetPallet.setActualVolumeCbm(volume(targetPallet.getActualVolumeCbm()).add(movedVolume));
boolean scrapped = sourcePallet.getActualPackageCount() == 0;
if (scrapped) {
if (sourcePallet.getWarehouseResourceLockId() == null) {
throw conflict("Source pallet has no warehouse resource lock");
}
warehouse.scrap(customerId, sourcePallet.getWarehouseResourceLockId());
sourcePallet.setAssignmentStatus("SCRAPPED");
sourcePallet.setWarehouseLocationId(null);
sourcePallet.setWarehouseResourceLockId(null);
}
repository.update(sourcePallet);
repository.update(targetPallet);
String groupNo = "PMG" + IdWorker.getId();
InboundPalletMerge merge = new InboundPalletMerge();
merge.setId(IdWorker.getId());
merge.setCustomerId(customerId);
merge.setTaskId(task.getId());
merge.setMergeGroupNo(groupNo);
merge.setSourcePalletId(sourcePallet.getId());
merge.setTargetPalletId(targetPallet.getId());
merge.setSourceAllocationId(source.getId());
merge.setTargetAllocationId(target.getId());
merge.setSourceLineId(sourceLine.getId());
merge.setTargetLineId(targetLine.getId());
merge.setSourceOrderId(sourceLine.getOrderId());
merge.setTargetOrderId(targetLine.getOrderId());
merge.setMovedPackageCount(request.packageCount());
merge.setMovedVolumeCbm(movedVolume);
merge.setSourcePalletScrapped(scrapped);
merge.setStatus("CONFIRMED");
merge.setCreatedAt(LocalDateTime.now());
repository.insert(merge);
PdaResponses.Merge result = new PdaResponses.Merge("MERGED", groupNo, sourceLine.getOrderId(),
targetLine.getOrderId(), request.packageCount(), movedVolume, scrapped);
saveOperation(customerId, task.getId(), targetAllocationId, "MERGE", request.idempotencyKey(), request, result);
return result;
}
@Transactional
public PdaResponses.Task complete(String customerId, String planNo) {
InboundPlan plan = requirePlan(customerId, planNo);
InboundPdaTask task = repository.lockTask(customerId, 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<>();
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());
if (lines.isEmpty()) throw conflict("Inbound plan has no lines");
for (InboundPlanLine line : lines) {
if (pickedByLine.getOrDefault(line.getId(), 0) != value(line.getRequiredPackageCount())) {
throw conflict("All line picked package totals must exactly equal required package totals");
}
}
LocalDateTime now = LocalDateTime.now();
stockReceipts.stockIn(customerId, plan, task, lines, allocations, repository.pallets(plan.getId()), now);
task.setStatus("COMPLETED");
task.setCompletedAt(now);
task.setUpdatedAt(now);
task.setVersion(value(task.getVersion()) + 1);
repository.update(task);
plan.setStatus("COMPLETED");
plan.setUpdatedAt(now);
repository.update(plan);
return task(task, planNo);
}
private void moveSourceAllocation(Long planId, InboundPlanPalletAllocation source, Long targetPalletId,
int packageCount, BigDecimal movedVolume) {
source.setPickedPackageCount(value(source.getPickedPackageCount()) - packageCount);
source.setCountedPackageCount(Math.max(source.getPickedPackageCount(),
value(source.getCountedPackageCount()) - packageCount));
source.setActualVolumeCbm(nonNegative(volume(source.getActualVolumeCbm()).subtract(movedVolume)));
source.setStatus(source.getPickedPackageCount() == 0 ? "MERGED_OUT" : "PICKED");
bump(source);
repository.update(source);
InboundPlanPalletAllocation moved = repository.allocations(planId).stream()
.filter(value -> Objects.equals(value.getLineId(), source.getLineId()))
.filter(value -> Objects.equals(value.getPalletId(), targetPalletId))
.findFirst().orElse(null);
if (moved == null) {
moved = new InboundPlanPalletAllocation();
moved.setId(IdWorker.getId());
moved.setPlanId(planId);
moved.setLineId(source.getLineId());
moved.setPalletId(targetPalletId);
moved.setAllocatedVolumeCbm(ZERO_VOLUME);
moved.setPlannedPackageCount(0);
moved.setCountedPackageCount(packageCount);
moved.setPickedPackageCount(packageCount);
moved.setActualVolumeCbm(movedVolume);
moved.setStatus("MERGED_IN");
moved.setVersion(0);
repository.insert(moved);
} else {
moved.setCountedPackageCount(value(moved.getCountedPackageCount()) + packageCount);
moved.setPickedPackageCount(value(moved.getPickedPackageCount()) + packageCount);
moved.setActualVolumeCbm(volume(moved.getActualVolumeCbm()).add(movedVolume));
moved.setStatus("MERGED_IN");
bump(moved);
repository.update(moved);
}
}
private List<PdaResponses.MergeCandidate> candidates(InboundPlan plan,
InboundPlanPalletAllocation target,
int needed) {
Map<Long, InboundPlanPallet> palletById = new HashMap<>();
for (InboundPlanPallet pallet : repository.pallets(plan.getId())) palletById.put(pallet.getId(), pallet);
Map<Long, InboundPlanLine> lineById = new HashMap<>();
for (InboundPlanLine line : repository.lines(plan.getId())) lineById.put(line.getId(), line);
InboundPlanPallet targetPallet = palletById.get(target.getPalletId());
if (targetPallet == null) throw notFound("Target pallet not found");
List<PdaResponses.MergeCandidate> result = new ArrayList<>();
for (InboundPlanPalletAllocation candidate : repository.allocations(plan.getId())) {
if (Objects.equals(candidate.getPalletId(), target.getPalletId())) continue;
int available = value(candidate.getPickedPackageCount());
if (available <= 0) continue;
InboundPlanPallet pallet = palletById.get(candidate.getPalletId());
InboundPlanLine line = lineById.get(candidate.getLineId());
if (pallet == null || line == null || !sameWarehouse(targetPallet, pallet)) continue;
if (targetPallet.getCapacityCbm() != null) {
BigDecimal remainingVolume = targetPallet.getCapacityCbm()
.subtract(volume(targetPallet.getActualVolumeCbm()));
BigDecimal packageVolume = perPackageVolume(line);
if (packageVolume.signum() > 0) {
int capacityPackages = remainingVolume.signum() <= 0 ? 0
: remainingVolume.divide(packageVolume, 0, RoundingMode.DOWN).intValue();
available = Math.min(available, capacityPackages);
if (available <= 0) continue;
}
}
result.add(new PdaResponses.MergeCandidate(candidate.getId(), pallet.getId(), pallet.getPalletNo(),
line.getId(), line.getOrderId(), line.getInboundCode(), available,
perPackageVolume(line).multiply(BigDecimal.valueOf(available)).setScale(4, RoundingMode.HALF_UP)));
}
result.sort(Comparator.comparingInt(PdaResponses.MergeCandidate::availablePackageCount).reversed());
return result;
}
private boolean sameWarehouse(InboundPlanPallet left, InboundPlanPallet right) {
String leftKey = left.getDestinationWarehouseId() != null
? left.getDestinationWarehouseId() : left.getDestinationWarehouseCode();
String rightKey = right.getDestinationWarehouseId() != null
? right.getDestinationWarehouseId() : right.getDestinationWarehouseCode();
return leftKey != null && leftKey.equals(rightKey);
}
private void validateDestinationWarehouse(InboundPlanPallet pallet, long warehouseId) {
String destinationId = pallet.getDestinationWarehouseId();
if (destinationId == null || destinationId.isBlank()) return;
try {
if (Long.parseLong(destinationId) != warehouseId) {
throw conflict("Warehouse resource must belong to the planned destination warehouse");
}
} catch (NumberFormatException exception) {
throw conflict("Planned destination warehouse ID is not a numeric warehouse resource ID");
}
}
private void startPicking(InboundPdaTask task, InboundPlan plan, String operatorId, String operatorName) {
LocalDateTime now = LocalDateTime.now();
if (task.getStartedAt() == null) task.setStartedAt(now);
task.setStatus("PICKING");
task.setOperatorId(operatorId);
task.setOperatorName(operatorName);
task.setUpdatedAt(now);
task.setVersion(value(task.getVersion()) + 1);
repository.update(task);
plan.setStatus("PICKING");
plan.setUpdatedAt(now);
repository.update(plan);
}
private boolean isNotAssigned(InboundPlanPallet pallet) {
return pallet.getWarehousePalletId() == null || pallet.getWarehouseLocationId() == null
|| pallet.getWarehouseResourceLockId() == null
|| !("ASSIGNED".equals(pallet.getAssignmentStatus()) || "LOCKED".equals(pallet.getAssignmentStatus()));
}
private InboundPlan requirePlan(String customerId, String planNo) {
InboundPlan plan = repository.lockPlan(customerId, planNo);
if (plan == null) throw notFound("Inbound plan not found");
return plan;
}
private InboundPdaTask requireActiveTask(String customerId, InboundPlan plan) {
InboundPdaTask task = repository.lockTask(customerId, plan.getId());
if (task == null) throw conflict("Push the inbound plan to PDA before operating");
if ("COMPLETED".equals(task.getStatus())) throw conflict("PDA task is already completed");
return task;
}
private InboundPlanPalletAllocation requireAllocation(Long planId, Long id) {
InboundPlanPalletAllocation value = repository.lockAllocation(planId, id);
if (value == null) throw notFound("Pallet allocation not found");
return value;
}
private InboundPlanPallet requirePallet(Long planId, Long id) {
InboundPlanPallet value = repository.lockPallet(planId, id);
if (value == null) throw notFound("Planned pallet not found");
return value;
}
private InboundPlanLine requireLine(Long planId, Long id) {
InboundPlanLine value = repository.line(planId, id);
if (value == null) throw notFound("Inbound plan line not found");
return value;
}
private PdaResponses.Operation operation(String result, InboundPlanPalletAllocation allocation,
InboundPlanLine line,
List<PdaResponses.MergeCandidate> candidates,
Integer remaining, Integer requested, Integer merge) {
return new PdaResponses.Operation(result, allocation.getId(), value(line.getRequiredPackageCount()),
value(allocation.getCountedPackageCount()), value(allocation.getPickedPackageCount()),
volume(allocation.getActualVolumeCbm()), remaining, requested, merge, candidates);
}
private PdaResponses.Task task(InboundPdaTask task, String planNo) {
return new PdaResponses.Task(task.getId(), task.getTaskNo(), planNo, task.getStatus(),
task.getPushedAt(), task.getStartedAt(), task.getCompletedAt());
}
private BigDecimal perPackageVolume(InboundPlanLine line) {
int required = value(line.getRequiredPackageCount());
if (required <= 0) throw conflict("Inbound line required package count must be positive");
return volume(line.getVolumeCbm()).divide(BigDecimal.valueOf(required), 8, RoundingMode.HALF_UP);
}
private InboundPdaOperation replay(String customerId, String action, String key, Object request) {
InboundPdaOperation operation = repository.operation(customerId, key);
if (operation == null) return null;
if (!action.equals(operation.getAction()) || !hash(request).equals(operation.getRequestHash())) {
throw conflict("Idempotency key was already used with a different request");
}
return operation;
}
private void saveOperation(String customerId, Long taskId, Long allocationId, String action,
String key, Object request, Object result) {
InboundPdaOperation operation = new InboundPdaOperation();
operation.setId(IdWorker.getId());
operation.setCustomerId(customerId);
operation.setTaskId(taskId);
operation.setAllocationId(allocationId);
operation.setAction(action);
operation.setIdempotencyKey(key);
operation.setRequestHash(hash(request));
operation.setRequestJson(write(request));
operation.setResultJson(write(result));
operation.setCreatedAt(LocalDateTime.now());
repository.insert(operation);
}
private String hash(Object value) {
try {
byte[] digest = MessageDigest.getInstance("SHA-256").digest(write(value).getBytes(StandardCharsets.UTF_8));
return java.util.HexFormat.of().formatHex(digest);
} 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 PDA operation", exception);
}
}
private <T> T read(InboundPdaOperation operation, Class<T> type) {
try {
return objectMapper.readValue(operation.getResultJson(), type);
} catch (JsonProcessingException exception) {
throw new IllegalStateException("Cannot read persisted PDA operation result", exception);
}
}
private void bump(InboundPlanPalletAllocation allocation) {
allocation.setVersion(value(allocation.getVersion()) + 1);
}
private static int value(Integer value) { return value == null ? 0 : value; }
private static BigDecimal volume(BigDecimal value) { return value == null ? ZERO_VOLUME : value; }
private static BigDecimal nonNegative(BigDecimal value) { return value.signum() < 0 ? ZERO_VOLUME : value.setScale(4, RoundingMode.HALF_UP); }
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,193 @@
package com.baiyue.lastleg.inboundplan.pda.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.entity.LastlegOrder;
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.LastlegOrderMapper;
import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper;
import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
import com.baiyue.lastleg.dropship.service.DropshipOrderService;
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.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
/** Converts final PDA package counts into inbound-order/pallet inventory. */
@Service
public class InboundStockReceiptService {
@Resource private LastlegOrderMapper orders;
@Resource private InboundStockReceiptMapper receipts;
@Resource private InventoryCommandService inventory;
@Resource private DropshipOrderService dropshipOrders;
@Resource private LastlegOrderStatusHistoryMapper orderHistories;
@Transactional
public void stockIn(String customerId, InboundPlan plan, InboundPdaTask task,
List<InboundPlanLine> lines, List<InboundPlanPalletAllocation> allocations,
List<InboundPlanPallet> pallets, LocalDateTime receivedAt) {
Map<Long, LastlegOrder> orderById = orders.selectBatchIds(lines.stream()
.map(InboundPlanLine::getOrderId).collect(Collectors.toSet())).stream()
.filter(order -> customerId.equals(order.getCustomerId()))
.collect(Collectors.toMap(LastlegOrder::getId, order -> order));
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));
Set<Long> expectedAllocationIds = allocationsByLine.values().stream().flatMap(List::stream)
.map(InboundPlanPalletAllocation::getId).collect(Collectors.toSet());
List<InboundStockReceipt> existing = receipts.listTask(customerId, task.getId());
if (!existing.isEmpty()) {
Set<Long> receivedAllocationIds = existing.stream().map(InboundStockReceipt::getAllocationId)
.filter(Objects::nonNull).collect(Collectors.toSet());
if (receivedAllocationIds.equals(expectedAllocationIds)) {
activateDropship(customerId, task.getId(), orderById, existing);
completeOrders(customerId, plan, orderById, receivedAt);
return;
}
throw conflict("PDA stock-in receipts are incomplete; repair the task receipts before retrying");
}
List<InboundStockReceipt> created = new java.util.ArrayList<>();
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());
long pickedPackages = lineAllocations.stream()
.mapToLong(allocation -> value(allocation.getPickedPackageCount())).sum();
if (pickedPackages != value(line.getRequiredPackageCount())) {
throw conflict("PDA picked packages must equal the inbound order package total");
}
for (InboundPlanPalletAllocation allocation : lineAllocations) {
created.add(persist(customerId, plan, task, line, allocation,
requirePallet(palletById, allocation), order, receivedAt));
}
}
activateDropship(customerId, task.getId(), orderById, created);
completeOrders(customerId, plan, orderById, receivedAt);
}
private void completeOrders(String customerId, InboundPlan plan, Map<Long, LastlegOrder> orderById,
LocalDateTime completedAt) {
for (LastlegOrder snapshot : orderById.values()) {
LastlegOrder order = orders.lockById(customerId, snapshot.getId());
if (order == null) throw conflict("Inbound tail-leg order disappeared while completing stock-in");
if ("INBOUND_COMPLETED".equals(order.getCurrentStatus())) continue;
String before = order.getCurrentStatus();
order.setCurrentStatus("INBOUND_COMPLETED");
order.setSubOrderStatus("INBOUND_PLAN:COMPLETED");
order.setUpdatedAt(completedAt);
orders.updateById(order);
LastlegOrderStatusHistory history = new LastlegOrderStatusHistory();
history.setOrderId(order.getId());
history.setFromStatus(before);
history.setToStatus("INBOUND_COMPLETED");
history.setEventId("PDA_INBOUND_COMPLETE:" + plan.getPlanNo());
history.setRemark("PDA inbound completed and inventory stocked in");
history.setCreatedAt(completedAt);
orderHistories.insert(history);
}
}
private InboundStockReceipt persist(String customerId, InboundPlan plan, InboundPdaTask task, InboundPlanLine line,
InboundPlanPalletAllocation allocation, InboundPlanPallet pallet,
LastlegOrder order, LocalDateTime receivedAt) {
long packages = value(allocation.getPickedPackageCount());
if (packages <= 0 || pallet.getWarehousePalletId() == null) {
throw conflict("Stock-in requires picked packages and an assigned warehouse pallet");
}
String destinationCode = required(first(line.getDestinationWarehouseCode(), order.getFbaWarehouseCode()),
"Destination warehouse code is required for stock-in");
String operationKey = "PDA_STOCK_IN:" + task.getId() + ":" + allocation.getId();
String overseasWarehouseId = required(order.getOverseasWarehouseId(),
"Actual overseas warehouse is required for stock-in");
InventoryCommandResponse result = inventory.stockIn(customerId, task.getOperatorId(),
new InventoryCommandRequests.StockIn(null, order.getCustomerName(), overseasWarehouseId,
null, order.getOverseasWarehouseName(), line.getDestinationWarehouseId(), destinationCode,
line.getDestinationWarehouseName(), order.getId(), plan.getPlanNo(), line.getInboundCode(),
pallet.getWarehousePalletId(), pallet.getPalletNo(), plan.getPlanNo(),
pallet.getWarehouseLocationId(), null, packages, order.getOrderNo(),
order.getSourceEventId(), operationKey,
"PDA inbound completion for " + line.getInboundCode()));
InboundStockReceipt receipt = new InboundStockReceipt();
receipt.setId(IdWorker.getId());
receipt.setCustomerId(customerId);
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.setWarehousePalletId(pallet.getWarehousePalletId());
receipt.setPalletNo(pallet.getPalletNo());
receipt.setWarehouseLocationId(pallet.getWarehouseLocationId());
receipt.setBatchNo(plan.getPlanNo());
receipt.setReceivedPackages(packages);
receipt.setInventoryBalanceId(result.balanceId());
receipt.setInventoryPositionId(result.positionId());
receipt.setInventoryOperationKey(operationKey);
receipt.setReceivedAt(receivedAt);
receipt.setCreatedAt(receivedAt);
receipts.insert(receipt);
return receipt;
}
private void activateDropship(String customerId, Long taskId, Map<Long, LastlegOrder> orderById,
List<InboundStockReceipt> stockReceipts) {
Map<Long, List<InboundStockReceipt>> receiptsByOrder = stockReceipts.stream()
.filter(receipt -> receipt.getOrderId() != null)
.collect(Collectors.groupingBy(InboundStockReceipt::getOrderId));
for (LastlegOrder order : orderById.values()) {
if ("ONE_CLICK_DROPSHIP".equals(order.getOrderType())) {
dropshipOrders.activateAfterStockIn(customerId, order.getId(), taskId,
receiptsByOrder.getOrDefault(order.getId(), List.of()));
}
}
}
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 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);
}
}
@@ -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 customerId, 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(customerId, appointmentNo);
if (existing != null) {
return verifyReplay(existing, lineIds, request.appointmentAt(), status, remark);
}
List<FbaAppointmentLineSnapshot> lines = queryMapper.lockLines(customerId, 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(customerId, 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.setCustomerId(customerId);
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(customerId, 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 customerId, String appointmentNo) {
InboundFbaAppointment appointment = find(customerId,
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 customerId, String appointmentNo) {
return appointmentMapper.selectOne(new LambdaQueryWrapper<InboundFbaAppointment>()
.eq(InboundFbaAppointment::getCustomerId, customerId)
.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.getCustomerId(),
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();
}
}
@@ -0,0 +1,243 @@
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.InboundPlanResponse;
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.mapper.InboundPlanLineMapper;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanMapper;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanOrderMapper;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletAllocationMapper;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletMapper;
import com.baiyue.lastleg.inboundplan.model.SourceOrderSnapshot;
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.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;
@Service
public class InboundPlanService {
@Resource private InboundPlanMapper planMapper;
@Resource private InboundPlanLineMapper lineMapper;
@Resource private InboundPlanPalletMapper palletMapper;
@Resource private InboundPlanPalletAllocationMapper allocationMapper;
@Resource private InboundPlanOrderMapper orderMapper;
@Transactional
public InboundPlanResponse convert(String customerId, Long selectedOrderId) {
String containerNo = trimToNull(orderMapper.findContainerNo(customerId, selectedOrderId));
if (containerNo == null) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"Order does not exist in this tenant or container number is empty");
}
List<SourceOrderSnapshot> orders = orderMapper.lockContainerOrders(customerId, containerNo);
if (orders.isEmpty()) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Container orders not found");
}
InboundPlan existing = planMapper.findByContainer(customerId, containerNo);
if (existing != null) {
return detail(customerId, existing);
}
validateOrders(orders);
LocalDateTime now = LocalDateTime.now();
InboundPlan plan = new InboundPlan();
plan.setId(IdWorker.getId());
plan.setPlanNo("LIP" + plan.getId());
plan.setCustomerId(customerId);
plan.setContainerNo(containerNo);
plan.setStatus("PENDING_ASSIGNMENT");
plan.setLineCount(orders.size());
plan.setTotalVolumeCbm(orders.stream().map(SourceOrderSnapshot::volumeCbm)
.filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add));
plan.setCreatedAt(now);
plan.setUpdatedAt(now);
Map<Long, SourceOrderSnapshot> orderById = orders.stream()
.collect(Collectors.toMap(SourceOrderSnapshot::orderId, value -> value));
Map<Long, InboundPlanLine> lineByOrder = createLines(plan, orders);
PalletAllocator.Result allocation = PalletAllocator.allocate(orders.stream()
.map(order -> new PalletAllocator.LineInput(order.orderId(), warehouseKey(order), volume(order),
order.packageCount()))
.toList());
plan.setPalletCount(allocation.pallets().size());
planMapper.insert(plan);
lineByOrder.values().forEach(lineMapper::insert);
Map<PalletAllocator.PalletDraft, InboundPlanPallet> persistedPallets = createPallets(plan, allocation, orderById);
createAllocations(plan, allocation, lineByOrder, persistedPallets);
Map<Long, Integer> palletCounts = allocation.allocations().stream().collect(Collectors.groupingBy(
PalletAllocator.AllocationDraft::orderId,
Collectors.collectingAndThen(Collectors.mapping(item -> item.pallet().sequenceNo(), Collectors.toSet()), java.util.Set::size)));
for (SourceOrderSnapshot order : orders) {
int count = palletCounts.getOrDefault(order.orderId(), 0);
InboundPlanLine line = lineByOrder.get(order.orderId());
line.setPlannedPalletCount(count);
lineMapper.updateById(line);
if (orderMapper.markPlanned(customerId, order.orderId(), plan.getPlanNo(), count, now) != 1) {
throw new IllegalStateException("Failed to update order " + order.orderId());
}
orderMapper.insertHistory(order.orderId(), order.currentStatus(),
"Container converted to inbound plan " + plan.getPlanNo(), now);
}
return detail(customerId, plan);
}
@Transactional(readOnly = true)
public InboundPlanResponse detailByPlanNo(String customerId, String planNo) {
InboundPlan plan = planMapper.selectOne(new LambdaQueryWrapper<InboundPlan>()
.eq(InboundPlan::getCustomerId, customerId).eq(InboundPlan::getPlanNo, planNo));
if (plan == null) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Inbound plan not found");
}
return detail(customerId, plan);
}
private Map<Long, InboundPlanLine> createLines(InboundPlan plan, List<SourceOrderSnapshot> orders) {
Map<Long, InboundPlanLine> result = new HashMap<>();
for (SourceOrderSnapshot order : orders) {
InboundPlanLine line = new InboundPlanLine();
line.setId(IdWorker.getId());
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());
line.setVolumeCbm(volume(order));
line.setRequiredPackageCount(order.packageCount());
line.setPlannedPalletCount(0);
result.put(order.orderId(), line);
}
return result;
}
private Map<PalletAllocator.PalletDraft, InboundPlanPallet> createPallets(
InboundPlan plan, PalletAllocator.Result allocation, Map<Long, SourceOrderSnapshot> orderById) {
Map<String, SourceOrderSnapshot> warehouseSamples = orderById.values().stream()
.collect(Collectors.toMap(this::warehouseKey, value -> value, (left, right) -> left));
Map<PalletAllocator.PalletDraft, InboundPlanPallet> result = new HashMap<>();
for (PalletAllocator.PalletDraft draft : allocation.pallets()) {
SourceOrderSnapshot sample = warehouseSamples.get(draft.warehouseKey());
InboundPlanPallet pallet = new InboundPlanPallet();
pallet.setId(IdWorker.getId());
pallet.setPlanId(plan.getId());
pallet.setPalletNo(plan.getPlanNo() + "-P" + String.format("%03d", result.size() + 1));
pallet.setDestinationWarehouseId(sample.destinationWarehouseId());
pallet.setDestinationWarehouseCode(sample.destinationWarehouseCode());
pallet.setDestinationWarehouseName(sample.destinationWarehouseName());
pallet.setCapacityCbm(PalletAllocator.PALLET_CAPACITY_CBM);
pallet.setAllocatedVolumeCbm(draft.allocatedVolumeCbm());
pallet.setSequenceNo(result.size() + 1);
pallet.setAssignmentStatus("UNASSIGNED");
pallet.setActualVolumeCbm(BigDecimal.ZERO);
pallet.setActualPackageCount(0);
palletMapper.insert(pallet);
result.put(draft, pallet);
}
return result;
}
private void createAllocations(InboundPlan plan, PalletAllocator.Result allocation,
Map<Long, InboundPlanLine> lineByOrder,
Map<PalletAllocator.PalletDraft, InboundPlanPallet> pallets) {
for (PalletAllocator.AllocationDraft draft : allocation.allocations()) {
InboundPlanPalletAllocation item = new InboundPlanPalletAllocation();
item.setId(IdWorker.getId());
item.setPlanId(plan.getId());
item.setLineId(lineByOrder.get(draft.orderId()).getId());
item.setPalletId(pallets.get(draft.pallet()).getId());
item.setAllocatedVolumeCbm(draft.allocatedVolumeCbm());
item.setPlannedPackageCount(draft.plannedPackageCount());
item.setCountedPackageCount(0);
item.setPickedPackageCount(0);
item.setActualVolumeCbm(BigDecimal.ZERO);
item.setStatus("PENDING_COUNT");
item.setVersion(0);
allocationMapper.insert(item);
}
}
private void validateOrders(List<SourceOrderSnapshot> orders) {
for (SourceOrderSnapshot order : orders) {
if (!"PENDING_ASSIGNMENT".equals(order.currentStatus())) {
throw new ResponseStatusException(HttpStatus.CONFLICT,
"Inbound code " + order.inboundCode() + " is not ready for an inbound plan");
}
if (order.destinationCount() != 1 || order.destinationMismatchCount() != 0 || warehouseKey(order) == null) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"Inbound code " + order.inboundCode()
+ " must have one destination warehouse consistent with all cargo details");
}
if (order.inboundOrderNo() != null && !order.inboundOrderNo().isBlank()) {
throw new ResponseStatusException(HttpStatus.CONFLICT,
"Inbound code " + order.inboundCode() + " already belongs to an inbound plan");
}
if (volume(order).signum() < 0) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"Inbound code " + order.inboundCode() + " has negative volume");
}
if (order.packageCount() < 0 || (order.packageCount() > 0 && volume(order).signum() == 0)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"Inbound code " + order.inboundCode() + " has invalid package count or volume");
}
}
}
private InboundPlanResponse detail(String customerId, InboundPlan plan) {
if (!customerId.equals(plan.getCustomerId())) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Inbound plan not found");
}
List<InboundPlanLine> lines = lineMapper.selectList(new LambdaQueryWrapper<InboundPlanLine>()
.eq(InboundPlanLine::getPlanId, plan.getId()).orderByAsc(InboundPlanLine::getId));
List<InboundPlanPallet> pallets = palletMapper.selectList(new LambdaQueryWrapper<InboundPlanPallet>()
.eq(InboundPlanPallet::getPlanId, plan.getId()).orderByAsc(InboundPlanPallet::getSequenceNo));
List<InboundPlanPalletAllocation> allocations = allocationMapper.selectList(
new LambdaQueryWrapper<InboundPlanPalletAllocation>()
.eq(InboundPlanPalletAllocation::getPlanId, plan.getId())
.orderByAsc(InboundPlanPalletAllocation::getId));
return new InboundPlanResponse(plan.getId(), plan.getPlanNo(), plan.getCustomerId(), plan.getContainerNo(),
plan.getStatus(), plan.getLineCount(), plan.getPalletCount(), plan.getTotalVolumeCbm(),
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(),
pallet.getDestinationWarehouseId(), pallet.getDestinationWarehouseCode(), pallet.getDestinationWarehouseName(),
pallet.getCapacityCbm(), pallet.getAllocatedVolumeCbm(), pallet.getSequenceNo(),
pallet.getWarehousePalletId(), pallet.getWarehouseLocationId(), pallet.getWarehouseResourceLockId(),
pallet.getAssignmentStatus(), pallet.getLockedAt(), pallet.getActualVolumeCbm(),
pallet.getActualPackageCount())).toList(),
allocations.stream().map(item -> new InboundPlanResponse.Allocation(item.getLineId(), item.getPalletId(),
item.getAllocatedVolumeCbm(), item.getPlannedPackageCount(), item.getCountedPackageCount(),
item.getPickedPackageCount(), item.getActualVolumeCbm(), item.getStatus(), item.getVersion())).toList());
}
private BigDecimal volume(SourceOrderSnapshot order) {
return Objects.requireNonNullElse(order.volumeCbm(), BigDecimal.ZERO);
}
private String warehouseKey(SourceOrderSnapshot order) {
String id = trimToNull(order.destinationWarehouseId());
String code = trimToNull(order.destinationWarehouseCode());
return id != null ? "ID:" + id : code == null ? null : "CODE:" + code;
}
private static String trimToNull(String value) {
return value == null || value.isBlank() ? null : value.trim();
}
}
@@ -0,0 +1,129 @@
package com.baiyue.lastleg.inboundplan.service;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
public final class PalletAllocator {
public static final BigDecimal PALLET_CAPACITY_CBM = new BigDecimal("2.0000");
private PalletAllocator() {
}
public static Result allocate(List<LineInput> lines) {
Map<String, List<LineInput>> groups = new LinkedHashMap<>();
for (LineInput line : lines) {
if (line.volumeCbm() == null || line.volumeCbm().signum() < 0) {
throw new IllegalArgumentException("Volume must be zero or positive");
}
if (line.packageCount() < 0) {
throw new IllegalArgumentException("Package count must be zero or positive");
}
if (line.volumeCbm().signum() == 0 && line.packageCount() > 0) {
throw new IllegalArgumentException("Positive package count requires positive volume");
}
groups.computeIfAbsent(line.warehouseKey(), ignored -> new ArrayList<>()).add(line);
}
List<PalletDraft> pallets = new ArrayList<>();
List<AllocationDraft> allocations = new ArrayList<>();
for (List<LineInput> group : groups.values()) {
PalletDraft current = null;
int sequence = 0;
for (LineInput line : group) {
BigDecimal remaining = line.volumeCbm();
while (remaining.signum() > 0) {
if (current == null || current.remainingCapacity().signum() == 0) {
current = new PalletDraft(line.warehouseKey(), ++sequence, BigDecimal.ZERO);
pallets.add(current);
}
BigDecimal allocated = remaining.min(current.remainingCapacity());
current.add(allocated);
allocations.add(new AllocationDraft(line.orderId(), current, allocated, 0));
remaining = remaining.subtract(allocated);
}
}
}
return new Result(List.copyOf(pallets), distributePackages(lines, allocations));
}
private static List<AllocationDraft> distributePackages(List<LineInput> lines,
List<AllocationDraft> allocations) {
Map<Long, LineInput> linesByOrder = new LinkedHashMap<>();
for (LineInput line : lines) {
if (linesByOrder.put(line.orderId(), line) != null) {
throw new IllegalArgumentException("Duplicate order id " + line.orderId());
}
}
Map<Long, List<AllocationDraft>> allocationsByOrder = new LinkedHashMap<>();
for (AllocationDraft allocation : allocations) {
allocationsByOrder.computeIfAbsent(allocation.orderId(), ignored -> new ArrayList<>()).add(allocation);
}
List<AllocationDraft> result = new ArrayList<>(allocations.size());
Map<AllocationDraft, AllocationDraft> replacements = new LinkedHashMap<>();
for (LineInput line : lines) {
List<AllocationDraft> orderAllocations = allocationsByOrder.getOrDefault(line.orderId(), List.of());
int assigned = 0;
for (int index = 0; index < orderAllocations.size(); index++) {
AllocationDraft allocation = orderAllocations.get(index);
int packages = index == orderAllocations.size() - 1
? line.packageCount() - assigned
: allocation.allocatedVolumeCbm().multiply(BigDecimal.valueOf(line.packageCount()))
.divide(line.volumeCbm(), 0, RoundingMode.DOWN).intValueExact();
assigned += packages;
replacements.put(allocation, new AllocationDraft(allocation.orderId(), allocation.pallet(),
allocation.allocatedVolumeCbm(), packages));
}
}
for (AllocationDraft allocation : allocations) {
result.add(replacements.get(allocation));
}
return List.copyOf(result);
}
public record LineInput(Long orderId, String warehouseKey, BigDecimal volumeCbm, int packageCount) {
}
public static final class PalletDraft {
private final String warehouseKey;
private final int sequenceNo;
private BigDecimal allocatedVolumeCbm;
private PalletDraft(String warehouseKey, int sequenceNo, BigDecimal allocatedVolumeCbm) {
this.warehouseKey = warehouseKey;
this.sequenceNo = sequenceNo;
this.allocatedVolumeCbm = allocatedVolumeCbm;
}
private void add(BigDecimal volume) {
allocatedVolumeCbm = allocatedVolumeCbm.add(volume);
}
public String warehouseKey() {
return warehouseKey;
}
public int sequenceNo() {
return sequenceNo;
}
public BigDecimal allocatedVolumeCbm() {
return allocatedVolumeCbm;
}
public BigDecimal remainingCapacity() {
return PALLET_CAPACITY_CBM.subtract(allocatedVolumeCbm);
}
}
public record AllocationDraft(Long orderId, PalletDraft pallet, BigDecimal allocatedVolumeCbm,
int plannedPackageCount) {
}
public record Result(List<PalletDraft> pallets, List<AllocationDraft> allocations) {
}
}