入库修改

This commit is contained in:
2026-08-20 13:34:34 +08:00
parent 3b19a1bc16
commit b139255d3b
24 changed files with 528 additions and 62 deletions
@@ -6,6 +6,7 @@ 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.dto.SupplementPalletRequest;
import com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService;
import com.baiyue.lastleg.inboundplan.service.InboundPlanService;
import io.swagger.v3.oas.annotations.Operation;
@@ -25,7 +26,7 @@ public class InboundPlanController {
@Resource private InboundFbaAppointmentService appointmentService;
@PostMapping("/convert")
@Operation(summary = "Convert the entire container to one inbound plan")
@Operation(summary = "Convert one inbound order 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()));
@@ -39,6 +40,14 @@ public class InboundPlanController {
return ApiResponse.success(service.detailByPlanNo(scope(user, customerId), planNo));
}
@PostMapping("/{planNo}/pallets/supplement")
@Operation(summary = "Add one printable supplemental pallet for a plan destination warehouse")
public ApiResponse<InboundPlanResponse.Pallet> supplementPallet(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String planNo,
@Valid @RequestBody SupplementPalletRequest request) {
return ApiResponse.success(service.supplementPallet(scope(user, request.customerId()), planNo, request));
}
@PostMapping("/fba-appointments/batch")
@Operation(summary = "Create one FBA appointment for selected inbound lines")
public ApiResponse<FbaAppointmentResponse> batchFbaAppointment(
@@ -8,6 +8,7 @@ public record InboundPlanResponse(
Long id,
String planNo,
String customerId,
Long sourceOrderId,
String containerNo,
String status,
int lineCount,
@@ -0,0 +1,7 @@
package com.baiyue.lastleg.inboundplan.dto;
import jakarta.validation.constraints.NotBlank;
/** 托盘纸不足时,按计划内目的仓补打一张空托盘标签。 */
public record SupplementPalletRequest(@NotBlank String destinationWarehouseId, String destinationWarehouseCode,
String destinationWarehouseName, String customerId) {}
@@ -15,6 +15,7 @@ public class InboundPlan {
private Long id;
private String planNo;
private String customerId;
private Long sourceOrderId;
private String containerNo;
private String status;
private Integer lineCount;
@@ -0,0 +1,35 @@
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_plan_carton")
public class InboundPlanCarton {
@TableId(type = IdType.INPUT)
private Long id;
private String customerId;
private Long orderId;
private String sourceInboundDetailId;
private String skuCode;
private String cartonMark;
private String destinationWarehouseId;
private String destinationWarehouseCode;
private String destinationWarehouseName;
private Long planId;
private Long lineId;
private Long planPalletId;
private Long warehousePalletId;
private Long warehouseLocationId;
private Long pdaTaskId;
private String status;
private Integer version;
private LocalDateTime boundAt;
private LocalDateTime receivedAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,34 @@
package com.baiyue.lastleg.inboundplan.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface InboundPlanCartonMapper extends BaseMapper<InboundPlanCarton> {
@Select("""
SELECT l.* FROM ll_inbound_plan_line l
JOIN ll_order_detail d ON d.order_id=l.order_id
WHERE l.plan_id=#{planId}
AND d.source_inbound_detail_id=#{sourceInboundDetailId}
LIMIT 1
""")
com.baiyue.lastleg.inboundplan.entity.InboundPlanLine findPlanLine(@Param("planId") Long planId,
@Param("sourceInboundDetailId") String sourceInboundDetailId);
@Select("""
SELECT COUNT(*) FROM ll_inbound_plan_carton
WHERE plan_id=#{planId} AND line_id=#{lineId} AND status='ON_PALLET'
""")
long scannedCount(@Param("planId") Long planId, @Param("lineId") Long lineId);
@Select("""
SELECT EXISTS(SELECT 1 FROM ll_inbound_plan_line l
JOIN ll_order_detail d ON d.order_id=l.order_id
JOIN ll_inbound_plan_carton c ON c.source_inbound_detail_id=d.source_inbound_detail_id
WHERE l.plan_id=#{planId})
""")
boolean hasLabelPool(@Param("planId") Long planId);
}
@@ -8,6 +8,6 @@ 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);
@Select("SELECT * FROM ll_inbound_plan WHERE customer_id=#{customerId} AND source_order_id=#{sourceOrderId} LIMIT 1")
InboundPlan findBySourceOrder(@Param("customerId") String customerId, @Param("sourceOrderId") Long sourceOrderId);
}
@@ -29,12 +29,12 @@ public interface InboundPlanOrderMapper {
"<> 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} " +
"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," +
"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);
List<SourceOrderSnapshot> lockOrder(@Param("customerId") String customerId,
@Param("orderId") Long orderId);
@Update("UPDATE ll_order SET inbound_order_no=#{planNo},current_status='INBOUND_PLANNED'," +
"pallet_merge_flag='MERGED',pallet_count=#{palletCount},updated_at=#{updatedAt} " +
@@ -62,6 +62,24 @@ public class InboundPdaController {
return ApiResponse.success(service.pick(scope(user, customerId), planNo, allocationId, request));
}
@PostMapping("/cartons/scan")
public ApiResponse<PdaResponses.Carton> scanCarton(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String planNo,
@RequestParam(required = false) String customerId,
@Valid @RequestBody PdaRequests.ScanCarton request) {
requireOperate(user);
return ApiResponse.success(service.scanCarton(scope(user, customerId), planNo, request));
}
@PostMapping("/cartons/remove")
public ApiResponse<PdaResponses.Carton> removeCarton(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String planNo,
@RequestParam(required = false) String customerId,
@Valid @RequestBody PdaRequests.RemoveCarton request) {
requireOperate(user);
return ApiResponse.success(service.removeCarton(scope(user, customerId), planNo, request));
}
@GetMapping("/allocations/{allocationId}/merge-candidates")
public ApiResponse<List<PdaResponses.MergeCandidate>> candidates(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String planNo,
@@ -16,4 +16,10 @@ public final class PdaRequests {
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 RemoveCarton(@NotBlank String cartonMark, @NotBlank String idempotencyKey,
String operatorId, String operatorName) {}
}
@@ -23,4 +23,7 @@ public final class PdaResponses {
public record Merge(String result, String mergeGroupNo, long sourceOrderId, long targetOrderId,
int movedPackageCount, BigDecimal movedVolumeCbm,
boolean sourcePalletScrapped) {}
public record Carton(String result, String cartonMark, long planPalletId, long allocationId,
int palletActualPackageCount) {}
}
@@ -9,6 +9,8 @@ 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.mapper.InboundPlanCartonMapper;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton;
import com.baiyue.lastleg.inboundplan.pda.entity.InboundPalletMerge;
import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaOperation;
import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask;
@@ -29,6 +31,7 @@ public class PdaWorkflowRepository {
@Resource private InboundPdaTaskMapper tasks;
@Resource private InboundPdaOperationMapper operations;
@Resource private InboundPalletMergeMapper merges;
@Resource private InboundPlanCartonMapper cartons;
public InboundPlan lockPlan(String customerId, String planNo) {
return plans.selectOne(new LambdaQueryWrapper<InboundPlan>()
@@ -45,6 +48,27 @@ public class PdaWorkflowRepository {
.eq(InboundPlanPalletAllocation::getId, allocationId).last("FOR UPDATE"));
}
public InboundPlanPalletAllocation lockAllocation(Long planId, Long lineId, Long palletId) {
return allocations.selectOne(new LambdaQueryWrapper<InboundPlanPalletAllocation>()
.eq(InboundPlanPalletAllocation::getPlanId, planId).eq(InboundPlanPalletAllocation::getLineId, lineId)
.eq(InboundPlanPalletAllocation::getPalletId, palletId).last("FOR UPDATE"));
}
public InboundPlanCarton lockCarton(String customerId, String cartonMark) {
return cartons.selectOne(new LambdaQueryWrapper<InboundPlanCarton>()
.eq(InboundPlanCarton::getCustomerId, customerId).eq(InboundPlanCarton::getCartonMark, cartonMark)
.last("FOR UPDATE"));
}
public List<InboundPlanCarton> cartons(Long planId) {
return cartons.selectList(new LambdaQueryWrapper<InboundPlanCarton>().eq(InboundPlanCarton::getPlanId, planId));
}
public InboundPlanLine lineForCarton(Long planId, InboundPlanCarton carton) {
return cartons.findPlanLine(planId, carton.getSourceInboundDetailId());
}
public long scannedCartons(Long planId, Long lineId) { return cartons.scannedCount(planId, lineId); }
public boolean hasCartonLabelPool(Long planId) { return cartons.hasLabelPool(planId); }
public InboundPlanPallet lockPallet(Long planId, Long palletId) {
return pallets.selectOne(new LambdaQueryWrapper<InboundPlanPallet>()
.eq(InboundPlanPallet::getPlanId, planId).eq(InboundPlanPallet::getId, palletId).last("FOR UPDATE"));
@@ -76,4 +100,5 @@ public class PdaWorkflowRepository {
public void update(InboundPdaTask value) { tasks.updateById(value); }
public void update(InboundPlanPallet value) { pallets.updateById(value); }
public void update(InboundPlanPalletAllocation value) { allocations.updateById(value); }
public void update(InboundPlanCarton value) { cartons.updateById(value); }
}
@@ -5,6 +5,7 @@ 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.entity.InboundPlanCarton;
import com.baiyue.lastleg.inboundplan.pda.dto.PdaRequests;
import com.baiyue.lastleg.inboundplan.pda.dto.PdaResponses;
import com.baiyue.lastleg.inboundplan.pda.entity.InboundPalletMerge;
@@ -99,9 +100,13 @@ public class InboundPdaWorkflowService {
&& Objects.equals(pallet.getWarehouseLocationId(), request.locationId())) return;
throw conflict("Planned pallet is already assigned to another warehouse resource");
}
String businessId = planNo + ":" + planPalletId;
String destinationKey = destinationKey(pallet);
String businessId = plan.getContainerNo() + ":" + destinationKey;
// A container is unloaded by destination pallet, not by inbound-plan pallet.
// The stable reservation key lets each plan from the same container reuse it.
String reservationKey = "INBOUND_CONTAINER_DESTINATION_PALLET:" + businessId;
long lockId = warehouse.reserve(customerId, request.warehouseId(), request.palletId(), request.locationId(),
"INBOUND_PLAN_PALLET", businessId, request.idempotencyKey());
"INBOUND_CONTAINER_DESTINATION_PALLET", businessId, reservationKey);
pallet.setWarehousePalletId(request.palletId());
pallet.setWarehouseLocationId(request.locationId());
pallet.setWarehouseResourceLockId(lockId);
@@ -128,6 +133,7 @@ public class InboundPdaWorkflowService {
public PdaResponses.Operation count(String customerId, String planNo, long allocationId,
PdaRequests.PackageOperation request) {
InboundPlan plan = requirePlan(customerId, planNo);
requireLegacyCounting(plan);
InboundPdaOperation replay = replay(customerId, "COUNT", request.idempotencyKey(), request);
if (replay != null) return read(replay, PdaResponses.Operation.class);
InboundPdaTask task = requireActiveTask(customerId, plan);
@@ -155,6 +161,7 @@ public class InboundPdaWorkflowService {
public PdaResponses.Operation pick(String customerId, String planNo, long allocationId,
PdaRequests.PackageOperation request) {
InboundPlan plan = requirePlan(customerId, planNo);
requireLegacyCounting(plan);
InboundPdaOperation replay = replay(customerId, "PICK", request.idempotencyKey(), request);
if (replay != null) return read(replay, PdaResponses.Operation.class);
InboundPdaTask task = requireActiveTask(customerId, plan);
@@ -193,6 +200,90 @@ public class InboundPdaWorkflowService {
return result;
}
/** 海外首次扫描箱唛:仅接受本柜国内冻结且已归属本计划的箱。 */
@Transactional
public PdaResponses.Carton scanCarton(String customerId, String planNo, PdaRequests.ScanCarton request) {
InboundPlan plan = requirePlan(customerId, planNo);
InboundPdaOperation replay = replay(customerId, "SCAN_CARTON", request.idempotencyKey(), request);
if (replay != null) return read(replay, PdaResponses.Carton.class);
InboundPdaTask task = requireActiveTask(customerId, plan);
InboundPlanCarton carton = repository.lockCarton(customerId, request.cartonMark());
if (carton == null || (carton.getPlanId() != null && !Objects.equals(carton.getPlanId(), plan.getId()))) {
throw notFound("Carton mark is not available to this inbound plan");
}
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());
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())) {
throw conflict("This inbound plan line has reached its required carton count");
}
InboundPlanPalletAllocation allocation = repository.lockAllocation(plan.getId(), line.getId(), pallet.getId());
if (allocation == null) {
allocation = new InboundPlanPalletAllocation(); allocation.setId(IdWorker.getId()); allocation.setPlanId(plan.getId());
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);
}
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));
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));
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());
saveOperation(customerId, task.getId(), allocation.getId(), "SCAN_CARTON", request.idempotencyKey(), request, result);
return result;
}
@Transactional
public PdaResponses.Carton removeCarton(String customerId, String planNo, PdaRequests.RemoveCarton request) {
InboundPlan plan = requirePlan(customerId, planNo);
InboundPdaOperation replay = replay(customerId, "REMOVE_CARTON", request.idempotencyKey(), request);
if (replay != null) return read(replay, PdaResponses.Carton.class);
InboundPdaTask task = requireActiveTask(customerId, plan);
InboundPlanCarton carton = repository.lockCarton(customerId, request.cartonMark());
if (carton == null || !Objects.equals(carton.getPlanId(), plan.getId())) throw notFound("Carton mark is not expected by this inbound plan");
if (!"ON_PALLET".equals(carton.getStatus()) || carton.getPlanPalletId() == null) throw conflict("Only a carton currently on a pallet may be removed");
InboundPlanPallet pallet = requirePallet(plan.getId(), carton.getPlanPalletId());
InboundPlanLine line = requireLine(plan.getId(), carton.getLineId());
InboundPlanPalletAllocation allocation = repository.lockAllocation(plan.getId(), line.getId(), pallet.getId());
if (allocation == null || value(allocation.getPickedPackageCount()) <= 0 || value(pallet.getActualPackageCount()) <= 0) {
throw conflict("Carton pallet allocation is inconsistent");
}
BigDecimal perPackage = perPackageVolume(line);
allocation.setCountedPackageCount(Math.max(0, value(allocation.getCountedPackageCount()) - 1));
allocation.setPickedPackageCount(value(allocation.getPickedPackageCount()) - 1);
allocation.setActualVolumeCbm(nonNegative(volume(allocation.getActualVolumeCbm()).subtract(perPackage)));
allocation.setStatus(value(allocation.getPickedPackageCount()) == 0 ? "PENDING_COUNT" : "PICKING");
bump(allocation); repository.update(allocation);
pallet.setActualPackageCount(value(pallet.getActualPackageCount()) - 1);
pallet.setActualVolumeCbm(nonNegative(volume(pallet.getActualVolumeCbm()).subtract(perPackage))); repository.update(pallet);
carton.setOrderId(null); carton.setPlanId(null); carton.setLineId(null); carton.setPlanPalletId(null);
carton.setWarehousePalletId(null); carton.setWarehouseLocationId(null); carton.setPdaTaskId(null);
carton.setStatus("UNASSIGNED"); carton.setBoundAt(null); carton.setVersion(value(carton.getVersion()) + 1);
carton.setUpdatedAt(LocalDateTime.now()); repository.update(carton);
PdaResponses.Carton result = new PdaResponses.Carton("REMOVED", carton.getCartonMark(), pallet.getId(),
allocation.getId(), pallet.getActualPackageCount());
saveOperation(customerId, task.getId(), allocation.getId(), "REMOVE_CARTON", request.idempotencyKey(), request, result);
return result;
}
@Transactional
public List<PdaResponses.MergeCandidate> mergeCandidates(String customerId, String planNo,
long allocationId) {
@@ -205,6 +296,7 @@ public class InboundPdaWorkflowService {
public PdaResponses.Merge merge(String customerId, String planNo, long targetAllocationId,
PdaRequests.ConfirmMerge request) {
InboundPlan plan = requirePlan(customerId, planNo);
requireLegacyCounting(plan);
InboundPdaOperation replay = replay(customerId, "MERGE", request.idempotencyKey(), request);
if (replay != null) return read(replay, PdaResponses.Merge.class);
InboundPdaTask task = requireActiveTask(customerId, plan);
@@ -292,8 +384,16 @@ public class InboundPdaWorkflowService {
throw conflict("All line picked package totals must exactly equal required package totals");
}
}
List<InboundPlanCarton> cartons = repository.cartons(plan.getId());
if (!cartons.isEmpty() && cartons.stream().anyMatch(carton -> !"ON_PALLET".equals(carton.getStatus()))) {
throw conflict("All expected carton marks must be scanned onto a pallet before stock-in");
}
LocalDateTime now = LocalDateTime.now();
stockReceipts.stockIn(customerId, plan, task, lines, allocations, repository.pallets(plan.getId()), now);
for (InboundPlanCarton carton : cartons) {
carton.setStatus("AVAILABLE"); carton.setReceivedAt(now); carton.setUpdatedAt(now);
carton.setVersion(value(carton.getVersion()) + 1); repository.update(carton);
}
task.setStatus("COMPLETED");
task.setCompletedAt(now);
task.setUpdatedAt(now);
@@ -387,6 +487,21 @@ public class InboundPdaWorkflowService {
return leftKey != null && leftKey.equals(rightKey);
}
private boolean sameWarehouse(InboundPlanCarton carton, InboundPlanPallet pallet) {
String cartonKey = carton.getDestinationWarehouseId() != null
? carton.getDestinationWarehouseId() : carton.getDestinationWarehouseCode();
String palletKey = pallet.getDestinationWarehouseId() != null
? pallet.getDestinationWarehouseId() : pallet.getDestinationWarehouseCode();
return cartonKey != null && cartonKey.equals(palletKey);
}
private String destinationKey(InboundPlanPallet pallet) {
String value = pallet.getDestinationWarehouseId() != null
? pallet.getDestinationWarehouseId() : pallet.getDestinationWarehouseCode();
if (value == null || value.isBlank()) throw conflict("Planned pallet destination warehouse is empty");
return value;
}
private void validateDestinationWarehouse(InboundPlanPallet pallet, long warehouseId) {
String destinationId = pallet.getDestinationWarehouseId();
if (destinationId == null || destinationId.isBlank()) return;
@@ -413,6 +528,12 @@ public class InboundPdaWorkflowService {
repository.update(plan);
}
private void requireLegacyCounting(InboundPlan plan) {
if (repository.hasCartonLabelPool(plan.getId())) {
throw conflict("This inbound plan has a carton manifest and must be operated by carton scanning");
}
}
private boolean isNotAssigned(InboundPlanPallet pallet) {
return pallet.getWarehousePalletId() == null || pallet.getWarehouseLocationId() == null
|| pallet.getWarehouseResourceLockId() == null
@@ -3,6 +3,7 @@ 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.dto.SupplementPalletRequest;
import com.baiyue.lastleg.inboundplan.entity.InboundPlan;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet;
@@ -37,17 +38,13 @@ public class InboundPlanService {
@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.lockOrder(customerId, selectedOrderId);
if (orders == null || orders.isEmpty()) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Inbound order not found");
}
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);
String containerNo = trimToNull(orders.get(0).containerNo());
if (containerNo == null) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Inbound order container number is empty");
InboundPlan existing = planMapper.findBySourceOrder(customerId, selectedOrderId);
if (existing != null) {
return detail(customerId, existing);
}
@@ -58,6 +55,7 @@ public class InboundPlanService {
plan.setId(IdWorker.getId());
plan.setPlanNo("LIP" + plan.getId());
plan.setCustomerId(customerId);
plan.setSourceOrderId(selectedOrderId);
plan.setContainerNo(containerNo);
plan.setStatus("PENDING_ASSIGNMENT");
plan.setLineCount(orders.size());
@@ -92,11 +90,12 @@ public class InboundPlanService {
throw new IllegalStateException("Failed to update order " + order.orderId());
}
orderMapper.insertHistory(order.orderId(), order.currentStatus(),
"Container converted to inbound plan " + plan.getPlanNo(), now);
"Inbound order 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>()
@@ -107,6 +106,32 @@ public class InboundPlanService {
return detail(customerId, plan);
}
@Transactional
public InboundPlanResponse.Pallet supplementPallet(String customerId, String planNo, SupplementPalletRequest request) {
InboundPlan plan = planMapper.selectOne(new LambdaQueryWrapper<InboundPlan>()
.eq(InboundPlan::getCustomerId, customerId).eq(InboundPlan::getPlanNo, planNo).last("FOR UPDATE"));
if (plan == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Inbound plan not found");
if ("COMPLETED".equals(plan.getStatus())) throw new ResponseStatusException(HttpStatus.CONFLICT, "Completed plan cannot add pallets");
List<InboundPlanLine> lines = lineMapper.selectList(new LambdaQueryWrapper<InboundPlanLine>()
.eq(InboundPlanLine::getPlanId, plan.getId()));
boolean destinationExists = lines.stream().anyMatch(line -> request.destinationWarehouseId().equals(line.getDestinationWarehouseId()));
if (!destinationExists) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Destination warehouse is not part of this inbound plan");
int sequence = palletMapper.selectList(new LambdaQueryWrapper<InboundPlanPallet>()
.eq(InboundPlanPallet::getPlanId, plan.getId())).stream().map(InboundPlanPallet::getSequenceNo)
.filter(Objects::nonNull).max(Integer::compareTo).orElse(0) + 1;
InboundPlanPallet pallet = new InboundPlanPallet();
pallet.setId(IdWorker.getId()); pallet.setPlanId(plan.getId()); pallet.setPalletNo(plan.getPlanNo() + "-P" + String.format("%03d", sequence));
pallet.setDestinationWarehouseId(request.destinationWarehouseId()); pallet.setDestinationWarehouseCode(request.destinationWarehouseCode());
pallet.setDestinationWarehouseName(request.destinationWarehouseName()); pallet.setCapacityCbm(PalletAllocator.PALLET_CAPACITY_CBM);
pallet.setAllocatedVolumeCbm(BigDecimal.ZERO); pallet.setSequenceNo(sequence); pallet.setAssignmentStatus("UNASSIGNED");
pallet.setActualVolumeCbm(BigDecimal.ZERO); pallet.setActualPackageCount(0); palletMapper.insert(pallet);
plan.setPalletCount(plan.getPalletCount() + 1); plan.setUpdatedAt(LocalDateTime.now()); planMapper.updateById(plan);
return new InboundPlanResponse.Pallet(pallet.getId(), pallet.getPalletNo(), pallet.getDestinationWarehouseId(),
pallet.getDestinationWarehouseCode(), pallet.getDestinationWarehouseName(), pallet.getCapacityCbm(),
pallet.getAllocatedVolumeCbm(), pallet.getSequenceNo(), null, null, null, pallet.getAssignmentStatus(),
null, pallet.getActualVolumeCbm(), pallet.getActualPackageCount());
}
private Map<Long, InboundPlanLine> createLines(InboundPlan plan, List<SourceOrderSnapshot> orders) {
Map<Long, InboundPlanLine> result = new HashMap<>();
for (SourceOrderSnapshot order : orders) {
@@ -211,7 +236,8 @@ public class InboundPlanService {
new LambdaQueryWrapper<InboundPlanPalletAllocation>()
.eq(InboundPlanPalletAllocation::getPlanId, plan.getId())
.orderByAsc(InboundPlanPalletAllocation::getId));
return new InboundPlanResponse(plan.getId(), plan.getPlanNo(), plan.getCustomerId(), plan.getContainerNo(),
return new InboundPlanResponse(plan.getId(), plan.getPlanNo(), plan.getCustomerId(), plan.getSourceOrderId(),
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(),
@@ -5,6 +5,9 @@ import com.baiyue.lastleg.entity.*;
import com.baiyue.lastleg.mapper.*;
import com.baiyue.lastleg.dropship.dto.DropshipRequests;
import com.baiyue.lastleg.dropship.service.DropshipOrderService;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanCartonMapper;
import com.baiyue.lastleg.inboundplan.service.InboundPlanService;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.DeserializationFeature;
import jakarta.annotation.Resource;
@@ -25,6 +28,8 @@ public class LoadingCompletedConsumer {
@Resource private LastlegOrderMapper orderMapper;
@Resource private LastlegOrderDetailMapper detailMapper;
@Resource private LastlegOrderStatusHistoryMapper historyMapper;
@Resource private InboundPlanCartonMapper cartonMapper;
@Resource private InboundPlanService inboundPlans;
@Resource private ObjectMapper objectMapper;
@Resource private LastlegOrderNoGenerator orderNoGenerator;
@Resource private DropshipOrderService dropshipOrders;
@@ -32,7 +37,8 @@ public class LoadingCompletedConsumer {
@Transactional
public int consume(EventEnvelope envelope) {
envelope.requireValid();
if(!EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED.equals(envelope.eventType())||envelope.eventVersion()!=1)
if (!EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED.equals(envelope.eventType())
|| (envelope.eventVersion() != 1 && envelope.eventVersion() != 2))
throw new IllegalArgumentException("不支持的尾程交接事件");
if (envelope.sourceRegion() != RegionCode.CN || envelope.targetRegion() != RegionCode.OVERSEAS)
throw new IllegalArgumentException("尾程交接事件区域无效");
@@ -71,6 +77,8 @@ public class LoadingCompletedConsumer {
try {
ObjectMapper compatibleMapper = objectMapper.copy()
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
// V2 only adds immutable item remarks; the current tail-leg snapshot model
// intentionally ignores those fields until its schema exposes them.
return compatibleMapper.treeToValue(envelope.payload(), FirstLegLoadingPlanCompletedV1.class);
}
catch(Exception exception) { throw new IllegalArgumentException("尾程事件负载无效",exception); }
@@ -133,10 +141,12 @@ public class LoadingCompletedConsumer {
detail.setDestinationWarehouseId(item.destinationWarehouseId()); detail.setDestinationWarehouseCode(item.destinationWarehouseCode());
detail.setDestinationWarehouseName(item.destinationWarehouseName()); detail.setFbaTrackingNo(item.fbaTrackingNo());
detail.setShipmentNo(item.shipmentNo()); detail.setShippingMark(item.shippingMark()); detailMapper.insert(detail);
createCartonLabelPool(customerId, item);
}
LastlegOrderStatusHistory history=new LastlegOrderStatusHistory();
history.setOrderId(order.getId()); history.setToStatus("PENDING_ASSIGNMENT"); history.setEventId(event.eventId());
history.setRemark("头程装柜完成自动生成"); history.setCreatedAt(LocalDateTime.now()); historyMapper.insert(history);
if ("FIRST_LEG".equals(orderType)) inboundPlans.convert(customerId, order.getId());
if ("ONE_CLICK_DROPSHIP".equals(orderType)) {
long warehouseId;
try { warehouseId = Long.parseLong(payload.warehouseId()); }
@@ -146,6 +156,24 @@ public class LoadingCompletedConsumer {
}
}
private void createCartonLabelPool(String customerId, FirstLegLoadingPlanCompletedV1.Item item) {
List<String> marks = item.cartonMarks() == null ? List.of() : item.cartonMarks();
if (marks.isEmpty()) return; // 历史 V1 事件保持原有按箱数入库路径。
if (marks.stream().anyMatch(mark -> mark == null || mark.isBlank()) || marks.stream().distinct().count() != marks.size()) {
throw new IllegalArgumentException("装柜事件的箱唛池不可包含空值或重复箱唛");
}
for (String mark : marks) {
InboundPlanCarton carton = new InboundPlanCarton();
carton.setId(IdWorker.getId()); carton.setCustomerId(customerId);
carton.setSourceInboundDetailId(item.sourceInboundDetailId()); carton.setSkuCode(item.skuCode());
carton.setCartonMark(mark); carton.setDestinationWarehouseId(item.destinationWarehouseId());
carton.setDestinationWarehouseCode(item.destinationWarehouseCode());
carton.setDestinationWarehouseName(item.destinationWarehouseName()); carton.setStatus("UNASSIGNED");
carton.setVersion(0); carton.setUpdatedAt(LocalDateTime.now());
try { cartonMapper.insert(carton); } catch (DuplicateKeyException ignored) { }
}
}
private List<DropshipRequests.Pallet> pallets(FirstLegLoadingPlanCompletedV1.Order source, JsonNode rawSource) {
if (source.pallets() != null && !source.pallets().isEmpty()) {
return source.pallets().stream().map(pallet -> new DropshipRequests.Pallet(pallet.palletNo(),