This commit is contained in:
2026-09-01 11:09:14 +08:00
191 changed files with 6486 additions and 403 deletions
@@ -12,6 +12,7 @@ import com.baiyue.lastleg.inboundplan.dto.InboundPlanListResponse;
import com.baiyue.lastleg.inboundplan.dto.InboundPlanRequests;
import com.baiyue.lastleg.inboundplan.dto.SupplementPalletRequest;
import com.baiyue.lastleg.inboundplan.dto.UpdateFbaAppointmentRequest;
import com.baiyue.lastleg.inboundplan.dto.UpdateInboundPlanRequest;
import com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService;
import com.baiyue.lastleg.inboundplan.service.InboundPlanService;
import com.baiyue.lastleg.security.OverseasPermissions;
@@ -75,6 +76,15 @@ public class InboundPlanController {
return ApiResponse.success(service.detailByPlanNo(scope(user, customerId), planNo));
}
@PutMapping("/{planNo}")
@PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_EDIT + "')")
@Operation(summary = "编辑入仓计划")
public ApiResponse<InboundPlanResponse> update(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String planNo,
@Valid @RequestBody UpdateInboundPlanRequest request) {
return ApiResponse.success(service.update(scope(user, request.customerId()), planNo, request));
}
@PostMapping("/{planNo}/pallets/supplement")
@PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_EDIT + "')")
/** 为计划目标仓新增一个可打印的补充托盘。 */
@@ -17,6 +17,7 @@ public record InboundPlanListResponse(
String fbaWarehouseCode,
String customerName,
Integer packageCount,
Integer pickedPackageCount,
BigDecimal volumeCbm,
Integer palletCount,
String status,
@@ -25,6 +26,20 @@ public record InboundPlanListResponse(
LocalDateTime appointmentAt,
LocalDateTime expectedLoadingAt,
String dispatchStatus,
LocalDateTime createdAt
LocalDateTime createdAt,
Integer version
) {
public InboundPlanListResponse(Long lineId, Long planId, String planNo, String customerId,
Long orderId, String inboundCode, String orderType,
String warehouseId, String warehouseCode, String warehouseName,
String fbaWarehouseCode, String customerName, Integer packageCount,
BigDecimal volumeCbm, Integer palletCount, String status,
String appointmentNo, String appointmentStatus,
LocalDateTime appointmentAt, LocalDateTime expectedLoadingAt,
String dispatchStatus, LocalDateTime createdAt) {
this(lineId, planId, planNo, customerId, orderId, inboundCode, orderType, warehouseId,
warehouseCode, warehouseName, fbaWarehouseCode, customerName, packageCount, 0,
volumeCbm, palletCount, status, appointmentNo, appointmentStatus, appointmentAt,
expectedLoadingAt, dispatchStatus, createdAt, 0);
}
}
@@ -14,10 +14,29 @@ public record InboundPlanResponse(
int lineCount,
int palletCount,
BigDecimal totalVolumeCbm,
int expectedPackageCount,
int pickedPackageCount,
List<Line> lines,
List<Pallet> pallets,
List<Allocation> allocations
List<Allocation> allocations,
int version
) {
public InboundPlanResponse(Long id, String planNo, String customerId, Long sourceOrderId,
String containerNo, String status, int lineCount, int palletCount,
BigDecimal totalVolumeCbm, List<Line> lines, List<Pallet> pallets,
List<Allocation> allocations) {
this(id, planNo, customerId, sourceOrderId, containerNo, status, lineCount, palletCount,
totalVolumeCbm, lines, pallets, allocations, 0);
}
public InboundPlanResponse(Long id, String planNo, String customerId, Long sourceOrderId,
String containerNo, String status, int lineCount, int palletCount,
BigDecimal totalVolumeCbm, List<Line> lines, List<Pallet> pallets,
List<Allocation> allocations, int version) {
this(id, planNo, customerId, sourceOrderId, containerNo, status, lineCount, palletCount,
totalVolumeCbm, lines.stream().mapToInt(Line::requiredPackageCount).sum(),
allocations.stream().mapToInt(Allocation::pickedPackageCount).sum(), lines, pallets, allocations, version);
}
public record Line(Long id, Long orderId, String inboundCode, String fbaWarehouseCode, String destinationWarehouseId,
String destinationWarehouseCode, String destinationWarehouseName,
BigDecimal volumeCbm, int requiredPackageCount, int plannedPalletCount) {
@@ -0,0 +1,12 @@
package com.baiyue.lastleg.inboundplan.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
public record UpdateInboundPlanRequest(
@NotBlank String customerId,
@NotNull Long overseasWarehouseId,
String warehouseCode,
String warehouseName,
@NotNull Integer version
) {}
@@ -3,6 +3,7 @@ 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 com.baomidou.mybatisplus.annotation.Version;
import lombok.Data;
import java.math.BigDecimal;
@@ -24,4 +25,6 @@ public class InboundPlan {
private BigDecimal totalVolumeCbm;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
@Version
private Integer version;
}
@@ -24,19 +24,17 @@ public interface InboundPlanOrderMapper {
@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," +
"COALESCE(o.pallet_count,0) AS recommendedPalletCount," +
"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 " +
"CASE WHEN COALESCE(NULLIF(o.transfer_warehouse_id,''),NULLIF(o.transfer_warehouse_code,'')) IS NOT NULL THEN 1 " +
"ELSE COUNT(DISTINCT COALESCE(NULLIF(d.destination_warehouse_id,''),NULLIF(d.destination_warehouse_code,''),'#EMPTY#')) END AS destinationCount," +
"0 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.id=#{orderId} " +
"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," +
"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.pallet_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> lockOrder(@Param("customerId") String customerId,
@@ -30,8 +30,8 @@ public interface InboundPlanQueryMapper {
"<if test='q.fbaWarehouseCode != null and q.fbaWarehouseCode != \"\"'> AND l.fba_warehouse_code LIKE CONCAT('%',#{q.fbaWarehouseCode},'%')</if>" +
"<choose><when test='q.appointmentStatus == \"UNAPPOINTED\"'> AND ai.id IS NULL</when>" +
"<when test='q.appointmentStatus != null and q.appointmentStatus != \"\"'> AND a.appointment_status=#{q.appointmentStatus}</when></choose>" +
"<choose><when test='q.dispatchStatus == \"SCHEDULED\"'> AND EXISTS (SELECT 1 FROM ll_dispatch_item di WHERE di.order_id=l.order_id <if test='customerId != null and customerId != \"\"'> AND di.customer_id=#{customerId}</if>)</when>" +
"<when test='q.dispatchStatus == \"UNSCHEDULED\"'> AND NOT EXISTS (SELECT 1 FROM ll_dispatch_item di WHERE di.order_id=l.order_id <if test='customerId != null and customerId != \"\"'> AND di.customer_id=#{customerId}</if>)</when></choose>" +
"<choose><when test='q.dispatchStatus == \"SCHEDULED\"'> AND EXISTS (SELECT 1 FROM ll_dispatch_item di WHERE <if test='customerId != null and customerId != \"\"'>di.customer_id=#{customerId} AND </if>di.order_id=l.order_id)</when>" +
"<when test='q.dispatchStatus == \"UNSCHEDULED\"'> AND NOT EXISTS (SELECT 1 FROM ll_dispatch_item di WHERE <if test='customerId != null and customerId != \"\"'>di.customer_id=#{customerId} AND </if>di.order_id=l.order_id)</when></choose>" +
"<if test='q.createdFrom != null'> AND p.created_at &gt;=#{q.createdFrom}</if>" +
"<if test='q.createdTo != null'> AND p.created_at &lt;=#{q.createdTo}</if>";
@@ -39,12 +39,15 @@ public interface InboundPlanQueryMapper {
"l.order_id orderId,l.inbound_code inboundCode,o.order_type orderType," +
"l.destination_warehouse_id warehouseId,l.destination_warehouse_code warehouseCode," +
"l.destination_warehouse_name warehouseName,l.fba_warehouse_code fbaWarehouseCode," +
"o.customer_name customerName,l.required_package_count packageCount,l.volume_cbm volumeCbm," +
"o.customer_name customerName,l.required_package_count packageCount," +
"COALESCE((SELECT SUM(pa.picked_package_count) FROM ll_inbound_plan_pallet_allocation pa WHERE pa.line_id=l.id),0) pickedPackageCount," +
"l.volume_cbm volumeCbm," +
"l.planned_pallet_count palletCount,p.status status,a.appointment_no appointmentNo," +
"a.appointment_status appointmentStatus,a.appointment_at appointmentAt," +
"a.expected_loading_at expectedLoadingAt," +
"CASE WHEN EXISTS (SELECT 1 FROM ll_dispatch_item di WHERE di.customer_id=#{customerId} AND di.order_id=l.order_id)" +
" THEN 'SCHEDULED' ELSE 'UNSCHEDULED' END dispatchStatus,p.created_at createdAt" +
"CASE WHEN EXISTS (SELECT 1 FROM ll_dispatch_item di WHERE " +
"<if test='customerId != null and customerId != \"\"'>di.customer_id=#{customerId} AND </if>di.order_id=l.order_id)" +
" THEN 'SCHEDULED' ELSE 'UNSCHEDULED' END dispatchStatus,p.created_at createdAt,p.version version" +
FROM_AND_FILTER + " ORDER BY p.created_at DESC,l.id DESC LIMIT #{limit} OFFSET #{offset}</script>")
List<InboundPlanListResponse> rows(@Param("customerId") String customerId,
@Param("q") InboundPlanRequests.Query query,
@@ -11,6 +11,7 @@ public record SourceOrderSnapshot(
String inboundOrderNo,
BigDecimal volumeCbm,
int packageCount,
int recommendedPalletCount,
String fbaWarehouseCode,
String destinationWarehouseId,
String destinationWarehouseCode,
@@ -18,13 +19,23 @@ public record SourceOrderSnapshot(
int destinationCount,
int destinationMismatchCount
) {
public 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) {
this(orderId, customerId, containerNo, inboundCode, currentStatus, inboundOrderNo, volumeCbm,
packageCount, 0, fbaWarehouseCode, destinationWarehouseId, destinationWarehouseCode,
destinationWarehouseName, destinationCount, 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,
packageCount, 0, null, destinationWarehouseId, destinationWarehouseCode,
destinationWarehouseName, destinationCount, destinationMismatchCount);
}
}
@@ -17,11 +17,22 @@ public final class PdaRequests {
public record PackageOperation(@Positive int packageCount, @NotBlank String idempotencyKey,
String operatorId, String operatorName) {}
public record WholePalletOperation(@NotBlank String idempotencyKey,
String operatorId, String operatorName) {}
public record ConfirmMerge(@Positive long sourceAllocationId, @Positive int packageCount,
@NotBlank String idempotencyKey, String operatorId, String operatorName) {}
public record ScanCarton(@NotBlank String cartonMark, @Positive long planPalletId,
@NotBlank String idempotencyKey, String operatorId, String operatorName) {}
public record ScanCarton(@NotBlank String cartonMark, Long planPalletId, Integer packageCount,
@NotBlank String idempotencyKey, String operatorId, String operatorName) {
/** Backward-compatible single-carton request with an explicitly selected pallet. */
public ScanCarton(String cartonMark, long planPalletId, String idempotencyKey,
String operatorId, String operatorName) {
this(cartonMark, planPalletId, 1, idempotencyKey, operatorId, operatorName);
}
}
public record ForceComplete(@NotBlank String idempotencyKey, String operatorId, String operatorName) {}
public record RemoveCarton(@NotBlank String cartonMark, @NotBlank String idempotencyKey,
String operatorId, String operatorName) {}
@@ -10,9 +10,34 @@ public final class PdaResponses {
public record Task(long taskId, String taskNo, String planNo, String status,
LocalDateTime pushedAt, LocalDateTime startedAt, LocalDateTime completedAt,
List<Long> unloadPhotoFileIds, LocalDateTime unloadPhotoUploadedAt,
LocalDateTime estimatedEmptyReturnAt,
LocalDateTime estimatedEmptyReturnAt, int expectedPackageCount,
int pickedPackageCount, String orderType, boolean wholePalletInbound,
List<DropshipPallet> dropshipPallets, String inboundMode,
List<Pallet> pallets, List<Allocation> allocations,
List<ExpectedCarton> expectedCartons) {}
List<ExpectedCarton> expectedCartons) {
public Task(long taskId, String taskNo, String planNo, String status,
LocalDateTime pushedAt, LocalDateTime startedAt, LocalDateTime completedAt,
List<Long> unloadPhotoFileIds, LocalDateTime unloadPhotoUploadedAt,
LocalDateTime estimatedEmptyReturnAt, int expectedPackageCount,
int pickedPackageCount) {
this(taskId, taskNo, planNo, status, pushedAt, startedAt, completedAt,
unloadPhotoFileIds, unloadPhotoUploadedAt, estimatedEmptyReturnAt,
expectedPackageCount, pickedPackageCount, null, false, List.of(), null,
List.of(), List.of(), List.of());
}
public Task(long taskId, String taskNo, String planNo, String status,
LocalDateTime pushedAt, LocalDateTime startedAt, LocalDateTime completedAt,
List<Long> unloadPhotoFileIds, LocalDateTime unloadPhotoUploadedAt,
LocalDateTime estimatedEmptyReturnAt) {
this(taskId, taskNo, planNo, status, pushedAt, startedAt, completedAt,
unloadPhotoFileIds, unloadPhotoUploadedAt, estimatedEmptyReturnAt, 0, 0);
}
}
public record DropshipPallet(String sourcePalletNo, long planPalletId, long allocationId,
long packageCount, List<DropshipSku> skus) {}
public record DropshipSku(String skuCode, String productName, long packageCount, long productQuantity) {}
public record Pallet(long planPalletId, String palletNo, String destinationWarehouseId,
String destinationWarehouseCode, String destinationWarehouseName,
@@ -46,5 +71,10 @@ public final class PdaResponses {
boolean sourcePalletScrapped) {}
public record Carton(String result, String cartonMark, long planPalletId, long allocationId,
int palletActualPackageCount) {}
int palletActualPackageCount, String palletNo, int scannedPackageCount) {
public Carton(String result, String cartonMark, long planPalletId, long allocationId,
int palletActualPackageCount) {
this(result, cartonMark, planPalletId, allocationId, palletActualPackageCount, null, 1);
}
}
}
@@ -30,14 +30,28 @@ public final class ReceivingContainerResponses {
String destinationWarehouseId,
String destinationWarehouseCode, String destinationWarehouseName,
String fbaWarehouseCode, int plannedPalletCount, int totalPackages,
int receivedPackages, int pendingPackages) { }
int receivedPackages, int pendingPackages,
boolean palletMergeRequired, String palletMergeHint) { }
public record CartonResolved(String cartonMark, String inboundCode,
String destinationType, String overseasWarehouseId,
String overseasWarehouseName, String destinationWarehouseId,
String destinationWarehouseCode, String destinationWarehouseName,
String fbaWarehouseCode, int plannedPalletCount, int totalPackages,
int receivedPackages, int pendingPackages) { }
int receivedPackages, int pendingPackages,
boolean palletMergeRequired, String palletMergeHint) {
public CartonResolved(String cartonMark, String inboundCode, String destinationType,
String overseasWarehouseId, String overseasWarehouseName,
String destinationWarehouseId, String destinationWarehouseCode,
String destinationWarehouseName, String fbaWarehouseCode,
int plannedPalletCount, int totalPackages, int receivedPackages,
int pendingPackages) {
this(cartonMark, inboundCode, destinationType, overseasWarehouseId, overseasWarehouseName,
destinationWarehouseId, destinationWarehouseCode, destinationWarehouseName,
fbaWarehouseCode, plannedPalletCount, totalPackages, receivedPackages,
pendingPackages, false, null);
}
}
public record PalletResolved(String palletCode, long warehouseId, String status,
String fbaWarehouseCode, String inboundCode,
@@ -45,7 +59,15 @@ public final class ReceivingContainerResponses {
public record Receipt(String result, String containerNo, String inboundCode,
String destinationType, String palletCode, int acceptedPackages,
int receivedPackages, int pendingPackages, String sessionStatus) { }
int receivedPackages, int pendingPackages, String sessionStatus,
boolean palletMergeRequired, String palletMergeHint) {
public Receipt(String result, String containerNo, String inboundCode,
String destinationType, String palletCode, int acceptedPackages,
int receivedPackages, int pendingPackages, String sessionStatus) {
this(result, containerNo, inboundCode, destinationType, palletCode, acceptedPackages,
receivedPackages, pendingPackages, sessionStatus, false, null);
}
}
public record Photo(String fileId, String fileName, String contentType, long sizeBytes) { }
}
@@ -17,6 +17,8 @@ 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 com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import org.springframework.stereotype.Repository;
import jakarta.annotation.Resource;
@@ -32,6 +34,7 @@ public class PdaWorkflowRepository {
@Resource private InboundPdaOperationMapper operations;
@Resource private InboundPalletMergeMapper merges;
@Resource private InboundPlanCartonMapper cartons;
@Resource private LastlegOrderMapper orders;
public InboundPlan lockPlan(String customerId, String planNo) {
return plans.selectOne(new LambdaQueryWrapper<InboundPlan>()
@@ -93,6 +96,8 @@ public class PdaWorkflowRepository {
.eq(InboundPlanPalletAllocation::getPlanId, planId));
}
public LastlegOrder order(Long orderId) { return orderId == null ? null : orders.selectById(orderId); }
public void insert(InboundPdaTask value) { tasks.insert(value); }
public void insert(InboundPdaOperation value) { operations.insert(value); }
public void insert(InboundPalletMerge value) { merges.insert(value); }
@@ -14,6 +14,8 @@ import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask;
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
import com.baiyue.lastleg.inboundplan.pda.gateway.UnloadPhotoFileGateway;
import com.baiyue.lastleg.inboundplan.pda.repository.PdaWorkflowRepository;
import com.baiyue.lastleg.dropship.model.DropshipPalletSnapshot;
import com.baiyue.lastleg.dropship.service.DropshipOrderService;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.fasterxml.jackson.core.JsonProcessingException;
@@ -54,6 +56,7 @@ public class InboundPdaWorkflowService {
@Resource private InboundUnloadCompletedOutboxService unloadCompletedOutbox;
@Resource private LastlegOrderMapper orders;
@Resource private UnloadPhotoFileGateway unloadPhotoFiles;
@Resource private DropshipOrderService dropshipOrders;
@Resource private ObjectMapper sourceObjectMapper;
private ObjectMapper objectMapper;
@@ -77,6 +80,9 @@ public class InboundPdaWorkflowService {
}
InboundPdaTask task = repository.lockTask(customerId, plan.getId());
LocalDateTime now = LocalDateTime.now();
if (task != null && terminal(task.getStatus())) {
throw conflict("PDA inbound task is already completed");
}
if (task == null) {
task = new InboundPdaTask();
task.setId(IdWorker.getId());
@@ -95,7 +101,7 @@ public class InboundPdaWorkflowService {
plan.setStatus("PDA_PENDING");
plan.setUpdatedAt(now);
repository.update(plan);
PdaResponses.Task result = task(task, planNo);
PdaResponses.Task result = taskResponse(task, plan, planNo);
saveOperation(customerId, task.getId(), null, "PUSH", request.idempotencyKey(), request, result);
return result;
}
@@ -136,7 +142,7 @@ public class InboundPdaWorkflowService {
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);
return taskResponse(task, plan, planNo);
}
@Transactional
@@ -255,7 +261,7 @@ public class InboundPdaWorkflowService {
public PdaResponses.Operation count(String customerId, String planNo, long allocationId,
PdaRequests.PackageOperation request) {
InboundPlan plan = requirePlan(customerId, planNo);
requireLegacyCounting(plan);
requireCountMode(plan);
InboundPdaOperation replay = replay(customerId, "COUNT", request.idempotencyKey(), request);
if (replay != null) return read(replay, PdaResponses.Operation.class);
InboundPdaTask task = requireActiveTask(customerId, plan);
@@ -283,7 +289,7 @@ public class InboundPdaWorkflowService {
public PdaResponses.Operation pick(String customerId, String planNo, long allocationId,
PdaRequests.PackageOperation request) {
InboundPlan plan = requirePlan(customerId, planNo);
requireLegacyCounting(plan);
requireCountMode(plan);
InboundPdaOperation replay = replay(customerId, "PICK", request.idempotencyKey(), request);
if (replay != null) return read(replay, PdaResponses.Operation.class);
InboundPdaTask task = requireActiveTask(customerId, plan);
@@ -322,10 +328,44 @@ public class InboundPdaWorkflowService {
return result;
}
@Transactional
public PdaResponses.Operation pickWholePallet(String customerId, String planNo, long allocationId,
PdaRequests.WholePalletOperation request) {
InboundPlan plan = requirePlan(customerId, planNo);
requireDropship(plan);
InboundPdaOperation replay = replay(customerId, "PICK_WHOLE_PALLET", 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 planned = value(allocation.getPlannedPackageCount());
if (planned <= 0) throw conflict("One-click dropship pallet has no planned packages");
if (value(allocation.getPickedPackageCount()) != 0) {
throw conflict("One-click dropship pallet has already been partially operated");
}
allocation.setCountedPackageCount(planned);
allocation.setPickedPackageCount(planned);
allocation.setActualVolumeCbm(volume(allocation.getAllocatedVolumeCbm()));
allocation.setStatus("PICKED");
bump(allocation);
repository.update(allocation);
InboundPlanPallet pallet = requirePallet(plan.getId(), allocation.getPalletId());
pallet.setActualPackageCount(planned);
pallet.setActualVolumeCbm(volume(allocation.getAllocatedVolumeCbm()));
repository.update(pallet);
startPicking(task, plan, request.operatorId(), request.operatorName());
PdaResponses.Operation result = operation("WHOLE_PALLET_PICKED", allocation, line,
List.of(), 0, planned, 0);
saveOperation(customerId, task.getId(), allocationId, "PICK_WHOLE_PALLET",
request.idempotencyKey(), request, result);
return result;
}
/** 海外首次扫描箱唛:仅接受本柜国内冻结且已归属本计划的箱。 */
@Transactional
public PdaResponses.Carton scanCarton(String customerId, String planNo, PdaRequests.ScanCarton request) {
InboundPlan plan = requirePlan(customerId, planNo);
requireCartonScanMode(plan);
InboundPdaOperation replay = replay(customerId, "SCAN_CARTON", request.idempotencyKey(), request);
if (replay != null) return read(replay, PdaResponses.Carton.class);
InboundPdaTask task = requireActiveTask(customerId, plan);
@@ -336,16 +376,23 @@ public class InboundPdaWorkflowService {
if (!("UNASSIGNED".equals(carton.getStatus()) || "EXPECTED".equals(carton.getStatus()))) {
throw conflict("Carton mark has already been scanned; remove it before scanning again");
}
InboundPlanPallet pallet = requirePallet(plan.getId(), request.planPalletId());
long requestedPalletId = request.planPalletId() == null ? 0L : request.planPalletId();
InboundPlanPallet pallet = requestedPalletId > 0
? requirePallet(plan.getId(), requestedPalletId)
: autoPallet(plan, carton);
if (isNotAssigned(pallet)) throw conflict("Planned pallet must be assigned and locked before carton scanning");
if (!sameWarehouse(carton, pallet)) throw conflict("Carton and pallet destination warehouses must match");
InboundPlanLine line = carton.getLineId() == null
? repository.lineForCarton(plan.getId(), carton)
: requireLine(plan.getId(), carton.getLineId());
if (line == null) throw notFound("Carton mark does not belong to cargo in this inbound plan");
if (repository.scannedCartons(plan.getId(), line.getId()) >= value(line.getRequiredPackageCount())) {
int requestedCount = request.packageCount() == null ? 1 : request.packageCount();
if (requestedCount != 1) throw conflict("Overseas warehouse carton scanning accepts exactly one carton per scan");
int alreadyScanned = (int) repository.scannedCartons(plan.getId(), line.getId());
if (alreadyScanned + requestedCount > value(line.getRequiredPackageCount())) {
throw conflict("This inbound plan line has reached its required carton count");
}
List<InboundPlanCarton> cartonsToScan = List.of(carton);
InboundPlanPalletAllocation allocation = repository.lockAllocation(plan.getId(), line.getId(), pallet.getId());
if (allocation == null) {
allocation = new InboundPlanPalletAllocation(); allocation.setId(IdWorker.getId());
@@ -353,23 +400,29 @@ public class InboundPdaWorkflowService {
allocation.setLineId(line.getId()); allocation.setPalletId(pallet.getId()); allocation.setAllocatedVolumeCbm(ZERO_VOLUME);
allocation.setPlannedPackageCount(0); allocation.setCountedPackageCount(0); allocation.setPickedPackageCount(0);
allocation.setActualVolumeCbm(ZERO_VOLUME); allocation.setStatus("PICKING"); allocation.setVersion(0); repository.insert(allocation);
} else if (value(allocation.getPlannedPackageCount()) > 0
&& value(allocation.getPickedPackageCount()) + requestedCount > value(allocation.getPlannedPackageCount())) {
throw conflict("Requested carton quantity exceeds the recommended pallet package count");
}
BigDecimal perPackage = perPackageVolume(line);
carton.setOrderId(line.getOrderId()); carton.setPlanId(plan.getId()); carton.setLineId(line.getId());
carton.setPlanPalletId(pallet.getId()); carton.setWarehousePalletId(pallet.getWarehousePalletId());
carton.setWarehouseLocationId(pallet.getWarehouseLocationId()); carton.setPdaTaskId(task.getId());
carton.setStatus("ON_PALLET"); carton.setBoundAt(LocalDateTime.now()); carton.setUpdatedAt(LocalDateTime.now());
carton.setVersion(value(carton.getVersion()) + 1); repository.update(carton);
allocation.setCountedPackageCount(value(allocation.getCountedPackageCount()) + 1);
allocation.setPickedPackageCount(value(allocation.getPickedPackageCount()) + 1);
allocation.setActualVolumeCbm(volume(allocation.getActualVolumeCbm()).add(perPackage).setScale(4, RoundingMode.HALF_UP));
LocalDateTime now = LocalDateTime.now();
for (InboundPlanCarton item : cartonsToScan) {
item.setOrderId(line.getOrderId()); item.setPlanId(plan.getId()); item.setLineId(line.getId());
item.setPlanPalletId(pallet.getId()); item.setWarehousePalletId(pallet.getWarehousePalletId());
item.setWarehouseLocationId(pallet.getWarehouseLocationId()); item.setPdaTaskId(task.getId());
item.setStatus("ON_PALLET"); item.setBoundAt(now); item.setUpdatedAt(now);
item.setVersion(value(item.getVersion()) + 1); repository.update(item);
}
allocation.setCountedPackageCount(value(allocation.getCountedPackageCount()) + requestedCount);
allocation.setPickedPackageCount(value(allocation.getPickedPackageCount()) + requestedCount);
allocation.setActualVolumeCbm(volume(allocation.getActualVolumeCbm()).add(perPackage.multiply(BigDecimal.valueOf(requestedCount))).setScale(4, RoundingMode.HALF_UP));
allocation.setStatus(value(allocation.getPickedPackageCount()) >= value(allocation.getPlannedPackageCount()) ? "PICKED" : "PICKING");
bump(allocation); repository.update(allocation);
pallet.setActualPackageCount(value(pallet.getActualPackageCount()) + 1);
pallet.setActualVolumeCbm(volume(pallet.getActualVolumeCbm()).add(perPackage).setScale(4, RoundingMode.HALF_UP));
pallet.setActualPackageCount(value(pallet.getActualPackageCount()) + requestedCount);
pallet.setActualVolumeCbm(volume(pallet.getActualVolumeCbm()).add(perPackage.multiply(BigDecimal.valueOf(requestedCount))).setScale(4, RoundingMode.HALF_UP));
repository.update(pallet); startPicking(task, plan, request.operatorId(), request.operatorName());
PdaResponses.Carton result = new PdaResponses.Carton("SCANNED", carton.getCartonMark(), pallet.getId(),
allocation.getId(), pallet.getActualPackageCount());
allocation.getId(), pallet.getActualPackageCount(), pallet.getPalletNo(), requestedCount);
saveOperation(customerId, task.getId(), allocation.getId(), "SCAN_CARTON", request.idempotencyKey(), request, result);
return result;
}
@@ -377,6 +430,7 @@ public class InboundPdaWorkflowService {
@Transactional
public PdaResponses.Carton removeCarton(String customerId, String planNo, PdaRequests.RemoveCarton request) {
InboundPlan plan = requirePlan(customerId, planNo);
requireCartonScanMode(plan);
InboundPdaOperation replay = replay(customerId, "REMOVE_CARTON", request.idempotencyKey(), request);
if (replay != null) return read(replay, PdaResponses.Carton.class);
InboundPdaTask task = requireActiveTask(customerId, plan);
@@ -542,6 +596,7 @@ public class InboundPdaWorkflowService {
if ("COMPLETED".equals(task.getStatus())) {
return new CompletionContext(plan, task, List.of(), List.of(), List.of(), List.of());
}
if ("PARTIAL_INBOUND".equals(task.getStatus())) throw conflict("PDA inbound task was partially completed");
List<Long> unloadPhotoFileIds = unloadPhotoFileIds(task);
if (unloadPhotoFileIds.isEmpty() || task.getUnloadPhotoUploadedAt() == null
|| task.getEstimatedEmptyReturnAt() == null) {
@@ -589,6 +644,41 @@ public class InboundPdaWorkflowService {
repository.update(plan);
}
/** Explicit operator decision to receive only the cartons actually picked. */
@Transactional
public PdaResponses.Task partialComplete(String customerId, String planNo, PdaRequests.ForceComplete request) {
InboundPlan plan = requirePlan(customerId, planNo);
InboundPdaOperation replay = replay(customerId, "PARTIAL_COMPLETE", request.idempotencyKey(), request);
if (replay != null) return read(replay, PdaResponses.Task.class);
InboundPdaTask task = requireActiveTask(customerId, plan);
List<Long> unloadPhotoFileIds = unloadPhotoFileIds(task);
if (unloadPhotoFileIds.isEmpty() || task.getUnloadPhotoUploadedAt() == null
|| task.getEstimatedEmptyReturnAt() == null) {
throw conflict("Upload unload photos before partially completing the PDA inbound task");
}
List<InboundPlanLine> lines = repository.lines(plan.getId());
List<InboundPlanPalletAllocation> allocations = repository.allocations(plan.getId());
int expected = lines.stream().mapToInt(line -> value(line.getRequiredPackageCount())).sum();
int picked = allocations.stream().mapToInt(allocation -> value(allocation.getPickedPackageCount())).sum();
if (picked <= 0) throw conflict("At least one carton must be picked before partial inbound completion");
if (picked >= expected) throw conflict("All cartons are picked; use normal inbound completion");
LocalDateTime now = LocalDateTime.now();
stockReceipts.stockIn(customerId, plan, task, lines, allocations, repository.pallets(plan.getId()), now, true);
for (InboundPlanCarton carton : repository.cartons(plan.getId())) {
if ("ON_PALLET".equals(carton.getStatus())) {
carton.setStatus("AVAILABLE"); carton.setReceivedAt(now); carton.setUpdatedAt(now);
carton.setVersion(value(carton.getVersion()) + 1); repository.update(carton);
}
}
task.setStatus("PARTIAL_INBOUND"); task.setCompletedAt(now); task.setUpdatedAt(now);
task.setVersion(value(task.getVersion()) + 1); repository.update(task);
plan.setStatus("PARTIAL_INBOUND"); plan.setUpdatedAt(now); repository.update(plan);
unloadCompletedOutbox.publish(plan, task, unloadPhotoFileIds);
PdaResponses.Task result = taskResponse(task, plan, planNo);
saveOperation(customerId, task.getId(), null, "PARTIAL_COMPLETE", request.idempotencyKey(), request, result);
return result;
}
@Transactional
public PdaResponses.Task uploadUnloadPhotos(String customerId, String planNo, PdaRequests.UnloadPhotos request) {
InboundPlan plan = requirePlan(customerId, planNo);
@@ -605,7 +695,7 @@ public class InboundPdaWorkflowService {
task.setUpdatedAt(uploadedAt);
task.setVersion(value(task.getVersion()) + 1);
repository.update(task);
PdaResponses.Task result = task(task, planNo);
PdaResponses.Task result = taskResponse(task, plan, planNo);
saveOperation(customerId, task.getId(), null, "UPLOAD_UNLOAD_PHOTOS", request.idempotencyKey(), request, result);
return result;
}
@@ -749,12 +839,34 @@ public class InboundPdaWorkflowService {
repository.update(plan);
}
private void requireCountMode(InboundPlan plan) {
InboundMode mode = mode(plan);
if (mode == InboundMode.DROPSHIP) {
throw conflict("One-click dropship inbound must be picked by complete source pallet");
}
if (mode == InboundMode.OWN_OVERSEAS) {
throw conflict("Overseas warehouse inbound must be operated by one-carton scanning");
}
}
/** Backward-compatible guard for pallet merge, which remains a legacy quantity workflow. */
private void requireLegacyCounting(InboundPlan plan) {
if (isDropship(plan)) {
throw conflict("One-click dropship inbound must be picked by complete source pallet");
}
if (repository.hasCartonLabelPool(plan.getId())) {
throw conflict("This inbound plan has a carton manifest and must be operated by carton scanning");
}
}
private void requireCartonScanMode(InboundPlan plan) {
if (mode(plan) != InboundMode.OWN_OVERSEAS) {
throw conflict(mode(plan) == InboundMode.FBA
? "FBA inbound must be operated by package quantity, not carton scanning"
: "One-click dropship inbound must be operated by complete source pallet");
}
}
private boolean isNotAssigned(InboundPlanPallet pallet) {
return pallet.getWarehousePalletId() == null || pallet.getWarehouseLocationId() == null
|| pallet.getWarehouseResourceLockId() == null
@@ -770,7 +882,7 @@ public class InboundPdaWorkflowService {
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");
if (terminal(task.getStatus())) throw conflict("PDA task is already completed");
return task;
}
@@ -786,6 +898,24 @@ public class InboundPdaWorkflowService {
return value;
}
private InboundPlanPallet autoPallet(InboundPlan plan, InboundPlanCarton carton) {
List<InboundPlanPallet> candidates = repository.pallets(plan.getId()).stream()
.filter(pallet -> sameWarehouse(carton, pallet))
.filter(pallet -> !isNotAssigned(pallet))
.sorted(Comparator.comparingInt(pallet -> value(pallet.getSequenceNo())))
.toList();
if (candidates.isEmpty()) throw conflict("No assigned pallet exists for the carton destination warehouse");
Map<Long, Integer> plannedByPallet = new HashMap<>();
for (InboundPlanPalletAllocation allocation : repository.allocations(plan.getId())) {
plannedByPallet.merge(allocation.getPalletId(), value(allocation.getPlannedPackageCount()), Integer::sum);
}
InboundPlanPallet selected = candidates.stream()
.filter(pallet -> value(pallet.getActualPackageCount()) < plannedByPallet.getOrDefault(pallet.getId(), Integer.MAX_VALUE))
.findFirst().orElse(null);
if (selected == null) throw conflict("All assigned pallets for the carton destination warehouse are full");
return repository.lockPallet(plan.getId(), selected.getId());
}
private InboundPlanLine requireLine(Long planId, Long id) {
InboundPlanLine value = repository.line(planId, id);
if (value == null) throw notFound("Inbound plan line not found");
@@ -801,6 +931,30 @@ public class InboundPdaWorkflowService {
volume(allocation.getActualVolumeCbm()), remaining, requested, merge, candidates);
}
private PdaResponses.Task taskResponse(InboundPdaTask task, InboundPlan plan, String planNo) {
int expected = repository.lines(plan.getId()).stream().mapToInt(line -> value(line.getRequiredPackageCount())).sum();
List<InboundPlanPalletAllocation> allocations = repository.allocations(plan.getId());
int picked = allocations.stream().mapToInt(allocation -> value(allocation.getPickedPackageCount())).sum();
LastlegOrder order = repository.order(plan.getSourceOrderId());
String orderType = order == null ? null : order.getOrderType();
boolean wholePalletInbound = "ONE_CLICK_DROPSHIP".equals(orderType);
Map<String, InboundPlanPallet> palletsByNo = wholePalletInbound
? repository.pallets(plan.getId()).stream().collect(java.util.stream.Collectors.toMap(
InboundPlanPallet::getPalletNo, value -> value)) : Map.of();
Map<Long, InboundPlanPalletAllocation> allocationsByPallet = wholePalletInbound
? allocations.stream().collect(java.util.stream.Collectors.toMap(
InboundPlanPalletAllocation::getPalletId, value -> value)) : Map.of();
List<PdaResponses.DropshipPallet> dropshipPallets = wholePalletInbound
? dropshipOrders.palletSnapshots(task.getCustomerId(), order.getId()).stream()
.map(pallet -> dropshipPallet(pallet, palletsByNo, allocationsByPallet)).toList() : List.of();
PdaResponses.Task workInstructions = task(task, planNo);
return new PdaResponses.Task(task.getId(), task.getTaskNo(), planNo, task.getStatus(),
task.getPushedAt(), task.getStartedAt(), task.getCompletedAt(), unloadPhotoFileIds(task),
task.getUnloadPhotoUploadedAt(), task.getEstimatedEmptyReturnAt(), expected, picked,
orderType, wholePalletInbound, dropshipPallets, mode(plan).name(),
workInstructions.pallets(), workInstructions.allocations(), workInstructions.expectedCartons());
}
private PdaResponses.Task task(InboundPdaTask task, String planNo) {
List<InboundPlanPallet> planPallets = repository.pallets(task.getPlanId());
List<InboundPlanPalletAllocation> planAllocations = repository.allocations(task.getPlanId());
@@ -830,8 +984,10 @@ public class InboundPdaWorkflowService {
.toList();
return new PdaResponses.Task(task.getId(), task.getTaskNo(), planNo, task.getStatus(),
task.getPushedAt(), task.getStartedAt(), task.getCompletedAt(), unloadPhotoFileIds(task),
task.getUnloadPhotoUploadedAt(), task.getEstimatedEmptyReturnAt(), pallets, allocations,
expectedCartons);
task.getUnloadPhotoUploadedAt(), task.getEstimatedEmptyReturnAt(),
lines.values().stream().mapToInt(line -> value(line.getRequiredPackageCount())).sum(),
planAllocations.stream().mapToInt(value -> InboundPdaWorkflowService.value(value.getPickedPackageCount())).sum(),
null, false, List.of(), null, pallets, allocations, expectedCartons);
}
private long recommendedPallet(InboundPlanCarton carton, List<InboundPlanPallet> pallets) {
@@ -842,6 +998,39 @@ public class InboundPdaWorkflowService {
.map(InboundPlanPallet::getId).findFirst().orElse(0L);
}
private PdaResponses.DropshipPallet dropshipPallet(DropshipPalletSnapshot pallet,
Map<String, InboundPlanPallet> palletsByNo,
Map<Long, InboundPlanPalletAllocation> allocationsByPallet) {
InboundPlanPallet planPallet = palletsByNo.get(pallet.sourcePalletNo());
if (planPallet == null) throw conflict("Dropship source pallet is missing from the inbound plan");
InboundPlanPalletAllocation allocation = allocationsByPallet.get(planPallet.getId());
if (allocation == null) throw conflict("Dropship source pallet has no inbound allocation");
return new PdaResponses.DropshipPallet(pallet.sourcePalletNo(), planPallet.getId(), allocation.getId(),
pallet.packageCount(), pallet.skus().stream()
.map(sku -> new PdaResponses.DropshipSku(sku.skuCode(), sku.productName(),
sku.packageCount(), sku.productQuantity())).toList());
}
private void requireDropship(InboundPlan plan) {
if (!isDropship(plan)) {
throw conflict("Whole-pallet picking is only available for one-click dropship inbound plans");
}
}
private boolean isDropship(InboundPlan plan) {
LastlegOrder order = repository.order(plan.getSourceOrderId());
return order != null && "ONE_CLICK_DROPSHIP".equals(order.getOrderType());
}
private InboundMode mode(InboundPlan plan) {
if (isDropship(plan)) return InboundMode.DROPSHIP;
List<InboundPlanLine> lines = repository.lines(plan.getId());
return lines.stream().anyMatch(line -> line.getFbaWarehouseCode() != null
&& !line.getFbaWarehouseCode().isBlank()) ? InboundMode.FBA : InboundMode.OWN_OVERSEAS;
}
private enum InboundMode { DROPSHIP, OWN_OVERSEAS, FBA }
private List<Long> unloadPhotoFileIds(InboundPdaTask task) {
if (task.getUnloadPhotoFileIds() == null || task.getUnloadPhotoFileIds().isBlank()) return List.of();
try {
@@ -945,6 +1134,7 @@ public class InboundPdaWorkflowService {
List<InboundPlanCarton> cartons) { }
private static int value(Integer value) { return value == null ? 0 : value; }
private static boolean terminal(String status) { return "COMPLETED".equals(status) || "PARTIAL_INBOUND".equals(status); }
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); }
@@ -20,6 +20,7 @@ import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper;
import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper;
import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
import com.baiyue.lastleg.dropship.service.DropshipOrderService;
import com.baiyue.lastleg.dropship.model.DropshipPalletSnapshot;
import jakarta.annotation.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
@@ -49,6 +50,13 @@ public class InboundStockReceiptService {
public void stockIn(String customerId, InboundPlan plan, InboundPdaTask task,
List<InboundPlanLine> lines, List<InboundPlanPalletAllocation> allocations,
List<InboundPlanPallet> pallets, LocalDateTime receivedAt) {
stockIn(customerId, plan, task, lines, allocations, pallets, receivedAt, false);
}
@Transactional
public void stockIn(String customerId, InboundPlan plan, InboundPdaTask task,
List<InboundPlanLine> lines, List<InboundPlanPalletAllocation> allocations,
List<InboundPlanPallet> pallets, LocalDateTime receivedAt, boolean allowPartial) {
Map<Long, LastlegOrder> orderById = orders.selectBatchIds(lines.stream()
.map(InboundPlanLine::getOrderId).collect(Collectors.toSet())).stream()
.filter(order -> customerId.equals(order.getCustomerId()))
@@ -70,8 +78,8 @@ public class InboundStockReceiptService {
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);
if (!allowPartial) activateDropship(customerId, task.getId(), orderById, existing);
completeOrders(customerId, plan, orderById, receivedAt, allowPartial);
return;
}
throw conflict("PDA stock-in receipts are incomplete; repair the task receipts before retrying");
@@ -85,7 +93,9 @@ public class InboundStockReceiptService {
.getOrDefault(line.getId(), List.of());
long pickedPackages = lineAllocations.stream()
.mapToLong(allocation -> value(allocation.getPickedPackageCount())).sum();
if (pickedPackages != value(line.getRequiredPackageCount())) {
if (pickedPackages == 0 && allowPartial) continue;
if ((!allowPartial && pickedPackages != value(line.getRequiredPackageCount()))
|| (allowPartial && pickedPackages > value(line.getRequiredPackageCount()))) {
throw conflict("PDA picked packages must equal the inbound order package total");
}
List<ReceivingSkuReceipt> exactSkuReceipts = skuReceiptsByLine.getOrDefault(line.getId(), List.of());
@@ -95,12 +105,25 @@ public class InboundStockReceiptService {
continue;
}
List<SkuRemainder> skuRemainders = "ONE_CLICK_DROPSHIP".equals(order.getOrderType())
? List.of() : skuRemainders(order.getId(), pickedPackages);
? List.of() : skuRemainders(order.getId(), value(line.getRequiredPackageCount()));
Map<String, DropshipPalletSnapshot> dropshipPallets = "ONE_CLICK_DROPSHIP".equals(order.getOrderType())
? dropshipOrders.palletSnapshots(customerId, order.getId()).stream()
.collect(Collectors.toMap(DropshipPalletSnapshot::sourcePalletNo, value -> value))
: Map.of();
for (InboundPlanPalletAllocation allocation : lineAllocations) {
InboundPlanPallet pallet = requirePallet(palletById, allocation);
if ("ONE_CLICK_DROPSHIP".equals(order.getOrderType())) {
created.add(persist(customerId, plan, task, line, allocation, pallet, order,
SkuSnapshot.unspecified(value(allocation.getPickedPackageCount())), receivedAt));
if (value(allocation.getPickedPackageCount()) != value(allocation.getPlannedPackageCount())) {
throw conflict("One-click dropship inbound must receive a complete source pallet");
}
DropshipPalletSnapshot sourcePallet = dropshipPallets.get(pallet.getPalletNo());
if (sourcePallet == null || sourcePallet.packageCount() != value(allocation.getPickedPackageCount())) {
throw conflict("One-click dropship source pallet SKU package total does not match PDA receipt");
}
for (DropshipPalletSnapshot.Sku sku : sourcePallet.skus()) {
created.add(persist(customerId, plan, task, line, allocation, pallet, order,
new SkuSnapshot(sku.skuCode(), sku.productName(), null, sku.packageCount()), receivedAt));
}
} else {
long allocationPackages = value(allocation.getPickedPackageCount());
for (SkuSnapshot sku : consume(skuRemainders, allocationPackages)) {
@@ -108,12 +131,12 @@ public class InboundStockReceiptService {
}
}
}
if (skuRemainders.stream().anyMatch(value -> value.remaining > 0)) {
if (!allowPartial && skuRemainders.stream().anyMatch(value -> value.remaining > 0)) {
throw conflict("Inbound SKU package snapshots were not fully received");
}
}
activateDropship(customerId, task.getId(), orderById, created);
completeOrders(customerId, plan, orderById, receivedAt);
if (!allowPartial) activateDropship(customerId, task.getId(), orderById, created);
completeOrders(customerId, plan, orderById, receivedAt, allowPartial);
}
private List<InboundStockReceipt> persistExactSkuReceipts(String customerId, InboundPlan plan,
@@ -159,14 +182,16 @@ public class InboundStockReceiptService {
}
private void completeOrders(String customerId, InboundPlan plan, Map<Long, LastlegOrder> orderById,
LocalDateTime completedAt) {
LocalDateTime completedAt, boolean partial) {
String subOrderStatus = partial ? "INBOUND_PLAN:PARTIAL_INBOUND" : "INBOUND_PLAN:COMPLETED";
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;
if ("INBOUND_COMPLETED".equals(order.getCurrentStatus())
&& subOrderStatus.equals(order.getSubOrderStatus())) continue;
String before = order.getCurrentStatus();
order.setCurrentStatus("INBOUND_COMPLETED");
order.setSubOrderStatus("INBOUND_PLAN:COMPLETED");
order.setSubOrderStatus(subOrderStatus);
order.setUpdatedAt(completedAt);
orders.updateById(order);
LastlegOrderStatusHistory history = new LastlegOrderStatusHistory();
@@ -174,8 +199,11 @@ public class InboundStockReceiptService {
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.setEventId((partial ? "PDA_PARTIAL_INBOUND_COMPLETE:" : "PDA_INBOUND_COMPLETE:")
+ plan.getPlanNo());
history.setRemark(partial
? "PDA inbound partially completed and actual inventory stocked in"
: "PDA inbound completed and inventory stocked in");
history.setCreatedAt(completedAt);
orderHistories.insert(history);
}
@@ -207,15 +235,17 @@ public class InboundStockReceiptService {
if (overseasWarehouseId.equals(trim(order.getSourceWarehouseId()))) {
throw conflict("Actual overseas warehouse must not use the domestic source warehouse");
}
InventoryCommandResponse result = inventory.stockIn(customerId, task.getOperatorId(),
new InventoryCommandRequests.StockIn(null, customerName, overseasWarehouseId,
null, overseasWarehouseName, line.getDestinationWarehouseId(), destinationCode,
InventoryCommandRequests.StockIn stockIn = new InventoryCommandRequests.StockIn(null, customerName,
overseasWarehouseId, null, overseasWarehouseName, line.getDestinationWarehouseId(), destinationCode,
line.getDestinationWarehouseName(), order.getId(), plan.getPlanNo(), line.getInboundCode(),
sku.skuCode(), sku.productNameZh(), sku.productNameEn(),
pallet.getWarehousePalletId(), pallet.getPalletNo(), plan.getPlanNo(),
pallet.getWarehouseLocationId(), null, packages, order.getOrderNo(),
order.getSourceEventId(), operationKey,
"PDA inbound completion for " + line.getInboundCode()));
"PDA inbound completion for " + line.getInboundCode());
InventoryCommandResponse result = Boolean.TRUE.equals(order.getDetained())
? inventory.stockInHeld(customerId, task.getOperatorId(), stockIn)
: inventory.stockIn(customerId, task.getOperatorId(), stockIn);
InboundStockReceipt receipt = new InboundStockReceipt();
receipt.setId(IdWorker.getId());
@@ -334,9 +364,5 @@ public class InboundStockReceiptService {
}
}
private record SkuSnapshot(String skuCode, String productNameZh, String productNameEn, long packages) {
private static SkuSnapshot unspecified(long packages) {
return new SkuSnapshot("UNSPECIFIED", null, null, packages);
}
}
private record SkuSnapshot(String skuCode, String productNameZh, String productNameEn, long packages) {}
}
@@ -15,7 +15,7 @@ import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.util.UUID;
/** 至少一次投递海外卸柜完成事件;国内事件桥按 eventId 幂等接收。 */
/** 至少一次投递海外到国内业务事件;国内事件桥按 eventId 幂等接收。 */
@Component
@Slf4j
public class InboundUnloadCompletedOutboxRelay {
@@ -44,7 +44,7 @@ public class InboundUnloadCompletedOutboxRelay {
} catch (Exception exception) {
repository.retry(row.getId(), workerId, LocalDateTime.now().plusSeconds(Math.max(retrySeconds, 1)),
exception.getMessage());
log.warn("海外卸柜完成事件投递失败: eventKey={}, reason={}", row.getEventKey(), exception.getMessage());
log.warn("海外到国内事件投递失败: eventKey={}, reason={}", row.getEventKey(), exception.getMessage());
}
}
}
@@ -53,7 +53,7 @@ public class InboundUnloadCompletedOutboxRelay {
com.baiyue.common.core.api.ApiResponse<EventBridgeClient.Acknowledgement> response) {
if (response == null) throw new IllegalStateException("国内事件桥返回空响应");
if (!response.successful()) {
throw new IllegalStateException("国内事件桥拒绝海外卸柜完成事件: code=" + response.code()
throw new IllegalStateException("国内事件桥拒绝海外事件: code=" + response.code()
+ ", message=" + response.message() + ", requestId=" + response.requestId());
}
if (response.data() == null) throw new IllegalStateException("国内事件桥成功响应缺少确认数据");
@@ -61,6 +61,7 @@ import java.util.stream.Collectors;
public class ReceivingContainerService {
private static final String PHOTO_DOWNLOAD_PATH =
"/api/pda/v1/last-leg/receiving-containers/current/unload-photos/files/{fileId}/download";
private static final String PALLET_MERGE_HINT = "该入仓单已标记合托,请按合托要求拣货";
@Resource private InboundPlanMapper plans;
@Resource private InboundPlanLineMapper lines;
@@ -258,7 +259,8 @@ public class ReceivingContainerService {
inbound.inboundCode(), inbound.destinationType(), inbound.overseasWarehouseId(),
inbound.overseasWarehouseName(), inbound.destinationWarehouseId(),
inbound.destinationWarehouseCode(), inbound.destinationWarehouseName(), inbound.fbaWarehouseCode(),
inbound.plannedPalletCount(), inbound.totalPackages(), inbound.receivedPackages(), inbound.pendingPackages());
inbound.plannedPalletCount(), inbound.totalPackages(), inbound.receivedPackages(), inbound.pendingPackages(),
inbound.palletMergeRequired(), inbound.palletMergeHint());
}
public ReceivingContainerResponses.PalletResolved resolvePallet(String operatorKey,
@@ -309,7 +311,8 @@ public class ReceivingContainerService {
int total = fbaPlanRefs.stream().mapToInt(refValue -> value(refValue.line().getRequiredPackageCount())).sum();
ReceivingContainerResponses.Receipt result = new ReceivingContainerResponses.Receipt("RECEIVED",
session.getContainerNo(), ref.line().getInboundCode(), "FBA", resource.palletNo(), accepted,
received, Math.max(0, total - received), session.getStatus());
received, Math.max(0, total - received), session.getStatus(), palletMergeRequired(ref.order()),
palletMergeHint(ref.order()));
save(session, "BULK_RECEIVE", request.idempotencyKey(), request, result);
return result;
} catch (RuntimeException failure) {
@@ -347,7 +350,8 @@ public class ReceivingContainerService {
ReceivingContainerResponses.Inbound inbound = inbound(ref);
ReceivingContainerResponses.Receipt result = new ReceivingContainerResponses.Receipt("RECEIVED",
session.getContainerNo(), inbound.inboundCode(), inbound.destinationType(), resource.palletNo(), 1,
inbound.receivedPackages(), inbound.pendingPackages(), session.getStatus());
inbound.receivedPackages(), inbound.pendingPackages(), session.getStatus(),
inbound.palletMergeRequired(), inbound.palletMergeHint());
save(session, "CARTON_RECEIVE", request.idempotencyKey(), request, result);
return result;
} catch (RuntimeException failure) {
@@ -666,7 +670,17 @@ public class ReceivingContainerService {
: String.valueOf(ref.plan().getOverseasWarehouseId()), ref.order().getOverseasWarehouseName(),
ref.line().getDestinationWarehouseId(), ref.line().getDestinationWarehouseCode(),
ref.line().getDestinationWarehouseName(), ref.line().getFbaWarehouseCode(),
value(ref.line().getPlannedPalletCount()), total, received, Math.max(0, total - received));
value(ref.line().getPlannedPalletCount()), total, received, Math.max(0, total - received),
palletMergeRequired(ref.order()), palletMergeHint(ref.order()));
}
private boolean palletMergeRequired(LastlegOrder order) {
return order != null && ("REQUIRED".equals(order.getPalletMergeFlag())
|| "MERGED".equals(order.getPalletMergeFlag()));
}
private String palletMergeHint(LastlegOrder order) {
return palletMergeRequired(order) ? PALLET_MERGE_HINT : null;
}
private Totals totals(List<InboundPlan> containerPlans) {
@@ -7,6 +7,7 @@ import com.baiyue.lastleg.inboundplan.dto.InboundPlanListResponse;
import com.baiyue.lastleg.inboundplan.dto.InboundPlanRequests;
import com.baiyue.lastleg.inboundplan.dto.InboundPlanResponse;
import com.baiyue.lastleg.inboundplan.dto.SupplementPalletRequest;
import com.baiyue.lastleg.inboundplan.dto.UpdateInboundPlanRequest;
import com.baiyue.lastleg.inboundplan.entity.InboundPlan;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet;
@@ -33,10 +34,12 @@ import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.stream.Collectors;
@Service
public class InboundPlanService {
@Resource private com.baiyue.lastleg.service.LastlegOrderNoGenerator orderNoGenerator;
@Resource private InboundPlanMapper planMapper;
@Resource private InboundPlanLineMapper lineMapper;
@Resource private InboundPlanPalletMapper palletMapper;
@@ -61,6 +64,37 @@ public class InboundPlanService {
@Transactional
public InboundPlanResponse convert(String customerId, Long selectedOrderId) {
return convert(customerId, selectedOrderId, List.of());
}
@Transactional
public InboundPlanResponse convert(String customerId, Long selectedOrderId, List<String> sourcePalletNos) {
return convert(customerId, selectedOrderId, sourcePalletNos, null);
}
@Transactional
public InboundPlanResponse convertDropship(String customerId, Long selectedOrderId,
List<WholePalletInput> sourcePallets) {
if (sourcePallets == null || sourcePallets.isEmpty()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"One-click dropship inbound requires source pallets");
}
HashSet<String> palletNos = new HashSet<>();
for (WholePalletInput pallet : sourcePallets) {
if (pallet == null || trimToNull(pallet.palletNo()) == null || pallet.packageCount() <= 0) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"One-click dropship source pallet number and package count are required");
}
if (!palletNos.add(pallet.palletNo().trim())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"Duplicate one-click dropship source pallet number: " + pallet.palletNo());
}
}
return convert(customerId, selectedOrderId, List.of(), List.copyOf(sourcePallets));
}
private InboundPlanResponse convert(String customerId, Long selectedOrderId, List<String> sourcePalletNos,
List<WholePalletInput> wholePallets) {
if (customerId == null || customerId.isBlank() || "0".equals(customerId.trim())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "平台生成入库计划必须指定客户");
}
@@ -75,16 +109,17 @@ public class InboundPlanService {
return detail(customerId, existing);
}
validateOrders(orders);
return createPlan(customerId, selectedOrderId, orders);
return createPlan(customerId, selectedOrderId, orders, sourcePalletNos, wholePallets);
}
private InboundPlanResponse createPlan(String customerId, Long selectedOrderId,
List<SourceOrderSnapshot> orders) {
List<SourceOrderSnapshot> orders, List<String> sourcePalletNos,
List<WholePalletInput> wholePallets) {
String containerNo = requiredContainerNo(orders.get(0).containerNo());
LocalDateTime now = LocalDateTime.now();
InboundPlan plan = new InboundPlan();
plan.setId(IdWorker.getId());
plan.setPlanNo("LIP" + plan.getId());
plan.setPlanNo(orderNoGenerator.next("INBOUND_PLAN", orders.get(0).destinationWarehouseCode(), plan.getId()));
plan.setCustomerId(customerId);
plan.setSourceOrderId(selectedOrderId);
plan.setContainerNo(containerNo);
@@ -98,20 +133,27 @@ public class InboundPlanService {
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()
PalletAllocator.Result allocation = wholePallets == null ? PalletAllocator.allocate(orders.stream()
.map(order -> new PalletAllocator.LineInput(order.orderId(), warehouseKey(order), volume(order),
order.packageCount()))
.toList());
plan.setPalletCount(allocation.pallets().size());
order.packageCount(), order.recommendedPalletCount())).toList()) : null;
if (wholePallets != null) validateWholePallets(orders, wholePallets);
plan.setPalletCount(wholePallets == null ? allocation.pallets().size() : wholePallets.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)));
Map<Long, Integer> palletCounts;
if (wholePallets == null) {
Map<PalletAllocator.PalletDraft, InboundPlanPallet> persistedPallets = createPallets(plan, allocation,
orderById, sourcePalletNos == null ? List.of() : sourcePalletNos);
createAllocations(plan, allocation, lineByOrder, persistedPallets);
palletCounts = allocation.allocations().stream().collect(Collectors.groupingBy(
PalletAllocator.AllocationDraft::orderId, Collectors.collectingAndThen(Collectors.mapping(
item -> item.pallet().sequenceNo(), Collectors.toSet()), java.util.Set::size)));
} else {
SourceOrderSnapshot order = orders.get(0);
createWholePallets(plan, order, lineByOrder.get(order.orderId()), wholePallets);
palletCounts = Map.of(order.orderId(), wholePallets.size());
}
for (SourceOrderSnapshot order : orders) {
int count = palletCounts.getOrDefault(order.orderId(), 0);
InboundPlanLine line = lineByOrder.get(order.orderId());
@@ -180,7 +222,7 @@ public class InboundPlanService {
List<InboundPlanResponse> created = new ArrayList<>(refs.size());
for (int index = 0; index < refs.size(); index++) {
InboundPlanOrderMapper.ContainerOrderRef ref = refs.get(index);
created.add(createPlan(ref.customerId(), ref.orderId(), snapshots.get(index)));
created.add(createPlan(ref.customerId(), ref.orderId(), snapshots.get(index), List.of(), null));
}
return List.copyOf(created);
}
@@ -201,6 +243,53 @@ public class InboundPlanService {
}
}
private void validateWholePallets(List<SourceOrderSnapshot> orders, List<WholePalletInput> pallets) {
if (orders.size() != 1) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"One-click dropship inbound plan must belong to one inbound order");
}
int packages = pallets.stream().mapToInt(WholePalletInput::packageCount).sum();
if (packages != orders.get(0).packageCount()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"One-click dropship pallet package total must equal inbound order package count");
}
}
private void createWholePallets(InboundPlan plan, SourceOrderSnapshot order, InboundPlanLine line,
List<WholePalletInput> sourcePallets) {
BigDecimal remainingVolume = volume(order);
int remainingPackages = order.packageCount();
for (int index = 0; index < sourcePallets.size(); index++) {
WholePalletInput source = sourcePallets.get(index);
BigDecimal palletVolume = index == sourcePallets.size() - 1 ? remainingVolume
: volume(order).multiply(BigDecimal.valueOf(source.packageCount()))
.divide(BigDecimal.valueOf(order.packageCount()), 4, java.math.RoundingMode.HALF_UP);
if (palletVolume.compareTo(remainingVolume) > 0) palletVolume = remainingVolume;
InboundPlanPallet pallet = new InboundPlanPallet();
pallet.setId(IdWorker.getId()); pallet.setCustomerId(plan.getCustomerId()); pallet.setPlanId(plan.getId());
pallet.setPalletNo(source.palletNo().trim()); pallet.setDestinationWarehouseId(order.destinationWarehouseId());
pallet.setDestinationWarehouseCode(order.destinationWarehouseCode());
pallet.setDestinationWarehouseName(order.destinationWarehouseName());
pallet.setCapacityCbm(PalletAllocator.PALLET_CAPACITY_CBM); pallet.setAllocatedVolumeCbm(palletVolume);
pallet.setSequenceNo(index + 1); pallet.setAssignmentStatus("UNASSIGNED");
pallet.setActualVolumeCbm(BigDecimal.ZERO); pallet.setActualPackageCount(0); palletMapper.insert(pallet);
InboundPlanPalletAllocation allocation = new InboundPlanPalletAllocation();
allocation.setId(IdWorker.getId()); allocation.setCustomerId(plan.getCustomerId()); allocation.setPlanId(plan.getId());
allocation.setLineId(line.getId()); allocation.setPalletId(pallet.getId());
allocation.setAllocatedVolumeCbm(palletVolume); allocation.setPlannedPackageCount(source.packageCount());
allocation.setCountedPackageCount(0); allocation.setPickedPackageCount(0);
allocation.setActualVolumeCbm(BigDecimal.ZERO); allocation.setStatus("PENDING_COUNT"); allocation.setVersion(0);
allocationMapper.insert(allocation);
remainingVolume = remainingVolume.subtract(palletVolume);
remainingPackages -= source.packageCount();
}
if (remainingPackages != 0 || remainingVolume.signum() != 0) {
throw new IllegalStateException("Failed to conserve one-click dropship pallet totals");
}
}
public record WholePalletInput(String palletNo, int packageCount) {}
@Transactional(readOnly = true)
public InboundPlanResponse detailByPlanNo(String customerId, String planNo) {
@@ -214,6 +303,44 @@ public class InboundPlanService {
return detail(customerId, plan);
}
@Transactional
public InboundPlanResponse update(String customerId, String planNo, UpdateInboundPlanRequest request) {
InboundPlan plan = planMapper.selectOne(new LambdaQueryWrapper<InboundPlan>()
.eq(InboundPlan::getPlanNo, planNo).eq(customerId != null, InboundPlan::getCustomerId, customerId).last("FOR UPDATE"));
if (plan == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Inbound plan not found");
if (!java.util.Set.of("PENDING_ASSIGNMENT", "ASSIGNED").contains(plan.getStatus()))
throw new ResponseStatusException(HttpStatus.CONFLICT, "Inbound plan has started and cannot be edited");
int currentVersion = plan.getVersion() == null ? 0 : plan.getVersion();
if (request.version() != currentVersion)
throw new ResponseStatusException(HttpStatus.CONFLICT, "Inbound plan version is stale; reload before editing");
plan.setOverseasWarehouseId(request.overseasWarehouseId());
plan.setUpdatedAt(LocalDateTime.now());
planMapper.updateById(plan);
// Keep the denormalized destination snapshot used by list queries in sync with the plan.
List<InboundPlanLine> lines = lineMapper.selectList(new LambdaQueryWrapper<InboundPlanLine>()
.eq(InboundPlanLine::getPlanId, plan.getId())
.eq(InboundPlanLine::getCustomerId, plan.getCustomerId()));
for (InboundPlanLine line : lines) {
line.setDestinationWarehouseId(String.valueOf(request.overseasWarehouseId()));
if (trimToNull(request.warehouseCode()) != null) line.setDestinationWarehouseCode(request.warehouseCode().trim());
if (trimToNull(request.warehouseName()) != null) line.setDestinationWarehouseName(request.warehouseName().trim());
lineMapper.updateById(line);
}
// Pallets carry the same destination snapshot and are consumed by PDA/resource assignment.
// Update them in this transaction so a plan edit cannot leave lines and pallets pointing at
// different warehouses.
List<InboundPlanPallet> pallets = palletMapper.selectList(new LambdaQueryWrapper<InboundPlanPallet>()
.eq(InboundPlanPallet::getPlanId, plan.getId())
.eq(InboundPlanPallet::getCustomerId, plan.getCustomerId()));
for (InboundPlanPallet pallet : pallets) {
pallet.setDestinationWarehouseId(String.valueOf(request.overseasWarehouseId()));
if (trimToNull(request.warehouseCode()) != null) pallet.setDestinationWarehouseCode(request.warehouseCode().trim());
if (trimToNull(request.warehouseName()) != null) pallet.setDestinationWarehouseName(request.warehouseName().trim());
palletMapper.updateById(pallet);
}
return detail(customerId, plan);
}
@Transactional
public InboundPlanResponse.Pallet supplementPallet(String customerId, String planNo, SupplementPalletRequest request) {
InboundPlan plan = planMapper.selectOne(new LambdaQueryWrapper<InboundPlan>()
@@ -265,7 +392,8 @@ public class InboundPlanService {
}
private Map<PalletAllocator.PalletDraft, InboundPlanPallet> createPallets(
InboundPlan plan, PalletAllocator.Result allocation, Map<Long, SourceOrderSnapshot> orderById) {
InboundPlan plan, PalletAllocator.Result allocation, Map<Long, SourceOrderSnapshot> orderById,
List<String> sourcePalletNos) {
Map<String, SourceOrderSnapshot> warehouseSamples = orderById.values().stream()
.collect(Collectors.toMap(this::warehouseKey, value -> value, (left, right) -> left));
Map<PalletAllocator.PalletDraft, InboundPlanPallet> result = new HashMap<>();
@@ -275,7 +403,10 @@ public class InboundPlanService {
pallet.setId(IdWorker.getId());
pallet.setCustomerId(plan.getCustomerId());
pallet.setPlanId(plan.getId());
pallet.setPalletNo(plan.getPlanNo() + "-P" + String.format("%03d", result.size() + 1));
int palletIndex = result.size();
String sourcePalletNo = palletIndex < sourcePalletNos.size() ? sourcePalletNos.get(palletIndex) : null;
pallet.setPalletNo(sourcePalletNo == null || sourcePalletNo.isBlank()
? plan.getPlanNo() + "-P" + String.format("%03d", palletIndex + 1) : sourcePalletNo.trim());
pallet.setDestinationWarehouseId(sample.destinationWarehouseId());
pallet.setDestinationWarehouseCode(sample.destinationWarehouseCode());
pallet.setDestinationWarehouseName(sample.destinationWarehouseName());
@@ -319,7 +450,7 @@ public class InboundPlanService {
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) {
if (order.destinationCount() != 1 || warehouseKey(order) == null) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"Inbound code " + order.inboundCode()
+ " must have one destination warehouse consistent with all cargo details");
@@ -365,7 +496,8 @@ public class InboundPlanService {
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());
item.getPickedPackageCount(), item.getActualVolumeCbm(), item.getStatus(), item.getVersion())).toList(),
plan.getVersion() == null ? 0 : plan.getVersion());
}
private BigDecimal volume(SourceOrderSnapshot order) {
@@ -34,6 +34,23 @@ public final class PalletAllocator {
PalletDraft current = null;
int sequence = 0;
for (LineInput line : group) {
if (line.recommendedPalletCount() > 0) {
int minimumByVolume = line.volumeCbm().divide(PALLET_CAPACITY_CBM, 0, RoundingMode.CEILING).intValue();
int count = Math.max(line.recommendedPalletCount(), minimumByVolume);
BigDecimal remaining = line.volumeCbm();
for (int index = 0; index < count; index++) {
PalletDraft recommended = new PalletDraft(line.warehouseKey(), ++sequence, BigDecimal.ZERO);
pallets.add(recommended);
int remainingPallets = count - index;
BigDecimal allocated = remainingPallets == 1 ? remaining
: remaining.divide(BigDecimal.valueOf(remainingPallets), 4, RoundingMode.HALF_UP);
recommended.add(allocated);
allocations.add(new AllocationDraft(line.orderId(), recommended, allocated, 0));
remaining = remaining.subtract(allocated);
}
current = null;
continue;
}
BigDecimal remaining = line.volumeCbm();
while (remaining.signum() > 0) {
if (current == null || current.remainingCapacity().signum() == 0) {
@@ -85,7 +102,11 @@ public final class PalletAllocator {
return List.copyOf(result);
}
public record LineInput(Long orderId, String warehouseKey, BigDecimal volumeCbm, int packageCount) {
public record LineInput(Long orderId, String warehouseKey, BigDecimal volumeCbm, int packageCount,
int recommendedPalletCount) {
public LineInput(Long orderId, String warehouseKey, BigDecimal volumeCbm, int packageCount) {
this(orderId, warehouseKey, volumeCbm, packageCount, 0);
}
}
public static final class PalletDraft {