暂存提交
This commit is contained in:
+9
@@ -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 {
|
||||
}
|
||||
+74
@@ -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());
|
||||
}
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package com.baiyue.lastleg.inboundplan.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
public record BatchFbaAppointmentRequest(
|
||||
String 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
|
||||
) {
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package com.baiyue.lastleg.inboundplan.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
public record ConvertInboundPlanRequest(
|
||||
@NotNull Long orderId,
|
||||
String customerId
|
||||
) {
|
||||
}
|
||||
+23
@@ -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) {
|
||||
}
|
||||
}
|
||||
+37
@@ -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) {
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package com.baiyue.lastleg.inboundplan.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("ll_inbound_fba_appointment")
|
||||
public class InboundFbaAppointment {
|
||||
@TableId(type = IdType.INPUT)
|
||||
private Long id;
|
||||
private String 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;
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package com.baiyue.lastleg.inboundplan.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("ll_inbound_fba_appointment_item")
|
||||
public class InboundFbaAppointmentItem {
|
||||
@TableId(type = IdType.INPUT)
|
||||
private Long id;
|
||||
private Long appointmentId;
|
||||
private Long planId;
|
||||
private Long lineId;
|
||||
private Long orderId;
|
||||
private String inboundCode;
|
||||
private String fbaWarehouseCode;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package com.baiyue.lastleg.inboundplan.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.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;
|
||||
}
|
||||
+31
@@ -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;
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package com.baiyue.lastleg.inboundplan.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.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;
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package com.baiyue.lastleg.inboundplan.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.lastleg.inboundplan.entity.InboundFbaAppointmentItem;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface InboundFbaAppointmentItemMapper extends BaseMapper<InboundFbaAppointmentItem> {
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package com.baiyue.lastleg.inboundplan.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.lastleg.inboundplan.entity.InboundFbaAppointment;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface InboundFbaAppointmentMapper extends BaseMapper<InboundFbaAppointment> {
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package com.baiyue.lastleg.inboundplan.mapper;
|
||||
|
||||
import com.baiyue.lastleg.inboundplan.model.FbaAppointmentLineSnapshot;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface InboundFbaAppointmentQueryMapper {
|
||||
@Select("<script>" +
|
||||
"SELECT l.id AS lineId,l.plan_id AS planId,l.order_id AS orderId,l.inbound_code AS inboundCode," +
|
||||
"l.fba_warehouse_code AS fbaWarehouseCode " +
|
||||
"FROM ll_inbound_plan_line l JOIN ll_inbound_plan p ON p.id=l.plan_id " +
|
||||
"WHERE p.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);
|
||||
}
|
||||
+9
@@ -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> {
|
||||
}
|
||||
+13
@@ -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);
|
||||
}
|
||||
+50
@@ -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);
|
||||
}
|
||||
+9
@@ -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> {
|
||||
}
|
||||
+9
@@ -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> {
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
package com.baiyue.lastleg.inboundplan.model;
|
||||
|
||||
public record FbaAppointmentLineSnapshot(
|
||||
Long lineId,
|
||||
Long planId,
|
||||
Long orderId,
|
||||
String inboundCode,
|
||||
String fbaWarehouseCode
|
||||
) {
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
package com.baiyue.lastleg.inboundplan.model;
|
||||
|
||||
public enum FbaAppointmentStatus {
|
||||
PENDING,
|
||||
SUCCESS,
|
||||
FAILED,
|
||||
CANCELLED
|
||||
}
|
||||
+30
@@ -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);
|
||||
}
|
||||
}
|
||||
+43
@@ -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());
|
||||
}
|
||||
}
|
||||
+107
@@ -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) {}
|
||||
}
|
||||
+26
@@ -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) {}
|
||||
}
|
||||
+32
@@ -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;
|
||||
}
|
||||
+24
@@ -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;
|
||||
}
|
||||
+27
@@ -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;
|
||||
}
|
||||
+34
@@ -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;
|
||||
}
|
||||
+89
@@ -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) {}
|
||||
}
|
||||
+11
@@ -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) {}
|
||||
}
|
||||
+9
@@ -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> {
|
||||
}
|
||||
+13
@@ -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);
|
||||
}
|
||||
+16
@@ -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);
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package com.baiyue.lastleg.inboundplan.pda.mapper;
|
||||
|
||||
import com.baiyue.lastleg.inboundplan.pda.entity.InboundStockReceipt;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface InboundStockReceiptMapper extends BaseMapper<InboundStockReceipt> {
|
||||
@Select("SELECT * FROM ll_inbound_stock_receipt WHERE customer_id=#{customerId} AND task_id=#{taskId} " +
|
||||
"ORDER BY allocation_id")
|
||||
List<InboundStockReceipt> listTask(@Param("customerId") String customerId, @Param("taskId") Long taskId);
|
||||
}
|
||||
+79
@@ -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); }
|
||||
}
|
||||
+532
@@ -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); }
|
||||
}
|
||||
+193
@@ -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);
|
||||
}
|
||||
}
|
||||
+180
@@ -0,0 +1,180 @@
|
||||
package com.baiyue.lastleg.inboundplan.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import com.baiyue.lastleg.inboundplan.dto.BatchFbaAppointmentRequest;
|
||||
import com.baiyue.lastleg.inboundplan.dto.FbaAppointmentResponse;
|
||||
import com.baiyue.lastleg.inboundplan.entity.InboundFbaAppointment;
|
||||
import com.baiyue.lastleg.inboundplan.entity.InboundFbaAppointmentItem;
|
||||
import com.baiyue.lastleg.inboundplan.mapper.InboundFbaAppointmentItemMapper;
|
||||
import com.baiyue.lastleg.inboundplan.mapper.InboundFbaAppointmentMapper;
|
||||
import com.baiyue.lastleg.inboundplan.mapper.InboundFbaAppointmentQueryMapper;
|
||||
import com.baiyue.lastleg.inboundplan.model.FbaAppointmentLineSnapshot;
|
||||
import com.baiyue.lastleg.inboundplan.model.FbaAppointmentStatus;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
@Service
|
||||
public class InboundFbaAppointmentService {
|
||||
@Resource private InboundFbaAppointmentMapper appointmentMapper;
|
||||
@Resource private InboundFbaAppointmentItemMapper itemMapper;
|
||||
@Resource private InboundFbaAppointmentQueryMapper queryMapper;
|
||||
|
||||
@Transactional
|
||||
public FbaAppointmentResponse batchCreate(String 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();
|
||||
}
|
||||
}
|
||||
+243
@@ -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();
|
||||
}
|
||||
}
|
||||
+129
@@ -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) {
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user