贴标申请,仓储转运

This commit is contained in:
2026-10-08 15:55:47 +08:00
parent e09f8785e1
commit 16d2d9111a
160 changed files with 5704 additions and 4799 deletions
@@ -18,6 +18,7 @@ import com.baiyue.lastleg.security.OverseasPermissions;
import com.baiyue.lastleg.service.LastlegOrderService;
import com.baiyue.lastleg.service.LastlegOrderCommandService;
import com.baiyue.lastleg.service.LoadingCompletedConsumer;
import com.baiyue.lastleg.returnreceipt.service.ReturnReceiptService;
import com.baiyue.lastleg.relabel.dto.RelabelRequests;
import com.baiyue.lastleg.relabel.dto.RelabelResponses;
import com.baiyue.lastleg.relabel.service.RelabelApplicationService;
@@ -50,6 +51,7 @@ import java.util.List;
@RestController
@Tag(name = "Tail-leg orders")
public class LastlegOrderController {
@Resource private ReturnReceiptService returnReceiptService;
@Resource private LoadingCompletedConsumer consumer;
@Resource private ContainerShippingNodeConsumer shippingNodeConsumer;
@Resource private LastlegOrderService orderService;
@@ -165,7 +167,9 @@ public class LastlegOrderController {
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@PathVariable long id) {
return ApiResponse.success(orderService.detail(platformScope(user, customerId), id));
String scopedCustomerId = platformScope(user, customerId);
returnReceiptService.ensureTailCargo(scopedCustomerId, id);
return ApiResponse.success(orderService.detail(scopedCustomerId, id));
}
@PutMapping("/api/platform/v1/last-leg/orders/{id}/relabel-review")
@@ -78,7 +78,18 @@ public final class DeliveryRequests {
@NotBlank @Size(max=128) String returnNo,
@NotNull @Size(min=1, max=200) List<@NotBlank @Size(max=128) String> cartonMarks,
@Size(max=1000) String remark,
@NotBlank @Size(max=255) String idempotencyKey) {}
@NotBlank @Size(max=255) String idempotencyKey,
@Size(max=128) String skuCode,
@jakarta.validation.constraints.Pattern(regexp = "BOX|PALLET") String unitType) {
public ReturnOutbound(String recipient, String overseasWarehouseId, String returnNo,
List<String> cartonMarks, String remark, String idempotencyKey) {
this(recipient, overseasWarehouseId, returnNo, cartonMarks, remark, idempotencyKey, null, null);
}
public ReturnOutbound(String recipient, String overseasWarehouseId, String returnNo,
List<String> cartonMarks, String remark, String idempotencyKey, String skuCode) {
this(recipient, overseasWarehouseId, returnNo, cartonMarks, remark, idempotencyKey, skuCode, null);
}
}
public record WarehouseTransferOutbound(
@NotBlank @Size(max=64) String transferNo,
@NotNull @Size(min=1, max=100) List<@NotNull Long> palletIds,
@@ -102,9 +113,14 @@ public final class DeliveryRequests {
}
public record PdaPick(@NotNull Long outboundPalletItemId, Long inventoryPositionId,
@NotNull @Min(1) Long packages, @NotBlank @Size(max=255) String idempotencyKey,
@Size(max=500) List<@NotBlank @Size(max=255) String> cartonMarks) {
@Size(max=500) List<@NotBlank @Size(max=255) String> cartonMarks,
@Size(max=128) String skuCode) {
public PdaPick(Long outboundPalletItemId, Long inventoryPositionId, Long packages, String idempotencyKey) {
this(outboundPalletItemId, inventoryPositionId, packages, idempotencyKey, List.of());
this(outboundPalletItemId, inventoryPositionId, packages, idempotencyKey, List.of(), null);
}
public PdaPick(Long outboundPalletItemId, Long inventoryPositionId, Long packages,
String idempotencyKey, List<String> cartonMarks) {
this(outboundPalletItemId, inventoryPositionId, packages, idempotencyKey, cartonMarks, null);
}
}
public record PdaPalletPick(@NotBlank @Size(max=255) String idempotencyKey) {}
@@ -41,7 +41,15 @@ public final class DeliveryResponses {
public record DeliverySku(String inboundCode, String customerName, String destinationWarehouseCode,
String receivingAddress, String shipmentNo, String fbaTrackingNo,
String skuCode, String productName, int packageCount,
BigDecimal weightKg, BigDecimal volumeCbm) {}
BigDecimal weightKg, BigDecimal volumeCbm, Long productQuantity) {
public DeliverySku(String inboundCode, String customerName, String destinationWarehouseCode,
String receivingAddress, String shipmentNo, String fbaTrackingNo,
String skuCode, String productName, int packageCount,
BigDecimal weightKg, BigDecimal volumeCbm) {
this(inboundCode, customerName, destinationWarehouseCode, receivingAddress, shipmentNo,
fbaTrackingNo, skuCode, productName, packageCount, weightKg, volumeCbm, null);
}
}
public record DeliveryFeeRow(Long id, String feeDirection, String feeType, String currency,
BigDecimal amount, String remark, String reviewStatus, String sourceStatus, String voidStatus) {}
@@ -65,7 +73,7 @@ public final class DeliveryResponses {
String skuCode, String productName, long availablePackages,
BigDecimal grossWeightKg, BigDecimal volumeWeightKg, BigDecimal volumeCbm) {}
public record ReturnOutboundCandidate(String returnNo, String overseasWarehouseId,
String cartonMark, String fbaCartonMark,
String cartonMark, String palletCode, String fbaCartonMark,
String destinationWarehouseCode, String destinationWarehouseName,
long totalQuantity, String status) {}
public record WarehouseTransferOutboundCandidate(String customerId, String customerName,
@@ -78,8 +86,7 @@ public final class DeliveryResponses {
Long palletId, Long warehousePalletId, String palletNo,
int packageCount, BigDecimal grossWeightKg,
BigDecimal volumeCbm) {}
public record PdaTask(Long id, String taskNo, String status, String outboundNo, String outboundType,
LocalDateTime pushedAt, LocalDateTime completedAt) {}
public record PdaTask(Long id, String taskNo, String status, String outboundNo, LocalDateTime pushedAt, LocalDateTime completedAt, String outboundType) {}
public record PdaTaskResolution(String workflow, String taskNo, String outboundNo, String dispatchNo) {}
public record PdaPickPosition(Long inventoryPositionId, String palletNo, String cartonMark,
String skuCode, String inboundCode, long availablePackages,
@@ -1,9 +1,5 @@
package com.baiyue.lastleg.delivery.service;
import com.baiyue.lastleg.fleet.trip.FleetDispatchBindingGuard;
import com.baiyue.lastleg.fleet.trip.FleetDispatchGateway;
import com.baiyue.lastleg.fleet.trip.FleetBookingFreight;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
@@ -114,6 +110,7 @@ public class DeliveryTransferService {
@Resource private DropshipSkuInventoryMapper dropshipSkuInventory;
@Resource private ReturnInventoryCartonMapper returnCartons;
@Resource private com.baiyue.lastleg.returnreceipt.service.ReturnInventoryService returnStock;
@Resource private com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper returnCartonItems;
@Resource private InboundPlanPalletAllocationMapper planAllocations;
@Resource private InboundPlanPalletMapper inboundPlanPallets;
@Resource private WarehouseResourceGateway warehouseResources;
@@ -122,31 +119,11 @@ public class DeliveryTransferService {
@Resource private LastlegOrderMapper tailOrders;
@Resource private LastlegOrderStatusHistoryMapper histories;
@Resource private LastlegOrderDetailMapper tailOrderDetails;
@Resource private com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaPickOrderMapper dispatchPickOrders;
@Resource private RelabelApplicationMapper relabelApplications;
@Resource private RelabelItemMapper relabelItems;
@Resource private RelabelApplicationService relabelService;
@Resource private LastlegOrderNoGenerator orderNoGenerator;
@Resource private ObjectMapper objectMapper;
@Resource private FleetDispatchBindingGuard fleetBindings;
@Resource private FleetBookingFreight bookingFreight;
@Resource private DeliveryFeeCoverageService feeCoverage;
@Transactional(readOnly = true, isolation = org.springframework.transaction.annotation.Isolation.REPEATABLE_READ)
public FleetBookingFreight.Preview externalFreight(String dispatchNo) {
DispatchOrder dispatch = dispatchOrders.findGlobalByDispatchNo(trim(dispatchNo));
if (dispatch == null) throw notFound("Dispatch order not found");
return externalFreight(dispatch, dispatchItems.findByDispatchOrder(dispatch.getCustomerId(), dispatch.getId()));
}
private FleetBookingFreight.Preview externalFreight(DispatchOrder dispatch, List<DispatchItem> items) {
return bookingFreight.preview(dispatch.getId(), dispatch.getDispatchNo(), dispatch.getCustomerId(), items.stream()
.map(item -> new FleetDispatchGateway.SourceCargo(item.getInboundCode(),
item.getWarehousePalletId() == null ? null : item.getWarehousePalletId().toString(), item.getPalletNo(),
Objects.requireNonNullElse(item.getPackageCount(), 0), FleetDispatchGateway.destinationKey(item.getDestinationType(),
item.getDestinationWarehouseCode(), item.getFbaWarehouseCode(), item.getDeliveryAddress(), item.getPostalCode())))
.toList());
}
@Transactional
public DeliveryResponses.TransferResult platformTransfer(String dispatchNo, String operatorId,
@@ -159,8 +136,6 @@ public class DeliveryTransferService {
@Transactional
public DeliveryResponses.TransferResult merchantTransfer(String customerId, String dispatchNo, String operatorId,
DeliveryRequests.Transfer request) {
if (request.feeCoverage() != null && !request.feeCoverage().isEmpty())
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "原运费服务覆盖由平台确认");
DispatchOrder found = dispatchOrders.findByDispatchNo(customerId, dispatchNo);
if (found == null) throw notFound("Dispatch order not found");
DispatchOrder dispatch = dispatchOrders.lockById(customerId, found.getId());
@@ -171,20 +146,16 @@ public class DeliveryTransferService {
private DeliveryResponses.TransferResult transferResolved(DispatchOrder dispatch, String operatorId,
DeliveryRequests.Transfer request) {
String customerId = dispatch.getCustomerId();
String requestHash = transferRequestHash(request);
OutboundOrder replay = outboundOrders.findByIdempotency(customerId, request.idempotencyKey().trim());
if (replay != null) {
if (!dispatch.getId().equals(replay.getDispatchOrderId())) throw conflict("Idempotency key belongs to another dispatch");
return replayTransfer(replay, requestHash);
return result(replay, requireDeliveries(customerId, replay.getId()), true);
}
OutboundOrder existing = outboundOrders.lockByDispatch(customerId, dispatch.getId());
if (existing != null) return replayTransfer(existing, requestHash);
if (existing != null) return result(existing, requireDeliveries(customerId, existing.getId()), true);
if (Set.of("CANCELLED", "DEPARTED", "PARTIAL_DEPARTED", "COMPLETED").contains(dispatch.getStatus())) {
throw conflict("Dispatch cannot be transferred to delivery in its current status");
}
fleetBindings.requireMatchingAssignment(dispatch.getId(),
new FleetDispatchGateway.Assignment(dispatch.getVehiclePlate(), dispatch.getDriverName(), dispatch.getDriverPhone()),
new FleetDispatchGateway.Assignment(request.vehiclePlate(), request.driverName(), request.driverPhone()));
dispatchItems.bindReceivedPallets(customerId, dispatch.getId());
List<DispatchItem> items = dispatchItems.findByDispatchOrder(customerId, dispatch.getId());
if (items.isEmpty()) throw conflict("Dispatch has no pallets");
@@ -192,16 +163,7 @@ public class DeliveryTransferService {
throw conflict("All dispatch pallets must be received before transfer to delivery");
}
if (!StringUtils.hasText(dispatch.getOverseasWarehouseId())) throw conflict("Dispatch warehouse is required");
var freight = externalFreight(dispatch, items);
if ((!freight.quotes().isEmpty() || StringUtils.hasText(request.externalFreightHash()))
&& !Objects.equals(freight.snapshotHash(), request.externalFreightHash()))
throw conflict("外接实收或约定运费已变化,请重新打开转派送核对");
var quotedIncomingNos = freight.quotes().stream().map(FleetBookingFreight.Quote::incomingNo).collect(Collectors.toSet());
var externalItems = items.stream().filter(item -> quotedIncomingNos.contains(item.getInboundCode())).map(DispatchItem::getId).collect(Collectors.toSet());
var coverage = request.feeCoverage() == null || request.feeCoverage().isEmpty() ? DeliveryFeeCoverageService.Decision.none()
: feeCoverage.confirm(dispatch.getDispatchNo(), request, operatorId, externalItems);
validateTransferFees(request, freight.requiresDeliveryFee(), coverage.includedServices());
if (!freight.priorInboundCodes().isEmpty()) feeSources.requireNoActiveDeliveryReceivables(customerId, freight.priorInboundCodes());
validateTransferFees(request);
LocalDateTime now = LocalDateTime.now();
OutboundOrder outbound = new OutboundOrder();
@@ -212,15 +174,10 @@ public class DeliveryTransferService {
outbound.setLxCodes(resolveLxCodes(customerId, items));
outbound.setPackageCount(items.stream().map(DispatchItem::getPackageCount).filter(Objects::nonNull).mapToInt(Integer::intValue).sum());
outbound.setPalletCount(items.size());
outbound.setGrossWeightKg(sumKnownPhysicalWeight(items, DispatchItem::getGrossWeightKg));
outbound.setGrossWeightKg(sum(items, DispatchItem::getGrossWeightKg));
outbound.setVolumeWeightKg(sum(items, DispatchItem::getVolumeWeightKg));
outbound.setVolumeCbm(sum(items, DispatchItem::getVolumeCbm));
outbound.setIdempotencyKey(request.idempotencyKey().trim());
outbound.setRequestHash(requestHash);
if (coverage.evidence() != null) {
try { outbound.setFeeCoverageJson(objectMapper.writeValueAsString(coverage.evidence())); }
catch (JsonProcessingException exception) { throw new IllegalStateException("费用覆盖确认无法保存", exception); }
}
outbound.setCreatedBy(operatorId); outbound.setCreatedAt(now); outbound.setUpdatedAt(now);
try {
outboundOrders.insert(outbound);
@@ -229,11 +186,11 @@ public class DeliveryTransferService {
// the document created by the winning transaction rather than receive a false failure.
OutboundOrder concurrent = outboundOrders.findByIdempotency(customerId, request.idempotencyKey().trim());
if (concurrent != null && dispatch.getId().equals(concurrent.getDispatchOrderId())) {
return replayTransfer(concurrent, requestHash);
return result(concurrent, requireDeliveries(customerId, concurrent.getId()), true);
}
concurrent = outboundOrders.lockByDispatch(customerId, dispatch.getId());
if (concurrent != null) {
return replayTransfer(concurrent, requestHash);
return result(concurrent, requireDeliveries(customerId, concurrent.getId()), true);
}
throw conflict("Dispatch was concurrently transferred to delivery");
}
@@ -294,57 +251,25 @@ public class DeliveryTransferService {
delivery.setPackageCount(groupItems.stream().map(DispatchItem::getPackageCount).filter(Objects::nonNull).mapToInt(Integer::intValue).sum());
delivery.setPalletCount(groupItems.size());
delivery.setTotalVolumeCbm(sum(groupItems, DispatchItem::getVolumeCbm));
delivery.setTotalWeightKg(sumKnownPhysicalWeight(groupItems, DispatchItem::getGrossWeightKg)); delivery.setExpectedArrivalAt(dispatch.getEstimatedArrivalAt()); delivery.setRemark(trim(request.remark()));
delivery.setTotalWeightKg(sum(groupItems, DispatchItem::getGrossWeightKg)); delivery.setExpectedArrivalAt(dispatch.getEstimatedArrivalAt()); delivery.setRemark(trim(request.remark()));
delivery.setIdempotencyKey(childKey(request.idempotencyKey(), "DEST-" + groupIndex++)); delivery.setCreatedBy(operatorId);
delivery.setCreatedAt(now); delivery.setUpdatedAt(now); deliveryOrders.insert(delivery);
recordOperation(customerId, delivery.getId(), operatorId, "CREATE_DELIVERY", "按目的FBA仓生成派送单", now);
bindTailOrders(customerId, groupItems, outbound.getOutboundNo(), delivery.getDeliveryNo(), request.remark(), now);
deliveries.add(delivery);
}
for (var quote : freight.quotes()) {
var source = items.stream().filter(item -> quote.incomingNo().equals(item.getInboundCode())).findFirst().orElseThrow();
var destination = deliveries.stream().filter(item -> deliveryGroupKey(source).equals(item.getDeliveryGroupKey())).findFirst().orElseThrow();
recordOperation(customerId, destination.getId(), operatorId, "CONFIRM_EXTERNAL_FREIGHT",
"外接预订 " + quote.bookingNo() + ";实收确认版本 " + quote.recordVersion() + ";约定运费 "
+ quote.currency() + " " + quote.amount().toPlainString(), now);
// An explicitly agreed free shipment remains a commercial fact, not a fabricated zero fee.
if (quote.amount().signum() == 0) continue;
feeSources.publishApproved(operatorId, "FLEET:" + quote.bookingNo() + ":" + dispatch.getDispatchNo() + ":FREIGHT",
new OverseasFeeSourceDtos.SaveRequest(customerId, quote.customerName(),
"OVERSEAS_DELIVERY_FEE", "DELIVERY_ORDER", destination.getId(), destination.getDeliveryNo(),
null, null, quote.inboundCode(), null, null, null, parseLong(destination.getOverseasWarehouseId()),
"DELIVERY_FEE", "外接运费(" + quote.bookingNo() + ")", "RECEIVABLE", null, null, null,
null, null, null, null, null, LocalDate.now(), quote.currency(), quote.amount(), quote.quantity(),
quote.quantityUnit(), quote.unitPrice(), null, null));
}
DeliveryOrder feeDelivery = deliveries.get(0);
LastlegOrder firstOrder = firstTailOrder(customerId, items);
List<DeliveryRequests.FeeItem> feeInputs = request.fees() == null ? List.of() : request.fees();
var quotedIncoming = freight.quotes().stream().map(FleetBookingFreight.Quote::incomingNo).collect(Collectors.toSet());
for (DeliveryRequests.FeeItem input : feeInputs) {
String feeCode = input.feeType().trim().toUpperCase(Locale.ROOT);
var uncovered = coverage.uncovered(feeCode);
var uncoveredItemIds = uncovered.stream().map(com.baiyue.lastleg.delivery.dto.DeliveryFeeCoverageModels.Cargo::dispatchItemId).collect(Collectors.toSet());
var chargeItems = coverage.evidence() != null && "RECEIVABLE".equals(input.direction())
? items.stream().filter(item -> uncoveredItemIds.contains(item.getId())).toList()
: "RECEIVABLE".equals(input.direction()) && "DELIVERY_FEE".equals(feeCode) && freight.requiresDeliveryFee()
? items.stream().filter(item -> !quotedIncoming.contains(item.getInboundCode())).toList() : items;
var feeDestination = "DELIVERY_FEE".equalsIgnoreCase(input.feeType()) && !chargeItems.isEmpty()
? deliveries.stream().filter(item -> deliveryGroupKey(chargeItems.get(0)).equals(item.getDeliveryGroupKey())).findFirst().orElseThrow()
: feeDelivery;
LastlegOrder firstOrder = firstTailOrder(customerId, chargeItems);
if (coverage.evidence() != null && "RECEIVABLE".equals(input.direction())) {
var uncoveredOrderIds = uncovered.stream().map(com.baiyue.lastleg.delivery.dto.DeliveryFeeCoverageModels.Cargo::orderId).filter(Objects::nonNull).distinct().toList();
firstOrder = uncoveredOrderIds.size() == 1 ? tailOrders.selectById(uncoveredOrderIds.get(0)) : null;
if (firstOrder != null && !customerId.equals(firstOrder.getCustomerId())) throw conflict("新增费用来源客户不一致");
}
feeSources.publishApproved(operatorId, childKey(request.idempotencyKey(), input.direction() + ":" + feeCode),
new OverseasFeeSourceDtos.SaveRequest(customerId, feeDestination.getCustomerName(),
"OVERSEAS_DELIVERY_FEE", "DELIVERY_ORDER", feeDestination.getId(), feeDestination.getDeliveryNo(),
feeSources.publishApproved(operatorId, childKey(request.idempotencyKey(), input.feeType()),
new OverseasFeeSourceDtos.SaveRequest(customerId, feeDelivery.getCustomerName(),
"OVERSEAS_DELIVERY_FEE", "DELIVERY_ORDER", feeDelivery.getId(), feeDelivery.getDeliveryNo(),
firstOrder == null ? null : firstOrder.getId(), null,
firstOrder == null ? null : firstOrder.getInboundCode(), null,
firstOrder == null ? null : parseLong(firstOrder.getSourceContainerBookingId()),
firstOrder == null ? null : firstOrder.getContainerNo(), parseLong(feeDestination.getOverseasWarehouseId()),
feeCode, input.feeName().trim(), input.direction(), input.supplierId(),
firstOrder == null ? null : firstOrder.getContainerNo(), parseLong(feeDelivery.getOverseasWarehouseId()),
input.feeType().trim(), input.feeName().trim(), input.direction(), input.supplierId(),
input.supplierName(), input.supplierCategory(), input.settlementMode(), input.paidAt(),
input.paymentMethod(), input.paymentReferenceNo(), input.paymentVoucherFileIds(), LocalDate.now(),
input.currency(), input.amount(), input.chargeableQuantity(), input.quantityUnit(), input.unitPrice(),
@@ -353,45 +278,27 @@ public class DeliveryTransferService {
return result(outbound, deliveries, false);
}
private DeliveryResponses.TransferResult replayTransfer(OutboundOrder outbound, String requestHash) {
if (!requestHash.equals(outbound.getRequestHash()))
throw conflict("转派送请求与原已保存内容不一致,请查看原单据;费用调整须通过正式费用更正流程");
return result(outbound, requireDeliveries(outbound.getCustomerId(), outbound.getId()), true);
}
String transferRequestHash(DeliveryRequests.Transfer request) {
// A new command key cannot change an already transferred dispatch's business content.
com.fasterxml.jackson.databind.node.ObjectNode content = objectMapper.valueToTree(request);
content.remove("idempotencyKey");
if (request.feeCoverage() == null || request.feeCoverage().isEmpty()) {
content.remove("feeCoverage"); content.remove("feeCoverageCargoHash");
}
return hash(content);
}
private void validateTransferFees(DeliveryRequests.Transfer request, boolean requiresDeliveryFee, Set<String> covered) {
private void validateTransferFees(DeliveryRequests.Transfer request) {
List<DeliveryRequests.FeeItem> inputs = request.fees() == null ? List.of() : request.fees();
if (inputs.isEmpty()) throw conflict("Delivery fee is required");
Set<String> allowed = Set.of("DELIVERY_FEE", "LOADING_FEE", "APPOINTMENT_FEE");
Set<String> seen = new HashSet<>();
for (DeliveryRequests.FeeItem input : inputs) {
String feeType = input.feeType().trim().toUpperCase(Locale.ROOT);
if (!allowed.contains(feeType)) throw conflict("Unsupported delivery fee type: " + feeType);
if (!seen.add(input.direction() + ":" + feeType)) throw conflict("Duplicate delivery fee type: " + feeType);
if ("RECEIVABLE".equals(input.direction()) && covered.contains(feeType)) throw conflict("本次货物的该服务已由原费用覆盖,请勿重复登记应收");
if (!seen.add(feeType)) throw conflict("Duplicate delivery fee type: " + feeType);
if (!Set.of("CNY", "USD").contains(input.currency().trim().toUpperCase(Locale.ROOT)))
throw conflict("Delivery fees only support CNY or USD");
if (input.amount().signum() <= 0) throw conflict("Delivery fee amount must be greater than zero");
}
if (requiresDeliveryFee && !covered.contains("DELIVERY_FEE") && !seen.contains("RECEIVABLE:DELIVERY_FEE")) throw conflict("尚未有约定运费的货物须填写派送费");
if (!requiresDeliveryFee && inputs.stream().anyMatch(item -> "DELIVERY_FEE".equalsIgnoreCase(item.feeType().trim())
&& "RECEIVABLE".equals(item.direction()))) throw conflict("本排车全部货物已带入外接约定运费,请勿重复填写派送应收");
if (!seen.contains("DELIVERY_FEE")) throw conflict("Delivery fee is required");
if (Set.of("FLOOR", "MIXED").contains(request.loadingType().trim().toUpperCase(Locale.ROOT))
&& !covered.contains("LOADING_FEE") && !seen.contains("RECEIVABLE:LOADING_FEE")) {
&& !seen.contains("LOADING_FEE")) {
throw conflict("Loading fee is required for floor or mixed loading");
}
if ("BUY_APPOINTMENT".equalsIgnoreCase(trim(request.appointmentAccount()))) {
if (!StringUtils.hasText(request.sellerName())) throw conflict("Seller name is required when buying an appointment");
if (!covered.contains("APPOINTMENT_FEE") && !seen.contains("RECEIVABLE:APPOINTMENT_FEE")) throw conflict("Appointment fee is required when buying an appointment");
if (!seen.contains("APPOINTMENT_FEE")) throw conflict("Appointment fee is required when buying an appointment");
}
}
@@ -475,6 +382,10 @@ public class DeliveryTransferService {
}
}
String transferRequestHash(DeliveryRequests.Transfer request) {
return hash(request);
}
public List<DeliveryResponses.OneClickCandidate> oneClickCandidates(String customerId, String warehouseId) {
return dropshipProfiles.listOutboundCandidates(customerId,
StringUtils.hasText(warehouseId) ? warehouseId.trim() : null).stream()
@@ -486,45 +397,96 @@ public class DeliveryTransferService {
if (!StringUtils.hasText(warehouseId)) return List.of();
return returnCartons.findAvailable(required(customerId, "Customer is required"), warehouseId.trim()).stream()
.map(c -> new DeliveryResponses.ReturnOutboundCandidate(c.getSourceReturnNo(), c.getOverseasWarehouseId(),
c.getCartonMark(), c.getFbaCartonMark(), c.getDestinationWarehouseCode(), c.getDestinationWarehouseName(),
c.getCartonMark(), c.getPalletCode(), c.getFbaCartonMark(), c.getDestinationWarehouseCode(), c.getDestinationWarehouseName(),
value(c.getTotalQuantity()), c.getStatus())).toList();
}
/** Overseas gateway freight was removed; callers must provide delivery fees explicitly. */
@Transactional(readOnly = true)
public com.baiyue.lastleg.fleet.trip.FleetBookingFreight.Preview externalFreight(String dispatchNo) {
return new com.baiyue.lastleg.fleet.trip.FleetBookingFreight.Preview(
"", true, List.of(), List.of());
}
@Transactional
public DeliveryResponses.PdaTask createReturnOutbound(String customerId, String operatorId,
DeliveryRequests.ReturnOutbound request) {
String requestHash = hash(request);
OutboundOrder existing = outboundOrders.findByIdempotency(customerId, request.idempotencyKey().trim());
if (existing != null) return taskResult(existing, pdaTasks.lockByOutbound(customerId, existing.getId()));
if (existing != null) return replayReturnOutbound(customerId, existing, requestHash);
String warehouseId = request.overseasWarehouseId().trim();
List<String> marks = request.cartonMarks().stream().map(String::trim).filter(StringUtils::hasText).distinct().toList();
if (marks.size() != request.cartonMarks().size()) throw conflict("箱唛不能为空且不可重复");
LocalDateTime now = LocalDateTime.now();
List<ReturnInventoryCarton> locked = new ArrayList<>();
for (String mark : marks) {
for (String mark : marks.stream().sorted().toList()) {
ReturnInventoryCarton carton = returnCartons.lockMark(mark);
if (carton == null || !customerId.equals(carton.getCustomerId()) || !warehouseId.equals(carton.getOverseasWarehouseId())
|| !"AVAILABLE".equals(carton.getStatus()) || !Objects.equals(request.returnNo().trim(), carton.getSourceReturnNo())) {
|| !returnStock.matchesReturnSource(carton, request.returnNo())) {
throw conflict("所选退货箱唛不可出库或不属于该退货单:" + mark);
}
locked.add(carton);
}
existing = outboundOrders.findByIdempotency(customerId, request.idempotencyKey().trim());
if (existing != null) return replayReturnOutbound(customerId, existing, requestHash);
for (ReturnInventoryCarton carton : locked) {
if (!"AVAILABLE".equals(carton.getStatus())) throw conflict("退货箱已被其他作业占用:" + carton.getCartonMark());
var contents = returnCartonItems.lockCarton(carton.getId());
if (contents.isEmpty() || contents.stream().anyMatch(line -> value(line.getPackingReservedQuantity()) > 0))
throw conflict("退货箱缺少明细或已被合箱计划锁定:" + carton.getCartonMark());
if (StringUtils.hasText(request.skuCode()) && (contents.size() != 1
|| !request.skuCode().trim().equals(contents.get(0).getSkuCode())))
throw conflict("单SKU出库只支持指定SKU的散件库存");
if (StringUtils.hasText(request.skuCode()) && !"PARCEL".equals(carton.getSourceUnitType()))
throw conflict("已装箱库存请按箱唛出库");
if ("PALLET".equals(request.unitType()) && !StringUtils.hasText(carton.getPalletCode()))
throw conflict("整托出库必须存在托盘绑定");
}
if ("PALLET".equals(request.unitType())) {
String pallet = locked.get(0).getPalletCode();
if (locked.stream().anyMatch(c -> !Objects.equals(pallet, c.getPalletCode()))) throw conflict("整托出库只能选择同一托盘");
var all = returnCartons.listActivePallet(warehouseId, pallet);
if (all.size() != marks.size() || all.stream().anyMatch(c -> !customerId.equals(c.getCustomerId())
|| !marks.contains(c.getCartonMark()) || !"AVAILABLE".equals(c.getStatus())))
throw conflict("整托出库必须选择该托盘全部可用箱,不可遗漏或包含其他客户库存");
}
OutboundOrder outbound = new OutboundOrder();
outbound.setId(IdWorker.getId()); outbound.setCustomerId(customerId);
outbound.setOutboundNo(orderNoGenerator.next("RETURN_OUTBOUND", warehouseId, outbound.getId()));
outbound.setOutboundType("RETURN_OUTBOUND"); outbound.setStatus("WAIT_PICK");
outbound.setRecipientInfo(request.recipient().trim()); outbound.setPackageCount(marks.size()); outbound.setPalletCount(0);
outbound.setOverseasWarehouseId(warehouseId); outbound.setIdempotencyKey(request.idempotencyKey().trim());
outbound.setRequestHash(requestHash);
outbound.setCreatedBy(operatorId); outbound.setCreatedAt(now); outbound.setUpdatedAt(now); outboundOrders.insert(outbound);
for (ReturnInventoryCarton carton : locked) {
OutboundPalletItem item = new OutboundPalletItem(); item.setId(IdWorker.getId()); item.setCustomerId(customerId);
item.setOutboundOrderId(outbound.getId()); item.setPackageCount(1); item.setPickedPackageCount(0); item.setStatus("WAIT_PICK");
item.setCartonMarks(carton.getCartonMark()); item.setInboundCode(carton.getSourceReturnNo()); item.setCreatedAt(now); outboundPallets.insert(item);
item.setCartonMarks(carton.getCartonMark()); item.setInboundCode(carton.getSourceReturnNo());
item.setSkuCode(trim(request.skuCode()));
item.setPalletNo("PALLET".equals(request.unitType()) ? carton.getPalletCode() : null);
item.setCreatedAt(now); outboundPallets.insert(item);
carton.setStatus("OUTBOUND_PENDING"); carton.setUpdatedAt(now); returnCartons.updateById(carton);
}
DeliveryOrder delivery = new DeliveryOrder(); delivery.setId(IdWorker.getId()); delivery.setCustomerId(customerId);
delivery.setDeliveryNo(orderNoGenerator.next("DELIVERY", warehouseId, delivery.getId()));
delivery.setOutboundOrderId(outbound.getId()); delivery.setOutboundNo(outbound.getOutboundNo());
delivery.setOverseasWarehouseId(warehouseId); delivery.setDeliveryGroupKey("RETURN:" + request.returnNo().trim());
delivery.setStatus("WAIT_DELIVERY"); delivery.setDeliveryAddress(request.recipient().trim());
delivery.setPackageCount(marks.size()); delivery.setPalletCount("PALLET".equals(request.unitType()) ? 1 : 0);
delivery.setRemark(trim(request.remark())); delivery.setIdempotencyKey(childKey(request.idempotencyKey(), "RETURN-DELIVERY"));
delivery.setVersion(0L); delivery.setCreatedBy(operatorId); delivery.setCreatedAt(now); delivery.setUpdatedAt(now);
deliveryOrders.insert(delivery);
OutboundPdaTask task = new OutboundPdaTask(); task.setId(IdWorker.getId()); task.setCustomerId(customerId); task.setOutboundOrderId(outbound.getId());
task.setTaskNo("PDA" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + task.getId()); task.setStatus("PENDING"); task.setPushedAt(now); task.setCreatedAt(now); task.setUpdatedAt(now); pdaTasks.insert(task);
return taskResult(outbound, task);
}
private DeliveryResponses.PdaTask replayReturnOutbound(String customerId, OutboundOrder existing, String requestHash) {
if (!"RETURN_OUTBOUND".equals(existing.getOutboundType()) || !requestHash.equals(existing.getRequestHash()))
throw conflict("相同幂等键不能用于不同的退货出库请求");
return taskResult(existing, pdaTasks.lockByOutbound(customerId, existing.getId()));
}
@Transactional(readOnly = true)
public List<DeliveryResponses.WarehouseTransferOutboundCandidate> warehouseTransferOutboundCandidates(
String customerId, String warehouseId) {
@@ -744,11 +706,11 @@ public class DeliveryTransferService {
throw conflict("Idempotency key was reused with a different outbound pick request");
}
OutboundOrder replayOutbound = requireOutbound(task);
if ("RETURN_OUTBOUND".equals(replayOutbound.getOutboundType())) {
OutboundPalletItem replayItem = outboundPallets.selectById(replay.getOutboundPalletItemId());
requireReturnScan(replayOutbound, replayItem, request);
}
return taskResult(replayOutbound, task);
OutboundPalletItem replayItem = outboundPallets.selectById(request.outboundPalletItemId());
if ("RETURN_OUTBOUND".equals(replayOutbound.getOutboundType())
|| ("RELABEL_OUTBOUND".equals(replayOutbound.getOutboundType()) && replayItem != null && replayItem.getWarehousePalletId() == null))
requireReturnScan(replayOutbound, outboundPallets.selectById(request.outboundPalletItemId()), request);
return taskResult(requireOutbound(task), task);
}
OutboundOrder existingOutbound = requireOutbound(task);
OutboundOrder outbound = outboundOrders.lockByNo(customerId, existingOutbound.getOutboundNo());
@@ -761,16 +723,12 @@ public class DeliveryTransferService {
if (item == null || !customerId.equals(item.getCustomerId())
|| !task.getOutboundOrderId().equals(item.getOutboundOrderId())) throw notFound("Outbound pallet item not found");
if (request.packages() > item.getPackageCount() - value(item.getPickedPackageCount())) throw conflict("Picked quantity exceeds outbound pallet quantity");
if ("RETURN_OUTBOUND".equals(outbound.getOutboundType())) {
if ("RETURN_OUTBOUND".equals(outbound.getOutboundType())
|| ("RELABEL_OUTBOUND".equals(outbound.getOutboundType()) && item.getWarehousePalletId() == null)) {
requireReturnScan(outbound, item, request);
if (value(item.getPickedPackageCount()) > 0) throw conflict("退货箱唛已完成拣货");
ReturnInventoryCarton carton = returnCartons.lockMark(item.getCartonMarks());
requireReturnSource(outbound, item, carton);
if (!"AVAILABLE".equals(carton.getStatus())) throw conflict("退货箱唛不可出库:" + item.getCartonMarks());
long outboundPickId = IdWorker.getId();
returnStock.shipCarton(customerId, warehouseId, item.getInboundCode(), item.getCartonMarks(),
outbound.getOutboundNo(), outboundPickId);
OutboundPdaPick pick = new OutboundPdaPick(); pick.setId(outboundPickId); pick.setCustomerId(customerId); pick.setTaskId(task.getId()); pick.setOutboundPalletItemId(item.getId()); pick.setInventoryPositionId(null); pick.setPackages(request.packages()); pick.setIdempotencyKey(request.idempotencyKey().trim()); pick.setPickedAt(LocalDateTime.now()); pdaPicks.insert(pick);
if (value(item.getPickedPackageCount()) > 0) throw conflict("退货箱不可重复出库");
OutboundPdaPick pick = new OutboundPdaPick(); pick.setId(IdWorker.getId()); pick.setCustomerId(customerId); pick.setTaskId(task.getId()); pick.setOutboundPalletItemId(item.getId()); pick.setInventoryPositionId(null); pick.setPackages(request.packages()); pick.setIdempotencyKey(request.idempotencyKey().trim()); pick.setPickedAt(LocalDateTime.now()); pdaPicks.insert(pick);
returnStock.shipCarton(customerId, warehouseId, item.getInboundCode(), item.getCartonMarks(), outbound.getOutboundNo(), pick.getId());
item.setPickedPackageCount(request.packages().intValue()); item.setStatus("PICKED"); outboundPallets.updateById(item);
updateOutboundPickingStatus(customerId, outbound);
task.setStatus("IN_PROGRESS"); task.setUpdatedAt(LocalDateTime.now()); pdaTasks.updateById(task);
@@ -934,7 +892,7 @@ public class DeliveryTransferService {
if (outbound == null || !boundWarehouseId.equals(outbound.getOverseasWarehouseId())) {
throw notFound("Outbound order not found in the bound warehouse");
}
if (!Set.of("ONE_CLICK_DROPSHIP", "RELABEL_OUTBOUND", "WAREHOUSE_TRANSFER_OUTBOUND").contains(outbound.getOutboundType())) {
if (!Set.of("ONE_CLICK_DROPSHIP", "RELABEL_OUTBOUND", "WAREHOUSE_TRANSFER_OUTBOUND", "RETURN_OUTBOUND").contains(outbound.getOutboundType())) {
throw conflict("整托扫码仅适用于一件代发或换标出库");
}
if (!Set.of("PENDING", "IN_PROGRESS").contains(task.getStatus())) {
@@ -947,6 +905,17 @@ public class DeliveryTransferService {
return taskResult(outbound, task);
}
if ("RETURN_OUTBOUND".equals(outbound.getOutboundType())) {
DeliveryResponses.PdaTask result = taskResult(outbound, task);
for (OutboundPalletItem item : palletItems.stream().sorted(Comparator.comparing(OutboundPalletItem::getId)).toList()) {
if (value(item.getPickedPackageCount()) >= value(item.getPackageCount())) continue;
result = pdaPick(boundWarehouseId, operatorId, code,
new DeliveryRequests.PdaPick(item.getId(), null, 1L,
childKey(request.idempotencyKey(), "RETURN:" + item.getId()), List.of(item.getCartonMarks())));
}
return result;
}
List<PalletPickPlan> plans = new ArrayList<>();
int sequence = 0;
for (OutboundPalletItem item : palletItems) {
@@ -1052,6 +1021,10 @@ public class DeliveryTransferService {
+ value(position.getPickedPackages()) + value(position.getReceivingPackages()))
.sum();
if (remaining != 0) continue;
if (inventoryPositions.activePhysicalPallet(warehouseId, palletId) > 0) continue;
List<String> carrierCodes = items.stream().filter(item -> palletId.equals(item.getWarehousePalletId()))
.map(OutboundPalletItem::getPalletNo).filter(StringUtils::hasText).distinct().toList();
if (carrierCodes.stream().anyMatch(code -> !returnCartons.listActivePallet(warehouseId, code).isEmpty())) continue;
Long lockId = null;
WarehouseTransferPallet transferPallet = warehouseTransferPallets.selectOne(new LambdaQueryWrapper<WarehouseTransferPallet>()
.eq(WarehouseTransferPallet::getCustomerId, customerId)
@@ -1070,21 +1043,20 @@ public class DeliveryTransferService {
}
@Transactional
public void cancelOutbound(String outboundNo, String operatorId, DeliveryRequests.Cancel request) {
OutboundOrder observed = outboundOrders.findGlobalByNo(trim(outboundNo));
if (observed == null) throw notFound("Outbound order not found");
// Dispatch picking/departure owns the same dispatch -> outbound lock order.
DispatchOrder dispatch = observed.getDispatchOrderId() == null ? null
: dispatchOrders.lockById(observed.getCustomerId(), observed.getDispatchOrderId());
public void cancelOutbound(String outboundNo, DeliveryRequests.Cancel request) {
cancelOutboundResolved(outboundNo, "PLATFORM", request);
}
private void cancelOutboundResolved(String outboundNo, String operatorId, DeliveryRequests.Cancel request) {
required(request == null ? null : request.reason(), "取消原因不能为空");
// Confirmation locks the relabel application before its outbound document.
// Use the same order here to avoid cancellation/confirmation deadlocks.
OutboundOrder found = outboundOrders.findGlobalByNo(trim(outboundNo));
RelabelApplication application = found != null && "RELABEL_OUTBOUND".equals(found.getOutboundType())
? relabelApplications.lockByOutbound(found.getCustomerId(), found.getOutboundNo()) : null;
OutboundOrder outbound = outboundOrders.lockGlobalByNo(trim(outboundNo));
if (outbound == null) throw notFound("Outbound order not found");
if ("CANCELLED".equals(outbound.getStatus())) return;
if (outbound.getDispatchOrderId() != null) {
if (dispatch == null || !Objects.equals(dispatch.getId(), outbound.getDispatchOrderId())
|| !"PENDING_DEPARTURE".equals(dispatch.getStatus()) || value(dispatch.getPickedPackageCount()) > 0
|| value(dispatch.getShippedPackageCount()) > 0) throw conflict("排车已开始执行,不能直接取消出库");
fleetBindings.requireUnbound(dispatch.getId());
}
if (!Set.of("WAIT_OUTBOUND", "WAIT_PICK").contains(outbound.getStatus())) {
throw conflict("Only an unexecuted outbound order can be cancelled");
}
@@ -1098,23 +1070,35 @@ public class DeliveryTransferService {
if (existingDeliveries.stream().anyMatch(delivery -> !"WAIT_DELIVERY".equals(delivery.getStatus())))
throw conflict("Outbound order cannot be cancelled while delivery is in progress");
LocalDateTime now = LocalDateTime.now();
for (DeliveryOrder delivery : existingDeliveries) {
int voidRequests = 0;
for (var fee : feeSources.listForBusiness(customerId, "DELIVERY_ORDER", delivery.getId())) {
if ("RECEIVABLE".equals(fee.getDirection()) && "DELIVERY_FEE".equals(fee.getFeeItemCode())
&& "APPROVED".equals(fee.getReviewStatus()) && "ACTIVE".equals(fee.getSourceStatus())) {
feeSources.voidSource(fee.getSourceFeeId(), operatorId, new OverseasFeeSourceDtos.VoidRequest(
fee.getSourceVersion(), "取消出库 " + outboundNo + ";详见派送单取消原因",
"CANCEL_OUTBOUND:" + outbound.getId() + ":" + fee.getSourceFeeId()));
voidRequests++;
if ("RELABEL_OUTBOUND".equals(outbound.getOutboundType())) {
if (application == null || !customerId.equals(application.getCustomerId())
|| !outbound.getOutboundNo().equals(application.getOutboundOrderNo())
|| !RelabelApplicationService.READY_FOR_OUTBOUND.equals(application.getStatus()))
throw conflict("换标出库关联状态已变化,不能取消");
for (OutboundPalletItem item : outboundItems) {
if (item.getWarehousePalletId() != null) continue;
for (String mark : Objects.toString(item.getCartonMarks(), "").split(",")) {
ReturnInventoryCarton carton = returnCartons.lockMark(mark);
if (carton == null || !customerId.equals(carton.getCustomerId())
|| !Objects.equals(application.getId(), carton.getRelabelApplicationId())
|| !"OUTBOUND_PENDING".equals(carton.getStatus()))
throw conflict("退货换标箱预留状态异常,不能取消出库");
carton.setStatus("RELABEL_PENDING"); carton.setUpdatedAt(now);
returnCartons.updateById(carton);
}
}
recordOperation(customerId, delivery.getId(), operatorId, "CANCEL_OUTBOUND",
"出库取消;" + voidRequests + "笔未履行派送应收已申请撤销,其他费用保留待核对", now);
String reason = request.reason().trim();
for (int offset = 0; offset < reason.length(); offset += 480)
recordOperation(customerId, delivery.getId(), operatorId, "CANCEL_OUTBOUND_REASON",
"(" + (offset / 480 + 1) + ") " + reason.substring(offset, Math.min(offset + 480, reason.length())), now);
relabelService.changeStatus(customerId, application.getRelabelNo(), operatorId,
new RelabelRequests.ChangeStatus("CANCELLED", request.reason().trim()));
}
if ("RETURN_OUTBOUND".equals(outbound.getOutboundType())) {
for (String mark : outboundItems.stream().map(OutboundPalletItem::getCartonMarks).sorted().toList()) {
ReturnInventoryCarton carton = returnCartons.lockMark(mark);
if (carton == null || !customerId.equals(carton.getCustomerId()) || !"OUTBOUND_PENDING".equals(carton.getStatus()))
throw conflict("退货箱预留状态异常,不能取消出库");
carton.setStatus("AVAILABLE"); carton.setUpdatedAt(now); returnCartons.updateById(carton);
}
}
for (DeliveryOrder delivery : existingDeliveries) {
delivery.setStatus("CANCELLED"); delivery.setVersion(value(delivery.getVersion()) + 1);
delivery.setUpdatedAt(now); deliveryOrders.updateById(delivery);
}
@@ -1139,31 +1123,19 @@ public class DeliveryTransferService {
}
return;
}
if (!existingDeliveries.isEmpty()) {
var transferEvents = existingDeliveries.stream().map(delivery -> "TRANSFER_DELIVERY:" + delivery.getDeliveryNo()).toList();
for (LastlegOrder selected : tailOrders.selectList(new LambdaQueryWrapper<LastlegOrder>().eq(LastlegOrder::getCustomerId, customerId)
.eq(LastlegOrder::getOutboundOrderNo, outboundNo).orderByAsc(LastlegOrder::getId))) {
var order = tailOrders.lockById(customerId, selected.getId());
if (order == null || !outboundNo.equals(order.getOutboundOrderNo())) continue;
var previous = histories.selectOne(new LambdaQueryWrapper<LastlegOrderStatusHistory>()
.eq(LastlegOrderStatusHistory::getCustomerId, customerId).eq(LastlegOrderStatusHistory::getOrderId, order.getId())
.in(LastlegOrderStatusHistory::getEventId, transferEvents)
.orderByAsc(LastlegOrderStatusHistory::getCreatedAt, LastlegOrderStatusHistory::getId).last("LIMIT 1"));
if (previous == null || !StringUtils.hasText(previous.getFromStatus())) throw conflict("缺少本次转派送前的订单状态记录,请先核对来源");
String from = order.getCurrentStatus();
if (tailOrders.update(null, new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper<LastlegOrder>()
.eq(LastlegOrder::getId, order.getId()).eq(LastlegOrder::getCustomerId, customerId)
.eq(LastlegOrder::getOutboundOrderNo, outboundNo)
.set(LastlegOrder::getCurrentStatus, previous.getFromStatus())
.set(LastlegOrder::getSubOrderStatus, "OUTBOUND:CANCELLED;DELIVERY:CANCELLED")
.set(LastlegOrder::getOutboundOrderNo, null).set(LastlegOrder::getDeliveryOrderNo, null)
.set(LastlegOrder::getVersion, value(order.getVersion()) + 1).set(LastlegOrder::getUpdatedAt, now)) != 1)
throw conflict("订单关联已变化,请刷新后重试");
LastlegOrderStatusHistory history = new LastlegOrderStatusHistory(); history.setCustomerId(customerId); history.setOrderId(order.getId()); history.setFromStatus(from); history.setToStatus(previous.getFromStatus()); history.setEventId("CANCEL_OUTBOUND:" + outboundNo); history.setRemark(request.reason().trim()); history.setCreatedAt(now); histories.insert(history);
if (!existingDeliveries.isEmpty() && !"RELABEL_OUTBOUND".equals(outbound.getOutboundType())) {
for (LastlegOrder order : tailOrders.selectList(new LambdaQueryWrapper<LastlegOrder>().eq(LastlegOrder::getCustomerId, customerId).eq(LastlegOrder::getOutboundOrderNo, outboundNo))) {
String from = order.getCurrentStatus(); order.setCurrentStatus("RECEIVED"); order.setSubOrderStatus(null); order.setOutboundOrderNo(null); order.setDeliveryOrderNo(null); order.setUpdatedAt(now); tailOrders.updateById(order);
LastlegOrderStatusHistory history = new LastlegOrderStatusHistory(); history.setCustomerId(customerId); history.setOrderId(order.getId()); history.setFromStatus(from); history.setToStatus("RECEIVED"); history.setEventId("CANCEL_OUTBOUND:" + outboundNo); history.setRemark(request.reason().trim()); history.setCreatedAt(now); histories.insert(history);
}
}
}
@Transactional
public void cancelOutbound(String outboundNo, String operatorId, DeliveryRequests.Cancel request) {
cancelOutboundResolved(outboundNo, operatorId, request);
}
public List<DeliveryResponses.PdaTask> pdaTasks(String warehouseId, String status) {
String boundWarehouseId = required(warehouseId, "PDA warehouse binding is required");
return pdaTasks.listByWarehouse(boundWarehouseId, StringUtils.hasText(status) ? status.trim() : null).stream()
@@ -1180,7 +1152,8 @@ public class DeliveryTransferService {
task.getOutboundOrderId()).stream().map(item -> new DeliveryResponses.PdaTaskItem(item.getId(),
item.getPalletNo(), item.getSkuCode(), item.getInboundCode(), value(item.getPackageCount()),
value(item.getPickedPackageCount()), item.getStatus(),
"RETURN_OUTBOUND".equals(outbound.getOutboundType()) ? returnPickPositions(outbound, task, item)
("RETURN_OUTBOUND".equals(outbound.getOutboundType())
|| ("RELABEL_OUTBOUND".equals(outbound.getOutboundType()) && item.getWarehousePalletId() == null)) ? returnPickPositions(outbound, item)
: pdaPickPositions(task.getCustomerId(), boundWarehouseId, item))).toList();
return new DeliveryResponses.PdaTaskDetail(taskResult(outbound, task), items);
}
@@ -1219,28 +1192,21 @@ public class DeliveryTransferService {
if (item == null || !outbound.getId().equals(item.getOutboundOrderId())
|| !outbound.getCustomerId().equals(item.getCustomerId())) throw notFound("退货出库明细不存在");
if (request.inventoryPositionId() != null || !Long.valueOf(1).equals(request.packages()) || value(item.getPackageCount()) != 1)
throw conflict("退货出库须逐箱扫描,无需库存仓位");
if (request.cartonMarks() == null || request.cartonMarks().size() != 1 || request.cartonMarks().get(0) == null
|| !Objects.equals(item.getCartonMarks(), request.cartonMarks().get(0).trim()))
throw conflict("退货出库须逐箱或逐SKU扫描,无需库存仓位");
if (StringUtils.hasText(item.getSkuCode())) {
if (!Objects.equals(item.getSkuCode(), trim(request.skuCode()))) throw conflict("请扫描指定的退货SKU");
} else if (request.cartonMarks() == null || request.cartonMarks().size() != 1
|| !Objects.equals(item.getCartonMarks(), trim(request.cartonMarks().get(0))))
throw conflict("请扫描当前退货出库明细指定的箱唛");
}
private void requireReturnSource(OutboundOrder outbound, OutboundPalletItem item, ReturnInventoryCarton carton) {
private List<DeliveryResponses.PdaPickPosition> returnPickPositions(OutboundOrder outbound, OutboundPalletItem item) {
ReturnInventoryCarton carton = returnCartons.findMark(item.getCartonMarks());
if (carton == null || !outbound.getCustomerId().equals(carton.getCustomerId())
|| !outbound.getOverseasWarehouseId().equals(carton.getOverseasWarehouseId())
|| !Objects.equals(item.getInboundCode(), carton.getSourceReturnNo())
|| !Objects.equals(item.getCartonMarks(), carton.getCartonMark()))
throw conflict("退货箱唛与本次出库的客户、仓库或来源退货单不一致");
}
private List<DeliveryResponses.PdaPickPosition> returnPickPositions(OutboundOrder outbound, OutboundPdaTask task,
OutboundPalletItem item) {
ReturnInventoryCarton carton = returnCartons.findMark(item.getCartonMarks());
requireReturnSource(outbound, item, carton);
boolean pending = Set.of("PENDING", "IN_PROGRESS").contains(task.getStatus()) && value(item.getPickedPackageCount()) == 0;
long available = pending && "AVAILABLE".equals(carton.getStatus()) ? 1 : 0;
|| !returnStock.matchesReturnSource(carton, item.getInboundCode())) throw conflict("退货箱出库来源异常");
return List.of(new DeliveryResponses.PdaPickPosition(null, null, item.getCartonMarks(), item.getSkuCode(),
item.getInboundCode(), available, value(item.getPickedPackageCount())));
item.getInboundCode(), "OUTBOUND_PENDING".equals(carton.getStatus()) ? 1 : 0, value(item.getPickedPackageCount())));
}
private List<DeliveryResponses.PdaPickPosition> pdaPickPositions(String customerId, String warehouseId,
@@ -1286,7 +1252,7 @@ public class DeliveryTransferService {
&& Objects.equals(item.getInboundCode(), balance.getInboundCode());
}
private DeliveryResponses.PdaTask taskResult(OutboundOrder outbound, OutboundPdaTask task) { return new DeliveryResponses.PdaTask(task.getId(), task.getTaskNo(), task.getStatus(), outbound.getOutboundNo(), outbound.getOutboundType(), task.getPushedAt(), task.getCompletedAt()); }
private DeliveryResponses.PdaTask taskResult(OutboundOrder outbound, OutboundPdaTask task) { return new DeliveryResponses.PdaTask(task.getId(), task.getTaskNo(), task.getStatus(), outbound.getOutboundNo(), task.getPushedAt(), task.getCompletedAt(), outbound.getOutboundType()); }
private DeliveryResponses.PdaTaskResolution outboundResolution(OutboundOrder outbound, OutboundPdaTask task) {
return new DeliveryResponses.PdaTaskResolution("OUTBOUND", task.getTaskNo(), outbound.getOutboundNo(),
outbound.getDispatchNo());
@@ -1353,75 +1319,51 @@ public class DeliveryTransferService {
public DeliveryResponses.DeliveryDetail deliveryDetail(String customerId, String deliveryNo) {
DeliveryOrder delivery = deliveryOrders.findByNo(customerId, deliveryNo);
if (delivery == null) throw notFound("Delivery order not found");
OutboundOrder sourceOutbound = outboundOrders.selectById(delivery.getOutboundOrderId());
if (sourceOutbound != null && customerId.equals(sourceOutbound.getCustomerId())
&& ("RETURN_OUTBOUND".equals(sourceOutbound.getOutboundType())
|| ("RELABEL_OUTBOUND".equals(sourceOutbound.getOutboundType())
&& Objects.toString(delivery.getDeliveryGroupKey(), "").startsWith("RETURN_RELABEL:")))) {
List<DeliveryResponses.DeliverySku> cargo = new ArrayList<>();
for (OutboundPalletItem item : outboundPallets.findByOutbound(customerId, sourceOutbound.getId())) {
if ("RELABEL_OUTBOUND".equals(sourceOutbound.getOutboundType())
&& !Objects.equals(delivery.getDestinationWarehouseCode(), item.getDestinationWarehouseCode())) continue;
ReturnInventoryCarton carton = returnCartons.findCustomerMark(customerId, item.getCartonMarks());
if (carton == null) throw conflict("退货派送单来源箱记录缺失");
for (var line : returnCartonItems.listCarton(carton.getId()))
cargo.add(new DeliveryResponses.DeliverySku(item.getInboundCode(), delivery.getCustomerName(),
carton.getDestinationWarehouseCode(), delivery.getDeliveryAddress(), null, null,
line.getSkuCode(), line.getProductNameZh(), 1, null, null, line.getQuantity()));
}
return new DeliveryResponses.DeliveryDetail(deliveryRow(delivery), cargo,
feeSources.listForBusiness(customerId, "DELIVERY_ORDER", delivery.getId()).stream().map(this::feeRow).toList(),
deliveryOperations.findByDelivery(customerId, delivery.getId()).stream().map(this::operationRow).toList(), null);
}
List<DispatchItem> items = dispatchItems.findByDispatchOrder(customerId, delivery.getDispatchOrderId()).stream()
.filter(item -> delivery.getDeliveryGroupKey().equals(deliveryGroupKey(item))).toList();
var picked = dispatchPickOrders.deliveryPickedSkus(customerId, delivery.getDispatchOrderId()).stream()
.filter(row -> items.stream().anyMatch(item -> Objects.equals(item.getId(), row.dispatchItemId()))).toList();
Set<Long> sourceOrderIds = new LinkedHashSet<>();
items.stream().map(DispatchItem::getOrderId).filter(Objects::nonNull).forEach(sourceOrderIds::add);
picked.stream().map(com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaPickOrderMapper.DeliveryPickedSku::orderId)
.filter(Objects::nonNull).forEach(sourceOrderIds::add);
List<Long> orderIds = List.copyOf(sourceOrderIds);
List<Long> orderIds = items.stream().filter(item -> !"RETURN_RELABEL".equals(item.getSourceStage()))
.map(DispatchItem::getOrderId).filter(Objects::nonNull).distinct().toList();
Map<Long, LastlegOrder> ordersById = orderIds.isEmpty() ? Map.of() : tailOrders.selectBatchIds(orderIds).stream()
.filter(order -> customerId.equals(order.getCustomerId())).collect(java.util.stream.Collectors.toMap(LastlegOrder::getId, order -> order));
List<LastlegOrderDetail> details = orderIds.isEmpty() ? List.of() : tailOrderDetails.selectList(
new LambdaQueryWrapper<LastlegOrderDetail>().eq(LastlegOrderDetail::getCustomerId, customerId).in(LastlegOrderDetail::getOrderId, orderIds));
List<DeliveryResponses.DeliverySku> skus = deliveryCargoSkus(items, picked, ordersById, details);
return new DeliveryResponses.DeliveryDetail(deliveryRow(delivery), skus,
feeSources.listForBusiness(customerId, "DELIVERY_ORDER", delivery.getId()).stream().map(this::feeRow).toList(),
deliveryOperations.findByDelivery(customerId, delivery.getId()).stream().map(this::operationRow).toList(),
feeCoverageEvidence(customerId, delivery.getOutboundNo(), items));
}
private List<DeliveryResponses.DeliverySku> deliveryCargoSkus(List<DispatchItem> items,
List<com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaPickOrderMapper.DeliveryPickedSku> picked,
Map<Long, LastlegOrder> orders, List<LastlegOrderDetail> details) {
List<DeliveryResponses.DeliverySku> result = new ArrayList<>();
List<DeliveryResponses.DeliverySku> skus = new ArrayList<>(details.stream()
.map(detail -> sku(detail, ordersById.get(detail.getOrderId()))).toList());
for (DispatchItem item : items) {
var rows = picked.stream().filter(row -> Objects.equals(item.getId(), row.dispatchItemId())).toList();
long total = 0;
for (var row : rows) {
if (row.packages() == null || row.packages() <= 0) throw conflict("当前派送拣货数量无效");
total = Math.addExact(total, row.packages());
String skuCode = trim(row.skuCode());
if ("MIXED".equalsIgnoreCase(skuCode)) skuCode = null;
String currentSku = skuCode;
var matching = currentSku == null ? List.<LastlegOrderDetail>of() : details.stream()
.filter(detail -> Objects.equals(row.orderId(), detail.getOrderId())
&& currentSku.equals(detail.getSkuCode())).toList();
LastlegOrderDetail metadata = matching.size() == 1 ? matching.get(0) : null;
LastlegOrder order = row.orderId() == null ? null : orders.get(row.orderId());
result.add(new DeliveryResponses.DeliverySku(order == null ? item.getInboundCode() : order.getInboundCode(),
order == null ? null : order.getCustomerName(), firstText(item.getDestinationWarehouseCode(), item.getFbaWarehouseCode()),
item.getDeliveryAddress(), metadata == null ? null : metadata.getShipmentNo(),
metadata == null ? null : metadata.getFbaTrackingNo(), skuCode,
skuCode == null ? null : row.productName(), Math.toIntExact(row.packages()), null, null));
}
long snapshotPackages = value(item.getPackageCount());
if (total > snapshotPackages) throw conflict("当前派送拣货数量超过冻结排车数量");
if (total < snapshotPackages) {
// Before picking, only the selected dispatch quantity is known; no SKU allocation is invented.
LastlegOrder order = item.getOrderId() == null ? null : orders.get(item.getOrderId());
result.add(new DeliveryResponses.DeliverySku(item.getInboundCode(), order == null ? null : order.getCustomerName(),
firstText(item.getDestinationWarehouseCode(), item.getFbaWarehouseCode()), item.getDeliveryAddress(),
null, null, null, null, Math.toIntExact(snapshotPackages - total), null, null));
if (!"RETURN_RELABEL".equals(item.getSourceStage())) continue;
for (String mark : Objects.toString(item.getCartonMarks(), "").split(",")) {
ReturnInventoryCarton carton = returnCartons.findCustomerMark(customerId, mark);
if (carton == null || !Objects.equals(delivery.getOverseasWarehouseId(), carton.getOverseasWarehouseId()))
throw conflict("退货换标派送单来源箱记录缺失");
for (var line : returnCartonItems.listCarton(carton.getId()))
skus.add(new DeliveryResponses.DeliverySku(item.getInboundCode(), delivery.getCustomerName(),
item.getFbaWarehouseCode(), delivery.getDeliveryAddress(), null, null,
line.getSkuCode(), line.getProductNameZh(), 1, null, null, line.getQuantity()));
}
}
return List.copyOf(result);
}
private com.baiyue.lastleg.delivery.dto.DeliveryFeeCoverageModels.Evidence feeCoverageEvidence(String customerId, String outboundNo, List<DispatchItem> items) {
var outbound = outboundNo == null ? null : outboundOrders.findByNo(customerId, outboundNo);
if (outbound == null || outbound.getFeeCoverageJson() == null) return null;
try {
var evidence = objectMapper.readValue(outbound.getFeeCoverageJson(), com.baiyue.lastleg.delivery.dto.DeliveryFeeCoverageModels.Evidence.class);
var ids = items.stream().map(DispatchItem::getId).collect(Collectors.toSet());
var cargo = evidence.cargo().stream().filter(row -> ids.contains(row.dispatchItemId())).toList();
var inboundIds = cargo.stream().filter(row -> "FIRSTLEG".equals(row.sourceRegion()))
.map(com.baiyue.lastleg.delivery.dto.DeliveryFeeCoverageModels.Cargo::sourceInboundId).collect(Collectors.toSet());
var references = evidence.references().stream().filter(row -> inboundIds.contains(row.source().sourceInboundId().toString())).toList();
return new com.baiyue.lastleg.delivery.dto.DeliveryFeeCoverageModels.Evidence(evidence.cargoHash(), cargo, references, evidence.operatorId(), evidence.confirmedAt());
} catch (JsonProcessingException exception) { throw new IllegalStateException("费用覆盖确认无法读取", exception); }
return new DeliveryResponses.DeliveryDetail(deliveryRow(delivery), skus,
feeSources.listForBusiness(customerId, "DELIVERY_ORDER", delivery.getId()).stream().map(this::feeRow).toList(),
deliveryOperations.findByDelivery(customerId, delivery.getId()).stream().map(this::operationRow).toList(), null);
}
private void bindTailOrders(String customerId, List<DispatchItem> items, String outboundNo, String deliveryNo,
@@ -1483,6 +1425,7 @@ public class DeliveryTransferService {
allocateMetric(candidate.getSourceVolumeCbm(), candidate.getSourcePackageCount(), available, 4));
}
private DeliveryResponses.DeliveryRow deliveryRow(DeliveryOrder d) { return new DeliveryResponses.DeliveryRow(d.getId(), d.getCustomerId(), d.getCustomerName(), d.getDeliveryNo(), d.getOutboundNo(), d.getDispatchNo(), d.getOverseasWarehouseId(), d.getOverseasWarehouseName(), d.getStatus(), d.getWaybillNo(), d.getDeliveryMethod(), d.getCarrierName(), d.getPickupMethod(), d.getLoadingType(), d.getVehicleType(), d.getVehicleSize(), d.getVehiclePlate(), d.getDriverName(), d.getDriverPhone(), d.getAppointmentAccount(), d.getSellerName(), value(d.getPalletCount()), value(d.getPackageCount()), d.getSignedPackages(), d.getTotalWeightKg(), d.getTotalVolumeCbm(), d.getPlannedDeliveryAt(), d.getDispatchedAt(), d.getExpectedArrivalAt(), d.getSignedAt(), d.getRemark(), d.getCreatedAt(), value(d.getVersion()), DeliveryStatusPolicy.allowedActions(d.getStatus()), d.getBolNo(), DeliveryDocumentSnapshot.summary(d.getDocumentsJson()), d.getDestinationWarehouseId(), d.getDestinationWarehouseCode(), d.getDestinationWarehouseName(), d.getDestinationType(), d.getDeliveryAddress(), d.getContactName(), d.getContactPhone(), d.getPostalCode()); }
private DeliveryResponses.DeliverySku sku(LastlegOrderDetail d, LastlegOrder order) { return new DeliveryResponses.DeliverySku(order == null ? null : order.getInboundCode(), order == null ? null : order.getCustomerName(), d.getDestinationWarehouseCode(), null, d.getShipmentNo(), d.getFbaTrackingNo(), d.getSkuCode(), firstText(d.getProductNameZh(), d.getProductNameEn()), value(d.getPackageCount()), d.getGrossWeightKg(), d.getVolumeCbm()); }
private DeliveryResponses.DeliveryFeeRow feeRow(OverseasFeeSource f) { return new DeliveryResponses.DeliveryFeeRow(f.getId(), f.getDirection(), f.getFeeItemCode(), f.getOriginalCurrency(), f.getOriginalAmount(), f.getReviewReason(), f.getReviewStatus(), f.getSourceStatus(), f.getVoidStatus()); }
private DeliveryResponses.DeliveryOperationRow operationRow(DeliveryOperation o) { return new DeliveryResponses.DeliveryOperationRow(o.getId(), o.getCreatedAt(), o.getOperationNode(), firstText(o.getOperatorName(), o.getOperatorId()), o.getResult()); }
private void recordOperation(String customerId, Long deliveryId, String operatorId, String node, String result, LocalDateTime now) { DeliveryOperation operation = new DeliveryOperation(); operation.setCustomerId(customerId); operation.setDeliveryOrderId(deliveryId); operation.setOperationNode(node); operation.setOperatorId(operatorId); operation.setOperatorName(operatorId); operation.setResult(result); operation.setCreatedAt(now); deliveryOperations.insert(operation); }
@@ -1539,11 +1482,6 @@ public class DeliveryTransferService {
java.util.function.Function<T, BigDecimal> getter) {
return items.stream().map(getter).filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add);
}
private static <T> BigDecimal sumKnownPhysicalWeight(List<T> items,
java.util.function.Function<T, BigDecimal> getter) {
if (items.isEmpty() || items.stream().map(getter).anyMatch(weight -> weight == null || weight.signum() <= 0)) return null;
return items.stream().map(getter).reduce(BigDecimal.ZERO, BigDecimal::add);
}
private static BigDecimal allocateMetric(BigDecimal sourceValue, Long sourcePackages,
long selectedPackages, int scale) {
if (sourceValue == null || sourceValue.signum() == 0 || sourcePackages == null
@@ -77,10 +77,7 @@ public class CargoDiscardController {
private String scope(AuthenticatedUser user, String requested) {
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) {
if (requested == null || requested.isBlank() || "0".equals(requested)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Platform request must specify target tenant");
}
return requested.trim();
return requested == null || requested.isBlank() || "0".equals(requested) ? null : requested.trim();
}
return String.valueOf(user.tenantId());
}
@@ -14,7 +14,7 @@ public final class CargoDiscardResponses {
List<CandidateSku> skus) {}
public record CandidateSku(String skuCode, String productName, long availablePackages) {}
public record Summary(Long id, String discardNo, String inboundCodes,
public record Summary(Long id, String customerId, String discardNo, String inboundCodes,
String overseasWarehouseId, String overseasWarehouseName,
int totalPackages, BigDecimal totalFee, String currency, String status,
String createdBy, LocalDateTime createdAt, LocalDateTime reviewedAt) {}
@@ -51,7 +51,7 @@ import java.util.Set;
@Service
public class CargoDiscardService {
@Resource private com.baiyue.lastleg.service.LastlegOrderNoGenerator orderNoGenerator;
public static final String APPROVAL_PERMISSION = "lastleg:cargo-discard:approve";
public static final String APPROVAL_PERMISSION = "platform:overseas-warehouse:discard:approve";
public static final String PENDING_REVIEW = "PENDING_REVIEW";
public static final String APPROVED = "APPROVED";
public static final String REJECTED = "REJECTED";
@@ -171,36 +171,7 @@ public class CargoDiscardService {
application.setCreatedAt(now); application.setUpdatedAt(now);
applications.insert(application);
// A discard request is also a customer-visible tail-leg order. Keep the
// application as the workflow record and link the order through its
// immutable source id so cancellation/status changes stay traceable.
LastlegOrder tailOrder = new LastlegOrder();
tailOrder.setId(IdWorker.getId());
tailOrder.setOrderNo(application.getDiscardNo());
tailOrder.setCustomerId(customerId);
tailOrder.setOrderType("CARGO_DISCARD");
tailOrder.setInboundCode(application.getInboundCodes());
tailOrder.setInboundOrderNo(application.getDiscardNo());
tailOrder.setSourceRegion("LASTLEG_DISCARD");
tailOrder.setSourceInboundId("CARGO_DISCARD:" + application.getId());
tailOrder.setSourceOrderId(String.valueOf(application.getId()));
tailOrder.setSourceOrderNo(application.getDiscardNo());
tailOrder.setOverseasWarehouseId(warehouseId);
tailOrder.setOverseasWarehouseName(warehouseName);
tailOrder.setPackageCount(total);
tailOrder.setCurrentStatus(PENDING_REVIEW);
tailOrder.setVersion(0);
tailOrder.setCreatedAt(now);
tailOrder.setUpdatedAt(now);
tailOrder.setSourceEventId("CARGO_DISCARD:" + application.getId());
tailOrder.setSourceLoadingPlanId("CARGO_DISCARD");
tailOrder.setSourcePayloadHash(requestHash);
orders.insert(tailOrder);
LastlegOrderStatusHistory initialOrderHistory = new LastlegOrderStatusHistory();
initialOrderHistory.setId(IdWorker.getId()); initialOrderHistory.setCustomerId(customerId); initialOrderHistory.setOrderId(tailOrder.getId());
initialOrderHistory.setFromStatus(null); initialOrderHistory.setToStatus(PENDING_REVIEW);
initialOrderHistory.setEventId("CARGO_DISCARD_CREATE:" + application.getId()); initialOrderHistory.setRemark(trim(request.remark())); initialOrderHistory.setCreatedAt(now);
orderHistories.insert(initialOrderHistory);
// 废弃申请独立承载废弃流程,不创建尾程订单。
Set<Long> reservedReturnCartons = new LinkedHashSet<>();
for (CargoDiscardRequests.Item input : request.items()) {
@@ -218,7 +189,7 @@ public class CargoDiscardService {
public PageResult<CargoDiscardResponses.Summary> page(String customerId, CargoDiscardRequests.Query query) {
validateQuery(query);
LambdaQueryWrapper<CargoDiscardApplication> wrapper = new LambdaQueryWrapper<CargoDiscardApplication>()
.eq(CargoDiscardApplication::getCustomerId, customerId)
.eq(StringUtils.hasText(customerId), CargoDiscardApplication::getCustomerId, customerId)
.like(StringUtils.hasText(query.discardNo()), CargoDiscardApplication::getDiscardNo, query.discardNo())
.like(StringUtils.hasText(query.inboundCode()), CargoDiscardApplication::getInboundCodes, query.inboundCode())
.eq(StringUtils.hasText(query.overseasWarehouseId()), CargoDiscardApplication::getOverseasWarehouseId,
@@ -432,7 +403,7 @@ public class CargoDiscardService {
}
private CargoDiscardResponses.Summary summary(CargoDiscardApplication value) {
return new CargoDiscardResponses.Summary(value.getId(), value.getDiscardNo(), value.getInboundCodes(),
return new CargoDiscardResponses.Summary(value.getId(), value.getCustomerId(), value.getDiscardNo(), value.getInboundCodes(),
value.getOverseasWarehouseId(), value.getOverseasWarehouseName(), value.getTotalPackages(),
value.getTotalFee(), value.getCurrency(), value.getStatus(), value.getCreatedBy(), value.getCreatedAt(),
value.getReviewedAt());
@@ -258,6 +258,27 @@ public interface DispatchCandidateMapper {
@Param("orderNo") String orderNo,
@Param("relabelNo") String relabelNo);
@Select("<script>SELECT a.customer_id AS customerId,a.lastleg_order_id AS orderId,a.overseas_warehouse_id AS overseasWarehouseId," +
"MIN(ri.source_warehouse_name) AS overseasWarehouseName,a.relabel_no AS orderNo,a.relabel_no AS orderRefs," +
"GROUP_CONCAT(DISTINCT c.source_return_no ORDER BY c.source_return_no SEPARATOR ',') AS inboundCode," +
"c.pallet_code AS palletNo,COUNT(DISTINCT c.id) AS packageCount,'RELABEL' AS sourceStage,1 AS returnCartonSource," +
"GROUP_CONCAT(DISTINCT c.carton_mark ORDER BY c.carton_mark SEPARATOR ',') AS relabelCartonMarks," +
"GROUP_CONCAT(DISTINCT ri.destination_fba_warehouse_code ORDER BY ri.destination_fba_warehouse_code SEPARATOR ',') AS fbaWarehouseCode," +
"GROUP_CONCAT(DISTINCT ri.destination_fba_warehouse_code ORDER BY ri.destination_fba_warehouse_code SEPARATOR ',') AS destinationWarehouseCode " +
"FROM ll_relabel_application a JOIN ll_relabel_item ri ON ri.application_id=a.id AND ri.customer_id=a.customer_id " +
"JOIN ll_return_inventory_carton c ON c.id=ri.return_carton_id AND c.customer_id=a.customer_id " +
"AND c.relabel_application_id=a.id AND c.overseas_warehouse_id=a.overseas_warehouse_id " +
"WHERE a.delivery_method='TRUCK' AND a.status='READY_FOR_OUTBOUND' AND a.fulfillment_status='WAIT_PRE_DISPATCH' " +
"AND c.status='RELABEL_PENDING' AND c.pallet_code IS NOT NULL " +
"<if test='customerId != null and customerId != \"\"'>AND a.customer_id=#{customerId} </if>" +
"<if test='relabelNo != null and relabelNo != \"\"'>AND a.relabel_no=#{relabelNo} </if>" +
"<if test='orderNo != null and orderNo != \"\"'>AND a.relabel_no LIKE CONCAT('%',#{orderNo},'%') </if>" +
"AND NOT EXISTS(SELECT 1 FROM ll_dispatch_item di JOIN ll_dispatch_order d ON d.id=di.dispatch_order_id " +
"AND d.customer_id=di.customer_id WHERE di.customer_id=a.customer_id AND di.source_business_no=a.relabel_no AND d.status!='CANCELLED') " +
"GROUP BY a.id,a.customer_id,a.lastleg_order_id,a.overseas_warehouse_id,a.relabel_no,c.pallet_code</script>")
List<DispatchPalletCandidate> nativeReturnRelabelAvailable(@Param("customerId") String customerId,
@Param("orderNo") String orderNo, @Param("relabelNo") String relabelNo);
@org.apache.ibatis.annotations.Update("UPDATE ll_relabel_application SET fulfillment_status='PRE_DISPATCH_CREATED',updated_at=CURRENT_TIMESTAMP(3) " +
"WHERE customer_id=#{customerId} AND relabel_no=#{relabelNo} AND fulfillment_status='WAIT_PRE_DISPATCH'")
int markRelabelPreDispatched(@Param("customerId") String customerId, @Param("relabelNo") String relabelNo);
@@ -44,6 +44,7 @@ public class DispatchPalletCandidate {
private String contactPhone;
private Long returnReceiptId;
private String returnReceiptNo;
private boolean returnCartonSource;
@com.fasterxml.jackson.annotation.JsonProperty("sourceType")
public String sourceType() { return returnReceiptId == null ? "INBOUND_PLAN" : "RETURN_RECEIPT"; }
@@ -8,8 +8,12 @@ import jakarta.validation.constraints.Size;
public final class DispatchPdaRequests {
private DispatchPdaRequests() {}
public record Pick(@NotNull Long positionId, @Positive long packages, @PositiveOrZero long productQuantity,
@NotBlank String idempotencyKey, String remark) {}
public record Pick(Long positionId, @Positive long packages, @PositiveOrZero long productQuantity,
@NotBlank String idempotencyKey, String remark, @Size(max = 255) String cartonMark) {
public Pick(Long positionId, long packages, long productQuantity, String idempotencyKey, String remark) {
this(positionId, packages, productQuantity, idempotencyKey, remark, null);
}
}
public record Depart(@NotBlank String idempotencyKey, String remark) {}
public record CompletePicking(@NotBlank String idempotencyKey) {}
}
@@ -16,7 +16,15 @@ public final class DispatchPdaResponses {
public record Item(Long dispatchItemId, String palletNo, String destinationWarehouseCode,
String destinationWarehouseName, String fbaWarehouseCode, String warehouseRouteLabel,
int scheduledPackages,
long pickedPackages, List<Position> positions) {}
long pickedPackages, List<Position> positions, List<ReturnCarton> returnCartons) {
public Item(Long dispatchItemId, String palletNo, String destinationWarehouseCode,
String destinationWarehouseName, String fbaWarehouseCode, String warehouseRouteLabel,
int scheduledPackages, long pickedPackages, List<Position> positions) {
this(dispatchItemId, palletNo, destinationWarehouseCode, destinationWarehouseName, fbaWarehouseCode,
warehouseRouteLabel, scheduledPackages, pickedPackages, positions, List.of());
}
}
public record ReturnCarton(Long id, String cartonMark, String status, long productQuantity) {}
public record Position(Long id, Long balanceId, long availablePackages, long allocatedPackages,
long pickedPackages) {}
public record Picked(Long dispatchItemId, Long positionId, long packages, long pickedPackages,
@@ -15,6 +15,7 @@ public class DispatchPdaPick {
private Long taskId;
private Long dispatchItemId;
private Long inventoryPositionId;
private Long returnCartonId;
private Long packages;
private Long productQuantity;
private String idempotencyKey;
@@ -16,6 +16,7 @@ public class DispatchPdaPickOrder {
private Long pickId;
private Long receiptId;
private Long returnReceiptId;
private Long returnCartonId;
private Long orderId;
private Long packages;
private Long productQuantity;
@@ -54,6 +54,7 @@ public class DispatchPdaWorkflowService {
@Resource private WarehouseTransferOrderMapper warehouseTransfers;
@Resource private DispatchPickOrderAttributionService pickAttribution;
@Resource private ObjectMapper objectMapper;
@Resource private com.baiyue.lastleg.dispatch.service.ReturnRelabelDispatchStock returnRelabelStock;
@Transactional(readOnly = true)
public List<DispatchPdaResponses.Task> list(String warehouseId, String status) {
@@ -115,13 +116,23 @@ public class DispatchPdaWorkflowService {
throw notFound("Dispatch pallet item not found");
}
if (!warehouseId.equals(item.getOverseasWarehouseId())) throw conflict("Pallet does not belong to selected warehouse");
InventoryPosition position = positions.selectById(request.positionId());
if (position == null || !customerId.equals(position.getCustomerId())
|| item.getWarehousePalletId() == null || !item.getWarehousePalletId().equals(position.getPalletId())) {
boolean nativeReturn = "RETURN_RELABEL".equals(item.getSourceStage());
if (!nativeReturn && request.positionId() == null) throw conflict("普通入仓拣货必须选择库存仓位");
if (nativeReturn && request.positionId() != null) throw conflict("退货箱拣货请扫描来源箱唛,不使用普通库存仓位");
InventoryPosition position = nativeReturn ? null : positions.selectById(request.positionId());
if (!nativeReturn && (position == null || !customerId.equals(position.getCustomerId())
|| item.getWarehousePalletId() == null || !item.getWarehousePalletId().equals(position.getPalletId()))) {
throw conflict("Inventory position does not belong to this dispatch pallet");
}
long picked = picks.totalItem(customerId, task.getId(), itemId);
if (picked + request.packages() > item.getPackageCount()) throw conflict("Picked packages exceed dispatch pallet packages");
long pickId = IdWorker.getId();
Long returnCartonId = null;
if (nativeReturn) {
pickAttribution.prepareNativeReturn(customerId, item.getOrderId());
returnCartonId = returnRelabelStock.pick(item, request.cartonMark(), request.packages(), request.productQuantity(),
pickId, order.getDispatchNo()).getId();
} else {
pickAttribution.preparePicking(customerId, request.positionId(),
"RELABEL_ORDER".equals(item.getSourceBusinessType()) ? item.getOrderId() : null);
String keyBase = "DISPATCH:" + order.getDispatchNo() + ":" + itemId + ":" + request.idempotencyKey();
@@ -142,12 +153,14 @@ public class DispatchPdaWorkflowService {
inventory.ship(customerId, operatorId, new InventoryCommandRequests.Move(request.positionId(),
request.packages(), order.getDispatchNo(), order.getDispatchNo(), null,
keyBase + ":SHIP", request.remark()));
}
DispatchPdaPick pick = new DispatchPdaPick();
pick.setId(IdWorker.getId()); pick.setCustomerId(customerId); pick.setTaskId(task.getId());
pick.setId(pickId); pick.setCustomerId(customerId); pick.setTaskId(task.getId()); pick.setReturnCartonId(returnCartonId);
pick.setDispatchItemId(itemId); pick.setInventoryPositionId(request.positionId()); pick.setPackages(request.packages());
pick.setProductQuantity(request.productQuantity()); pick.setIdempotencyKey(request.idempotencyKey());
pick.setPickedAt(LocalDateTime.now()); pick.setCreatedAt(pick.getPickedAt()); picks.insert(pick);
pickAttribution.attribute(customerId, pick,
if (nativeReturn) pickAttribution.attributeNativeReturn(customerId, pick, item.getOrderId());
else pickAttribution.attribute(customerId, pick,
"RELABEL_ORDER".equals(item.getSourceBusinessType()) ? item.getOrderId() : null, item.getReturnReceiptId());
long total = picked + request.packages();
order.setPickedPackageCount((int) (n(order.getPickedPackageCount()) + request.packages()));
@@ -289,6 +302,13 @@ public class DispatchPdaWorkflowService {
private DispatchPdaResponses.Task task(String customerId, DispatchOrder order, DispatchPdaTask task, List<DispatchItem> source) {
List<DispatchPdaResponses.Item> values = new ArrayList<>();
for (DispatchItem item : source) {
if ("RETURN_RELABEL".equals(item.getSourceStage())) {
values.add(new DispatchPdaResponses.Item(item.getId(), item.getPalletNo(), item.getDestinationWarehouseCode(),
item.getDestinationWarehouseName(), item.getFbaWarehouseCode(),
warehouseRouteLabel(item.getFbaWarehouseCode(), item.getDestinationWarehouseCode()), item.getPackageCount(),
picks.totalItem(customerId, task.getId(), item.getId()), List.of(), returnRelabelStock.detail(item)));
continue;
}
List<InventoryPosition> palletPositions = item.getWarehousePalletId() == null ? List.of()
: positions.listPallet(customerId, item.getWarehousePalletId());
boolean relabel = "RELABEL_ORDER".equals(item.getSourceBusinessType());
@@ -162,6 +162,24 @@ public class DispatchPickOrderAttributionService {
}
public record OrderPackages(Long orderId, long packages) {}
/** Use the same order-before-stock lock order as ordinary dispatch picks. */
@org.springframework.transaction.annotation.Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY)
public void prepareNativeReturn(String customerId, Long orderId) {
LastlegOrder order = orderId == null ? null : orders.lockById(customerId, orderId);
if (order == null || Boolean.TRUE.equals(order.getDetained()))
throw conflict("退货换标履约订单缺失或暂扣");
}
/** Native return boxes have their own stock ledger, but fulfill this relabel tail order. */
public void attributeNativeReturn(String customerId, DispatchPdaPick pick, Long orderId) {
LastlegOrder order = orderId == null ? null : orders.lockById(customerId, orderId);
if (order == null || Boolean.TRUE.equals(order.getDetained())) throw conflict("退货换标履约订单缺失或暂扣");
DispatchPdaPickOrder row = new DispatchPdaPickOrder();
row.setId(IdWorker.getId()); row.setCustomerId(customerId); row.setPickId(pick.getId()); row.setOrderId(orderId);
row.setReturnCartonId(pick.getReturnCartonId());
row.setPackages(pick.getPackages()); row.setProductQuantity(pick.getProductQuantity()); row.setCreatedAt(pick.getPickedAt());
if (attributions.insert(row) != 1 || orders.increasePicked(customerId, orderId, pick.getPackages()) != 1)
throw conflict("退货换标排车拣货归属或箱数不一致");
}
private static long value(Long value) { return value == null ? 0 : value; }
private static long value(Integer value) { return value == null ? 0 : value; }
private static ResponseStatusException conflict(String message) {
@@ -78,6 +78,7 @@ public class DispatchService {
@Resource private com.baiyue.lastleg.fleet.trip.FleetDispatchWorkflowQuery fleetWorkflow;
@Resource private RelabelApplicationMapper relabelApplications;
@Resource private com.baiyue.lastleg.relabel.service.RelabelBindingGuard relabelBindingGuard;
@Resource private ReturnRelabelDispatchStock returnRelabelStock;
public String resolveCustomerScopeForPlans(List<Long> planIds) {
return requireSingleCustomerScope(candidates.findCustomerScopesByPlanIds(normalizeIds(planIds, "Plan IDs")),
@@ -122,7 +123,9 @@ public class DispatchService {
List<DispatchPalletCandidate> result = "RELABEL".equals(sourceStage) ? List.of() : candidates.available(null, null, trimToNull(orderNo), trimToNull(fbaWarehouseCode),
trimToNull(sourceStage), trimToNull(draftToken));
if (!StringUtils.hasText(sourceStage) || Set.of("RECEIVED", "RELABEL").contains(sourceStage)) {
List<DispatchPalletCandidate> relabel = candidates.relabelAvailable(null, trimToNull(orderNo), null);
List<DispatchPalletCandidate> relabel = java.util.stream.Stream.concat(
candidates.relabelAvailable(null, trimToNull(orderNo), null).stream(),
returnRelabelStock.candidates(null, trimToNull(orderNo), null).stream()).toList();
if (relabel != null && !relabel.isEmpty()) {
result = java.util.stream.Stream.concat(result.stream(), relabel.stream().filter(item ->
!StringUtils.hasText(fbaWarehouseCode) || Objects.toString(item.fbaWarehouseCode(), "")
@@ -567,15 +570,22 @@ public class DispatchService {
@Transactional
private DispatchResponse preArrangeRelabel(String customerId, String operatorId, DispatchRequests.PreArrange request) {
RelabelApplication application = relabelApplications.findNo(customerId, request.relabelNo());
RelabelApplication application = relabelApplications.lockNo(customerId, request.relabelNo());
if (application == null || !RelabelApplicationService.READY_FOR_OUTBOUND.equals(application.getStatus())) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "换标尚未完成或客户尚未确认,无法进行排车");
}
relabelBindingGuard.requireBound(application);
List<DispatchPalletCandidate> selected = candidates.relabelAvailable(customerId, null, request.relabelNo());
if (!"TRUCK".equals(application.getDeliveryMethod()) || !"WAIT_PRE_DISPATCH".equals(application.getFulfillmentStatus()))
throw new ResponseStatusException(HttpStatus.CONFLICT, "换标单不是待排车状态");
List<DispatchPalletCandidate> selected = java.util.stream.Stream.concat(
candidates.relabelAvailable(customerId, null, request.relabelNo()).stream(),
returnRelabelStock.candidates(customerId, null, request.relabelNo()).stream()).toList();
Set<Long> requested = request.planPalletIds().stream().map(Long::valueOf).collect(Collectors.toSet());
if (selected.stream().anyMatch(DispatchPalletCandidate::returnCartonSource)
&& !requested.equals(selected.stream().map(DispatchPalletCandidate::warehousePalletId).collect(Collectors.toSet())))
throw new ResponseStatusException(HttpStatus.CONFLICT, "退货换标排车须选择本申请全部已绑定托盘");
selected = selected.stream().filter(c -> requested.contains(c.warehousePalletId())).toList();
if (selected.isEmpty() || selected.size() != requested.size()) throw new ResponseStatusException(HttpStatus.CONFLICT, "换标订单托盘候选已变化,请刷新后重试");
if (selected.isEmpty() || selected.stream().map(DispatchPalletCandidate::warehousePalletId).distinct().count() != requested.size()) throw new ResponseStatusException(HttpStatus.CONFLICT, "换标订单托盘候选已变化,请刷新后重试");
String warehouse = selected.get(0).overseasWarehouseId();
if (selected.stream().anyMatch(c -> !Objects.equals(warehouse, c.overseasWarehouseId()))) throw new ResponseStatusException(HttpStatus.CONFLICT, "换标订单只能选择同一海外仓");
LocalDateTime now = LocalDateTime.now();
@@ -584,6 +594,12 @@ public class DispatchService {
dispatch.setRoute(candidateRoute(selected)); dispatch.setFbaAppointmentNo(trimToNull(request.fbaAppointmentNo())); dispatch.setFbaAppointmentStatus(defaultStatus(request.fbaAppointmentStatus())); dispatch.setVehicleAppointmentStatus(defaultStatus(request.vehicleAppointmentStatus())); dispatch.setStatus(PENDING_DEPARTURE); dispatch.setItemCount(selected.size()); dispatch.setPalletCount(selected.size()); dispatch.setPackageCount(selected.stream().map(DispatchPalletCandidate::packageCount).filter(Objects::nonNull).mapToInt(Integer::intValue).sum()); dispatch.setPickedPackageCount(0); dispatch.setShippedPackageCount(0); dispatch.setCreatedBy(trimToNull(operatorId)); dispatch.setCreatedAt(now); dispatch.setUpdatedAt(now); orders.insert(dispatch);
for (DispatchPalletCandidate candidate : selected) { DispatchItem item = new DispatchItem(); item.setId(IdWorker.getId()); item.setDispatchOrderId(dispatch.getId()); item.setCustomerId(customerId); item.setWarehousePalletId(candidate.warehousePalletId()); item.setOrderId(candidate.orderId()); item.setOverseasWarehouseId(candidate.overseasWarehouseId()); item.setOverseasWarehouseName(candidate.overseasWarehouseName()); item.setOrderNo(candidate.orderNo()); item.setOrderRefs(candidate.orderRefs()); item.setInboundCode(candidate.inboundCode()); item.setPalletNo(candidate.palletNo()); item.setDestinationWarehouseCode(candidate.destinationWarehouseCode()); item.setDestinationWarehouseName(candidate.destinationWarehouseName()); item.setFbaWarehouseCode(candidate.fbaWarehouseCode()); item.setPackageCount(Objects.requireNonNullElse(candidate.packageCount(), 0)); item.setGrossWeightKg(candidate.grossWeightKg()); item.setVolumeWeightKg(Objects.requireNonNullElse(candidate.volumeWeightKg(), BigDecimal.ZERO)); item.setVolumeCbm(Objects.requireNonNullElse(candidate.volumeCbm(), BigDecimal.ZERO)); item.setSourceStage("RELABEL"); item.setCartonMarks(candidate.relabelCartonMarks()); item.setSourceBusinessType("RELABEL_ORDER"); item.setSourceBusinessNo(request.relabelNo()); item.setCreatedAt(now); items.insert(item); }
DispatchPdaTask task = new DispatchPdaTask(); task.setId(IdWorker.getId()); task.setCustomerId(customerId); task.setDispatchOrderId(dispatch.getId()); task.setTaskNo("DPDA" + task.getId()); task.setStatus("PDA_PENDING"); task.setWarehouseId(warehouse); task.setPushedAt(now); task.setCreatedAt(now); task.setUpdatedAt(now); pdaTasks.insert(task);
for (DispatchItem item : items.findByDispatchOrder(customerId, dispatch.getId())) {
if (selected.stream().anyMatch(c -> c.returnCartonSource() && Objects.equals(c.warehousePalletId(), item.getWarehousePalletId())
&& Objects.equals(c.relabelCartonMarks(), item.getCartonMarks()))) {
item.setSourceStage("RETURN_RELABEL"); items.updateById(item); returnRelabelStock.reserve(item);
}
}
if (candidates.markRelabelPreDispatched(customerId, request.relabelNo()) != 1) throw new ResponseStatusException(HttpStatus.CONFLICT, "换标订单已被其他排车占用");
return response(dispatch, items.findByDispatchOrder(customerId, dispatch.getId()));
}
@@ -685,6 +701,8 @@ public class DispatchService {
requirePalletEditingAllowed(dispatch);
DispatchItem item = items.lockById(dispatch.getCustomerId(), dispatch.getId(), itemId);
if (item == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch pallet not found");
if ("RETURN_RELABEL".equals(item.getSourceStage()))
throw new ResponseStatusException(HttpStatus.CONFLICT, "退货换标按整单排车,请取消整张排车单后重新排车");
List<DispatchPalletLock> activeLocks = locks.selectList(new LambdaQueryWrapper<DispatchPalletLock>()
.eq(DispatchPalletLock::getCustomerId, dispatch.getCustomerId())
@@ -734,10 +752,17 @@ public class DispatchService {
throw new ResponseStatusException(HttpStatus.CONFLICT, "Only a pending dispatch can be cancelled");
}
String customerId = dispatch.getCustomerId();
if (Objects.requireNonNullElse(dispatch.getPickedPackageCount(), 0) > 0)
throw new ResponseStatusException(HttpStatus.CONFLICT, "排车已开始拣货,不能取消并释放已出库箱");
if (outboundOrders.findByDispatch(customerId, dispatch.getId()) != null)
throw new ResponseStatusException(HttpStatus.CONFLICT, "已生成出库单,请先取消对应出库单再取消排车");
LocalDateTime now = LocalDateTime.now();
dispatch.setStatus(CANCELLED);
dispatch.setUpdatedAt(now);
orders.updateById(dispatch);
for (DispatchItem item : items.findByDispatchOrder(customerId, dispatch.getId())) {
if ("RETURN_RELABEL".equals(item.getSourceStage())) returnRelabelStock.release(item);
}
items.findByDispatchOrder(customerId, dispatch.getId()).stream()
.filter(item -> "RELABEL_ORDER".equals(item.getSourceBusinessType()))
.map(DispatchItem::getSourceBusinessNo).filter(StringUtils::hasText).distinct()
@@ -0,0 +1,85 @@
package com.baiyue.lastleg.dispatch.service;
import com.baiyue.lastleg.dispatch.entity.DispatchItem;
import com.baiyue.lastleg.dispatch.mapper.DispatchCandidateMapper;
import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate;
import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaResponses;
import com.baiyue.lastleg.palletops.gateway.PalletPhysicalGateway;
import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper;
import com.baiyue.lastleg.returnreceipt.service.ReturnInventoryService;
import jakarta.annotation.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDateTime;
import java.util.*;
/** Native return stock stays in its own ledger; no synthetic InventoryPosition rows. */
@Service
public class ReturnRelabelDispatchStock {
@Resource private DispatchCandidateMapper candidates;
@Resource private ReturnInventoryCartonMapper cartons;
@Resource private PalletPhysicalGateway physical;
@Resource private ReturnInventoryService inventory;
public List<DispatchPalletCandidate> candidates(String customer, String orderNo, String relabelNo) {
List<DispatchPalletCandidate> result = new ArrayList<>();
for (var candidate : candidates.nativeReturnRelabelAvailable(customer, orderNo, relabelNo)) {
var pallet = physical.resolve(candidate.palletNo());
if (pallet == null || !String.valueOf(pallet.warehouseId()).equals(candidate.overseasWarehouseId())
|| !Set.of("IDLE", "OCCUPIED").contains(pallet.status())) continue;
candidate.warehousePalletId(pallet.palletId());
result.add(candidate);
}
return result;
}
public void reserve(DispatchItem item) {
for (String mark : marks(item).stream().sorted().toList()) {
var carton = require(item, mark, "RELABEL_PENDING");
carton.setStatus("OUTBOUND_PENDING"); carton.setUpdatedAt(LocalDateTime.now());
if (cartons.updateById(carton) != 1) throw conflict("退货箱排车预占失败");
}
}
public void release(DispatchItem item) {
for (String mark : marks(item).stream().sorted().toList()) {
var carton = require(item, mark, "OUTBOUND_PENDING");
carton.setStatus("RELABEL_PENDING"); carton.setUpdatedAt(LocalDateTime.now());
if (cartons.updateById(carton) != 1) throw conflict("退货箱排车取消释放失败");
}
}
public ReturnInventoryCarton pick(DispatchItem item, String mark, long packages, long quantity, long pickId, String dispatchNo) {
if (packages != 1 || !marks(item).contains(mark)) throw conflict("请扫描本排车的来源箱唛,每次拣货一箱");
var carton = require(item, mark, "OUTBOUND_PENDING");
if (quantity != carton.getTotalQuantity()) throw conflict("退货箱 SKU 总数量已变化,请刷新后重试");
inventory.shipCarton(item.getCustomerId(), item.getOverseasWarehouseId(), carton.getSourceReturnNo(),
carton.getCartonMark(), dispatchNo, pickId);
return carton;
}
public List<DispatchPdaResponses.ReturnCarton> detail(DispatchItem item) {
return marks(item).stream().map(mark -> {
var carton = cartons.findCustomerMark(item.getCustomerId(), mark);
if (carton == null || !item.getOverseasWarehouseId().equals(carton.getOverseasWarehouseId()))
throw conflict("排车来源退货箱缺失");
return new DispatchPdaResponses.ReturnCarton(carton.getId(), mark, carton.getStatus(), carton.getTotalQuantity());
}).toList();
}
private ReturnInventoryCarton require(DispatchItem item, String mark, String status) {
var carton = cartons.lockMark(mark);
if (carton == null || !item.getCustomerId().equals(carton.getCustomerId())
|| !item.getOverseasWarehouseId().equals(carton.getOverseasWarehouseId())
|| !Objects.equals(item.getPalletNo(), carton.getPalletCode())
|| !Objects.equals(item.getSourceBusinessNo(), carton.getRelabelNo()) || !status.equals(carton.getStatus()))
throw conflict("退货箱已被占用或来源、客户、仓库、托盘不一致");
return carton;
}
private Set<String> marks(DispatchItem item) {
return new LinkedHashSet<>(Arrays.asList(Objects.toString(item.getCartonMarks(), "").split(",")));
}
private ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
}
@@ -26,6 +26,12 @@ public interface InventoryPositionMapper extends BaseMapper<InventoryPosition> {
List<InventoryPosition> listPallet(@Param("customerId") String customerId,
@Param("palletId") Long palletId);
/** Physical carriers can contain stock owned by more than one customer. */
@Select("SELECT COUNT(*) FROM ll_inventory_position WHERE overseas_warehouse_id=#{warehouseId} " +
"AND pallet_id=#{palletId} AND (available_packages>0 OR hold_packages>0 OR allocated_packages>0 " +
"OR picked_packages>0 OR receiving_packages>0)")
long activePhysicalPallet(@Param("warehouseId") String warehouseId, @Param("palletId") Long palletId);
@Select("SELECT * FROM ll_inventory_position WHERE customer_id=#{customerId} AND balance_id=#{balanceId} " +
"AND pallet_id=#{palletId} AND batch_no=#{batchNo} LIMIT 1 FOR UPDATE")
InventoryPosition lockPosition(@Param("customerId") String customerId,
@@ -108,7 +108,8 @@ public class PdaPalletOperationService {
PalletPhysicalGateway.PalletReference pallet = physical.resolve(carton.getPalletCode());
if (pallet == null || pallet.warehouseId() != identity.overseasWarehouseId())
throw conflict("退货换标来源托盘不属于当前仓库");
long quantity = value(carton.getTotalQuantity());
long quantity = entry.getValue().stream().anyMatch(item -> "RETURN_BULK_SKU_LABEL".equals(item.getOperationType()))
? value(carton.getTotalQuantity()) : 1;
result.add(new PalletOperationResponses.RelabelSource(carton.getId(), 0L,
pallet.palletId(), pallet.palletCode(), null, carton.getSourceBatchNo(), quantity, quantity,
entry.getValue().stream().map(RelabelItem::getCartonMark).filter(Objects::nonNull).toList()));
@@ -159,11 +160,12 @@ public class PdaPalletOperationService {
String fbaValue = destination == null ? pallet.fbaWarehouseCode() : destination;
String fba = HtmlUtils.htmlEscape(fbaValue == null ? "" : fbaValue);
if (fba == null || fba.isBlank()) fba = "待绑定";
String html = "<!doctype html><html><head><meta charset=\"UTF-8\"><title>" + code
+ "</title><style>@page{size:100mm 70mm;margin:4mm}body{font-family:Arial,sans-serif;text-align:center;margin:0}"
+ "h1{font-size:24px;margin:8mm 0 4mm;letter-spacing:0}.code{font-size:18px;font-weight:700;word-break:break-all}"
+ ".ref{font-size:12px;margin-top:6mm}</style></head><body><h1>托盘标签</h1><div class=\"code\">"
+ code + "</div><div class=\"ref\">换标单:" + order + "<br>FBA目的仓:" + fba + "</div></body></html>";
String html = "<!doctype html><html><head><meta charset=\"UTF-8\"><title>托盘标签 " + code
+ "</title><style>@page{size:100mm 70mm;margin:0}*{box-sizing:border-box}body{width:100mm;height:70mm;margin:0;padding:6mm 8mm;display:flex;flex-direction:column;align-items:center;justify-content:center;font-family:Arial,'Microsoft YaHei',sans-serif;text-align:center;color:#111}"
+ ".code{max-width:84mm;font-size:14px;font-weight:700;line-height:1.15;overflow-wrap:anywhere}.ref{max-width:84mm;font-size:9px;line-height:1.35;margin-top:3mm;overflow-wrap:anywhere}</style></head><body><div class=\"code\">"
+ code + "</div><div style=\"width:76mm;margin-top:3mm\">"
+ com.baiyue.lastleg.returnreceipt.service.ReturnCartonBarcode.svg(pallet.palletCode())
+ "</div><div class=\"ref\">" + order + "<br>" + fba + "</div></body></html>";
return new PalletOperationResponses.PrintablePallet(pallet.palletId(), pallet.palletCode(), pallet.version(),
relabelNo, html);
}
@@ -335,7 +337,8 @@ public class PdaPalletOperationService {
String requestHash = hash(request);
PdaPalletOperation operation = begin(identity, "RELABEL_PALLET_DISASSEMBLE", key, requestHash);
if ("COMPLETED".equals(operation.getStatus())) return read(operation).asReplay();
physical.apply(identity.overseasWarehouseId(), target, "OCCUPIED", target.locationId(), key, "退货换标组托");
var transition = physical.apply(identity.overseasWarehouseId(), target, "OCCUPIED", target.locationId(), key, "退货换标组托");
try {
return transaction.execute(status -> {
for (int index = 0; index < cartons.size(); index++) {
RelabelItem item = selected.get(index);
@@ -345,7 +348,8 @@ public class PdaPalletOperationService {
|| !Objects.equals(carton.getRelabelApplicationId(), application.getId())
|| !"RELABEL_PENDING".equals(carton.getStatus()))
throw conflict("退货换标来源库存已变化,请刷新后重试");
long move = value(item.getNotifiedPackageCount());
long move = "RETURN_BULK_SKU_LABEL".equals(item.getOperationType())
? value(item.getNotifiedPackageCount()) : value(carton.getTotalQuantity());
if (move <= 0 || move > value(carton.getTotalQuantity())) throw conflict("来源托盘可拆数量不足");
if (move < value(carton.getTotalQuantity())) {
splitReturnRelabelCarton(application, item, carton, target, move);
@@ -367,6 +371,11 @@ public class PdaPalletOperationService {
operation.setUpdatedAt(result.completedAt()); mapper.updateById(operation);
return result;
});
} catch (RuntimeException failure) {
try { physical.compensate(identity.overseasWarehouseId(), transition); }
catch (RuntimeException compensationFailure) { failure.addSuppressed(compensationFailure); }
throw failure;
}
}
/** Keep the unlabelled remainder on its original pallet; move only the confirmed SKU pieces. */
@@ -100,6 +100,7 @@ public class RelabelApplicationService implements WalletResultHandler {
@Resource private OutboundOrderMapper outboundOrders;
@Resource private OutboundPalletItemMapper outboundItems;
@Resource private OutboundPdaTaskMapper outboundTasks;
@Resource private com.baiyue.lastleg.delivery.mapper.DeliveryOrderMapper deliveryOrders;
@Resource private OverseasFeeSourceService feeSources;
@Resource private WarehouseTransferOrderMapper warehouseTransfers;
@Resource private com.baiyue.lastleg.transfer.service.WarehouseTransferRelabelCompletionService transferRelabelCompletion;
@@ -264,6 +265,7 @@ public class RelabelApplicationService implements WalletResultHandler {
"INBOUND", null, null, List.of(), 0);
}).filter(candidate -> candidate.availablePackages() > 0).toList());
for (ReturnInventoryCarton carton : returnCartons.findAvailable(customerId, operationWarehouseId.trim())) {
if ("PARCEL".equals(carton.getSourceUnitType())) continue;
String keyword = trim(inboundCode);
if (StringUtils.hasText(keyword)
&& !containsIgnoreCase(carton.getCartonMark(), keyword)
@@ -390,6 +392,11 @@ public class RelabelApplicationService implements WalletResultHandler {
if (!"AVAILABLE".equals(carton.getStatus()) || carton.getRelabelApplicationId() != null) {
throw conflict("仓库唛头 " + carton.getCartonMark() + " 已不可用于换标");
}
if (returnCartonItems.lockCarton(carton.getId()).stream().anyMatch(line -> value(line.getPackingReservedQuantity()) > 0))
throw conflict("退货箱SKU已被合箱计划锁定");
if (Set.of("CARTON_LABEL", "CARTON_AND_SKU").contains(item.operationType())
&& (item.totalPackageCount() != 1 || item.notifiedPackageCount() != 1))
throw conflict("退货箱唛换标按真实箱计数,每个来源箱只能填写一箱");
long previouslyRelabelled = returnCartons.relabelledQuantity(carton.getId(), item.skuCode());
long requestedPackages = selectedReturnPackages.merge(carton.getId(),
(long) item.notifiedPackageCount(), Math::addExact);
@@ -910,8 +917,13 @@ public class RelabelApplicationService implements WalletResultHandler {
carton.setRelabelStatus("COMPLETED"); carton.setRelabelCompletedAt(now);
// 换标已完成后释放“当前申请占用”关系,但保留 COMPLETED 作为历史状态。
// 否则退货箱虽显示 AVAILABLE,下一次贴标申请仍会被旧 relabel_application_id 拦截。
carton.setRelabelApplicationId(null); carton.setRelabelNo(null);
carton.setStatus("AVAILABLE"); carton.setUpdatedAt(now); returnCartons.updateById(carton);
boolean awaitingTruck = requiresOutbound && "TRUCK".equals(application.getDeliveryMethod());
if (!StringUtils.hasText(application.getOutboundOrderNo()) && !awaitingTruck) {
carton.setRelabelApplicationId(null); carton.setRelabelNo(null);
}
carton.setStatus(StringUtils.hasText(application.getOutboundOrderNo()) ? "OUTBOUND_PENDING"
: awaitingTruck ? "RELABEL_PENDING" : "AVAILABLE");
carton.setUpdatedAt(now); returnCartons.updateById(carton);
}
insertHistory(customerId, application.getId(), before, application.getStatus(), operatorId,
request.remark(), now);
@@ -992,11 +1004,20 @@ public class RelabelApplicationService implements WalletResultHandler {
return;
}
List<RelabelItem> relabelItems = itemList(application.getId());
for (RelabelItem source : relabelItems) {
if (source.getReturnCartonId() == null) continue;
ReturnInventoryCarton carton = returnCartons.lockId(source.getReturnCartonId());
if (carton == null || !application.getCustomerId().equals(carton.getCustomerId())
|| !application.getId().equals(carton.getRelabelApplicationId())
|| !"RELABEL_PENDING".equals(carton.getStatus())
|| intValue(source.getNotifiedPackageCount()) != 1)
throw conflict("退货整箱换标出库必须覆盖该箱全部 SKU 数量:" + source.getCartonMark());
}
OutboundOrder outbound = new OutboundOrder();
outbound.setId(IdWorker.getId()); outbound.setCustomerId(application.getCustomerId());
outbound.setOutboundNo(orderNoGenerator.next("RELABEL_OUTBOUND", application.getOverseasWarehouseId(), outbound.getId()));
outbound.setOverseasWarehouseId(application.getOverseasWarehouseId()); outbound.setOutboundType("RELABEL_OUTBOUND");
outbound.setStatus("WAIT_PICK"); outbound.setPackageCount(relabelItems.stream().mapToInt(item -> intValue(item.getNotifiedPackageCount())).sum());
outbound.setStatus("WAIT_PICK"); outbound.setPackageCount(relabelItems.stream().mapToInt(item -> item.getReturnCartonId() == null ? intValue(item.getNotifiedPackageCount()) : 1).sum());
outbound.setPalletCount((int) relabelItems.stream().map(this::outboundSourceKey).distinct().count());
outbound.setRecipientInfo(application.getDeliveryMethod()); outbound.setIdempotencyKey(key);
outbound.setCreatedBy(operatorId); outbound.setCreatedAt(now); outbound.setUpdatedAt(now); outboundOrders.insert(outbound);
@@ -1012,9 +1033,13 @@ public class RelabelApplicationService implements WalletResultHandler {
item.setCustomerId(application.getCustomerId()); item.setOutboundOrderId(outbound.getId());
item.setWarehousePalletId(position == null ? null : position.getPalletId());
item.setPalletNo(position == null ? null : position.getPalletNo());
item.setPackageCount(group.stream().mapToInt(value -> intValue(value.getNotifiedPackageCount())).sum());
item.setPackageCount(group.stream().mapToInt(value -> value.getReturnCartonId() == null ? intValue(value.getNotifiedPackageCount()) : 1).sum());
item.setPickedPackageCount(0); item.setStatus("WAIT_PICK");
item.setCartonMarks(group.stream().map(RelabelItem::getCartonMark).filter(StringUtils::hasText).distinct().collect(Collectors.joining(",")));
// Return-carton relabel picking must scan the original warehouse carton mark;
// the requested/new carton mark is retained in the relabel snapshot only.
item.setCartonMarks(group.stream()
.map(value -> value.getReturnCartonId() == null ? value.getCartonMark() : value.getSourceCartonMark())
.filter(StringUtils::hasText).distinct().collect(Collectors.joining(",")));
item.setDestinationWarehouseCode(group.stream().map(RelabelItem::getDestinationFbaWarehouseCode)
.filter(StringUtils::hasText).distinct().collect(Collectors.joining(",")));
item.setInboundCode(group.stream().map(RelabelItem::getSourceInboundCode).filter(StringUtils::hasText).distinct().collect(Collectors.joining(",")));
@@ -1024,6 +1049,27 @@ public class RelabelApplicationService implements WalletResultHandler {
OutboundPdaTask task = new OutboundPdaTask(); task.setId(IdWorker.getId()); task.setCustomerId(application.getCustomerId());
task.setOutboundOrderId(outbound.getId()); task.setTaskNo("PDA" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + task.getId());
task.setStatus("PENDING"); task.setPushedAt(now); task.setCreatedAt(now); task.setUpdatedAt(now); outboundTasks.insert(task);
if (relabelItems.stream().allMatch(item -> item.getReturnCartonId() != null)) {
Map<String, List<RelabelItem>> destinations = relabelItems.stream().collect(Collectors.groupingBy(
item -> Objects.toString(item.getDestinationFbaWarehouseCode(), ""), LinkedHashMap::new, Collectors.toList()));
int index = 0;
for (var destination : destinations.entrySet()) {
var delivery = new com.baiyue.lastleg.delivery.entity.DeliveryOrder();
delivery.setId(IdWorker.getId()); delivery.setCustomerId(application.getCustomerId());
delivery.setDeliveryNo(orderNoGenerator.next("DELIVERY", application.getOverseasWarehouseId(), delivery.getId()));
delivery.setOutboundOrderId(outbound.getId()); delivery.setOutboundNo(outbound.getOutboundNo());
delivery.setOverseasWarehouseId(application.getOverseasWarehouseId());
delivery.setDeliveryGroupKey("RETURN_RELABEL:" + destination.getKey());
delivery.setDestinationWarehouseCode(destination.getKey());
delivery.setDestinationType(StringUtils.hasText(destination.getKey()) ? "FBA" : null);
delivery.setContactName(application.getContactName()); delivery.setContactPhone(application.getContactPhone());
delivery.setDeliveryMethod(application.getDeliveryMethod()); delivery.setStatus("WAIT_DELIVERY");
delivery.setPackageCount(destination.getValue().size()); delivery.setPalletCount(0);
delivery.setIdempotencyKey(key + ":DELIVERY:" + index++); delivery.setVersion(0L);
delivery.setCreatedBy(operatorId); delivery.setCreatedAt(now); delivery.setUpdatedAt(now);
deliveryOrders.insert(delivery);
}
}
application.setOutboundOrderNo(outbound.getOutboundNo());
}
@@ -1071,6 +1117,7 @@ public class RelabelApplicationService implements WalletResultHandler {
ReturnInventoryCarton carton = sourceInventory.returnCarton();
item.setReturnCartonId(carton.getId()); item.setSourceCartonMark(carton.getCartonMark());
item.setSourceInboundOrderNo(carton.getSourceReturnNo());
item.setSourceInboundCode(carton.getSourceReturnNo());
item.setSourceWarehouseId(carton.getOverseasWarehouseId());
}
item.setCreatedAt(now); items.insert(item);
@@ -44,6 +44,14 @@ public class RelabelBindingGuard {
} catch (Exception e) { throw conflict("组托记录无法读取,请核对后重试"); }
}
for (RelabelItem item : cartons) {
if (item.getReturnCartonId() != null && !"RETURN_BULK_SKU_LABEL".equals(item.getOperationType())) {
var carton = returnCartons.selectById(item.getReturnCartonId());
if (carton == null || !app.getCustomerId().equals(carton.getCustomerId())) throw conflict("退货箱来源缺失");
if (!"TRUCK".equals(app.getDeliveryMethod())) continue;
if (!bindings.getOrDefault(item.getCartonMark(), Set.of()).contains(carton.getPalletCode()))
throw conflict("退货箱尚未完成排车托盘绑定");
continue;
}
if ("RETURN_BULK_SKU_LABEL".equals(item.getOperationType())) {
var carton = item.getReturnCartonId() == null ? null : returnCartons.selectById(item.getReturnCartonId());
if (carton == null || !bindings.getOrDefault(item.getCartonMark(), Set.of()).contains(carton.getPalletCode())
@@ -0,0 +1,38 @@
package com.baiyue.lastleg.returnreceipt.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.returnreceipt.dto.ReturnInventoryRequests;
import com.baiyue.lastleg.returnreceipt.dto.ReturnInventoryResponses;
import com.baiyue.lastleg.returnreceipt.service.ReturnInventoryService;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
@RestController
@RequestMapping("/api/merchant/v1/last-leg/return-packing-plans")
public class MerchantReturnPackingPlanController {
@Resource private ReturnInventoryService service;
@GetMapping
@PreAuthorize("hasAuthority('merchant:overseas-warehouse:return-inventory:list')")
public ApiResponse<PageResult<ReturnInventoryResponses.PackingPlan>> list(@AuthenticationPrincipal AuthenticatedUser user, @Valid ReturnInventoryRequests.PackingPlanQuery request) {
return ApiResponse.success(service.customerPackingPlans(customer(user), request));
}
@PostMapping
@PreAuthorize("hasAuthority('merchant:overseas-warehouse:return-inventory:list')")
public ApiResponse<ReturnInventoryResponses.PackingPlan> create(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody ReturnInventoryRequests.PackingPlanCreate request) {
return ApiResponse.success(service.createPackingPlan(user == null ? "merchant" : user.username(), customer(user), request));
}
private String customer(AuthenticatedUser user) {
if (user == null || user.tenantId() == null || "PLATFORM".equals(user.appCode()) || user.tenantId() == 0L) throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Merchant identity is required");
return String.valueOf(user.tenantId());
}
}
@@ -35,9 +35,47 @@ public class PdaReturnInventoryController {
return ApiResponse.success(service.merge(PdaWarehouseIdentity.require(user), request));
}
@PostMapping("/cartons/pack")
public ApiResponse<ReturnInventoryResponses.PackingPlanCompletion> pack(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody ReturnInventoryRequests.PackingExecution request) {
return ApiResponse.success(service.pack(PdaWarehouseIdentity.require(user), request));
}
@GetMapping("/cartons/{cartonMark}")
public ApiResponse<ReturnInventoryResponses.CartonDetail> scanCarton(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String cartonMark) {
return ApiResponse.success(service.scanCarton(PdaWarehouseIdentity.require(user), cartonMark));
}
@PostMapping("/cartons/merge-boxes")
public ApiResponse<ReturnInventoryResponses.MergeResult> mergeBoxes(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody ReturnInventoryRequests.BoxMerge request) {
return ApiResponse.success(service.mergeBoxes(PdaWarehouseIdentity.require(user), request));
}
@GetMapping("/cartons/{cartonMark}/label")
public ApiResponse<ReturnInventoryResponses.Label> label(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String cartonMark) {
return ApiResponse.success(service.label(PdaWarehouseIdentity.require(user), cartonMark));
}
@PostMapping("/cartons/{cartonMark}/putaway")
public ApiResponse<ReturnInventoryResponses.PutawayResult> putaway(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String cartonMark, @Valid @RequestBody ReturnInventoryRequests.Putaway request) {
return ApiResponse.success(service.putaway(PdaWarehouseIdentity.require(user), cartonMark, request));
}
@GetMapping("/packing/plans")
public ApiResponse<java.util.List<ReturnInventoryResponses.PackingPlan>> plans(@AuthenticationPrincipal AuthenticatedUser user) {
return ApiResponse.success(service.listPackingPlans(PdaWarehouseIdentity.require(user)));
}
@GetMapping("/packing/plans/{planNo}")
public ApiResponse<ReturnInventoryResponses.PackingPlan> plan(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String planNo) {
return ApiResponse.success(service.packingPlan(PdaWarehouseIdentity.require(user), planNo));
}
@PostMapping("/packing/plans/{planNo}/complete")
public ApiResponse<ReturnInventoryResponses.PackingPlanCompletion> completePlan(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String planNo, @Valid @RequestBody ReturnInventoryRequests.PackingPlanComplete request) {
return ApiResponse.success(service.completePackingPlan(PdaWarehouseIdentity.require(user), planNo, request));
}
}
@@ -77,7 +77,7 @@ public class PdaReturnReceiptController {
String code = org.springframework.web.util.HtmlUtils.htmlEscape(pallet.palletCode());
String returnNo = org.springframework.web.util.HtmlUtils.htmlEscape(request.returnNo());
String sku = org.springframework.web.util.HtmlUtils.htmlEscape(request.skuCode());
String html = "<!doctype html><html><head><meta charset=\"UTF-8\"><title>" + code + "</title><style>@page{size:100mm 70mm;margin:4mm}body{font-family:Arial,sans-serif;text-align:center;margin:0}h1{font-size:24px;margin:7mm 0 3mm}.code{font-size:17px;font-weight:700;word-break:break-all}.ref{font-size:11px;margin-top:4mm}</style></head><body><h1>退货托盘标签</h1><div class=\"code\">" + code + "</div><div class=\"ref\">退货单:" + returnNo + "<br>SKU:" + sku + "</div></body></html>";
String html = "<!doctype html><html><head><meta charset=\"UTF-8\"><title>" + code + "</title><style>@page{size:100mm 70mm;margin:0}*{box-sizing:border-box}body{width:100mm;height:70mm;margin:0;padding:6mm 8mm;display:flex;flex-direction:column;align-items:center;justify-content:center;font-family:Arial,'Microsoft YaHei',sans-serif;text-align:center;color:#111}.code{max-width:84mm;font-size:14px;font-weight:700;line-height:1.15;overflow-wrap:anywhere}.ref{max-width:84mm;margin-top:3mm;font-size:9px;line-height:1.35;overflow-wrap:anywhere}</style></head><body><div class=\"code\">" + code + "</div><div class=\"ref\">" + returnNo + "<br>" + sku + "</div></body></html>";
return ApiResponse.success(new Pallet(pallet.palletCode(), pallet.palletId(), pallet.status(), html));
}
@PostMapping("/pallets/check") public ApiResponse<PdaReturnReceiptResponses.PalletCheck> checkPallet(@AuthenticationPrincipal AuthenticatedUser user,
@@ -36,6 +36,12 @@ public class PlatformReturnInventoryController {
return ApiResponse.success(service.platformMovementPage(request));
}
@GetMapping("/packing-sources")
@PreAuthorize("hasAuthority('platform:overseas-warehouse:return-inventory:list') and hasAuthority('platform:overseas-warehouse:return-cartons:list')")
public ApiResponse<PageResult<ReturnInventoryResponses.PackingSource>> packingSources(@Valid ReturnInventoryRequests.PlatformQuery request) {
return ApiResponse.success(service.platformPackingSourcePage(request));
}
@GetMapping("/cartons/{cartonMark}")
@PreAuthorize("hasAuthority('platform:overseas-warehouse:return-cartons:list')")
public ApiResponse<ReturnInventoryResponses.CartonDetail> carton(@PathVariable String cartonMark) {
@@ -14,6 +14,20 @@ import org.springframework.web.bind.annotation.*;
@PreAuthorize("hasAuthority('platform:overseas-warehouse:return-inventory:list')")
public class PlatformReturnOrderController {
@Resource private ReturnReceiptService service;
@Resource private com.baiyue.lastleg.returnreceipt.service.ReturnInventoryService inventory;
@PostMapping("/{returnNo}/receipts/{receiptNo}/repair-cartons")
@PreAuthorize("hasAuthority('platform:overseas-warehouse:return-inventory:list') and hasAuthority('lastleg:inventory:manage')")
public ApiResponse<java.util.List<ReturnOrderResponses.CartonLabel>> repairCartons(
@org.springframework.security.core.annotation.AuthenticationPrincipal com.baiyue.common.security.AuthenticatedUser user,
@RequestHeader("X-Customer-Id") String customerId, @PathVariable String returnNo, @PathVariable String receiptNo,
@Valid @RequestBody com.baiyue.lastleg.returnreceipt.dto.ReturnInventoryRequests.ReceiptCartonRepair request) {
return ApiResponse.success(inventory.repairReceiptCartons("platform:" + user.username(), customerId, returnNo, receiptNo, request));
}
@GetMapping("/{returnNo}")
public ApiResponse<ReturnOrderResponses.Detail> detail(@RequestHeader("X-Customer-Id") String customerId,
@PathVariable String returnNo) {
return ApiResponse.success(service.detail(customerId, returnNo));
}
@PostMapping("/{returnNo}/review")
public ApiResponse<ReturnOrderResponses.Detail> review(@RequestHeader("X-Customer-Id") String customerId,
@PathVariable String returnNo, @Valid @RequestBody ReturnOrderRequests.Review request) {
@@ -0,0 +1,42 @@
package com.baiyue.lastleg.returnreceipt.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.returnreceipt.dto.ReturnInventoryRequests;
import com.baiyue.lastleg.returnreceipt.dto.ReturnInventoryResponses;
import com.baiyue.lastleg.returnreceipt.service.ReturnInventoryService;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/platform/v1/last-leg/return-packing-plans")
public class PlatformReturnPackingPlanController {
@Resource private ReturnInventoryService service;
@PostMapping
@PreAuthorize("hasAuthority('platform:overseas-warehouse:return-inventory:list') and hasAuthority('lastleg:inventory:manage')")
public ApiResponse<ReturnInventoryResponses.PackingPlan> create(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody ReturnInventoryRequests.PackingPlanCreate request) {
return ApiResponse.success(service.createPackingPlan(user == null ? "platform" : user.username(), request));
}
@GetMapping
@PreAuthorize("hasAuthority('platform:overseas-warehouse:return-inventory:list')")
public ApiResponse<com.baiyue.common.core.api.PageResult<ReturnInventoryResponses.PackingPlan>> list(@Valid ReturnInventoryRequests.PackingPlanQuery request) {
return ApiResponse.success(service.platformPackingPlans(request));
}
@PostMapping("/{planNo}/review")
@PreAuthorize("hasAuthority('platform:overseas-warehouse:return-inventory:list') and hasAuthority('lastleg:inventory:manage')")
public ApiResponse<ReturnInventoryResponses.PackingPlan> review(@PathVariable String planNo) {
return ApiResponse.success(service.reviewPackingPlanPlatform(planNo));
}
@PostMapping("/{planNo}/cancel")
@PreAuthorize("hasAuthority('platform:overseas-warehouse:return-inventory:list') and hasAuthority('lastleg:inventory:manage')")
public ApiResponse<ReturnInventoryResponses.PackingPlan> cancel(@PathVariable String planNo) {
return ApiResponse.success(service.cancelPackingPlan(planNo));
}
}
@@ -7,6 +7,7 @@ import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;
import java.util.List;
import java.math.BigDecimal;
public final class PdaReturnReceiptRequests {
private PdaReturnReceiptRequests() { }
@@ -18,10 +19,25 @@ public final class PdaReturnReceiptRequests {
public record Confirm(@NotBlank @Size(max = 128) String returnNo,
@Size(max = 128) String trackingNo,
@NotBlank @Size(max = 16) String startedBy,
@NotBlank @Size(max = 128) String palletCode,
@Size(max = 128) String palletCode,
@NotEmpty @Size(max = 500) List<@Valid Item> items,
@NotBlank @Size(max = 255) String idempotencyKey) { }
@NotBlank @Size(max = 255) String idempotencyKey,
@Size(max = 16) String unitType,
@Size(max = 128) String unitCode,
BigDecimal length, BigDecimal width, BigDecimal height, BigDecimal weight,
Long totalBoxCount) {
public Confirm(String returnNo, String trackingNo, String startedBy, String palletCode,
List<@Valid Item> items, String idempotencyKey) {
this(returnNo, trackingNo, startedBy, palletCode, items, idempotencyKey,
null, null, null, null, null, null, null);
}
}
public record Item(@NotBlank @Size(max = 128) String skuCode,
@com.fasterxml.jackson.databind.annotation.JsonDeserialize(using = ReturnQuantityDeserializer.class)
@Positive long receivedQuantity) { }
@Positive long receivedQuantity,
Long boxCount) {
public Item(String skuCode, long receivedQuantity) {
this(skuCode, receivedQuantity, null);
}
}
}
@@ -16,5 +16,7 @@ public final class PdaReturnReceiptResponses {
public record Item(String skuCode, String productNameZh, long expectedQuantity, long receivedQuantity) { }
public record Confirmed(String returnNo, String receiptNo, String receiptBatchNo, String palletCode, String orderStatus,
boolean fullyMatched, long expectedQuantity, long receivedQuantity,
LocalDateTime signedAt) { }
LocalDateTime signedAt, String unitType, String unitCode,
String cartonMark, String printableHtml, long totalBoxCount,
List<ReturnOrderResponses.CartonLabel> labels) { }
}
@@ -14,6 +14,8 @@ import java.util.List;
public final class ReturnInventoryRequests {
private ReturnInventoryRequests() { }
public record ReceiptCartonRepair(@Positive long confirmedBoxCount,
@NotBlank @Size(max = 255) String idempotencyKey) { }
public record Query(@Min(1) long page, @Min(1) @Max(200) long size, String overseasWarehouseId,
String skuCode, String cartonMark, String status, String returnNo) {
@@ -31,7 +33,46 @@ public final class ReturnInventoryRequests {
public PlatformQuery { if (page == 0) page = 1; if (size == 0) size = 20; }
}
public record PackingQuery(@Size(max = 128) String returnNo) { }
public record PackingQuery(@Size(max = 128) String returnNo, @Size(max = 128) String skuCode) { }
public record PackingPlanQuery(@Min(1) long page, @Min(1) @Max(200) long size, String status, @Size(max = 128) String planNo,
@Size(max = 64) String customerId, @Size(max = 64) String overseasWarehouseId, @Size(max = 128) String skuCode) {
public PackingPlanQuery { if (page == 0) page = 1; if (size == 0) size = 20; }
}
public record PackingPlanCreate(@NotEmpty @Size(max = 100) List<@NotBlank @Size(max = 128) String> returnNos,
@NotBlank @Size(max = 40) String mode,
@NotEmpty @Size(max = 100) List<@Valid PlanBox> boxes,
@Size(max = 1000) String remark,
@NotBlank @Size(max = 255) String idempotencyKey,
@Size(max = 500) List<@NotBlank String> sourceCartonMarks) {
public PackingPlanCreate(List<String> returnNos, String mode, List<PlanBox> boxes, String remark, String idempotencyKey) {
this(returnNos, mode, boxes, remark, idempotencyKey, List.of());
}
}
public record PlanBox(@Size(max = 100) List<@Valid PlanItem> items,
@Positive BigDecimal length, @Positive BigDecimal width,
@Positive BigDecimal height, @Positive BigDecimal weight,
@Size(max = 128) String targetCartonMark) {
public PlanBox(List<PlanItem> items, BigDecimal length, BigDecimal width, BigDecimal height, BigDecimal weight) {
this(items, length, width, height, weight, null);
}
}
public record PlanItem(@NotBlank @Size(max = 128) String skuCode,
@Positive long quantity) { }
public record PackingPlanComplete(@NotEmpty @Size(max = 100) List<@Valid PlanBox> boxes,
@NotBlank @Size(max = 255) String idempotencyKey,
@Size(max = 1000) List<@Valid SourceDeduction> sourceDeductions) {
public PackingPlanComplete(List<PlanBox> boxes, String idempotencyKey) { this(boxes, idempotencyKey, null); }
}
public record SourceDeduction(@NotBlank String cartonMark, @NotBlank String skuCode, @Positive long quantity) { }
public record PackingExecution(@NotEmpty @Size(max = 100) List<@NotBlank String> returnNos,
@NotEmpty @Size(max = 100) List<@Valid PlanBox> boxes,
@NotBlank @Size(max = 180) String idempotencyKey) { }
public record Merge(@NotEmpty @Size(min = 1, max = 100) List<@NotBlank @Size(max = 128) String> returnNos,
@NotEmpty @Size(max = 500) List<@Valid ValidItem> items,
@@ -44,4 +85,19 @@ public final class ReturnInventoryRequests {
public record ValidItem(@NotBlank @Size(max = 128) String skuCode,
@com.fasterxml.jackson.databind.annotation.JsonDeserialize(using = ReturnQuantityDeserializer.class)
@Positive long quantity) { }
public record Putaway(@NotBlank @Size(max = 128) String locationCode,
@Size(max = 128) String palletCode,
@NotBlank @Size(max = 255) String idempotencyKey) { }
public record BoxMerge(@NotEmpty @Size(max = 100) List<@NotBlank String> sourceCartonMarks,
String targetCartonMark,
@NotBlank @Size(max = 255) String idempotencyKey,
@Size(max = 1000) List<@Valid SourceDeduction> sourceDeductions,
@Positive BigDecimal length, @Positive BigDecimal width,
@Positive BigDecimal height, @Positive BigDecimal weight) {
public BoxMerge(List<String> sourceCartonMarks, String targetCartonMark, String idempotencyKey) {
this(sourceCartonMarks, targetCartonMark, idempotencyKey, null, null, null, null, null);
}
}
}
@@ -6,6 +6,8 @@ import java.util.List;
public final class ReturnInventoryResponses {
private ReturnInventoryResponses() { }
public record PackingSource(String cartonMark, String sourceReturnNo, String sourceUnitType,
List<CartonItem> items) { }
public record CartonSummary(String cartonMark, String overseasWarehouseId, String sourceBatchNo,
String sourceReturnNo, String status, int skuTypes, long totalQuantity,
@@ -20,22 +22,42 @@ public final class ReturnInventoryResponses {
public record CartonDetail(String cartonMark, String customerId, String overseasWarehouseId, String sourceBatchNo, String sourceReturnNo, String status,
String fbaCartonMark, String destinationWarehouseCode, String destinationWarehouseName, String relabelNo, String relabelStatus,
int skuTypes, long totalQuantity, BigDecimal length, BigDecimal width, BigDecimal height,
BigDecimal weight, LocalDateTime receivedAt, List<CartonItem> items) { }
public record CartonItem(String skuCode, String productNameZh, long quantity) { }
BigDecimal weight, LocalDateTime receivedAt, List<CartonItem> items,
String locationCode, String palletCode, LocalDateTime putawayAt, List<MergeHistory> mergeHistory) { }
public record MergeHistory(String planNo, String sourceCartonMark, String targetCartonMark, String returnNo,
String skuCode, long quantity, LocalDateTime createdAt) { }
public record CartonItem(String skuCode, String productNameZh, long quantity, long availableQuantity) {
public CartonItem(String skuCode, String productNameZh, long quantity) { this(skuCode, productNameZh, quantity, quantity); }
}
public record Label(String cartonMark, String overseasWarehouseId, String customerId, String sourceBatchNo, String sourceReturnNo,
long totalQuantity, BigDecimal length, BigDecimal width, BigDecimal height, BigDecimal weight,
List<CartonItem> items, LocalDateTime generatedAt, String printableHtml) { }
public record MergeResult(String cartonMark, String skuCode, long totalQuantity, Label label) { }
public record MergeResult(String cartonMark, String skuCode, long totalQuantity, Label label, String planNo) {
public MergeResult(String cartonMark, String skuCode, long totalQuantity, Label label) { this(cartonMark, skuCode, totalQuantity, label, null); }
}
public record PutawayResult(String cartonMark, String locationCode, String palletCode, LocalDateTime putawayAt) { }
public record PackingOrder(String returnNo, String customerId, String customerReferenceNo, String status) { }
public record PackingSku(String skuCode, String productNameZh, long availableQuantity) { }
public record PackingPreview(List<PackingOrder> orders, String customerId, List<PackingSku> skus) { }
public record PackingCustomer(String customerId, List<PackingOrder> orders, List<PackingSku> skus) { }
public record PackingPreview(List<PackingOrder> orders, String customerId, List<PackingSku> skus, List<PackingCustomer> customers) {
public PackingPreview(List<PackingOrder> orders, String customerId, List<PackingSku> skus) { this(orders, customerId, skus, List.of()); }
}
public record PackingPlan(String planNo, String customerId, String overseasWarehouseId, List<String> returnNos,
String mode, String status, List<PlanBox> boxes, String remark, LocalDateTime pushedAt,
List<String> sourceCartonMarks, List<PlanSource> sources,
LocalDateTime createdAt, List<CartonDetail> resultCartons, LocalDateTime completedAt) { }
public record PlanSource(String cartonMark, List<PlanItem> items) { }
public record PlanBox(int boxNo, List<PlanItem> items, BigDecimal length, BigDecimal width, BigDecimal height, BigDecimal weight) { }
public record PlanItem(String skuCode, long quantity) { }
public record PackingPlanCompletion(String planNo, List<Label> labels) { }
public record PlatformStock(String customerId, String overseasWarehouseId, String skuCode, String productNameZh,
long totalQuantity, long availableQuantity, long cartonQuantity, LocalDateTime updatedAt) { }
long totalQuantity, long availableQuantity, long cartonQuantity, LocalDateTime updatedAt,
long looseAvailableQuantity) { }
public record PlatformCarton(String customerId, String overseasWarehouseId, String cartonMark, String sourceReturnNo,
String sourceBatchNo, String status, int skuTypes, long totalQuantity,
String sourceUnitType, String sourceBatchNo, String status, int skuTypes, long totalQuantity,
String fbaCartonMark, String destinationWarehouseCode, String destinationWarehouseName, String relabelNo, String relabelStatus,
BigDecimal length, BigDecimal width, BigDecimal height, BigDecimal weight,
LocalDateTime createdAt) { }
LocalDateTime createdAt, String locationCode, String palletCode, LocalDateTime putawayAt) { }
public record PlatformMovement(String customerId, String overseasWarehouseId, String skuCode, String productNameZh,
String movementType, String referenceNo, long totalDelta, long availableDelta,
LocalDateTime occurredAt) { }
@@ -12,12 +12,43 @@ public final class ReturnOrderResponses {
List<com.baiyue.lastleg.dto.RelabelSubOrder> relabelApplications) { }
public record Item(String skuCode, String productNameZh, long expectedQuantity,
long receivedQuantity) { }
public record Detail(Summary order, String remark, List<Item> items, List<Receipt> receipts, List<PalletDetail> pallets, List<Photo> photos) { }
public record Detail(Summary order, String remark, List<Item> items, List<Receipt> receipts, List<PalletDetail> pallets, List<Photo> photos,
List<PackingDetail> packingDetails) {
public Detail(Summary order, String remark, List<Item> items, List<Receipt> receipts, List<PalletDetail> pallets, List<Photo> photos) {
this(order, remark, items, receipts, pallets, photos, List.of());
}
}
public record PackingDetail(String sourceCartonMark, String targetCartonMark, String returnNo,
String skuCode, long quantity, LocalDateTime createdAt) { }
public record Photo(long fileId, String fileName, String contentType, long sizeBytes) { }
public record PalletDetail(String palletCode, int skuTypes, long totalQuantity, List<PalletItem> items) { }
public record PalletItem(String skuCode, String productNameZh, long quantity) { }
public record PalletDetail(String palletCode, int skuTypes, long totalQuantity, List<PalletItem> items,
long totalBoxCount, String unitType, String unitCode) {
public PalletDetail(String palletCode, int skuTypes, long totalQuantity, List<PalletItem> items) {
this(palletCode, skuTypes, totalQuantity, items, 0, "PALLET", palletCode);
}
}
public record PalletItem(String skuCode, String productNameZh, long quantity, long boxCount) {
public PalletItem(String skuCode, String productNameZh, long quantity) { this(skuCode, productNameZh, quantity, 0); }
}
public record Receipt(String receiptNo, String receiptBatchNo, String palletCode, String trackingNo, String startedBy,
long receivedQuantity, String operatorName, LocalDateTime signedAt) { }
long receivedQuantity, String operatorName, LocalDateTime signedAt,
String unitType, String unitCode, long boxCount, List<ReceiptItem> items,
String cartonMark, String printableHtml, List<CartonLabel> labels, boolean labelRepairRequired) {
public Receipt(String receiptNo, String receiptBatchNo, String palletCode, String trackingNo, String startedBy,
long receivedQuantity, String operatorName, LocalDateTime signedAt,
String unitType, String unitCode, long boxCount, List<ReceiptItem> items,
String cartonMark, String printableHtml, List<CartonLabel> labels) {
this(receiptNo, receiptBatchNo, palletCode, trackingNo, startedBy, receivedQuantity, operatorName, signedAt,
unitType, unitCode, boxCount, items, cartonMark, printableHtml, labels, false);
}
public Receipt(String receiptNo, String receiptBatchNo, String palletCode, String trackingNo, String startedBy,
long receivedQuantity, String operatorName, LocalDateTime signedAt) {
this(receiptNo, receiptBatchNo, palletCode, trackingNo, startedBy, receivedQuantity, operatorName, signedAt,
"PALLET", palletCode, 0, List.of(), null, null, List.of());
}
}
public record CartonLabel(String cartonMark, String printableHtml) { }
public record ReceiptItem(String skuCode, String productNameZh, long quantity, long boxCount) { }
public record RelabelCandidate(Long cartonId, String cartonMark, String sourceReturnNo, String status,
long totalQuantity, List<Item> items, String relabelStatus) { }
public record RelabelCreated(String relabelNo, Long relabelApplicationId, String returnNo,
@@ -18,6 +18,8 @@ public class ReturnInventoryCarton {
private Long sourceReceiptId;
private String sourceBatchNo;
private String sourceReturnNo;
private String sourceUnitType;
private String sourceUnitCode;
private String palletCode;
private String cartonMark;
private String fbaCartonMark;
@@ -38,4 +40,6 @@ public class ReturnInventoryCarton {
private LocalDateTime updatedAt;
private LocalDateTime mergedAt;
private String newCartonMark;
private String locationCode;
private LocalDateTime putawayAt;
}
@@ -14,4 +14,5 @@ public class ReturnInventoryCartonItem {
private String skuCode;
private String productNameZh;
private Long quantity;
private Long packingReservedQuantity;
}
@@ -16,6 +16,7 @@ public class ReturnInventoryOperation {
private String requestHash;
private String operationType;
private Long resultCartonId;
private String resultJson;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,31 @@
package com.baiyue.lastleg.returnreceipt.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_return_packing_plan")
public class ReturnPackingPlan {
@TableId(type = IdType.INPUT) private Long id;
private String planNo;
private String customerId;
private String overseasWarehouseId;
private String returnNosJson;
private String boxesJson;
private String sourceItemsJson;
private String resultMarksJson;
private String resultLabelsJson;
private String requestHash;
private String completionHash;
private String mode;
private String status;
private String remark;
private String idempotencyKey;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private LocalDateTime pushedAt;
private LocalDateTime completedAt;
}
@@ -6,6 +6,7 @@ import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
import java.math.BigDecimal;
@Data
@TableName("ll_return_receipt")
@@ -15,6 +16,13 @@ public class ReturnReceipt {
private String customerId;
private String receiptNo;
private String receiptBatchNo;
private String unitType;
private String unitCode;
private Long totalBoxCount;
private BigDecimal length;
private BigDecimal width;
private BigDecimal height;
private BigDecimal weight;
private String palletCode;
private String cartonMark;
private String trackingNo;
@@ -14,4 +14,5 @@ public class ReturnReceiptItem {
private String customerId;
private String skuCode;
private Long receivedQuantity;
private Long boxCount;
}
@@ -12,6 +12,11 @@ import java.util.List;
@Mapper
public interface ReturnInventoryBalanceMapper extends BaseMapper<ReturnInventoryBalance> {
String FREE_STOCK_COLUMNS = "b.id,b.customer_id,b.overseas_warehouse_id,b.sku_code,b.product_name_zh,b.total_quantity,b.carton_quantity,b.created_at,b.updated_at," +
"LEAST(b.available_quantity,COALESCE((SELECT SUM(GREATEST(0,i.quantity-COALESCE(i.packing_reserved_quantity,0))) " +
"FROM ll_return_inventory_carton c JOIN ll_return_inventory_carton_item i ON i.carton_id=c.id AND i.customer_id=c.customer_id " +
"WHERE c.customer_id=b.customer_id AND c.overseas_warehouse_id=b.overseas_warehouse_id AND i.sku_code=b.sku_code " +
"AND c.status='AVAILABLE'),0)) AS available_quantity";
@Select("SELECT * FROM ll_return_inventory_balance WHERE customer_id=#{customerId} " +
"AND overseas_warehouse_id=#{warehouseId} AND sku_code=#{skuCode} FOR UPDATE")
ReturnInventoryBalance lockSku(@Param("customerId") String customerId, @Param("warehouseId") String warehouseId,
@@ -39,13 +44,29 @@ public interface ReturnInventoryBalanceMapper extends BaseMapper<ReturnInventory
int adjustCartonQuantity(@Param("customerId") String customerId, @Param("warehouseId") String warehouseId,
@Param("skuCode") String skuCode, @Param("cartons") long cartons);
@Update("UPDATE ll_return_inventory_balance SET available_quantity=available_quantity-#{quantity},updated_at=NOW(3) " +
"WHERE customer_id=#{customerId} AND overseas_warehouse_id=#{warehouseId} AND sku_code=#{skuCode} AND available_quantity>=#{quantity}")
int reserveAvailable(@Param("customerId") String customerId, @Param("warehouseId") String warehouseId,
@Param("skuCode") String skuCode, @Param("quantity") long quantity);
@Select("SELECT COALESCE(available_quantity,0) FROM ll_return_inventory_balance WHERE customer_id=#{customerId} AND overseas_warehouse_id=#{warehouseId} AND sku_code=#{skuCode} LIMIT 1")
long availableQuantity(@Param("customerId") String customerId, @Param("warehouseId") String warehouseId,
@Param("skuCode") String skuCode);
@Update("UPDATE ll_return_inventory_balance SET total_quantity=total_quantity-#{quantity}," +
"available_quantity=GREATEST(0,available_quantity-#{quantity}),carton_quantity=carton_quantity-1,updated_at=NOW(3) " +
"WHERE customer_id=#{customerId} AND overseas_warehouse_id=#{warehouseId} AND sku_code=#{skuCode} " +
"AND total_quantity>=#{quantity} AND carton_quantity>=1")
int shipCartonSku(@Param("customerId") String customerId, @Param("warehouseId") String warehouseId,
@Param("skuCode") String skuCode, @Param("quantity") long quantity);
@Select("<script>SELECT COUNT(*) FROM ll_return_inventory_balance WHERE customer_id=#{customerId} " +
"<if test='warehouseId != null and warehouseId != \"\"'>AND overseas_warehouse_id=#{warehouseId} </if>" +
"<if test='skuCode != null and skuCode != \"\"'>AND sku_code LIKE CONCAT('%',#{skuCode},'%') </if></script>")
long countPage(@Param("customerId") String customerId, @Param("warehouseId") String warehouseId,
@Param("skuCode") String skuCode);
@Select("<script>SELECT * FROM ll_return_inventory_balance WHERE customer_id=#{customerId} " +
@Select("<script>SELECT " + FREE_STOCK_COLUMNS + " FROM ll_return_inventory_balance b WHERE customer_id=#{customerId} " +
"<if test='warehouseId != null and warehouseId != \"\"'>AND overseas_warehouse_id=#{warehouseId} </if>" +
"<if test='skuCode != null and skuCode != \"\"'>AND sku_code LIKE CONCAT('%',#{skuCode},'%') </if>" +
"ORDER BY updated_at DESC,id DESC LIMIT #{offset},#{size}</script>")
@@ -58,7 +79,7 @@ public interface ReturnInventoryBalanceMapper extends BaseMapper<ReturnInventory
"<if test='skuCode != null and skuCode != \"\"'>AND sku_code LIKE CONCAT('%',#{skuCode},'%') </if></script>")
long platformCount(@Param("customerId") String customerId, @Param("warehouseId") String warehouseId, @Param("skuCode") String skuCode);
@Select("<script>SELECT * FROM ll_return_inventory_balance WHERE 1=1 " +
@Select("<script>SELECT " + FREE_STOCK_COLUMNS + " FROM ll_return_inventory_balance b WHERE 1=1 " +
"<if test='customerId != null and customerId != \"\"'>AND customer_id LIKE CONCAT('%',#{customerId},'%') </if>" +
"<if test='warehouseId != null and warehouseId != \"\"'>AND overseas_warehouse_id=#{warehouseId} </if>" +
"<if test='skuCode != null and skuCode != \"\"'>AND sku_code LIKE CONCAT('%',#{skuCode},'%') </if>" +
@@ -11,8 +11,18 @@ import java.util.List;
@Mapper
public interface ReturnInventoryCartonItemMapper extends BaseMapper<ReturnInventoryCartonItem> {
@Select("SELECT COALESCE(SUM(GREATEST(0,i.quantity-COALESCE(i.packing_reserved_quantity,0))),0) " +
"FROM ll_return_inventory_carton_item i JOIN ll_return_inventory_carton c ON c.id=i.carton_id " +
"WHERE c.customer_id=#{customerId} AND c.overseas_warehouse_id=#{warehouseId} " +
"AND c.source_unit_type='PARCEL' AND c.status='AVAILABLE' AND i.sku_code=#{skuCode}")
long looseAvailableQuantity(@Param("customerId") String customerId, @Param("warehouseId") String warehouseId,
@Param("skuCode") String skuCode);
@Select("SELECT * FROM ll_return_inventory_carton_item WHERE carton_id=#{cartonId} ORDER BY sku_code")
List<ReturnInventoryCartonItem> listCarton(@Param("cartonId") long cartonId);
@Select("<script>SELECT * FROM ll_return_inventory_carton_item WHERE carton_id IN " +
"<foreach collection='cartonIds' item='id' open='(' separator=',' close=')'>#{id}</foreach> " +
"ORDER BY carton_id,sku_code</script>")
List<ReturnInventoryCartonItem> listCartons(@Param("cartonIds") List<Long> cartonIds);
@Select("SELECT * FROM ll_return_inventory_carton_item WHERE carton_id=#{cartonId} ORDER BY sku_code FOR UPDATE")
List<ReturnInventoryCartonItem> lockCarton(@Param("cartonId") long cartonId);
@@ -10,6 +10,12 @@ import java.util.List;
@Mapper
public interface ReturnInventoryCartonMapper extends BaseMapper<ReturnInventoryCarton> {
@Select("SELECT * FROM ll_return_inventory_carton WHERE customer_id=#{customerId} AND overseas_warehouse_id=#{warehouseId} AND status='AVAILABLE' ORDER BY carton_mark")
List<ReturnInventoryCarton> packingAvailable(@Param("customerId") String customerId, @Param("warehouseId") String warehouseId);
@Update("UPDATE ll_return_inventory_carton SET location_code=NULL,putaway_at=NULL WHERE id=#{id}")
int clearPutaway(@Param("id") long id);
String MERCHANT_FILTER = " FROM ll_return_inventory_carton c WHERE c.customer_id=#{customerId} " +
"<if test='query.overseasWarehouseId != null and query.overseasWarehouseId != \"\"'>AND c.overseas_warehouse_id=#{query.overseasWarehouseId} </if>" +
"<if test='query.cartonMark != null and query.cartonMark != \"\"'>AND c.carton_mark LIKE CONCAT('%',#{query.cartonMark},'%') </if>" +
@@ -72,6 +78,9 @@ public interface ReturnInventoryCartonMapper extends BaseMapper<ReturnInventoryC
@Select("SELECT * FROM ll_return_inventory_carton WHERE source_receipt_id=#{receiptId} LIMIT 1")
ReturnInventoryCarton findReceipt(@Param("receiptId") long receiptId);
@Select("SELECT * FROM ll_return_inventory_carton WHERE source_receipt_id=#{receiptId} ORDER BY id")
List<ReturnInventoryCarton> listReceipt(@Param("receiptId") long receiptId);
@Select("SELECT c.* FROM ll_return_inventory_carton c JOIN ll_return_inventory_carton_item i ON i.carton_id=c.id WHERE c.customer_id=#{customerId} AND c.overseas_warehouse_id=#{warehouseId} AND c.pallet_code=#{palletCode} AND c.status='AVAILABLE' ORDER BY c.id LIMIT 1 FOR UPDATE")
ReturnInventoryCarton findPalletSku(@Param("customerId") String customerId, @Param("warehouseId") String warehouseId, @Param("palletCode") String palletCode);
@@ -81,6 +90,11 @@ public interface ReturnInventoryCartonMapper extends BaseMapper<ReturnInventoryC
@Select("SELECT * FROM ll_return_inventory_carton WHERE overseas_warehouse_id=#{warehouseId} AND pallet_code=#{palletCode} AND status='AVAILABLE' ORDER BY id")
List<ReturnInventoryCarton> listPalletInventory(@Param("warehouseId") String warehouseId, @Param("palletCode") String palletCode);
@Select("SELECT * FROM ll_return_inventory_carton WHERE overseas_warehouse_id=#{warehouseId} " +
"AND pallet_code=#{palletCode} AND status IN ('AVAILABLE','RELABEL_PENDING','OUTBOUND_PENDING') ORDER BY id")
List<ReturnInventoryCarton> listActivePallet(@Param("warehouseId") String warehouseId,
@Param("palletCode") String palletCode);
@Select("SELECT * FROM ll_return_inventory_carton WHERE customer_id=#{customerId} AND carton_mark=#{cartonMark} LIMIT 1")
ReturnInventoryCarton findCustomerMark(@Param("customerId") String customerId, @Param("cartonMark") String cartonMark);
@@ -19,4 +19,6 @@ public interface ReturnOrderItemMapper extends BaseMapper<ReturnOrderItem> {
"WHERE o.overseas_warehouse_id=#{warehouseId} AND o.status IN ('PENDING_RECEIPT','PARTIALLY_RECEIVED') " +
"AND i.sku_code=#{skuCode} AND i.received_quantity<i.expected_quantity ORDER BY o.created_at,o.id LIMIT 50")
List<ReturnOrderItem> activeSkuCandidates(@Param("warehouseId") String warehouseId, @Param("skuCode") String skuCode);
@Select("SELECT i.* FROM ll_return_order_item i JOIN ll_return_order o ON o.id=i.return_order_id WHERE o.overseas_warehouse_id=#{warehouseId} AND o.status='COMPLETED' AND i.sku_code=#{skuCode} AND i.received_quantity>0 ORDER BY o.created_at,o.id LIMIT 100")
List<ReturnOrderItem> completedSkuCandidates(@Param("warehouseId") String warehouseId, @Param("skuCode") String skuCode);
}
@@ -9,6 +9,8 @@ import java.util.List;
@Mapper
public interface ReturnOrderMapper extends BaseMapper<ReturnOrder> {
@Select("SELECT * FROM ll_return_order WHERE customer_id=#{customerId} AND overseas_warehouse_id=#{warehouseId} AND status='COMPLETED' ORDER BY created_at,id")
List<ReturnOrder> completedCustomer(@Param("customerId") String customerId, @Param("warehouseId") String warehouseId);
@Select("SELECT * FROM ll_return_order WHERE customer_id=#{customerId} AND idempotency_key=#{key} LIMIT 1")
ReturnOrder findKey(@Param("customerId") String customerId, @Param("key") String key);
@Select("SELECT * FROM ll_return_order WHERE customer_id=#{customerId} AND return_no=#{returnNo} LIMIT 1")
@@ -0,0 +1,32 @@
package com.baiyue.lastleg.returnreceipt.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.returnreceipt.entity.ReturnPackingPlan;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.util.List;
@Mapper
public interface ReturnPackingPlanMapper extends BaseMapper<ReturnPackingPlan> {
@Select("SELECT * FROM ll_return_packing_plan WHERE plan_no=#{planNo} LIMIT 1")
ReturnPackingPlan findNo(@Param("planNo") String planNo);
@Select("SELECT * FROM ll_return_packing_plan WHERE plan_no=#{planNo} LIMIT 1 FOR UPDATE")
ReturnPackingPlan lockNo(@Param("planNo") String planNo);
@Select("SELECT * FROM ll_return_packing_plan WHERE overseas_warehouse_id=#{warehouseId} AND status IN ('READY','IN_PROGRESS') ORDER BY created_at ASC,id ASC LIMIT 100")
List<ReturnPackingPlan> listPending(@Param("warehouseId") String warehouseId);
@Select("SELECT COUNT(1) FROM ll_return_packing_plan WHERE customer_id=#{customerId} AND (#{status} IS NULL OR status=#{status})")
long countCustomer(@Param("customerId") String customerId, @Param("status") String status);
@Select("SELECT * FROM ll_return_packing_plan WHERE customer_id=#{customerId} AND (#{status} IS NULL OR status=#{status}) ORDER BY created_at DESC,id DESC LIMIT #{offset},#{size}")
List<ReturnPackingPlan> listCustomer(@Param("customerId") String customerId, @Param("status") String status, @Param("offset") long offset, @Param("size") long size);
String PLATFORM_FILTER = " WHERE (#{status} IS NULL OR status=#{status}) AND (#{planNo} IS NULL OR LOCATE(#{planNo},plan_no)>0) AND (#{customerId} IS NULL OR customer_id=#{customerId}) AND (#{warehouseId} IS NULL OR overseas_warehouse_id=#{warehouseId}) AND (#{sku} IS NULL OR LOCATE(#{sku},JSON_UNQUOTE(JSON_EXTRACT(boxes_json,'$[*].items[*].skuCode')))>0)";
@Select("SELECT COUNT(1) FROM ll_return_packing_plan" + PLATFORM_FILTER)
long countPlatform(@Param("status") String status, @Param("planNo") String planNo, @Param("customerId") String customerId, @Param("warehouseId") String warehouseId, @Param("sku") String sku);
@Select("SELECT * FROM ll_return_packing_plan" + PLATFORM_FILTER + " ORDER BY created_at DESC,id DESC LIMIT #{offset},#{size}")
List<ReturnPackingPlan> listPlatform(@Param("status") String status, @Param("planNo") String planNo, @Param("customerId") String customerId, @Param("warehouseId") String warehouseId, @Param("sku") String sku, @Param("offset") long offset, @Param("size") long size);
@Select("SELECT * FROM ll_return_packing_plan WHERE customer_id=#{customerId} AND idempotency_key=#{key} LIMIT 1 FOR UPDATE")
ReturnPackingPlan lockCustomerKey(@Param("customerId") String customerId, @Param("key") String key);
@Update("UPDATE ll_return_packing_plan SET status=#{status},updated_at=#{updatedAt},pushed_at=#{pushedAt},completed_at=#{completedAt} WHERE id=#{id}")
int updateStatus(@Param("id") long id, @Param("status") String status, @Param("updatedAt") java.time.LocalDateTime updatedAt, @Param("pushedAt") java.time.LocalDateTime pushedAt, @Param("completedAt") java.time.LocalDateTime completedAt);
}
@@ -0,0 +1,30 @@
package com.baiyue.lastleg.returnreceipt.mapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Insert;
import java.time.LocalDateTime;
import java.util.List;
/** Immutable movements retain original return ownership when cartons are merged again. */
@Mapper
public interface ReturnPackingTraceMapper {
record Trace(long id, String sourceCartonMark, String targetCartonMark, String returnNo,
String skuCode, long quantity, LocalDateTime createdAt) { }
record Ownership(String returnNo, long quantity) { }
@Insert("INSERT INTO ll_return_packing_trace(id,customer_id,operation_id,source_carton_mark,target_carton_mark,return_no,sku_code,quantity,created_at) VALUES(#{id},#{customerId},#{operationId},#{source},#{target},#{returnNo},#{sku},#{quantity},#{at})")
int insert(@Param("id") long id, @Param("customerId") String customerId, @Param("operationId") long operationId,
@Param("source") String source, @Param("target") String target, @Param("returnNo") String returnNo,
@Param("sku") String sku, @Param("quantity") long quantity, @Param("at") LocalDateTime at);
@Select("SELECT return_no, SUM(CASE WHEN target_carton_mark=#{mark} THEN quantity ELSE -quantity END) AS quantity FROM ll_return_packing_trace WHERE customer_id=#{customerId} AND sku_code=#{sku} AND (source_carton_mark=#{mark} OR target_carton_mark=#{mark}) GROUP BY return_no HAVING quantity>0 ORDER BY return_no")
List<Ownership> ownership(@Param("customerId") String customerId, @Param("mark") String mark, @Param("sku") String sku);
@Select("SELECT id,source_carton_mark,target_carton_mark,return_no,sku_code,quantity,created_at FROM ll_return_packing_trace WHERE customer_id=#{customerId} AND return_no=#{returnNo} ORDER BY created_at,id")
List<Trace> listReturn(@Param("customerId") String customerId, @Param("returnNo") String returnNo);
@Select("SELECT JSON_UNQUOTE(JSON_EXTRACT(o.result_json,'$.planNo')) AS plan_no,t.source_carton_mark,t.target_carton_mark,t.return_no,t.sku_code,t.quantity,t.created_at FROM ll_return_packing_trace t JOIN ll_return_inventory_operation o ON o.id=t.operation_id WHERE t.customer_id=#{customerId} AND o.operation_type='MERGE' AND t.source_carton_mark<>'' AND (t.source_carton_mark=#{mark} OR t.target_carton_mark=#{mark}) ORDER BY t.created_at DESC,t.id DESC")
List<com.baiyue.lastleg.returnreceipt.dto.ReturnInventoryResponses.MergeHistory> listCartonHistory(@Param("customerId") String customerId, @Param("mark") String mark);
}
@@ -3,12 +3,12 @@ package com.baiyue.lastleg.returnreceipt.service;
import com.google.zxing.oned.Code128Writer;
/** Inline vector bars remain printable even when browser background printing is disabled. */
final class ReturnCartonBarcode {
public final class ReturnCartonBarcode {
private static final int QUIET_ZONE = 10;
private ReturnCartonBarcode() { }
static String svg(String cartonMark) {
public static String svg(String cartonMark) {
boolean[] modules = new Code128Writer().encode(cartonMark);
int width = modules.length + QUIET_ZONE * 2;
StringBuilder svg = new StringBuilder("<svg xmlns=\"http://www.w3.org/2000/svg\" role=\"img\" aria-label=\"箱号条码\" viewBox=\"0 0 ")
@@ -5,6 +5,7 @@ import com.baiyue.common.core.api.PageResult;
import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity;
import com.baiyue.lastleg.returnreceipt.dto.ReturnInventoryRequests;
import com.baiyue.lastleg.returnreceipt.dto.ReturnInventoryResponses;
import com.baiyue.lastleg.returnreceipt.dto.ReturnOrderResponses;
import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton;
import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCartonItem;
import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryOperation;
@@ -18,6 +19,10 @@ import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryQueryMapper;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryMovementMapper;
import com.baiyue.lastleg.returnreceipt.model.ReturnInventoryRow;
import com.baiyue.lastleg.service.LastlegOrderNoGenerator;
import com.baiyue.lastleg.returnreceipt.entity.ReturnPackingPlan;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnPackingPlanMapper;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.entity.LastlegOrderDetail;
import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper;
@@ -40,8 +45,11 @@ import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Collections;
import java.util.HashMap;
/** Return stock is deliberately separate from normal inbound stock because it has no putaway location yet. */
/** Return stock keeps its own receipt ownership, carton inventory and putaway facts. */
@Service
public class ReturnInventoryService {
public static final String AVAILABLE = "AVAILABLE";
@@ -55,10 +63,319 @@ public class ReturnInventoryService {
@Resource private ReturnInventoryMovementMapper movements;
@Resource private LastlegOrderNoGenerator numbers;
@Resource private com.baiyue.lastleg.returnreceipt.mapper.ReturnOrderMapper orders;
@Resource private com.baiyue.lastleg.returnreceipt.mapper.ReturnOrderItemMapper orderItems;
@Resource private com.baiyue.lastleg.returnreceipt.mapper.ReturnReceiptMapper receiptMapper;
@Resource private com.baiyue.lastleg.returnreceipt.mapper.ReturnReceiptItemMapper receiptItemMapper;
@Resource private LastlegOrderMapper lastlegOrders;
@Resource private LastlegOrderDetailMapper lastlegOrderDetails;
@Resource private com.baiyue.lastleg.palletops.gateway.PalletPhysicalGateway physicalPallets;
@Resource private ReturnPackingPlanMapper packingPlans;
@Resource private com.baiyue.lastleg.returnreceipt.mapper.ReturnPackingTraceMapper packingTrace;
@Resource private ObjectMapper objectMapper;
@Resource private com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway warehouseResources;
@Transactional
public ReturnInventoryResponses.PutawayResult putaway(PdaWarehouseIdentity identity, String cartonMark, ReturnInventoryRequests.Putaway request) {
ReturnInventoryCarton carton = cartons.lockMark(required(cartonMark, "箱唛不能为空"));
if (carton == null || !String.valueOf(identity.overseasWarehouseId()).equals(carton.getOverseasWarehouseId())) throw notFound("当前仓没有该退货箱");
String digest = hash(List.of(cartonMark, request));
ReturnInventoryOperation replay = operations.lockKey(identity.operatorKey(), request.idempotencyKey());
if (replay != null) {
if (!"PUTAWAY".equals(replay.getOperationType()) || !digest.equals(replay.getRequestHash())) throw conflict("幂等键已用于不同的上架请求");
return read(replay.getResultJson(), new TypeReference<ReturnInventoryResponses.PutawayResult>() { });
}
if (!AVAILABLE.equals(carton.getStatus()) || cartonItems.lockCarton(carton.getId()).stream().anyMatch(i -> value(i.getPackingReservedQuantity()) > 0))
throw conflict("退货箱已被占用或不可上架");
var location = warehouseResources.resolveLocation(identity.overseasWarehouseId(), required(request.locationCode(), "库位码不能为空"));
if (location == null || !List.of("ACTIVE", "IDLE", "OCCUPIED").contains(location.status())
|| "VIRTUAL".equalsIgnoreCase(location.locationType())) throw conflict("目标库位不存在、已停用或为虚拟收货位");
String palletCode = text(request.palletCode());
if (palletCode != null) {
var pallet = physicalPallets.resolve(palletCode);
if (pallet == null || pallet.warehouseId() != identity.overseasWarehouseId()
|| !List.of("IDLE", "OCCUPIED").contains(pallet.status()) || pallet.locationId() == null || pallet.locationId() != location.locationId())
throw conflict("托盘不在目标库位或不可使用,请先完成托盘移库");
palletCode = pallet.palletCode();
}
carton.setLocationCode(location.locationCode()); carton.setPalletCode(palletCode);
carton.setPutawayAt(LocalDateTime.now()); carton.setUpdatedAt(carton.getPutawayAt()); cartons.updateById(carton);
ReturnInventoryOperation operation = new ReturnInventoryOperation(); operation.setId(IdWorker.getId());
operation.setOperatorId(identity.operatorKey()); operation.setIdempotencyKey(request.idempotencyKey()); operation.setRequestHash(digest);
var result = new ReturnInventoryResponses.PutawayResult(carton.getCartonMark(), carton.getLocationCode(), carton.getPalletCode(), carton.getPutawayAt());
operation.setOperationType("PUTAWAY"); operation.setResultCartonId(carton.getId()); operation.setResultJson(json(result)); operation.setCreatedAt(LocalDateTime.now()); operation.setUpdatedAt(operation.getCreatedAt()); operations.insert(operation);
return result;
}
@Transactional
public ReturnInventoryResponses.PackingPlan createPackingPlan(String operator, ReturnInventoryRequests.PackingPlanCreate request) {
return createPackingPlan(operator, null, request);
}
@Transactional
public ReturnInventoryResponses.PackingPlan createPackingPlan(String operator, String customerId, ReturnInventoryRequests.PackingPlanCreate request) {
List<String> returnNos = request.returnNos().stream().map(this::requiredReturnNo).distinct().toList();
List<ReturnOrder> selected = returnNos.stream().sorted().map(orders::lockNo).filter(java.util.Objects::nonNull).toList();
if (selected.size() != returnNos.size()) throw notFound("存在不存在的退货单");
ReturnOrder first = selected.get(0);
if (customerId != null && !customerId.equals(first.getCustomerId())) throw notFound("退货单不存在");
if (!"COMPLETED".equals(first.getStatus())) throw conflict("只能为已完成入库的退货单创建合箱计划");
if (selected.stream().anyMatch(order -> !"COMPLETED".equals(order.getStatus()) || !first.getCustomerId().equals(order.getCustomerId()) || !first.getOverseasWarehouseId().equals(order.getOverseasWarehouseId()))) throw conflict("订单必须属于同一客户、同一海外仓且已完成入库");
ReturnPackingPlan replay = packingPlans.lockCustomerKey(first.getCustomerId(), request.idempotencyKey());
if (replay != null) {
if (!hash(request).equals(replay.getRequestHash())) throw conflict("幂等键已用于不同的合箱计划");
return packingPlan(replay);
}
String mode = required(request.mode(), "合箱方式不能为空").toUpperCase(java.util.Locale.ROOT);
if (!List.of("BOX", "SKU").contains(mode)) throw badRequest("合箱方式只能选择 BOX 或 SKU");
List<String> marks = request.sourceCartonMarks() == null ? List.of() : request.sourceCartonMarks().stream().distinct().sorted().toList();
if ("BOX".equals(mode) && marks.isEmpty()) throw badRequest("按箱合箱必须选择来源箱唛");
if ("SKU".equals(mode) && !marks.isEmpty()) throw badRequest("按 SKU 合箱不能同时选择来源箱");
Map<String, Long> requested = planQuantities(request.boxes());
List<SourceItem> allocation = new ArrayList<>();
List<ReturnInventoryCarton> sources = "BOX".equals(mode)
? marks.stream().map(cartons::lockMark).toList()
: packingSourceCartons(selected).stream().filter(c -> "PARCEL".equals(c.getSourceUnitType()))
.sorted(Comparator.comparing(ReturnInventoryCarton::getCartonMark)).map(c -> cartons.lockMark(c.getCartonMark())).toList();
Map<String, Long> remaining = new LinkedHashMap<>(requested);
for (ReturnInventoryCarton carton : sources) {
if (carton == null || !first.getCustomerId().equals(carton.getCustomerId())
|| !first.getOverseasWarehouseId().equals(carton.getOverseasWarehouseId())
|| !AVAILABLE.equals(carton.getStatus())
|| !sourceMatches(carton, returnNos)) throw conflict("来源箱不属于所选退货单或已被占用");
if ("BOX".equals(mode) && "PARCEL".equals(carton.getSourceUnitType())) throw badRequest("包裹散 SKU 不能作为整箱选择");
for (ReturnInventoryCartonItem item : cartonItems.lockCarton(carton.getId())) {
long free = Math.max(0, value(item.getQuantity()) - value(item.getPackingReservedQuantity()));
long take = "BOX".equals(mode) ? free : Math.min(free, value(remaining.get(item.getSkuCode())));
if ("BOX".equals(mode) && value(item.getPackingReservedQuantity()) > 0) throw conflict("来源箱已有合箱预占");
if (take <= 0) continue;
if ("SKU".equals(mode)) remaining.put(item.getSkuCode(), remaining.get(item.getSkuCode()) - take);
item.setPackingReservedQuantity(value(item.getPackingReservedQuantity()) + take);
cartonItems.updateById(item);
allocation.add(new SourceItem(carton.getCartonMark(), item.getSkuCode(), take));
}
if ("BOX".equals(mode)) { carton.setStatus("PACKING_RESERVED"); cartons.updateById(carton); }
}
if (allocation.isEmpty() || ("SKU".equals(mode) && remaining.values().stream().anyMatch(q -> q > 0))) throw conflict("来源库存不足,无法锁定合箱数量");
Map<String, Long> reserved = sourceQuantities(allocation);
for (Map.Entry<String, Long> entry : reserved.entrySet()) {
if (balances.reserveAvailable(first.getCustomerId(), first.getOverseasWarehouseId(), entry.getKey(), entry.getValue()) != 1)
throw conflict("SKU 可用库存不足,无法锁定合箱数量: " + entry.getKey());
}
List<ReturnInventoryRequests.PlanBox> boxes = List.of(new ReturnInventoryRequests.PlanBox(
reserved.entrySet().stream().map(e -> new ReturnInventoryRequests.PlanItem(e.getKey(), e.getValue())).toList(), null, null, null, null));
ReturnPackingPlan plan = new ReturnPackingPlan();
plan.setId(IdWorker.getId()); plan.setPlanNo(numbers.next("RETURN_PACKING_PLAN", first.getOverseasWarehouseId(), plan.getId()));
plan.setCustomerId(first.getCustomerId()); plan.setOverseasWarehouseId(first.getOverseasWarehouseId());
plan.setReturnNosJson(json(returnNos)); plan.setBoxesJson(json(boxes)); plan.setSourceItemsJson(json(allocation));
plan.setMode(mode); plan.setStatus("PENDING_REVIEW"); plan.setRemark(request.remark()); plan.setIdempotencyKey(request.idempotencyKey());
plan.setRequestHash(hash(request));
plan.setCreatedAt(LocalDateTime.now()); plan.setUpdatedAt(plan.getCreatedAt()); packingPlans.insert(plan);
return packingPlan(plan);
}
private record SourceItem(String cartonMark, String skuCode, long quantity) { }
@Transactional(readOnly = true)
public List<ReturnOrderResponses.PackingDetail> packingHistory(String customerId, String returnNo) {
return packingTrace.listReturn(customerId, returnNo).stream().filter(t -> StringUtils.hasText(t.sourceCartonMark()))
.map(t -> new ReturnOrderResponses.PackingDetail(t.sourceCartonMark(), t.targetCartonMark(), t.returnNo(), t.skuCode(), t.quantity(), t.createdAt())).toList();
}
@Transactional
public ReturnInventoryResponses.PackingPlan cancelPackingPlan(String planNo) {
ReturnPackingPlan plan = packingPlans.lockNo(required(planNo, "合箱计划号不能为空"));
if (plan == null) throw notFound("合箱计划不存在");
if ("CANCELLED".equals(plan.getStatus())) return packingPlan(plan);
if (!List.of("PENDING_REVIEW", "READY").contains(plan.getStatus())) throw conflict("当前计划不能取消");
List<SourceItem> allocation = read(plan.getSourceItemsJson(), new TypeReference<>() { });
for (SourceItem source : allocation.stream().sorted(Comparator.comparing(SourceItem::cartonMark)).toList()) {
ReturnInventoryCarton carton = cartons.lockMark(source.cartonMark());
if (carton == null || !plan.getCustomerId().equals(carton.getCustomerId())) throw conflict("计划来源箱异常");
var item = cartonItems.lockSku(carton.getId(), source.skuCode());
if (item == null || value(item.getPackingReservedQuantity()) < source.quantity()) throw conflict("计划预占数量异常");
item.setPackingReservedQuantity(value(item.getPackingReservedQuantity()) - source.quantity()); cartonItems.updateById(item);
if ("PACKING_RESERVED".equals(carton.getStatus())) { carton.setStatus(AVAILABLE); cartons.updateById(carton); }
}
for (var entry : sourceQuantities(allocation).entrySet()) balances.adjust(IdWorker.getId(), plan.getCustomerId(), plan.getOverseasWarehouseId(), entry.getKey(), null, 0, entry.getValue(), 0);
plan.setStatus("CANCELLED"); plan.setUpdatedAt(LocalDateTime.now()); packingPlans.updateById(plan);
return packingPlan(plan);
}
private List<ReturnInventoryCarton> packingSourceCartons(List<ReturnOrder> selected) {
List<ReturnInventoryCarton> result = new ArrayList<>();
for (ReturnOrder order : selected) for (ReturnReceipt receipt : receiptMapper.listOrder(order.getId()))
result.addAll(cartons.listReceipt(receipt.getId()));
// Merged cartons retain the actual source return numbers, even without a receipt ID.
for (ReturnOrder order : selected) result.addAll(cartons.packingAvailable(order.getCustomerId(), order.getOverseasWarehouseId())
.stream().filter(c -> sourceMatches(c, selected.stream().map(ReturnOrder::getReturnNo).toList())).toList());
return new ArrayList<>(result.stream().collect(java.util.stream.Collectors.toMap(
ReturnInventoryCarton::getCartonMark, c -> c, (a, b) -> a, LinkedHashMap::new)).values());
}
private boolean sourceMatches(ReturnInventoryCarton carton, List<String> returnNos) {
if (text(carton.getSourceReturnNo()) == null) return false;
return java.util.Arrays.stream(carton.getSourceReturnNo().split(",")).allMatch(returnNos::contains);
}
private Map<String, Long> planQuantities(List<ReturnInventoryRequests.PlanBox> boxes) {
Map<String, Long> result = new LinkedHashMap<>();
for (var box : boxes) if (box.items() != null) for (var item : box.items()) {
if (item.quantity() <= 0) throw badRequest("合箱数量必须大于 0");
result.merge(required(item.skuCode(), "SKU 不能为空"), item.quantity(), Math::addExact);
}
return result;
}
private Map<String, Long> sourceQuantities(List<SourceItem> items) {
Map<String, Long> result = new LinkedHashMap<>();
for (var item : items) result.merge(item.skuCode(), item.quantity(), Math::addExact);
return result;
}
@Transactional(readOnly = true)
public PageResult<ReturnInventoryResponses.PackingPlan> customerPackingPlans(String customerId, ReturnInventoryRequests.PackingPlanQuery request) {
String status = text(request.status());
long total = packingPlans.countCustomer(customerId, status);
List<ReturnInventoryResponses.PackingPlan> records = total == 0 ? List.of() : packingPlans.listCustomer(customerId, status,
(request.page() - 1) * request.size(), request.size()).stream().map(this::packingPlan).toList();
return new PageResult<>(records, request.page(), request.size(), total, (total + request.size() - 1) / request.size());
}
@Transactional(readOnly = true)
public PageResult<ReturnInventoryResponses.PackingPlan> platformPackingPlans(ReturnInventoryRequests.PackingPlanQuery request) {
String status = text(request.status());
String planNo = text(request.planNo());
String customerId = text(request.customerId()), warehouseId = text(request.overseasWarehouseId()), sku = text(request.skuCode());
long total = packingPlans.countPlatform(status, planNo, customerId, warehouseId, sku);
List<ReturnInventoryResponses.PackingPlan> records = total == 0 ? List.of() : packingPlans.listPlatform(status, planNo, customerId, warehouseId, sku,
(request.page() - 1) * request.size(), request.size()).stream().map(this::packingPlan).toList();
return new PageResult<>(records, request.page(), request.size(), total, (total + request.size() - 1) / request.size());
}
@Transactional
public ReturnInventoryResponses.PackingPlan reviewPackingPlanPlatform(String planNo) {
ReturnPackingPlan plan = packingPlans.lockNo(required(planNo, "合箱计划号不能为空"));
if (plan == null) throw notFound("合箱计划不存在");
if (!"PENDING_REVIEW".equals(plan.getStatus())) throw conflict("当前合箱计划不在待审核状态");
LocalDateTime now = LocalDateTime.now(); plan.setStatus("READY"); plan.setPushedAt(now); plan.setUpdatedAt(now); packingPlans.updateById(plan);
return packingPlan(plan);
}
@Transactional(readOnly = true)
public List<ReturnInventoryResponses.PackingPlan> listPackingPlans(PdaWarehouseIdentity identity) {
return packingPlans.listPending(String.valueOf(identity.overseasWarehouseId())).stream().map(this::packingPlan).toList();
}
@Transactional(readOnly = true)
public ReturnInventoryResponses.PackingPlan packingPlan(PdaWarehouseIdentity identity, String planNo) {
ReturnPackingPlan plan = packingPlans.findNo(required(planNo, "合箱计划号不能为空"));
if (plan == null || plan.getOverseasWarehouseId() == null || !plan.getOverseasWarehouseId().equals(String.valueOf(identity.overseasWarehouseId()))) throw notFound("合箱计划不存在");
return packingPlan(plan);
}
@Transactional
public ReturnInventoryResponses.PackingPlanCompletion completePackingPlan(PdaWarehouseIdentity identity, String planNo, ReturnInventoryRequests.PackingPlanComplete request) {
ReturnPackingPlan plan = packingPlans.lockNo(required(planNo, "合箱计划号不能为空"));
if (plan == null || !String.valueOf(identity.overseasWarehouseId()).equals(plan.getOverseasWarehouseId())) throw notFound("合箱计划不存在");
if ("COMPLETED".equals(plan.getStatus())) {
if (!hash(request).equals(plan.getCompletionHash())) throw conflict("已完成计划不能使用不同的箱明细重复提交");
return new ReturnInventoryResponses.PackingPlanCompletion(plan.getPlanNo(), read(plan.getResultLabelsJson(), new TypeReference<List<ReturnInventoryResponses.Label>>() { }));
}
if (!"READY".equals(plan.getStatus())) throw conflict("合箱计划尚未审核或已取消");
if ("BOX".equals(plan.getMode()) && request.boxes().size() != 1) throw badRequest("箱与箱合并必须扫描一个已有目标箱,不支持拆分来源箱");
List<SourceItem> allocation = read(plan.getSourceItemsJson(), new TypeReference<>() { });
if (allocation.isEmpty()) throw conflict("合箱计划缺少来源锁定明细,请重新创建");
String plannedTarget = "BOX".equals(plan.getMode()) && request.boxes().size() == 1
? text(request.boxes().get(0).targetCartonMark()) : null;
List<SourceItem> sourceAllocation = plannedTarget == null ? allocation : allocation.stream()
.filter(source -> !plannedTarget.equals(source.cartonMark())).toList();
if ("BOX".equals(plan.getMode()) && request.sourceDeductions() != null) {
Map<String, Long> seen = new LinkedHashMap<>();
List<SourceItem> selected = new ArrayList<>();
for (var deduction : request.sourceDeductions()) {
if (deduction.quantity() <= 0 || Objects.equals(plannedTarget, deduction.cartonMark())) throw badRequest("不能扣除目标箱库存");
long maximum = sourceAllocation.stream().filter(s -> s.cartonMark().equals(deduction.cartonMark()) && s.skuCode().equals(deduction.skuCode())).mapToLong(SourceItem::quantity).sum();
if (deduction.quantity() > maximum || seen.put(deduction.cartonMark() + ":" + deduction.skuCode(), deduction.quantity()) != null) throw conflict("来源箱 SKU 扣除数量超过计划或重复");
selected.add(new SourceItem(deduction.cartonMark(), deduction.skuCode(), deduction.quantity()));
}
sourceAllocation = selected;
}
final List<SourceItem> selectedAllocation = sourceAllocation;
if (!sourceQuantities(sourceAllocation).equals(planQuantities(request.boxes()))) throw conflict("来源箱 SKU 数量合计必须与计划锁定数量完全一致");
List<String> returnNos = read(plan.getReturnNosJson(), new TypeReference<>() { });
Map<String, Map<String, Long>> limits = new LinkedHashMap<>();
for (SourceItem source : allocation.stream().filter(source -> !Objects.equals(plannedTarget, source.cartonMark())).sorted(Comparator.comparing(SourceItem::cartonMark)).toList()) {
ReturnInventoryCarton carton = cartons.lockMark(source.cartonMark());
if (carton == null || !plan.getCustomerId().equals(carton.getCustomerId())
|| !List.of(AVAILABLE, "PACKING_RESERVED").contains(carton.getStatus())) throw conflict("计划来源箱状态异常");
ReturnInventoryCartonItem item = cartonItems.lockSku(carton.getId(), source.skuCode());
if (item == null || value(item.getPackingReservedQuantity()) < source.quantity()) throw conflict("计划库存预占丢失");
item.setPackingReservedQuantity(value(item.getPackingReservedQuantity()) - source.quantity());
cartonItems.updateById(item);
if ("PACKING_RESERVED".equals(carton.getStatus())) { carton.setStatus(AVAILABLE); cartons.updateById(carton); }
long selected = selectedAllocation.stream().filter(s -> s.cartonMark().equals(source.cartonMark()) && s.skuCode().equals(source.skuCode())).mapToLong(SourceItem::quantity).sum();
if (selected > 0) limits.computeIfAbsent(source.cartonMark(), ignored -> new LinkedHashMap<>()).merge(source.skuCode(), selected, Math::addExact);
}
if (plannedTarget != null) {
ReturnInventoryCarton targetSource = cartons.lockMark(plannedTarget);
if (targetSource == null || !plan.getCustomerId().equals(targetSource.getCustomerId())
|| !List.of(AVAILABLE, "PACKING_RESERVED").contains(targetSource.getStatus())) throw conflict("目标原箱状态异常");
for (SourceItem source : allocation.stream().filter(item -> plannedTarget.equals(item.cartonMark())).toList()) {
ReturnInventoryCartonItem item = cartonItems.lockSku(targetSource.getId(), source.skuCode());
if (item == null || value(item.getPackingReservedQuantity()) < source.quantity()) throw conflict("目标原箱库存预占丢失");
item.setPackingReservedQuantity(value(item.getPackingReservedQuantity()) - source.quantity()); cartonItems.updateById(item);
}
if ("PACKING_RESERVED".equals(targetSource.getStatus())) { targetSource.setStatus(AVAILABLE); cartons.updateById(targetSource); }
}
for (var entry : sourceQuantities(allocation).entrySet()) balances.adjust(IdWorker.getId(), plan.getCustomerId(),
plan.getOverseasWarehouseId(), entry.getKey(), null, 0, entry.getValue(), 0);
List<ReturnInventoryResponses.Label> labels = new ArrayList<>();
List<String> generatedMarks = "SKU".equals(plan.getMode()) || plannedTarget == null ? packingMarks(request.boxes()) : Collections.nCopies(request.boxes().size(), null);
int index = 0;
for (ReturnInventoryRequests.PlanBox box : request.boxes()) {
if (box.items() == null || box.items().isEmpty()) throw badRequest("每个目标箱至少填写一条 SKU");
if (( !"BOX".equals(plan.getMode()) || plannedTarget == null) && (box.length() == null || box.width() == null || box.height() == null || box.weight() == null
|| box.length().signum() <= 0 || box.width().signum() <= 0 || box.height().signum() <= 0 || box.weight().signum() <= 0))
throw badRequest("PDA 完成合箱时必须填写有效箱规");
List<ReturnInventoryRequests.ValidItem> items = box.items().stream().map(item -> new ReturnInventoryRequests.ValidItem(item.skuCode(), item.quantity())).toList();
var result = mergeScoped(identity, new ReturnInventoryRequests.Merge(returnNos, items, box.length(), box.width(), box.height(), box.weight(),
request.idempotencyKey() + "-" + index), limits,
"BOX".equals(plan.getMode()) ? text(box.targetCartonMark()) : null, generatedMarks.get(index++));
labels.add(result.label());
ReturnInventoryOperation completedOperation = operations.lockKey(identity.operatorKey(), request.idempotencyKey() + "-" + (index - 1));
if (completedOperation != null) {
completedOperation.setResultJson(json(new ReturnInventoryResponses.MergeResult(result.cartonMark(), result.skuCode(), result.totalQuantity(), result.label(), plan.getPlanNo())));
operations.updateById(completedOperation);
}
}
LocalDateTime now = LocalDateTime.now(); plan.setStatus("COMPLETED"); plan.setUpdatedAt(now); plan.setCompletedAt(now);
plan.setBoxesJson(json(request.boxes())); plan.setResultMarksJson(json(labels.stream().map(ReturnInventoryResponses.Label::cartonMark).toList()));
plan.setCompletionHash(hash(request));
plan.setResultLabelsJson(json(labels));
packingPlans.updateById(plan);
return new ReturnInventoryResponses.PackingPlanCompletion(plan.getPlanNo(), labels);
}
private ReturnInventoryResponses.PackingPlan packingPlan(ReturnPackingPlan plan) {
List<SourceItem> sources = read(plan.getSourceItemsJson(), new TypeReference<>() { });
List<ReturnInventoryRequests.PlanBox> boxes = read(plan.getBoxesJson(), new TypeReference<>() { });
List<ReturnInventoryResponses.PlanBox> responseBoxes = new ArrayList<>();
for (int i = 0; i < boxes.size(); i++) {
var box = boxes.get(i);
responseBoxes.add(new ReturnInventoryResponses.PlanBox(i + 1, box.items() == null ? List.of() : box.items().stream()
.map(item -> new ReturnInventoryResponses.PlanItem(item.skuCode(), item.quantity())).toList(), box.length(), box.width(), box.height(), box.weight()));
}
return new ReturnInventoryResponses.PackingPlan(plan.getPlanNo(), plan.getCustomerId(), plan.getOverseasWarehouseId(),
read(plan.getReturnNosJson(), new TypeReference<>() { }), plan.getMode(), plan.getStatus(), responseBoxes, plan.getRemark(), plan.getPushedAt(),
sources.stream().map(SourceItem::cartonMark).distinct().toList(),
sources.stream().map(SourceItem::cartonMark).distinct().map(mark -> new ReturnInventoryResponses.PlanSource(mark,
sources.stream().filter(source -> source.cartonMark().equals(mark)).map(source -> new ReturnInventoryResponses.PlanItem(source.skuCode(), source.quantity())).toList())).toList(),
plan.getCreatedAt(), "COMPLETED".equals(plan.getStatus())
? read(plan.getResultLabelsJson(), new TypeReference<List<ReturnInventoryResponses.Label>>() { }).stream()
.map(label -> cartons.findCustomerMark(plan.getCustomerId(), label.cartonMark()))
.filter(java.util.Objects::nonNull).map(this::cartonDetail).toList() : List.of(), plan.getCompletedAt());
}
private String json(Object value) { try { return objectMapper.writeValueAsString(value); } catch (Exception error) { throw new IllegalStateException("合箱计划序列化失败", error); } }
private <T> T read(String value, TypeReference<T> type) { try { return objectMapper.readValue(value == null ? "[]" : value, type); } catch (Exception error) { throw new IllegalStateException("合箱计划数据损坏", error); } }
/** Called inside the PDA pick transaction; carton contents remain the physical shipment snapshot. */
@Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY)
@@ -66,14 +383,15 @@ public class ReturnInventoryService {
String outboundNo, long outboundPickId) {
ReturnInventoryCarton carton = cartons.lockMark(required(cartonMark, "退货箱码不能为空"));
if (carton == null || !customerId.equals(carton.getCustomerId())
|| !warehouseId.equals(carton.getOverseasWarehouseId()) || !returnNo.equals(carton.getSourceReturnNo())
|| !AVAILABLE.equals(carton.getStatus())) throw conflict("退货箱状态或客户、仓库、来源不匹配");
|| !warehouseId.equals(carton.getOverseasWarehouseId()) || !matchesReturnSource(carton, returnNo)
|| !"OUTBOUND_PENDING".equals(carton.getStatus())) throw conflict("退货箱状态或客户、仓库、来源不匹配");
List<ReturnInventoryCartonItem> contents = cartonItems.lockCarton(carton.getId());
long total = 0;
var skus = new java.util.HashSet<String>();
for (ReturnInventoryCartonItem content : contents) {
if (!customerId.equals(content.getCustomerId()) || !carton.getId().equals(content.getCartonId())
|| text(content.getSkuCode()) == null || !skus.add(content.getSkuCode()) || value(content.getQuantity()) <= 0) {
|| text(content.getSkuCode()) == null || !skus.add(content.getSkuCode()) || value(content.getQuantity()) <= 0
|| value(content.getPackingReservedQuantity()) > 0) {
throw conflict("退货箱内容缺失或归属、SKU数量不一致");
}
total = Math.addExact(total, content.getQuantity());
@@ -117,8 +435,7 @@ public class ReturnInventoryService {
public ReturnInventoryCarton createReceiptCarton(ReturnOrder order, ReturnReceipt receipt,
String requestedMark, List<ReceiptLine> lines) {
if (lines.isEmpty()) throw new IllegalArgumentException("退货签收库存明细不能为空");
if (lines.size() != 1) throw conflict("一个签收批次只能签收一个 SKU");
String normalizedPalletCode = required(receipt.getPalletCode(), "托盘码不能为空");
String normalizedUnitCode = required(receipt.getUnitCode(), "实物单元编码不能为空");
required(lines.get(0).skuCode(), "SKU 不能为空");
String cartonMark = text(requestedMark);
long cartonId = IdWorker.getId();
@@ -130,7 +447,13 @@ public class ReturnInventoryService {
carton.setId(cartonId); carton.setCustomerId(order.getCustomerId()); carton.setOverseasWarehouseId(order.getOverseasWarehouseId());
carton.setSourceReceiptId(receipt.getId()); carton.setSourceBatchNo(receipt.getReceiptBatchNo());
carton.setSourceReturnNo(order.getReturnNo()); carton.setCartonMark(cartonMark);
carton.setPalletCode(normalizedPalletCode);
carton.setPalletCode(text(receipt.getPalletCode()));
carton.setSourceUnitType(receipt.getUnitType());
carton.setSourceUnitCode(normalizedUnitCode);
// Pallet measurements describe the carrier, never each child carton.
if ("BOX".equals(receipt.getUnitType())) {
carton.setLength(receipt.getLength()); carton.setWidth(receipt.getWidth()); carton.setHeight(receipt.getHeight()); carton.setWeight(receipt.getWeight());
}
carton.setStatus(AVAILABLE); carton.setSkuTypes(lines.size()); carton.setTotalQuantity(total); carton.setCreatedAt(now); carton.setUpdatedAt(now);
cartons.insert(carton);
for (ReceiptLine line : lines) {
@@ -144,6 +467,33 @@ public class ReturnInventoryService {
return carton;
}
/** 托盘仅作为载体;按 SKU 箱数生成独立的退货库存箱。 */
@Transactional
public List<ReturnInventoryCarton> createReceiptCartons(ReturnOrder order, ReturnReceipt receipt,
List<ReceiptLine> lines,
Map<String, Long> boxCounts) {
List<ReturnInventoryCarton> result = new ArrayList<>();
for (ReceiptLine line : lines) {
long boxes = boxCounts.getOrDefault(line.skuCode(), 0L);
if (boxes <= 0) throw badRequest("托盘签收的 SKU 箱数必须大于 0: " + line.skuCode());
long base = line.quantity() / boxes;
long remainder = line.quantity() % boxes;
for (long index = 0; index < boxes; index++) {
long quantity = base + (index < remainder ? 1 : 0);
if (quantity <= 0) throw badRequest("SKU 数量不足以分配到箱: " + line.skuCode());
String mark = shortReturnMark(order.getReturnNo(), receipt.getId(), line.skuCode(), boxes, index + 1);
result.add(createReceiptCarton(order, receipt, mark,
List.of(new ReceiptLine(line.skuCode(), line.productNameZh(), quantity))));
}
}
return result;
}
private String shortReturnMark(String returnNo, Long receiptId, String skuCode, long totalBoxes, long sequence) {
String prefix = hash(java.util.Arrays.asList(returnNo, receiptId, skuCode)).substring(0, 16).toUpperCase(java.util.Locale.ROOT);
return "RC" + prefix + "#" + totalBoxes + "-" + sequence;
}
@Transactional(readOnly = true)
public PageResult<ReturnInventoryResponses.InventoryItem> page(String customerId, ReturnInventoryRequests.Query request) {
String warehouseId = text(request.overseasWarehouseId());
@@ -185,7 +535,8 @@ public class ReturnInventoryService {
List<ReturnInventoryResponses.PlatformStock> records = total == 0 ? List.of() : balances.platformPage(customerId, warehouseId, sku,
(request.page() - 1) * request.size(), request.size()).stream().map(balance -> new ReturnInventoryResponses.PlatformStock(
balance.getCustomerId(), balance.getOverseasWarehouseId(), balance.getSkuCode(), balance.getProductNameZh(),
value(balance.getTotalQuantity()), value(balance.getAvailableQuantity()), value(balance.getCartonQuantity()), balance.getUpdatedAt())).toList();
value(balance.getTotalQuantity()), value(balance.getAvailableQuantity()), value(balance.getCartonQuantity()), balance.getUpdatedAt(),
cartonItems.looseAvailableQuantity(balance.getCustomerId(), balance.getOverseasWarehouseId(), balance.getSkuCode()))).toList();
return new PageResult<>(records, request.page(), request.size(), total, (total + request.size() - 1) / request.size());
}
@@ -205,9 +556,9 @@ public class ReturnInventoryService {
long total = cartons.platformCount(customerId, warehouseId, cartonMark, returnNo, status, sku);
List<ReturnInventoryResponses.PlatformCarton> records = total == 0 ? List.of() : cartons.platformPage(customerId, warehouseId, cartonMark,
returnNo, status, sku, (request.page() - 1) * request.size(), request.size()).stream().map(carton -> new ReturnInventoryResponses.PlatformCarton(
carton.getCustomerId(), carton.getOverseasWarehouseId(), carton.getCartonMark(), carton.getSourceReturnNo(), carton.getSourceBatchNo(),
carton.getCustomerId(), carton.getOverseasWarehouseId(), carton.getCartonMark(), carton.getSourceReturnNo(), carton.getSourceUnitType(), carton.getSourceBatchNo(),
carton.getStatus(), carton.getSkuTypes() == null ? 0 : carton.getSkuTypes(), value(carton.getTotalQuantity()), carton.getFbaCartonMark(), carton.getDestinationWarehouseCode(), carton.getDestinationWarehouseName(), carton.getRelabelNo(), carton.getRelabelStatus(), carton.getLength(),
carton.getWidth(), carton.getHeight(), carton.getWeight(), carton.getCreatedAt())).toList();
carton.getWidth(), carton.getHeight(), carton.getWeight(), carton.getCreatedAt(), carton.getLocationCode(), carton.getPalletCode(), carton.getPutawayAt())).toList();
return new PageResult<>(records, request.page(), request.size(), total, (total + request.size() - 1) / request.size());
}
@@ -218,6 +569,24 @@ public class ReturnInventoryService {
return cartonDetail(carton);
}
@Transactional(readOnly = true)
public PageResult<ReturnInventoryResponses.PackingSource> platformPackingSourcePage(ReturnInventoryRequests.PlatformQuery request) {
String customerId = required(request.customerId(), "客户不能为空");
String warehouseId = required(request.overseasWarehouseId(), "海外仓不能为空");
long total = cartons.platformCount(customerId, warehouseId, null, null, AVAILABLE, null);
List<ReturnInventoryCarton> page = total == 0 ? List.of() : cartons.platformPage(customerId, warehouseId,
null, null, AVAILABLE, null, (request.page() - 1) * request.size(), request.size());
java.util.Map<Long, List<ReturnInventoryCartonItem>> itemsByCarton = page.isEmpty() ? java.util.Map.of() :
cartonItems.listCartons(page.stream().map(ReturnInventoryCarton::getId).toList()).stream()
.collect(java.util.stream.Collectors.groupingBy(ReturnInventoryCartonItem::getCartonId));
List<ReturnInventoryResponses.PackingSource> records = page.stream().map(carton ->
new ReturnInventoryResponses.PackingSource(carton.getCartonMark(), carton.getSourceReturnNo(), carton.getSourceUnitType(),
itemsByCarton.getOrDefault(carton.getId(), List.of()).stream().map(item ->
new ReturnInventoryResponses.CartonItem(item.getSkuCode(), item.getProductNameZh(), value(item.getQuantity()),
Math.max(0, value(item.getQuantity()) - value(item.getPackingReservedQuantity())))).toList())).toList();
return new PageResult<>(records, request.page(), request.size(), total, (total + request.size() - 1) / request.size());
}
@Transactional(readOnly = true)
public ReturnInventoryResponses.Label platformLabel(String cartonMark) {
ReturnInventoryCarton carton = cartons.findMark(required(cartonMark, "箱号不能为空"));
@@ -246,33 +615,228 @@ public class ReturnInventoryService {
return label(carton);
}
/** Print only persisted physical cartons; a parcel's SKU rows are not cartons. */
@Transactional(readOnly = true)
public List<ReturnOrderResponses.CartonLabel> receiptLabels(ReturnOrder order, ReturnReceipt receipt,
List<ReturnOrderLine> lines) {
List<ReturnInventoryCarton> existing = cartons.listReceipt(receipt.getId()).stream()
.filter(carton -> !"MERGED".equals(carton.getStatus())).toList();
if ("PARCEL".equals(receipt.getUnitType())) return List.of();
long expected = "BOX".equals(receipt.getUnitType()) ? Math.max(1, value(receipt.getTotalBoxCount()))
: lines.stream().mapToLong(line -> Math.max(0, line.boxCount())).sum();
if (expected <= 0 || existing.size() >= expected) {
return existing.stream().map(carton -> new ReturnOrderResponses.CartonLabel(carton.getCartonMark(), label(order.getCustomerId(), carton.getCartonMark()).printableHtml())).toList();
}
return List.of();
}
@Transactional
public List<ReturnOrderResponses.CartonLabel> repairReceiptCartons(String operator, String customerId,
String returnNo, String receiptNo, ReturnInventoryRequests.ReceiptCartonRepair request) {
ReturnOrder order = orders.lockCustomerNo(customerId, required(returnNo, "退货单不能为空"));
if (order == null) throw notFound("退货单不存在");
ReturnReceipt receipt = receiptMapper.listOrder(order.getId()).stream()
.filter(value -> receiptNo.equals(value.getReceiptNo())).findFirst().orElseThrow(() -> notFound("签收记录不存在"));
String digest = hash(List.of(customerId, returnNo, receiptNo, request));
ReturnInventoryOperation replay = operations.lockKey(operator, request.idempotencyKey());
if (replay != null) {
if (!"REPAIR_RECEIPT".equals(replay.getOperationType()) || !digest.equals(replay.getRequestHash()))
throw conflict("幂等键已用于不同的历史箱修复请求");
return read(replay.getResultJson(), new TypeReference<List<ReturnOrderResponses.CartonLabel>>() { });
}
var lines = receiptItemMapper.listReceipt(receipt.getId());
long expected = lines.stream().mapToLong(line -> value(line.getBoxCount())).sum();
if (!"PALLET".equals(receipt.getUnitType()) || expected <= 1 || request.confirmedBoxCount() != expected)
throw conflict("只支持现场核实总箱数后的历史整托签收修复");
List<ReturnInventoryCarton> original = cartons.listReceipt(receipt.getId());
if (original.size() != 1) throw conflict("仅可修复旧版一条整托库存记录,其他情况需要逐箱核实");
ReturnInventoryCarton source = cartons.lockId(original.get(0).getId());
if (!customerId.equals(source.getCustomerId()) || !returnNo.equals(source.getSourceReturnNo())
|| !AVAILABLE.equals(source.getStatus()) || source.getRelabelApplicationId() != null || source.getPutawayAt() != null)
throw conflict("历史箱已被作业占用或上架,不能自动拆分修复");
var contents = cartonItems.lockCarton(source.getId()).stream().sorted(Comparator.comparing(ReturnInventoryCartonItem::getSkuCode)).toList();
var counts = new LinkedHashMap<String, Long>();
var quantities = new LinkedHashMap<String, Long>();
for (var line : lines) { counts.merge(line.getSkuCode(), value(line.getBoxCount()), Math::addExact); quantities.merge(line.getSkuCode(), value(line.getReceivedQuantity()), Math::addExact); }
if (contents.size() != counts.size() || value(source.getTotalQuantity()) != value(receipt.getReceivedQuantity()))
throw conflict("历史实收与当前箱内容不一致,需人工核对");
for (var item : contents) {
long quantity = value(item.getQuantity()), boxes = counts.getOrDefault(item.getSkuCode(), 0L);
if (quantity != quantities.getOrDefault(item.getSkuCode(), 0L) || boxes <= 0 || boxes > quantity
|| value(item.getPackingReservedQuantity()) > 0
|| !packingTrace.ownership(customerId, source.getCartonMark(), item.getSkuCode()).isEmpty())
throw conflict("历史箱SKU数量、箱数或来源已变化,不能自动修复");
var balance = balances.lockSku(customerId, order.getOverseasWarehouseId(), item.getSkuCode());
if (balance == null || value(balance.getAvailableQuantity()) < quantity || value(balance.getCartonQuantity()) < 1)
throw conflict("历史库存余额不足,不能自动修复");
}
long operationId = IdWorker.getId();
List<ReceiptLine> replacement = contents.stream().map(item -> new ReceiptLine(item.getSkuCode(), item.getProductNameZh(), item.getQuantity())).toList();
for (var item : contents) {
balances.adjust(IdWorker.getId(), customerId, order.getOverseasWarehouseId(), item.getSkuCode(), item.getProductNameZh(), -item.getQuantity(), -item.getQuantity(), -1);
item.setQuantity(0L); cartonItems.updateById(item);
}
source.setStatus("MERGED"); source.setTotalQuantity(0L); source.setSkuTypes(0); source.setUpdatedAt(LocalDateTime.now()); cartons.updateById(source);
var generated = createReceiptCartons(order, receipt, replacement, counts);
for (var carton : generated) for (var item : cartonItems.listCarton(carton.getId()))
packingTrace.insert(IdWorker.getId(), customerId, operationId, source.getCartonMark(), carton.getCartonMark(), returnNo,
item.getSkuCode(), item.getQuantity(), LocalDateTime.now());
receipt.setCartonMark(generated.get(0).getCartonMark()); receiptMapper.updateById(receipt);
List<ReturnOrderResponses.CartonLabel> result = generated.stream().map(carton -> new ReturnOrderResponses.CartonLabel(
carton.getCartonMark(), label(customerId, carton.getCartonMark()).printableHtml())).toList();
ReturnInventoryOperation operation = new ReturnInventoryOperation(); operation.setId(operationId);
operation.setOperatorId(operator); operation.setIdempotencyKey(request.idempotencyKey()); operation.setRequestHash(digest);
operation.setOperationType("REPAIR_RECEIPT"); operation.setResultCartonId(source.getId()); operation.setResultJson(json(result));
operation.setCreatedAt(LocalDateTime.now()); operation.setUpdatedAt(operation.getCreatedAt()); operations.insert(operation);
return result;
}
public boolean receiptRepairRequired(ReturnReceipt receipt, long expected) {
if ("PARCEL".equals(receipt.getUnitType()) || expected <= 0) return false;
// Consumed/merged originals are valid history, not missing physical box records.
return cartons.listReceipt(receipt.getId()).size() < expected;
}
@Transactional(readOnly = true)
public ReturnInventoryResponses.PackingPreview packingPreview(PdaWarehouseIdentity identity, ReturnInventoryRequests.PackingQuery request) {
List<ReturnOrder> candidates = orders.findCompletedLike(String.valueOf(identity.overseasWarehouseId()), text(request.returnNo()) == null ? "" : text(request.returnNo()));
List<ReturnOrder> candidates;
if (text(request.skuCode()) != null) {
List<ReturnOrder> matching = orderItems.completedSkuCandidates(String.valueOf(identity.overseasWarehouseId()), text(request.skuCode())).stream()
.map(item -> orders.selectById(item.getReturnOrderId())).filter(java.util.Objects::nonNull).toList();
candidates = matching.stream().map(ReturnOrder::getCustomerId).distinct()
.flatMap(customer -> orders.completedCustomer(customer, String.valueOf(identity.overseasWarehouseId())).stream()).toList();
} else {
candidates = orders.findCompletedLike(String.valueOf(identity.overseasWarehouseId()), text(request.returnNo()) == null ? "" : text(request.returnNo()));
}
Map<String, ReturnOrder> unique = new LinkedHashMap<>();
candidates.forEach(order -> unique.put(order.getReturnNo(), order));
List<ReturnOrder> selected = unique.values().stream().limit(100).toList();
String customerId = selected.size() == 1 ? selected.get(0).getCustomerId() : null;
Map<String, Long> quantities = new LinkedHashMap<>();
Map<String, String> names = new LinkedHashMap<>();
for (ReturnOrder order : selected) {
List<ReturnOrder> selected = unique.values().stream().toList();
Map<String, List<ReturnOrder>> byCustomer = selected.stream().collect(java.util.stream.Collectors.groupingBy(ReturnOrder::getCustomerId, LinkedHashMap::new, java.util.stream.Collectors.toList()));
List<ReturnInventoryResponses.PackingCustomer> customers = new ArrayList<>();
for (Map.Entry<String, List<ReturnOrder>> customer : byCustomer.entrySet()) {
Map<String, Long> quantities = new LinkedHashMap<>(); Map<String, String> names = new LinkedHashMap<>();
LinkedHashSet<String> ordersWithAvailableSku = new LinkedHashSet<>();
for (ReturnOrder order : customer.getValue()) {
for (ReturnReceipt receipt : receiptMapper.listOrder(order.getId())) {
ReturnInventoryCarton carton = cartons.findReceipt(receipt.getId());
if (carton == null || !AVAILABLE.equals(carton.getStatus())) continue;
for (ReturnInventoryCartonItem item : cartonItems.listCarton(carton.getId())) {
quantities.merge(item.getSkuCode(), value(item.getQuantity()), Math::addExact);
names.putIfAbsent(item.getSkuCode(), item.getProductNameZh());
for (ReturnInventoryCarton carton : cartons.listReceipt(receipt.getId())) {
if (!AVAILABLE.equals(carton.getStatus()) || !"PARCEL".equals(carton.getSourceUnitType())) continue;
for (ReturnInventoryCartonItem item : cartonItems.listCarton(carton.getId())) {
if (request.skuCode() != null && text(request.skuCode()).equals(item.getSkuCode()) && value(item.getQuantity()) - value(item.getPackingReservedQuantity()) > 0) {
ordersWithAvailableSku.add(order.getReturnNo());
}
quantities.merge(item.getSkuCode(), Math.max(0, value(item.getQuantity()) - value(item.getPackingReservedQuantity())), Math::addExact);
names.putIfAbsent(item.getSkuCode(), item.getProductNameZh());
}
}
}
}
quantities.replaceAll((sku, quantity) -> Math.min(quantity, balances.availableQuantity(customer.getKey(), String.valueOf(identity.overseasWarehouseId()), sku)));
if (text(request.skuCode()) != null && ordersWithAvailableSku.isEmpty()) continue;
List<ReturnOrder> customerOrders = customer.getValue();
customers.add(new ReturnInventoryResponses.PackingCustomer(customer.getKey(), customerOrders.stream().map(order -> new ReturnInventoryResponses.PackingOrder(order.getReturnNo(), order.getCustomerId(), order.getCustomerReferenceNo(), order.getStatus())).toList(), quantities.entrySet().stream().filter(entry -> entry.getValue() > 0).map(entry -> new ReturnInventoryResponses.PackingSku(entry.getKey(), names.get(entry.getKey()), entry.getValue())).toList()));
}
return new ReturnInventoryResponses.PackingPreview(selected.stream().map(order ->
new ReturnInventoryResponses.PackingOrder(order.getReturnNo(), order.getCustomerId(), order.getCustomerReferenceNo(), order.getStatus())).toList(),
customerId, quantities.entrySet().stream().map(entry -> new ReturnInventoryResponses.PackingSku(entry.getKey(), names.get(entry.getKey()), entry.getValue())).toList());
String customerId = customers.size() == 1 ? customers.get(0).customerId() : null;
ReturnInventoryResponses.PackingCustomer first = customers.size() == 1 ? customers.get(0) : null;
return new ReturnInventoryResponses.PackingPreview(selected.stream().map(order -> new ReturnInventoryResponses.PackingOrder(order.getReturnNo(), order.getCustomerId(), order.getCustomerReferenceNo(), order.getStatus())).toList(), customerId, first == null ? List.of() : first.skus(), customers);
}
@Transactional
public ReturnInventoryResponses.MergeResult merge(PdaWarehouseIdentity identity, ReturnInventoryRequests.Merge request) {
String requestHash = hash(request);
return mergeScoped(identity, request, null);
}
@Transactional
public ReturnInventoryResponses.PackingPlanCompletion pack(PdaWarehouseIdentity identity, ReturnInventoryRequests.PackingExecution request) {
String digest = hash(request);
for (String returnNo : request.returnNos().stream().distinct().sorted().toList()) {
var order = orders.lockNo(returnNo);
if (order == null || !String.valueOf(identity.overseasWarehouseId()).equals(order.getOverseasWarehouseId())) throw notFound("当前仓没有来源退货单");
}
ReturnInventoryOperation replay = operations.lockKey(identity.operatorKey(), request.idempotencyKey());
if (replay != null && (!"PACK".equals(replay.getOperationType()) || !digest.equals(replay.getRequestHash()))) throw conflict("幂等键已用于不同的多箱合箱请求");
if (replay != null) return read(replay.getResultJson(), new TypeReference<ReturnInventoryResponses.PackingPlanCompletion>() { });
List<ReturnInventoryResponses.Label> labels = new ArrayList<>();
List<String> generatedMarks = packingMarks(request.boxes());
int index = 0;
for (var box : request.boxes()) {
if (text(box.targetCartonMark()) != null) throw badRequest("散 SKU 合箱不能同时指定已有目标箱");
if (box.items() == null || box.items().isEmpty() || box.length() == null || box.width() == null || box.height() == null || box.weight() == null
|| box.length().signum() <= 0 || box.width().signum() <= 0 || box.height().signum() <= 0 || box.weight().signum() <= 0) throw badRequest("每个新箱都必须填写 SKU、数量和有效箱规");
var result = mergeScoped(identity, new ReturnInventoryRequests.Merge(request.returnNos(), box.items().stream().map(i -> new ReturnInventoryRequests.ValidItem(i.skuCode(), i.quantity())).toList(),
box.length(), box.width(), box.height(), box.weight(), request.idempotencyKey() + ":box:" + index), null, null, generatedMarks.get(index++));
labels.add(result.label());
}
if (replay == null) {
ReturnInventoryOperation operation = new ReturnInventoryOperation(); operation.setId(IdWorker.getId()); operation.setOperatorId(identity.operatorKey());
operation.setIdempotencyKey(request.idempotencyKey()); operation.setRequestHash(digest); operation.setOperationType("PACK");
operation.setResultJson(json(new ReturnInventoryResponses.PackingPlanCompletion(null, labels)));
operation.setCreatedAt(LocalDateTime.now()); operation.setUpdatedAt(operation.getCreatedAt()); operations.insert(operation);
}
return new ReturnInventoryResponses.PackingPlanCompletion(null, labels);
}
@Transactional(readOnly = true)
public ReturnInventoryResponses.CartonDetail scanCarton(PdaWarehouseIdentity identity, String mark) {
ReturnInventoryCarton carton = cartons.findMark(required(mark, "箱唛不能为空"));
if (carton == null || !String.valueOf(identity.overseasWarehouseId()).equals(carton.getOverseasWarehouseId())) throw notFound("当前仓没有该退货箱");
if (!AVAILABLE.equals(carton.getStatus()) || "PARCEL".equals(carton.getSourceUnitType())) throw conflict("该箱不是可用实物箱");
return cartonDetail(carton);
}
@Transactional
public ReturnInventoryResponses.MergeResult mergeBoxes(PdaWarehouseIdentity identity, ReturnInventoryRequests.BoxMerge request) {
// Bind replay to the complete scan command before looking up emptied sources.
ReturnInventoryOperation replay = operations.lockKey(identity.operatorKey(), request.idempotencyKey());
String digest = hash(request);
if (replay != null) return replayMerge(replay, digest);
java.util.stream.Stream.concat(request.sourceCartonMarks().stream(), java.util.stream.Stream.of(request.targetCartonMark()))
.filter(StringUtils::hasText).distinct().sorted().forEach(cartons::lockMark);
Map<String, Map<String, Long>> limits = new LinkedHashMap<>();
Map<String, Long> quantities = new LinkedHashMap<>();
LinkedHashSet<String> returnNos = new LinkedHashSet<>();
String customer = null;
for (String mark : request.sourceCartonMarks().stream().distinct().sorted().toList()) {
if (mark.equals(request.targetCartonMark())) throw badRequest("来源箱不能与目标箱相同");
ReturnInventoryCarton carton = cartons.lockMark(mark);
if (carton == null || !AVAILABLE.equals(carton.getStatus()) || "PARCEL".equals(carton.getSourceUnitType())
|| !String.valueOf(identity.overseasWarehouseId()).equals(carton.getOverseasWarehouseId())) throw conflict("来源箱不存在或不可合并");
if (customer != null && !customer.equals(carton.getCustomerId())) throw conflict("不同客户的箱不能合并");
customer = carton.getCustomerId(); returnNos.addAll(java.util.Arrays.asList(required(carton.getSourceReturnNo(), "来源退货单缺失").split(",")));
Map<String, Long> items = new LinkedHashMap<>();
for (var item : cartonItems.lockCarton(carton.getId())) {
if (value(item.getPackingReservedQuantity()) > 0) throw conflict("来源箱已被合箱计划占用");
long selected = request.sourceDeductions() == null ? value(item.getQuantity()) : request.sourceDeductions().stream()
.filter(d -> mark.equals(d.cartonMark()) && item.getSkuCode().equals(d.skuCode())).mapToLong(ReturnInventoryRequests.SourceDeduction::quantity).sum();
if (selected < 0 || selected > value(item.getQuantity())) throw conflict("来源箱 SKU 扣除数量超过库存");
if (selected > 0) { items.put(item.getSkuCode(), selected); quantities.merge(item.getSkuCode(), selected, Math::addExact); }
}
limits.put(mark, items);
}
if(request.sourceDeductions() != null && request.sourceDeductions().stream().anyMatch(d -> !limits.containsKey(d.cartonMark()) || !limits.get(d.cartonMark()).containsKey(d.skuCode()))) throw badRequest("扣除明细不属于已扫描来源箱");
String targetMark = text(request.targetCartonMark());
if(targetMark == null && (request.length()==null || request.width()==null || request.height()==null || request.weight()==null)) throw badRequest("新增目标箱必须填写箱规");
var planItems=quantities.entrySet().stream().map(e -> new ReturnInventoryRequests.PlanItem(e.getKey(),e.getValue())).toList();
String newMark=targetMark==null?packingMarks(List.of(new ReturnInventoryRequests.PlanBox(planItems,request.length(),request.width(),request.height(),request.weight()))).get(0):null;
var result = mergeScoped(identity, new ReturnInventoryRequests.Merge(new ArrayList<>(returnNos), quantities.entrySet().stream()
.map(e -> new ReturnInventoryRequests.ValidItem(e.getKey(), e.getValue())).toList(), request.length(), request.width(), request.height(), request.weight(), request.idempotencyKey()), limits, targetMark, newMark);
ReturnInventoryOperation operation = operations.lockKey(identity.operatorKey(), request.idempotencyKey());
operation.setRequestHash(digest); operations.updateById(operation);
return result;
}
private ReturnInventoryResponses.MergeResult mergeScoped(PdaWarehouseIdentity identity, ReturnInventoryRequests.Merge request,
Map<String, Map<String, Long>> limits) {
return mergeScoped(identity, request, limits, null);
}
private ReturnInventoryResponses.MergeResult mergeScoped(PdaWarehouseIdentity identity, ReturnInventoryRequests.Merge request,
Map<String, Map<String, Long>> limits, String targetMark) {
return mergeScoped(identity, request, limits, targetMark, null);
}
private ReturnInventoryResponses.MergeResult mergeScoped(PdaWarehouseIdentity identity, ReturnInventoryRequests.Merge request,
Map<String, Map<String, Long>> limits, String targetMark, String generatedMark) {
String requestHash = targetMark == null ? hash(request) : hash(List.of(request, targetMark, limits.keySet()));
String key = text(request.idempotencyKey());
ReturnInventoryOperation replay = operations.findKey(identity.operatorKey(), key);
if (replay != null) return replayMerge(replay, requestHash);
@@ -294,20 +858,41 @@ public class ReturnInventoryService {
String customerId = selectedOrders.get(0).getCustomerId();
if (selectedOrders.stream().anyMatch(order -> !"COMPLETED".equals(order.getStatus()))) throw conflict("只能合并已完成签收的退货单");
if (selectedOrders.stream().anyMatch(order -> !customerId.equals(order.getCustomerId()))) throw conflict("不同客户的退货单禁止合箱");
ReturnInventoryCarton existingTarget = targetMark == null ? null : cartons.lockMark(targetMark);
if (targetMark != null && (existingTarget == null || !customerId.equals(existingTarget.getCustomerId())
|| !String.valueOf(identity.overseasWarehouseId()).equals(existingTarget.getOverseasWarehouseId())
|| !AVAILABLE.equals(existingTarget.getStatus()) || "PARCEL".equals(existingTarget.getSourceUnitType())
|| limits.containsKey(targetMark))) throw conflict("目标箱不存在、被占用、跨客户或与来源箱相同");
if (existingTarget != null && cartonItems.lockCarton(existingTarget.getId()).stream().anyMatch(i -> value(i.getPackingReservedQuantity()) > 0))
throw conflict("目标箱已有合箱预占,不能并入");
if (existingTarget != null) for (var item : cartonItems.lockCarton(existingTarget.getId())) {
long tracked = packingTrace.ownership(customerId, targetMark, item.getSkuCode()).stream()
.mapToLong(com.baiyue.lastleg.returnreceipt.mapper.ReturnPackingTraceMapper.Ownership::quantity).sum();
if (tracked < value(item.getQuantity())) {
String[] origins = required(existingTarget.getSourceReturnNo(), "目标箱来源退货单缺失").split(",");
if (origins.length != 1) throw conflict("历史目标混箱缺少 SKU 来源明细");
packingTrace.insert(IdWorker.getId(), customerId, operation.getId(), "", targetMark,
origins[0], item.getSkuCode(), value(item.getQuantity()) - tracked, now);
}
}
List<ReturnInventoryCarton> sources = new ArrayList<>();
Map<Long, ReturnOrder> sourceOwners = new LinkedHashMap<>();
Map<String, ReturnOrder> candidateOwners = new java.util.TreeMap<>();
for (ReturnOrder order : selectedOrders) for (ReturnReceipt receipt : receiptMapper.listOrder(order.getId())) {
ReturnInventoryCarton carton = cartons.findReceipt(receipt.getId());
if (carton != null && AVAILABLE.equals(carton.getStatus())) {
candidateOwners.put(carton.getCartonMark(), order);
}
for (ReturnInventoryCarton carton : packingSourceCartons(selectedOrders)) {
if (!AVAILABLE.equals(carton.getStatus()) || (limits != null && !limits.containsKey(carton.getCartonMark()))
|| (limits == null && !"PARCEL".equals(carton.getSourceUnitType()))) continue;
if (!sourceMatches(carton, returnNos)) continue;
ReturnOrder owner = selectedOrders.stream().filter(order -> java.util.Arrays.asList(
carton.getSourceReturnNo().split(",")).contains(order.getReturnNo())).findFirst().orElse(null);
if (owner != null) candidateOwners.put(carton.getCartonMark(), owner);
}
// All requests acquire source locks in the same order. The earlier
// candidate query is only a snapshot: availability must be checked again.
for (Map.Entry<String, ReturnOrder> candidate : candidateOwners.entrySet()) {
ReturnInventoryCarton locked = cartons.lockMark(candidate.getKey());
if (locked != null && AVAILABLE.equals(locked.getStatus())) {
if (locked != null && AVAILABLE.equals(locked.getStatus()) && customerId.equals(locked.getCustomerId())
&& String.valueOf(identity.overseasWarehouseId()).equals(locked.getOverseasWarehouseId())
&& sourceMatches(locked, returnNos) && (limits != null || "PARCEL".equals(locked.getSourceUnitType()))) {
sources.add(locked); sourceOwners.put(locked.getId(), candidate.getValue());
}
}
@@ -323,6 +908,7 @@ public class ReturnInventoryService {
LinkedHashSet<String> consumedBatchNos = new LinkedHashSet<>();
LinkedHashSet<String> consumedReturnNos = new LinkedHashSet<>();
Map<Long, Map<String, Long>> attachedByOrder = new LinkedHashMap<>();
List<TraceTransfer> transfers = new ArrayList<>();
long total = 0;
for (ReturnInventoryCarton source : sources) {
// Read committed quantities under the source lock, never a stale MVCC snapshot.
@@ -330,10 +916,20 @@ public class ReturnInventoryService {
long sourceTotal = 0; int sourceTypes = 0;
for (ReturnInventoryCartonItem item : sourceItems) {
String sku = item.getSkuCode().toUpperCase(java.util.Locale.ROOT);
long take = Math.min(value(remaining.get(sku)), value(item.getQuantity()));
long free = Math.max(0, value(item.getQuantity()) - value(item.getPackingReservedQuantity()));
long allowed = limits == null ? free : Math.min(free, value(limits.getOrDefault(source.getCartonMark(), Map.of()).get(sku)));
long take = Math.min(value(remaining.get(sku)), allowed);
if (take > 0) {
if (limits != null) limits.get(source.getCartonMark()).put(sku, allowed - take);
consumedBatchNos.add(required(source.getSourceBatchNo(), "来源签收批次缺失,无法合箱"));
consumedReturnNos.add(required(source.getSourceReturnNo(), "来源退货单缺失,无法合箱"));
consumedReturnNos.addAll(java.util.Arrays.asList(required(source.getSourceReturnNo(), "来源退货单缺失,无法合箱").split(",")));
long toTrace = take;
for (var origin : ownership(source, sku, value(item.getQuantity()))) {
long traced = Math.min(toTrace, origin.quantity());
if (traced > 0) transfers.add(new TraceTransfer(source.getCartonMark(), origin.returnNo(), sku, traced));
toTrace -= traced;
}
if (toTrace != 0) throw conflict("来源箱 SKU 的退货单追溯数量不足");
remaining.put(sku, remaining.get(sku) - take); total = Math.addExact(total, take);
ReturnOrder owner = sourceOwners.get(source.getId());
if (owner != null) attachedByOrder.computeIfAbsent(owner.getId(), ignored -> new LinkedHashMap<>())
@@ -352,67 +948,77 @@ public class ReturnInventoryService {
}
if (remaining.values().stream().anyMatch(value -> value > 0)) throw conflict("合箱数量超过对应退货单的可用库存");
if (total <= 0) throw badRequest("至少填写一个大于 0 的 SKU 合箱数量");
long targetId = IdWorker.getId();
ReturnInventoryCarton target = new ReturnInventoryCarton();
// Stable across transaction rollback/retry; a generated target ID would leak a new resource on every retry.
String palletBusinessKey = "RETURN-MERGE:" + hash(List.of(identity.operatorKey(), key, requestHash));
var pallet = physicalPallets.ensureRelabelPallet(identity.overseasWarehouseId(), palletBusinessKey);
if (pallet == null || pallet.warehouseId() != identity.overseasWarehouseId()
|| pallet.palletId() <= 0 || text(pallet.palletCode()) == null) {
throw conflict("合箱目标托盘资源无效,请核对后重试");
}
target.setPalletCode(pallet.palletCode());
long targetId = existingTarget == null ? IdWorker.getId() : existingTarget.getId();
ReturnInventoryCarton target = existingTarget == null ? new ReturnInventoryCarton() : existingTarget;
// A merged carton may remain loose; pallet binding is an explicit warehouse operation.
target.setSourceUnitType("BOX");
target.setId(targetId); target.setCustomerId(customerId); target.setOverseasWarehouseId(sources.get(0).getOverseasWarehouseId());
target.setCartonMark(numbers.next("RETURN_CARTON", target.getOverseasWarehouseId(), targetId)); target.setSourceBatchNo(String.join(",", consumedBatchNos)); target.setSourceReturnNo(String.join(",", consumedReturnNos)); target.setStatus(AVAILABLE);
target.setSkuTypes(requested.size()); target.setTotalQuantity(total); target.setLength(request.length()); target.setWidth(request.width()); target.setHeight(request.height()); target.setWeight(request.weight()); target.setCreatedAt(now); target.setUpdatedAt(now); cartons.insert(target);
for (Map.Entry<String, Long> entry : requested.entrySet()) {
ReturnInventoryCartonItem targetItem = new ReturnInventoryCartonItem();
targetItem.setId(IdWorker.getId()); targetItem.setCartonId(targetId); targetItem.setCustomerId(customerId); targetItem.setSkuCode(entry.getKey());
targetItem.setProductNameZh(productNames.get(entry.getKey())); targetItem.setQuantity(entry.getValue()); cartonItems.insert(targetItem);
// A SKU's carton count is the number of available physical cartons containing it:
// remove fully emptied source cartons, then add the one merged target carton.
long cartonDelta = 1L - emptiedBySku.getOrDefault(entry.getKey(), 0);
if (balances.adjustCartonQuantity(customerId, target.getOverseasWarehouseId(), entry.getKey(), cartonDelta) != 1) {
throw new IllegalStateException("退货合箱库存箱数更新失败: " + entry.getKey());
}
if (existingTarget == null) target.setCartonMark(generatedMark == null ? numbers.next("RETURN_CARTON", target.getOverseasWarehouseId(), targetId) : generatedMark);
if (existingTarget != null) {
if (text(target.getSourceBatchNo()) != null) consumedBatchNos.addAll(java.util.Arrays.asList(target.getSourceBatchNo().split(",")));
if (text(target.getSourceReturnNo()) != null) consumedReturnNos.addAll(java.util.Arrays.asList(target.getSourceReturnNo().split(",")));
}
attachMergedCarton(target, attachedByOrder, selectedOrders, productNames, now);
operation.setResultCartonId(targetId); operation.setUpdatedAt(now); operations.updateById(operation);
return new ReturnInventoryResponses.MergeResult(target.getCartonMark(), requested.keySet().iterator().next(), total, label(target));
target.setSourceBatchNo(String.join(",", consumedBatchNos)); target.setSourceReturnNo(String.join(",", consumedReturnNos)); target.setStatus(AVAILABLE);
if (existingTarget == null) {
target.setSkuTypes(requested.size()); target.setTotalQuantity(total); target.setLength(request.length()); target.setWidth(request.width());
target.setHeight(request.height()); target.setWeight(request.weight()); target.setCreatedAt(now); target.setUpdatedAt(now); cartons.insert(target);
}
for (Map.Entry<String, Long> entry : requested.entrySet()) {
ReturnInventoryCartonItem targetItem = existingTarget == null ? null : cartonItems.lockSku(targetId, entry.getKey());
boolean addedSku = targetItem == null;
if (targetItem == null) {
targetItem = new ReturnInventoryCartonItem(); targetItem.setId(IdWorker.getId()); targetItem.setCartonId(targetId);
targetItem.setCustomerId(customerId); targetItem.setSkuCode(entry.getKey()); targetItem.setProductNameZh(productNames.get(entry.getKey()));
targetItem.setQuantity(entry.getValue()); targetItem.setPackingReservedQuantity(0L); cartonItems.insert(targetItem);
} else { targetItem.setQuantity(Math.addExact(value(targetItem.getQuantity()), entry.getValue())); cartonItems.updateById(targetItem); }
long cartonDelta = (addedSku ? 1L : 0L) - emptiedBySku.getOrDefault(entry.getKey(), 0);
if (balances.adjustCartonQuantity(customerId, target.getOverseasWarehouseId(), entry.getKey(), cartonDelta) != 1)
throw new IllegalStateException("退货合箱库存箱数更新失败: " + entry.getKey());
}
if (existingTarget != null) {
target.setTotalQuantity(cartonItems.totalQuantity(targetId)); target.setSkuTypes(cartonItems.skuTypes(targetId));
target.setUpdatedAt(now); target.setLocationCode(null); target.setPutawayAt(null); cartons.updateById(target); cartons.clearPutaway(targetId);
}
for (TraceTransfer transfer : transfers) packingTrace.insert(IdWorker.getId(), customerId, operation.getId(),
transfer.source(), target.getCartonMark(), transfer.returnNo(), transfer.sku(), transfer.quantity(), now);
var result = new ReturnInventoryResponses.MergeResult(target.getCartonMark(), requested.keySet().iterator().next(), value(target.getTotalQuantity()), label(target));
operation.setResultCartonId(targetId); operation.setResultJson(json(result)); operation.setUpdatedAt(now); operations.updateById(operation);
return result;
}
private void attachMergedCarton(ReturnInventoryCarton target, Map<Long, Map<String, Long>> attachedByOrder,
List<ReturnOrder> selectedOrders, Map<String, String> productNames, LocalDateTime now) {
Map<Long, ReturnOrder> byId = selectedOrders.stream().collect(java.util.stream.Collectors.toMap(ReturnOrder::getId, value -> value));
for (Map.Entry<Long, Map<String, Long>> orderEntry : attachedByOrder.entrySet()) {
ReturnOrder returnOrder = byId.get(orderEntry.getKey());
if (returnOrder == null || returnOrder.getLastlegOrderId() == null) continue;
LastlegOrder tail = lastlegOrders.selectById(returnOrder.getLastlegOrderId());
if (tail == null || !returnOrder.getCustomerId().equals(tail.getCustomerId())) continue;
long quantity = orderEntry.getValue().values().stream().mapToLong(Long::longValue).sum();
boolean firstSku = true;
for (Map.Entry<String, Long> skuEntry : orderEntry.getValue().entrySet()) {
LastlegOrderDetail detail = new LastlegOrderDetail();
detail.setId(IdWorker.getId()); detail.setCustomerId(returnOrder.getCustomerId()); detail.setOrderId(tail.getId());
detail.setSourceInboundDetailId("RETURN_CARTON:" + target.getId() + ":" + returnOrder.getId() + ":" + skuEntry.getKey());
detail.setSkuCode(skuEntry.getKey()); detail.setProductNameZh(productNames.get(skuEntry.getKey()));
detail.setPackageCount(firstSku ? 1 : 0); detail.setProductQuantity(Math.toIntExact(skuEntry.getValue())); detail.setShippingMark(target.getCartonMark());
detail.setDestinationWarehouseCode(target.getDestinationWarehouseCode()); detail.setDestinationWarehouseName(target.getDestinationWarehouseName());
lastlegOrderDetails.insert(detail); firstSku = false;
}
tail.setPackageCount(Math.addExact(tail.getPackageCount() == null ? 0 : tail.getPackageCount(), 1));
tail.setProductQuantity(Math.addExact(tail.getProductQuantity() == null ? 0 : tail.getProductQuantity(), Math.toIntExact(quantity)));
tail.setUpdatedAt(now); lastlegOrders.updateById(tail);
private record TraceTransfer(String source, String returnNo, String sku, long quantity) { }
/** Source numbers on a merged carton are a set of original return orders. */
public boolean matchesReturnSource(ReturnInventoryCarton carton, String returnNo) {
if (carton == null || text(returnNo) == null || text(carton.getSourceReturnNo()) == null) return false;
var actual = java.util.Arrays.stream(carton.getSourceReturnNo().split(",")).map(String::trim)
.filter(value -> !value.isEmpty()).collect(java.util.stream.Collectors.toSet());
var requested = java.util.Arrays.stream(returnNo.split(",")).map(String::trim)
.filter(value -> !value.isEmpty()).collect(java.util.stream.Collectors.toSet());
return !requested.isEmpty() && actual.containsAll(requested);
}
private List<com.baiyue.lastleg.returnreceipt.mapper.ReturnPackingTraceMapper.Ownership> ownership(
ReturnInventoryCarton carton, String sku, long quantity) {
var result = new ArrayList<>(packingTrace.ownership(carton.getCustomerId(), carton.getCartonMark(), sku));
long traced = result.stream().mapToLong(com.baiyue.lastleg.returnreceipt.mapper.ReturnPackingTraceMapper.Ownership::quantity).sum();
if (traced < quantity) {
String[] origins = required(carton.getSourceReturnNo(), "缺少来源退货单").split(",");
if (origins.length != 1) throw conflict("历史混箱缺少逐 SKU 来源明细,不能继续合并");
result.add(new com.baiyue.lastleg.returnreceipt.mapper.ReturnPackingTraceMapper.Ownership(origins[0], quantity - traced));
}
return result;
}
private ReturnInventoryResponses.MergeResult replayMerge(ReturnInventoryOperation operation, String requestHash) {
if (!requestHash.equals(operation.getRequestHash())) throw conflict("幂等键已用于不同的合箱请求");
if (text(operation.getResultJson()) != null) return read(operation.getResultJson(), new TypeReference<ReturnInventoryResponses.MergeResult>() { });
if (operation.getResultCartonId() == null) throw conflict("合箱请求正在处理");
// The result and its label must also bypass the snapshot established before the insert race.
ReturnInventoryCarton carton = cartons.lockId(operation.getResultCartonId());
if (carton == null) throw new IllegalStateException("合箱结果箱不存在");
List<ReturnInventoryCartonItem> items = cartonItems.lockCarton(carton.getId());
if (items.isEmpty()) throw conflict("历史合箱结果已被后续操作消耗,无法重放旧标签");
return new ReturnInventoryResponses.MergeResult(carton.getCartonMark(), items.get(0).getSkuCode(), carton.getTotalQuantity(), label(carton, items));
}
@@ -422,7 +1028,8 @@ public class ReturnInventoryService {
private ReturnInventoryResponses.Label label(ReturnInventoryCarton carton, List<ReturnInventoryCartonItem> cartonLines) {
List<ReturnInventoryResponses.CartonItem> items = cartonLines.stream()
.map(item -> new ReturnInventoryResponses.CartonItem(item.getSkuCode(), item.getProductNameZh(), item.getQuantity())).toList();
.map(item -> new ReturnInventoryResponses.CartonItem(item.getSkuCode(), item.getProductNameZh(), item.getQuantity(),
Math.max(0, value(item.getQuantity()) - value(item.getPackingReservedQuantity())))).toList();
return new ReturnInventoryResponses.Label(carton.getCartonMark(), carton.getOverseasWarehouseId(), carton.getCustomerId(),
carton.getSourceBatchNo(), carton.getSourceReturnNo(), value(carton.getTotalQuantity()), carton.getLength(), carton.getWidth(), carton.getHeight(), carton.getWeight(), items, LocalDateTime.now(), printableHtml(carton, items));
}
@@ -440,24 +1047,35 @@ public class ReturnInventoryService {
private ReturnInventoryResponses.CartonDetail cartonDetail(ReturnInventoryCarton carton) {
List<ReturnInventoryResponses.CartonItem> items = cartonItems.listCarton(carton.getId()).stream()
.map(item -> new ReturnInventoryResponses.CartonItem(item.getSkuCode(), item.getProductNameZh(), item.getQuantity())).toList();
.map(item -> new ReturnInventoryResponses.CartonItem(item.getSkuCode(), item.getProductNameZh(), item.getQuantity(),
"AVAILABLE".equals(carton.getStatus())
? Math.max(0, value(item.getQuantity()) - value(item.getPackingReservedQuantity())) : 0)).toList();
return new ReturnInventoryResponses.CartonDetail(carton.getCartonMark(), carton.getCustomerId(), carton.getOverseasWarehouseId(), carton.getSourceBatchNo(), carton.getSourceReturnNo(),
carton.getStatus(), carton.getFbaCartonMark(), carton.getDestinationWarehouseCode(), carton.getDestinationWarehouseName(), carton.getRelabelNo(), carton.getRelabelStatus(), carton.getSkuTypes() == null ? 0 : carton.getSkuTypes(), value(carton.getTotalQuantity()), carton.getLength(), carton.getWidth(), carton.getHeight(), carton.getWeight(), carton.getCreatedAt(), items);
carton.getStatus(), carton.getFbaCartonMark(), carton.getDestinationWarehouseCode(), carton.getDestinationWarehouseName(), carton.getRelabelNo(), carton.getRelabelStatus(), carton.getSkuTypes() == null ? 0 : carton.getSkuTypes(), value(carton.getTotalQuantity()), carton.getLength(), carton.getWidth(), carton.getHeight(), carton.getWeight(), carton.getCreatedAt(), items, carton.getLocationCode(), carton.getPalletCode(), carton.getPutawayAt(), packingTrace.listCartonHistory(carton.getCustomerId(), carton.getCartonMark()));
}
private static String printableHtml(ReturnInventoryCarton carton, List<ReturnInventoryResponses.CartonItem> items) {
StringBuilder rows = new StringBuilder();
for (ReturnInventoryResponses.CartonItem item : items) rows.append("<tr><td>").append(escape(item.skuCode())).append("</td><td>")
.append(escape(item.productNameZh())).append("</td><td class=\"qty\">").append(item.quantity()).append("</td></tr>");
for (ReturnInventoryResponses.CartonItem item : items) {
rows.append("<div class=\"line\"><span class=\"sku\">")
.append(escape(item.skuCode()))
.append("</span></div>");
}
return "<!doctype html><html><head><meta charset=\"utf-8\"><title>退货箱唛</title><style>"
+ "@page{size:100mm 150mm;margin:5mm}body{font-family:Arial,'Microsoft YaHei',sans-serif;color:#111;margin:0}.label{box-sizing:border-box;width:90mm;border:2px solid #111;padding:3mm;min-height:138mm}.brand{font-size:12px;font-weight:700}.title{font-size:16px;font-weight:700;margin:3mm 0}.mark{font:700 16px monospace;word-break:break-all}.meta{font-size:11px;margin:3mm 0;border-top:1px solid #111;border-bottom:1px solid #111;padding:2mm 0;overflow-wrap:anywhere}.barcode{margin:3mm 0}table{width:100%;border-collapse:collapse;font-size:11px;table-layout:fixed}th,td{border:1px solid #111;padding:1mm;text-align:left;overflow-wrap:anywhere}tr{break-inside:avoid}.qty{text-align:right;font-weight:700}.total{font-size:14px;font-weight:700;text-align:right;margin-top:3mm}</style></head><body><div class=\"label\"><div class=\"brand\">BAIYUE OVERSEAS WAREHOUSE</div><div class=\"title\">RETURN CARTON LABEL / 退货箱唛</div><div class=\"mark\">"
+ escape(carton.getCartonMark()) + "</div><div class=\"barcode\">" + ReturnCartonBarcode.svg(carton.getCartonMark()) + "</div>"
+ "<div class=\"meta\">来源退货单:" + escape(carton.getSourceReturnNo())
+ "<br>箱规:" + dimension(carton.getLength()) + " × " + dimension(carton.getWidth()) + " × " + dimension(carton.getHeight()) + " cm / " + dimension(carton.getWeight()) + " kg"
+ "<br>签收批次:" + escape(carton.getSourceBatchNo()) + "</div><table><thead><tr><th>SKU</th><th>品名</th><th>数量</th></tr></thead><tbody>"
+ rows + "</tbody></table><div class=\"total\">合计:" + carton.getTotalQuantity() + " 件 / " + carton.getSkuTypes() + " 种 SKU</div></div></body></html>";
+ "@page{size:100mm 100mm;margin:8mm}*{box-sizing:border-box}body{font-family:Arial,'Microsoft YaHei',sans-serif;color:#111;margin:0}.label{width:84mm;height:84mm;padding:4mm;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;page-break-after:always}.label:last-child{page-break-after:auto}.mark{font:700 14px Arial,sans-serif;line-height:1.25;overflow-wrap:anywhere;margin:0 0 5mm}.barcode{width:100%;max-width:76mm;margin:0 0 4mm}.barcode svg{display:block;width:100%;height:auto}.line{display:flex;justify-content:center;align-items:baseline;width:100%;padding:1mm 0;font-size:12px}.sku{max-width:76mm;overflow-wrap:anywhere;font-weight:700}</style></head><body><div class=\"label\"><div class=\"mark\">"
+ escape(carton.getCartonMark()) + "</div><div class=\"barcode\">"
+ ReturnCartonBarcode.svg(carton.getCartonMark()) + "</div>" + rows
+ "</div></body></html>";
}
private static String dimension(BigDecimal value) { return value == null ? "—" : value.stripTrailingZeros().toPlainString(); }
private List<String> packingMarks(List<ReturnInventoryRequests.PlanBox> boxes) {
List<List<String>> groups = boxes.stream().map(box -> box.items() == null ? List.<String>of() : box.items().stream()
.map(ReturnInventoryRequests.PlanItem::skuCode).distinct().sorted().toList()).toList();
Map<List<String>, Long> totals = groups.stream().collect(java.util.stream.Collectors.groupingBy(group -> group, LinkedHashMap::new, java.util.stream.Collectors.counting()));
Map<List<String>, String> prefixes = new HashMap<>(); Map<List<String>, Long> sequences = new HashMap<>();
return groups.stream().map(group -> prefixes.computeIfAbsent(group, ignored -> "RC" + java.util.UUID.randomUUID().toString().replace("-", "").substring(0, 16).toUpperCase(java.util.Locale.ROOT))
+ "#" + totals.get(group) + "-" + sequences.merge(group, 1L, Long::sum)).toList();
}
private String hash(Object request) { try { return java.util.HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(String.valueOf(request).getBytes(StandardCharsets.UTF_8))); }
catch (Exception exception) { throw new IllegalStateException("无法生成请求哈希", exception); } }
@@ -472,4 +1090,5 @@ public class ReturnInventoryService {
private static ResponseStatusException notFound(String message) { return new ResponseStatusException(HttpStatus.NOT_FOUND, message); }
public record ReceiptLine(String skuCode, String productNameZh, long quantity) { }
public record ReturnOrderLine(String skuCode, String productNameZh, long quantity, long boxCount) { }
}
@@ -31,6 +31,8 @@ import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCartonItem;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper;
import com.baiyue.lastleg.entity.LastlegOrderDetail;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
@@ -69,6 +71,7 @@ public class ReturnReceiptService {
@Resource private LastlegOrderNoGenerator numbers;
@Resource private ObjectMapper objectMapper;
@Resource private LastlegOrderMapper lastlegOrders;
@Resource private LastlegOrderDetailMapper lastlegOrderDetails;
@Resource private ReturnInventoryCartonMapper cartons;
@Resource private ReturnInventoryCartonItemMapper cartonItems;
@Resource private RelabelApplicationMapper relabelApplications;
@@ -192,6 +195,26 @@ public class ReturnReceiptService {
result.getSize(), result.getTotal(), result.getPages());
}
/** Backfill tail-leg cargo for receipts created before tail synchronization was added. */
@Transactional
public void ensureTailCargo(String customerId, long tailOrderId) {
if (lastlegOrderDetails == null) return;
long existing = lastlegOrderDetails.selectCount(new LambdaQueryWrapper<LastlegOrderDetail>()
.eq(LastlegOrderDetail::getCustomerId, customerId).eq(LastlegOrderDetail::getOrderId, tailOrderId));
if (existing > 0) return;
ReturnOrder order = orders.selectOne(new LambdaQueryWrapper<ReturnOrder>()
.eq(ReturnOrder::getCustomerId, customerId).eq(ReturnOrder::getLastlegOrderId, tailOrderId));
if (order == null) return;
Map<String, ReturnOrderItem> bySku = orderItems.listOrder(order.getId()).stream()
.collect(java.util.stream.Collectors.toMap(ReturnOrderItem::getSkuCode, value -> value, (left, right) -> left, LinkedHashMap::new));
for (ReturnReceipt receipt : receipts.listOrder(order.getId())) {
List<Allocation> allocations = receiptItems.listReceipt(receipt.getId()).stream()
.map(line -> new Allocation(bySku.get(line.getSkuCode()), value(line.getReceivedQuantity())))
.filter(item -> item.item() != null).toList();
if (!allocations.isEmpty()) syncTailCargo(order, receipt, allocations, receipt.getSignedAt() == null ? LocalDateTime.now() : receipt.getSignedAt());
}
}
@Transactional(readOnly = true)
public ReturnOrderResponses.Detail detail(String customerId, String returnNo) {
ReturnOrder order = orders.findNo(customerId, text(returnNo));
@@ -199,26 +222,74 @@ public class ReturnReceiptService {
List<ReturnOrderItem> orderLines = orderItems.listOrder(order.getId());
List<ReturnOrderResponses.Item> items = orderLines.stream().map(this::item).toList();
List<ReturnReceipt> orderReceipts = receipts.listOrder(order.getId());
List<ReturnOrderResponses.Receipt> receiptViews = orderReceipts.stream().map(receipt ->
new ReturnOrderResponses.Receipt(receipt.getReceiptNo(), receipt.getReceiptBatchNo(), receipt.getPalletCode(), receipt.getTrackingNo(),
receipt.getBoundBy(), value(receipt.getReceivedQuantity()), receipt.getOperatorName(), receipt.getSignedAt())).toList();
Map<String, Map<String, ReturnOrderResponses.PalletItem>> palletItems = new LinkedHashMap<>();
Map<String, String> productNames = new LinkedHashMap<>();
for (ReturnOrderItem line : orderLines) productNames.put(line.getSkuCode(), line.getProductNameZh());
List<ReturnOrderResponses.Receipt> receiptViews = orderReceipts.stream().map(receipt -> {
List<ReturnOrderResponses.ReceiptItem> lines = receiptItems.listReceipt(receipt.getId()).stream()
.map(line -> new ReturnOrderResponses.ReceiptItem(line.getSkuCode(), productNames.get(line.getSkuCode()), value(line.getReceivedQuantity()), value(line.getBoxCount())))
.toList();
long boxes = value(receipt.getTotalBoxCount());
if (boxes == 0) boxes = lines.stream().mapToLong(ReturnOrderResponses.ReceiptItem::boxCount).sum();
String cartonMark = text(receipt.getCartonMark());
List<ReturnInventoryService.ReturnOrderLine> labelLines = lines.stream()
.map(line -> new ReturnInventoryService.ReturnOrderLine(line.skuCode(), line.productNameZh(), line.quantity(), line.boxCount()))
.toList();
List<ReturnOrderResponses.CartonLabel> labels = inventory.receiptLabels(order, receipt, labelLines);
String printableHtml = combineLabels(labels);
return new ReturnOrderResponses.Receipt(receipt.getReceiptNo(), receipt.getReceiptBatchNo(), receipt.getPalletCode(), receipt.getTrackingNo(),
receipt.getBoundBy(), value(receipt.getReceivedQuantity()), receipt.getOperatorName(), receipt.getSignedAt(), receipt.getUnitType(), receipt.getUnitCode(), boxes, lines, cartonMark, printableHtml, labels,
inventory.receiptRepairRequired(receipt, boxes));
}).toList();
Map<String, Map<String, ReturnOrderResponses.PalletItem>> palletItems = new LinkedHashMap<>();
Map<String, String> palletTypes = new LinkedHashMap<>();
Map<String, Long> physicalBoxes = new LinkedHashMap<>();
for (ReturnReceipt receipt : orderReceipts) {
if (text(receipt.getPalletCode()) == null) continue;
Map<String, ReturnOrderResponses.PalletItem> bySku = palletItems.computeIfAbsent(receipt.getPalletCode(), ignored -> new LinkedHashMap<>());
if (!"PALLET".equals(receipt.getUnitType()) && !"BOX".equals(receipt.getUnitType())) continue;
String physicalCode = text(receipt.getUnitCode()) == null ? text(receipt.getPalletCode()) : text(receipt.getUnitCode());
if (physicalCode == null) continue;
String key = receipt.getUnitType() + ":" + physicalCode;
palletTypes.put(key, receipt.getUnitType());
physicalBoxes.merge(key, value(receipt.getTotalBoxCount()), Long::sum);
Map<String, ReturnOrderResponses.PalletItem> bySku = palletItems.computeIfAbsent(key, ignored -> new LinkedHashMap<>());
for (ReturnReceiptItem line : receiptItems.listReceipt(receipt.getId())) bySku.merge(line.getSkuCode(),
new ReturnOrderResponses.PalletItem(line.getSkuCode(), productNames.get(line.getSkuCode()), value(line.getReceivedQuantity())),
(left, right) -> new ReturnOrderResponses.PalletItem(left.skuCode(), text(left.productNameZh()) == null ? right.productNameZh() : left.productNameZh(), left.quantity() + right.quantity()));
new ReturnOrderResponses.PalletItem(line.getSkuCode(), productNames.get(line.getSkuCode()), value(line.getReceivedQuantity()), value(line.getBoxCount())),
(left, right) -> new ReturnOrderResponses.PalletItem(left.skuCode(), text(left.productNameZh()) == null ? right.productNameZh() : left.productNameZh(), left.quantity() + right.quantity(), left.boxCount() + right.boxCount()));
}
List<ReturnOrderResponses.PalletDetail> palletViews = palletItems.entrySet().stream().map(entry -> {
List<ReturnOrderResponses.PalletItem> palletLines = List.copyOf(entry.getValue().values());
return new ReturnOrderResponses.PalletDetail(entry.getKey(), palletLines.size(), palletLines.stream().mapToLong(ReturnOrderResponses.PalletItem::quantity).sum(), palletLines);
String[] parts = entry.getKey().split(":", 2);
return new ReturnOrderResponses.PalletDetail(parts.length > 1 ? parts[1] : entry.getKey(), palletLines.size(), palletLines.stream().mapToLong(ReturnOrderResponses.PalletItem::quantity).sum(), palletLines,
physicalBoxes.getOrDefault(entry.getKey(), 0L), palletTypes.get(entry.getKey()), parts.length > 1 ? parts[1] : entry.getKey());
}).toList();
List<ReturnOrderResponses.Photo> photos = photoList(order).stream()
.map(file -> new ReturnOrderResponses.Photo(file.fileId(), file.fileName(), file.contentType(), file.sizeBytes())).toList();
return new ReturnOrderResponses.Detail(summary(order, relabelSubOrders(List.of(order)).getOrDefault(order.getLastlegOrderId(), List.of())), order.getRemark(), items, receiptViews, palletViews, photos);
return new ReturnOrderResponses.Detail(summary(order, relabelSubOrders(List.of(order)).getOrDefault(order.getLastlegOrderId(), List.of())), order.getRemark(), items, receiptViews, palletViews, photos,
inventory.packingHistory(order.getCustomerId(), order.getReturnNo()));
}
private String combineLabels(List<ReturnOrderResponses.CartonLabel> labels) {
if (labels.isEmpty()) return null;
// Keep the label's physical page size and barcode layout in the combined print file.
java.util.Set<String> styles = new java.util.LinkedHashSet<>();
var stylePattern = java.util.regex.Pattern.compile("<style[^>]*>(.*?)</style>", java.util.regex.Pattern.DOTALL | java.util.regex.Pattern.CASE_INSENSITIVE);
for (var label : labels) {
if (label.printableHtml() == null) continue;
var matcher = stylePattern.matcher(label.printableHtml());
while (matcher.find()) styles.add(matcher.group(1));
}
StringBuilder html = new StringBuilder("<!doctype html><html><head><meta charset='UTF-8'><style>");
styles.forEach(html::append);
html.append(".return-label{break-after:page;page-break-after:always}.return-label .label{break-after:auto;page-break-after:auto}.return-label:last-child{break-after:auto;page-break-after:auto}</style></head><body>");
for (ReturnOrderResponses.CartonLabel label : labels) {
String value = label.printableHtml();
if (value == null || value.isBlank()) continue;
int bodyStart = value.indexOf("<body");
int contentStart = bodyStart < 0 ? 0 : value.indexOf('>', bodyStart) + 1;
int bodyEnd = value.lastIndexOf("</body>");
String content = contentStart > 0 && bodyEnd > contentStart ? value.substring(contentStart, bodyEnd) : value;
html.append("<section class='return-label'>").append(content).append("</section>");
}
return html.append("</body></html>").toString();
}
public com.baiyue.lastleg.relabel.pda.gateway.RelabelPhotoFileGateway.DownloadedFile downloadPhoto(String customerId, String returnNo, long fileId) {
@@ -255,11 +326,12 @@ public class ReturnReceiptService {
if (!COMPLETED.equals(order.getStatus())) throw conflict("退货单尚未完成签收");
List<ReturnOrderResponses.RelabelCandidate> result = new ArrayList<>();
for (ReturnReceipt receipt : receipts.listOrder(order.getId())) {
ReturnInventoryCarton carton = cartons.findReceipt(receipt.getId());
if (carton == null || !ReturnInventoryService.AVAILABLE.equals(carton.getStatus()) || carton.getRelabelApplicationId() != null) continue;
List<ReturnOrderResponses.Item> items = cartonItems.listCarton(carton.getId()).stream()
.map(i -> new ReturnOrderResponses.Item(i.getSkuCode(), i.getProductNameZh(), value(i.getQuantity()), value(i.getQuantity()))).toList();
result.add(new ReturnOrderResponses.RelabelCandidate(carton.getId(), carton.getCartonMark(), carton.getSourceReturnNo(), carton.getStatus(), value(carton.getTotalQuantity()), items, carton.getRelabelStatus()));
for (ReturnInventoryCarton carton : cartons.listReceipt(receipt.getId())) {
if (!ReturnInventoryService.AVAILABLE.equals(carton.getStatus()) || carton.getRelabelApplicationId() != null) continue;
List<ReturnOrderResponses.Item> items = cartonItems.listCarton(carton.getId()).stream()
.map(i -> new ReturnOrderResponses.Item(i.getSkuCode(), i.getProductNameZh(), value(i.getQuantity()), value(i.getQuantity()))).toList();
result.add(new ReturnOrderResponses.RelabelCandidate(carton.getId(), carton.getCartonMark(), carton.getSourceReturnNo(), carton.getStatus(), value(carton.getTotalQuantity()), items, carton.getRelabelStatus()));
}
}
return result;
}
@@ -345,6 +417,8 @@ public class ReturnReceiptService {
ReturnInventoryCarton carton = selectedCartons.computeIfAbsent(candidate.getId(), ignored -> cartons.lockId(candidate.getId()));
if (carton == null || !"AVAILABLE".equals(carton.getStatus()) || carton.getRelabelApplicationId() != null)
throw conflict("退货箱已被其他换标申请占用,请刷新后重试");
if (cartonItems.lockCarton(carton.getId()).stream().anyMatch(line -> value(line.getPackingReservedQuantity()) > 0))
throw conflict("退货 SKU 已被合箱计划锁定,请先完成或取消合箱计划");
long allocated = Math.min(remaining, skuQuantity);
RelabelItem item = new RelabelItem(); item.setId(IdWorker.getId()); item.setApplicationId(appId); item.setCustomerId(customerId);
item.setSkuCode(source.getSkuCode());
@@ -374,10 +448,10 @@ public class ReturnReceiptService {
List<PdaReturnReceiptResponses.CompletedPallet> completedPallets = receipts.listOrder(order.getId()).stream()
.filter(receipt -> text(receipt.getPalletCode()) != null)
.collect(java.util.stream.Collectors.toMap(ReturnReceipt::getPalletCode, receipt -> {
ReturnInventoryCarton carton = cartons.findReceipt(receipt.getId());
ReturnInventoryCartonItem item = carton == null ? null : cartonItems.listCarton(carton.getId()).stream().findFirst().orElse(null);
return new PdaReturnReceiptResponses.CompletedPallet(receipt.getPalletCode(), item == null ? "" : item.getSkuCode(),
item == null ? 0 : value(item.getQuantity()), receipt.getReceiptBatchNo());
List<ReturnReceiptItem> lines = receiptItems.listReceipt(receipt.getId());
String sku = lines.size() == 1 ? lines.get(0).getSkuCode() : "MIXED";
return new PdaReturnReceiptResponses.CompletedPallet(receipt.getPalletCode(), sku,
lines.stream().mapToLong(line -> value(line.getReceivedQuantity())).sum(), receipt.getReceiptBatchNo());
}, (left, right) -> new PdaReturnReceiptResponses.CompletedPallet(left.palletCode(), left.skuCode(),
left.quantity() + right.quantity(), right.receiptBatchNo()), java.util.LinkedHashMap::new))
.values().stream().toList();
@@ -432,6 +506,9 @@ public class ReturnReceiptService {
String startedBy = norm(request.startedBy());
if (!Set.of("RETURN_NO", "SKU").contains(startedBy)) throw badRequest("startedBy 必须为 RETURN_NO 或 SKU");
String trackingNo = text(request.trackingNo());
String unitType = norm(request.unitType());
if (unitType == null) unitType = "PALLET";
String unitCode = text(request.unitCode());
ReturnOrder order = orders.lockNo(text(request.returnNo()));
if (order == null || !String.valueOf(identity.overseasWarehouseId()).equals(order.getOverseasWarehouseId())) {
throw notFound("当前绑定仓没有该退货单");
@@ -448,8 +525,34 @@ public class ReturnReceiptService {
}
List<ReturnOrderItem> lockedItems = orderItems.lockOrder(order.getId());
Map<String, Long> receivedBySku = aggregate(request.items());
Map<String, Long> boxCountBySku = request.items().stream().filter(item -> item.boxCount() != null)
.collect(java.util.stream.Collectors.toMap(item -> text(item.skuCode()), PdaReturnReceiptRequests.Item::boxCount, Math::addExact, LinkedHashMap::new));
List<Allocation> allocations = allocate(lockedItems, receivedBySku);
long receiptQuantity = allocations.stream().mapToLong(Allocation::quantity).sum();
if (!Set.of("PALLET", "BOX", "PARCEL").contains(unitType)) throw badRequest("unitType 必须为 PALLET、BOX 或 PARCEL");
for (PdaReturnReceiptRequests.Item input : request.items()) {
if (!"PARCEL".equals(unitType) && (input.boxCount() == null || input.boxCount() <= 0)) throw badRequest("托盘或箱子签收的每 SKU 箱数必须大于 0");
if ("PARCEL".equals(unitType) && input.boxCount() != null && input.boxCount() != 0) throw badRequest("包裹签收不填写箱数");
}
// 托盘总箱数以本次各 SKU 箱数之和为准,避免操作员重复填写并产生不一致。
long totalBoxCount = "BOX".equals(unitType) ? 1 : "PALLET".equals(unitType)
? boxCountBySku.values().stream().mapToLong(Long::longValue).sum() : 0;
if ("PALLET".equals(unitType) && totalBoxCount <= 0) throw badRequest("托盘签收的总箱数必须大于 0");
if ("PALLET".equals(unitType) && boxCountBySku.values().stream().anyMatch(count -> count > totalBoxCount))
throw badRequest("每 SKU 箱数不能超过本托总箱数");
if ("BOX".equals(unitType) && boxCountBySku.values().stream().anyMatch(count -> count != 1))
throw badRequest("单箱签收时每 SKU 箱数应为 1");
if ("PARCEL".equals(unitType) && request.totalBoxCount() != null) throw badRequest("包裹签收不填写箱数");
if (unitCode == null && "PALLET".equals(unitType)) unitCode = text(request.palletCode());
if (unitCode == null && "BOX".equals(unitType))
unitCode = "RC" + hash(List.of(order.getReturnNo(), IdWorker.getId())).substring(0, 16).toUpperCase(java.util.Locale.ROOT) + "#1-1";
if (unitCode == null) throw badRequest(("PALLET".equals(unitType) ? "托盘码" : "包裹码") + "不能为空");
if ("PALLET".equals(unitType)
&& (request.length() == null || request.width() == null || request.height() == null || request.weight() == null
|| request.length().signum() <= 0 || request.width().signum() <= 0
|| request.height().signum() <= 0 || request.weight().signum() <= 0)) {
throw badRequest("托盘签收必须填写有效的长、宽、高和重量");
}
LocalDateTime now = LocalDateTime.now();
long receiptId = IdWorker.getId();
ReturnReceipt receipt = new ReturnReceipt();
@@ -457,7 +560,10 @@ public class ReturnReceiptService {
receipt.setReceiptNo(numbers.next("RETURN_RECEIPT", order.getOverseasWarehouseId(), receiptId));
receipt.setReceiptBatchNo(numbers.next("RETURN_BATCH", order.getOverseasWarehouseId(), receiptId));
receipt.setTrackingNo(trackingNo); receipt.setBoundBy(startedBy); receipt.setStatus("SIGNED");
receipt.setPalletCode(text(request.palletCode()));
receipt.setUnitType(unitType); receipt.setUnitCode(unitCode);
receipt.setTotalBoxCount(totalBoxCount);
receipt.setPalletCode("PALLET".equals(unitType) ? unitCode : ("BOX".equals(unitType) ? text(request.palletCode()) : null));
receipt.setLength(request.length()); receipt.setWidth(request.width()); receipt.setHeight(request.height()); receipt.setWeight(request.weight());
receipt.setReceivedQuantity(receiptQuantity); receipt.setOperatorId(identity.operatorKey());
receipt.setOperatorName(identity.operatorName()); receipt.setIdempotencyKey(key); receipt.setRequestHash(requestHash);
receipt.setSignedAt(now); receipt.setCreatedAt(now);
@@ -474,7 +580,7 @@ public class ReturnReceiptService {
ReturnReceiptItem receiptItem = new ReturnReceiptItem();
receiptItem.setId(IdWorker.getId()); receiptItem.setReceiptId(receiptId); receiptItem.setReturnOrderItemId(item.getId());
receiptItem.setCustomerId(order.getCustomerId()); receiptItem.setSkuCode(item.getSkuCode());
receiptItem.setReceivedQuantity(allocation.quantity()); receiptItems.insert(receiptItem);
receiptItem.setReceivedQuantity(allocation.quantity()); receiptItem.setBoxCount("PARCEL".equals(unitType) ? null : boxCountBySku.get(item.getSkuCode())); receiptItems.insert(receiptItem);
}
Map<String, ReturnInventoryService.ReceiptLine> inventoryLines = new LinkedHashMap<>();
for (Allocation allocation : allocations) {
@@ -484,18 +590,22 @@ public class ReturnReceiptService {
left.skuCode(), left.productNameZh() == null ? right.productNameZh() : left.productNameZh(),
Math.addExact(left.quantity(), right.quantity())));
}
if (allocations.stream().map(allocation -> allocation.item().getSkuCode()).distinct().count() != 1) {
throw conflict("一个签收批次只能签收一个 SKU");
List<ReturnInventoryCarton> inventoryCartons;
if ("PALLET".equals(unitType)) {
inventoryCartons = inventory.createReceiptCartons(order, receipt, new ArrayList<>(inventoryLines.values()), boxCountBySku);
} else if ("PARCEL".equals(unitType)) {
inventoryCartons = new ArrayList<>();
for (var line : inventoryLines.values())
inventoryCartons.add(inventory.createReceiptCarton(order, receipt, null, List.of(line)));
} else {
inventoryCartons = List.of(inventory.createReceiptCarton(order, receipt, unitCode, new ArrayList<>(inventoryLines.values())));
}
receipt.setCartonMark(inventory.createReceiptCarton(order, receipt, text(request.palletCode()),
new ArrayList<>(inventoryLines.values())).getCartonMark());
receipt.setCartonMark(inventoryCartons.get(0).getCartonMark());
receipts.updateById(receipt);
syncTailCargo(order, receipt, allocations, now);
List<ReturnOrderItem> finalItems = orderItems.listOrder(order.getId());
long received = finalItems.stream().mapToLong(item -> value(item.getReceivedQuantity())).sum();
boolean completed = finalItems.stream().allMatch(item -> value(item.getReceivedQuantity()) == value(item.getExpectedQuantity()));
if (completed && photoFiles != null && photoList(order).isEmpty()) {
throw conflict("本次将完成签收,请先拍摄并上传退货打托照片");
}
order.setReceivedQuantity(received); order.setStatus(completed ? COMPLETED : PARTIALLY_RECEIVED); order.setUpdatedAt(now); orders.updateById(order);
syncReturnTail(order, completed ? "INBOUND_COMPLETED" : "PARTIALLY_RECEIVED", completed || received > 0);
ReturnReceiptBatch batch = new ReturnReceiptBatch();
@@ -517,6 +627,50 @@ public class ReturnReceiptService {
lastlegOrders.updateById(tail);
}
/** Keep the platform tail-leg order detail in sync with PDA receipt facts. */
private void syncTailCargo(ReturnOrder order, ReturnReceipt receipt, List<Allocation> allocations, LocalDateTime now) {
if (lastlegOrderDetails == null || order.getLastlegOrderId() == null) return;
LastlegOrder tail = lastlegOrders.selectById(order.getLastlegOrderId());
if (tail == null || !Objects.equals(order.getCustomerId(), tail.getCustomerId())) return;
Map<String, Long> quantities = new LinkedHashMap<>();
Map<String, Long> boxes = new LinkedHashMap<>();
Map<String, String> names = new LinkedHashMap<>();
for (Allocation allocation : allocations) {
String sku = allocation.item().getSkuCode();
quantities.merge(sku, allocation.quantity(), Math::addExact);
names.putIfAbsent(sku, allocation.item().getProductNameZh());
}
for (ReturnReceiptItem line : receiptItems.listReceipt(receipt.getId())) {
boxes.merge(line.getSkuCode(), value(line.getBoxCount()), Math::addExact);
}
int packageCount = 0;
boolean firstSku = true;
for (Map.Entry<String, Long> entry : quantities.entrySet()) {
int skuBoxes = Math.toIntExact("PARCEL".equals(receipt.getUnitType()) ? 1 : boxes.getOrDefault(entry.getKey(), 0L));
if (skuBoxes <= 0) skuBoxes = 1;
LastlegOrderDetail detail = new LastlegOrderDetail();
detail.setId(IdWorker.getId()); detail.setCustomerId(order.getCustomerId()); detail.setOrderId(tail.getId());
detail.setSourceInboundDetailId("RETURN_RECEIPT:" + receipt.getId() + ":" + entry.getKey());
detail.setSkuCode(entry.getKey()); detail.setProductNameZh(names.get(entry.getKey())); detail.setPackageCount(skuBoxes);
detail.setProductQuantity(Math.toIntExact(entry.getValue())); detail.setPalletCount("PALLET".equals(receipt.getUnitType()) ? 1 : 0);
detail.setGrossWeightKg(firstSku ? receipt.getWeight() : null); detail.setVolumeCbm(firstSku && receipt.getLength() != null && receipt.getWidth() != null && receipt.getHeight() != null
? receipt.getLength().multiply(receipt.getWidth()).multiply(receipt.getHeight()).divide(java.math.BigDecimal.valueOf(1_000_000)) : null);
detail.setShippingMark(receipt.getCartonMark());
lastlegOrderDetails.insert(detail);
packageCount = Math.addExact(packageCount, skuBoxes);
firstSku = false;
}
tail.setPackageCount(Math.addExact(tail.getPackageCount() == null ? 0 : tail.getPackageCount(), packageCount));
tail.setReceivedPackageCount(Math.addExact(tail.getReceivedPackageCount() == null ? 0 : tail.getReceivedPackageCount(), packageCount));
tail.setProductQuantity(Math.addExact(tail.getProductQuantity() == null ? 0 : tail.getProductQuantity(), Math.toIntExact(allocations.stream().mapToLong(Allocation::quantity).sum())));
if ("PALLET".equals(receipt.getUnitType())) tail.setPalletCount(Math.addExact(tail.getPalletCount() == null ? 0 : tail.getPalletCount(), 1));
if (receipt.getLength() != null && receipt.getWidth() != null && receipt.getHeight() != null) {
tail.setVolumeCbm(receipt.getLength().multiply(receipt.getWidth()).multiply(receipt.getHeight()).divide(java.math.BigDecimal.valueOf(1_000_000)));
}
tail.setActualArrivalAt(tail.getActualArrivalAt() == null ? now : tail.getActualArrivalAt());
tail.setUpdatedAt(now); lastlegOrders.updateById(tail);
}
private List<RelabelPhotoFileGateway.UploadedFile> photoList(ReturnOrder order) {
return photoFiles == null ? List.of() : photoFiles.list(order.getCustomerId(), order.getId());
}
@@ -558,8 +712,22 @@ public class ReturnReceiptService {
return confirmed(order, receipt, COMPLETED.equals(order.getStatus()));
}
private PdaReturnReceiptResponses.Confirmed confirmed(ReturnOrder order, ReturnReceipt receipt, boolean completed) {
String cartonMark = text(receipt.getCartonMark());
String printableHtml = null;
List<ReturnOrderResponses.CartonLabel> labels = new java.util.ArrayList<>();
if (receipt.getId() != null) {
for (ReturnInventoryCarton carton : cartons.listReceipt(receipt.getId())) {
var label = inventory.label(order.getCustomerId(), carton.getCartonMark());
if (label != null) labels.add(new ReturnOrderResponses.CartonLabel(carton.getCartonMark(), label.printableHtml()));
}
}
if (cartonMark != null) {
var label = inventory.label(order.getCustomerId(), cartonMark);
if (label != null) printableHtml = label.printableHtml();
}
return new PdaReturnReceiptResponses.Confirmed(order.getReturnNo(), receipt.getReceiptNo(), receipt.getReceiptBatchNo(), receipt.getPalletCode(),
order.getStatus(), completed, value(order.getExpectedQuantity()), value(order.getReceivedQuantity()), receipt.getSignedAt());
order.getStatus(), completed, value(order.getExpectedQuantity()), value(order.getReceivedQuantity()), receipt.getSignedAt(), receipt.getUnitType(), receipt.getUnitCode(),
cartonMark, printableHtml, value(receipt.getTotalBoxCount()), labels);
}
private Map<Long, List<com.baiyue.lastleg.dto.RelabelSubOrder>> relabelSubOrders(List<ReturnOrder> values) {
var ids = values.stream().map(ReturnOrder::getLastlegOrderId).filter(java.util.Objects::nonNull).distinct().toList();
@@ -23,6 +23,7 @@ public final class OverseasPermissions {
public static final String INVENTORY_EXPORT = "platform:overseas-warehouse:inventory:export";
public static final String INVENTORY_EDIT = "platform:overseas-warehouse:inventory:edit";
public static final String INVENTORY_ADJUST = "platform:overseas-warehouse:inventory:adjust";
public static final String INVENTORY_MANAGE = "lastleg:inventory:manage";
public static final String DELIVERY_LIST = "platform:overseas-warehouse:delivery:list";
public static final String DELIVERY_RETURN = "platform:overseas-warehouse:delivery:return";
public static final String DELIVERY_STATUS = "platform:overseas-warehouse:delivery:status";
@@ -79,6 +80,7 @@ public final class OverseasPermissions {
public static final String LOOSEBOARD_CREATE = "platform:overseas-business:looseboard:create";
public static final String LOOSEBOARD_EDIT = "platform:overseas-business:looseboard:edit";
public static final String LOOSEBOARD_STATUS = "platform:overseas-business:looseboard:status";
public static final String CARGO_DISCARD_LIST = "platform:overseas-warehouse:discard:list";
public static final String QUOTE_LIST = "platform:overseas-master-data:quote:list";
public static final String QUOTE_CREATE = "platform:overseas-master-data:quote:create";
public static final String QUOTE_EDIT = "platform:overseas-master-data:quote:edit";
@@ -576,6 +576,7 @@ public class LastlegOrderService {
String currentStatus = StringUtils.hasText(query.currentStatus()) ? query.currentStatus() : query.status();
String warehouse = StringUtils.hasText(query.warehouse()) ? query.warehouse().trim() : null;
return new LambdaQueryWrapper<LastlegOrder>()
.ne(LastlegOrder::getOrderType, "CARGO_DISCARD")
.eq(StringUtils.hasText(customerId), LastlegOrder::getCustomerId, customerId)
.and(StringUtils.hasText(query.orderNo()), nested -> nested
.like(LastlegOrder::getOrderNo, query.orderNo())
@@ -1937,7 +1937,8 @@ CREATE TABLE IF NOT EXISTS ll_dispatch_pda_task (
CREATE TABLE IF NOT EXISTS ll_dispatch_pda_pick (
id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', task_id BIGINT NOT NULL COMMENT 'PDA任务ID',
dispatch_item_id BIGINT NOT NULL COMMENT '派车明细ID', inventory_position_id BIGINT NOT NULL COMMENT '库存仓位记录ID',
dispatch_item_id BIGINT NOT NULL COMMENT '派车明细ID', inventory_position_id BIGINT NULL COMMENT '普通库存仓位记录ID',
return_carton_id BIGINT NULL COMMENT '独立退货库存箱ID,与普通仓位互斥',
packages BIGINT NOT NULL COMMENT '本次操作箱数', product_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '产品数量', idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键',
picked_at DATETIME(3) NOT NULL COMMENT '拣货时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
PRIMARY KEY (id), UNIQUE KEY uk_ll_dispatch_pda_pick_key (customer_id,idempotency_key),
@@ -1982,8 +1983,9 @@ CREATE TABLE IF NOT EXISTS ll_dispatch_pda_pick_order (
receipt_id BIGINT NULL COMMENT '入仓实收记录ID', order_id BIGINT NOT NULL COMMENT '订单ID', packages BIGINT NOT NULL COMMENT '本次操作箱数',
product_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '产品数量', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
return_receipt_id BIGINT NULL COMMENT '明确选用的真实退回入仓来源',
return_carton_id BIGINT NULL COMMENT '原生退货箱来源ID,与实收来源互斥',
KEY idx_dispatch_pick_return_receipt(customer_id,return_receipt_id),
CONSTRAINT chk_dispatch_pick_receipt_source CHECK((receipt_id IS NOT NULL AND return_receipt_id IS NULL) OR (receipt_id IS NULL AND return_receipt_id IS NOT NULL)),
CONSTRAINT chk_dispatch_pick_receipt_source CHECK((receipt_id IS NOT NULL) + (return_receipt_id IS NOT NULL) + (return_carton_id IS NOT NULL) = 1),
PRIMARY KEY (id), KEY idx_ll_dispatch_pick_order_pick (customer_id,pick_id),
@@ -2572,6 +2574,7 @@ CREATE TABLE IF NOT EXISTS ll_dropship_carton_ledger (
CREATE TABLE IF NOT EXISTS ll_cargo_discard_application (
id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
customer_confirmed_at DATETIME(3) NULL COMMENT '客户确认完成时间',
discard_no VARCHAR(64) NOT NULL COMMENT '废弃申请单号', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID',
overseas_warehouse_name VARCHAR(255) NULL COMMENT '海外仓名称快照', inbound_codes VARCHAR(2000) NOT NULL COMMENT '入仓单号集合',
total_packages INT NOT NULL COMMENT '总箱数', fee_per_package DECIMAL(18,2) NOT NULL COMMENT '每箱处理费用',
@@ -2594,6 +2597,7 @@ CREATE TABLE IF NOT EXISTS ll_cargo_discard_item (
return_carton_id BIGINT NULL COMMENT '退货库存箱ID', inbound_code VARCHAR(128) NOT NULL COMMENT '入仓单号快照', profile_id BIGINT NULL COMMENT '托盘档案ID',
sku_inventory_id BIGINT NULL COMMENT 'SKU库存记录ID', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', product_name VARCHAR(255) NULL COMMENT '产品名称',
available_packages_snapshot INT NOT NULL COMMENT '可用箱数快照', discard_packages INT NOT NULL COMMENT '申请废弃箱数',
completion_photo_file_ids TEXT NULL COMMENT 'PDA完工照片文件ID JSON数组',
actual_discard_packages INT NULL COMMENT 'PDA确认实际废弃箱数', pda_status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT 'PDA处理状态:待处理PENDING、已完成COMPLETED',
pda_operator_id VARCHAR(128) NULL COMMENT 'PDA操作人', pda_completed_at DATETIME(3) NULL COMMENT 'PDA完成时间',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id),
@@ -2985,7 +2989,7 @@ CREATE TABLE IF NOT EXISTS ll_return_inventory_carton (
id BIGINT NOT NULL COMMENT '退货库存箱ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID', source_receipt_id BIGINT NULL COMMENT '来源PDA签收单ID', source_batch_no VARCHAR(128) NULL COMMENT '来源签收批次号', carton_mark VARCHAR(128) NOT NULL COMMENT '退货箱号和箱唛编码', status VARCHAR(32) NOT NULL COMMENT 'AVAILABLE可用或MERGED已合箱', sku_types INT NOT NULL COMMENT '箱内SKU种类数', total_quantity BIGINT NOT NULL COMMENT '箱内总件数', length DECIMAL(12,3) NULL COMMENT '箱长CM', width DECIMAL(12,3) NULL COMMENT '箱宽CM', height DECIMAL(12,3) NULL COMMENT '箱高CM', weight DECIMAL(12,3) NULL COMMENT '箱重KG', created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间', merged_at DATETIME(3) NULL COMMENT '合箱完成时间',
PRIMARY KEY(id), UNIQUE KEY uk_ll_return_inventory_carton_mark(carton_mark), UNIQUE KEY uk_ll_return_inventory_carton_receipt(source_receipt_id), KEY idx_ll_return_inventory_carton_customer(customer_id,overseas_warehouse_id,status,created_at), CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK((status IN('AVAILABLE','RELABEL_PENDING','OUTBOUNDED','DISCARD_PENDING') AND sku_types>0 AND total_quantity>0) OR (status IN('MERGED','DISCARDED') AND sku_types=0 AND total_quantity=0)), CONSTRAINT chk_ll_return_inventory_carton_status CHECK(status IN('AVAILABLE','RELABEL_PENDING','MERGED','OUTBOUNDED','DISCARD_PENDING','DISCARDED'))
) ENGINE=InnoDB COMMENT='退货实物库存箱及箱唛';
SET @ll_return_carton_add_pallet = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='pallet_code'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN pallet_code VARCHAR(128) NULL COMMENT ''签收时绑定的托盘码'' AFTER source_return_no'));
SET @ll_return_carton_add_pallet = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='pallet_code'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN pallet_code VARCHAR(128) NULL COMMENT ''签收时绑定的托盘码'' AFTER source_batch_no'));
PREPARE ll_return_carton_add_pallet_stmt FROM @ll_return_carton_add_pallet; EXECUTE ll_return_carton_add_pallet_stmt; DEALLOCATE PREPARE ll_return_carton_add_pallet_stmt;
-- 已合箱的来源箱保留审计记录,但其箱内库存必须归零;动态 SQL 兼容 Spring 的数据库初始化器。
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND constraint_name='chk_ll_return_inventory_carton_quantity'), 'ALTER TABLE ll_return_inventory_carton DROP CHECK chk_ll_return_inventory_carton_quantity', 'SELECT 1'));
@@ -3101,3 +3105,81 @@ CREATE TABLE IF NOT EXISTS event_delivery_attempt (
KEY idx_delivery_attempt_entry (service_name,entry_id,started_at),
KEY idx_delivery_attempt_outcome (outcome,started_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='本服务业务事件逐次投递审计';
-- 2026-10-08 退货合箱精确预占、来源追溯与箱级上架;MySQL 8,可重复执行。
SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND index_name='uk_ll_return_inventory_carton_receipt'), 'ALTER TABLE ll_return_inventory_carton DROP INDEX uk_ll_return_inventory_carton_receipt', 'SELECT 1'));
PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt;
SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_receipt' AND column_name='unit_type'), 'SELECT 1', 'ALTER TABLE ll_return_receipt ADD COLUMN unit_type VARCHAR(16) NULL COMMENT ''签收类型 PALLET/BOX/PARCEL'''));
PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt;
SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_receipt' AND column_name='unit_code'), 'SELECT 1', 'ALTER TABLE ll_return_receipt ADD COLUMN unit_code VARCHAR(128) NULL COMMENT ''签收单元业务码'''));
PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt;
SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_receipt' AND column_name='total_box_count'), 'SELECT 1', 'ALTER TABLE ll_return_receipt ADD COLUMN total_box_count BIGINT NULL COMMENT ''签收SKU箱数汇总'''));
PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt;
SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_receipt' AND column_name='length'), 'SELECT 1', 'ALTER TABLE ll_return_receipt ADD COLUMN length DECIMAL(12,3) NULL COMMENT ''实测尺寸cm'''));
PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt;
SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_receipt' AND column_name='width'), 'SELECT 1', 'ALTER TABLE ll_return_receipt ADD COLUMN width DECIMAL(12,3) NULL COMMENT ''实测尺寸cm'''));
PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt;
SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_receipt' AND column_name='height'), 'SELECT 1', 'ALTER TABLE ll_return_receipt ADD COLUMN height DECIMAL(12,3) NULL COMMENT ''实测尺寸cm'''));
PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt;
SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_receipt' AND column_name='weight'), 'SELECT 1', 'ALTER TABLE ll_return_receipt ADD COLUMN weight DECIMAL(12,3) NULL COMMENT ''实测重量kg'''));
PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt;
SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_receipt_item' AND column_name='box_count'), 'SELECT 1', 'ALTER TABLE ll_return_receipt_item ADD COLUMN box_count BIGINT NULL COMMENT ''SKU签收箱数'''));
PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt;
SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='source_unit_code'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN source_unit_code VARCHAR(128) NULL COMMENT ''来源单元业务码'''));
PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt;
CREATE TABLE IF NOT EXISTS ll_return_packing_plan (
id BIGINT NOT NULL COMMENT '合箱计划ID', plan_no VARCHAR(128) NOT NULL COMMENT '计划业务号',
customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID',
return_nos_json JSON NOT NULL COMMENT '来源退货单范围', boxes_json JSON NOT NULL COMMENT '目标箱明细',
mode VARCHAR(32) NOT NULL COMMENT 'SKU散件或BOX箱合并', status VARCHAR(32) NOT NULL COMMENT 'PENDING_REVIEW待审 READY已推送 COMPLETED完成 CANCELLED取消',
remark VARCHAR(1000) NULL COMMENT '合箱备注', idempotency_key VARCHAR(255) NOT NULL COMMENT '创建幂等键',
created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '更新时间',
pushed_at DATETIME(3) NULL COMMENT '审核推送时间', completed_at DATETIME(3) NULL COMMENT '作业完成时间',
PRIMARY KEY(id), UNIQUE KEY uk_ll_return_packing_plan_no(plan_no),
UNIQUE KEY uk_ll_return_packing_plan_key(customer_id,idempotency_key),
KEY idx_ll_return_packing_plan_warehouse(overseas_warehouse_id,status,pushed_at)
) ENGINE=InnoDB COMMENT='退货合箱计划';
SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton_item' AND column_name='packing_reserved_quantity'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton_item ADD COLUMN packing_reserved_quantity BIGINT NOT NULL DEFAULT 0 COMMENT ''合箱计划预占件数'''));
PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt;
SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_packing_plan' AND column_name='source_items_json'), 'SELECT 1', 'ALTER TABLE ll_return_packing_plan ADD COLUMN source_items_json JSON NULL COMMENT ''精确来源箱和SKU锁定数量'''));
PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt;
SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_packing_plan' AND column_name='result_marks_json'), 'SELECT 1', 'ALTER TABLE ll_return_packing_plan ADD COLUMN result_marks_json JSON NULL COMMENT ''完成生成或并入的目标箱唛'''));
PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt;
SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_packing_plan' AND column_name='request_hash'), 'SELECT 1', 'ALTER TABLE ll_return_packing_plan ADD COLUMN request_hash VARCHAR(64) NULL COMMENT ''创建载荷摘要'''));
PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt;
SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_packing_plan' AND column_name='completion_hash'), 'SELECT 1', 'ALTER TABLE ll_return_packing_plan ADD COLUMN completion_hash VARCHAR(64) NULL COMMENT ''完成载荷摘要'''));
PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt;
SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='location_code'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN location_code VARCHAR(128) NULL COMMENT ''退货箱当前库位'''));
PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt;
SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='putaway_at'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN putaway_at DATETIME(3) NULL COMMENT ''退货箱上架时间'''));
PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt;
SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='source_unit_type'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN source_unit_type VARCHAR(32) NULL COMMENT ''PALLET或BOX实物箱 PARCEL散SKU'''));
PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt;
SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND constraint_name='chk_ll_return_inventory_carton_quantity'), 'ALTER TABLE ll_return_inventory_carton DROP CHECK chk_ll_return_inventory_carton_quantity', 'SELECT 1'));
PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt;
SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND constraint_name='chk_ll_return_inventory_carton_status'), 'ALTER TABLE ll_return_inventory_carton DROP CHECK chk_ll_return_inventory_carton_status', 'SELECT 1'));
PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt;
ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK((status IN('AVAILABLE','PACKING_RESERVED','RELABEL_PENDING','OUTBOUND_PENDING','OUTBOUNDED','DISCARD_PENDING') AND sku_types>0 AND total_quantity>0) OR (status IN('MERGED','DISCARDED') AND sku_types=0 AND total_quantity=0));
ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_status CHECK(status IN('AVAILABLE','PACKING_RESERVED','RELABEL_PENDING','OUTBOUND_PENDING','MERGED','OUTBOUNDED','DISCARD_PENDING','DISCARDED'));
CREATE TABLE IF NOT EXISTS ll_return_packing_trace (
id BIGINT NOT NULL COMMENT '合箱追溯ID', customer_id VARCHAR(128) NOT NULL COMMENT '原客户隔离键',
operation_id BIGINT NOT NULL COMMENT '合箱操作ID', source_carton_mark VARCHAR(128) NOT NULL COMMENT '来源箱唛 空值表示目标箱原有库存快照',
target_carton_mark VARCHAR(128) NOT NULL COMMENT '目标箱唛', return_no VARCHAR(128) NOT NULL COMMENT '原始退货单号',
sku_code VARCHAR(128) NOT NULL COMMENT '商品SKU编码', quantity BIGINT NOT NULL COMMENT '本次移动件数',
created_at DATETIME(3) NOT NULL COMMENT '发生时间', PRIMARY KEY(id),
KEY idx_return_packing_trace_order(customer_id,return_no,created_at),
KEY idx_return_packing_trace_target(customer_id,target_carton_mark,sku_code),
KEY idx_return_packing_trace_source(customer_id,source_carton_mark,sku_code),
CONSTRAINT chk_return_packing_trace_quantity CHECK(quantity>0)
) ENGINE=InnoDB COMMENT='退货合箱逐SKU原始退货单不可变追溯';
ALTER TABLE ll_return_packing_trace MODIFY COLUMN sku_code VARCHAR(128) NOT NULL COMMENT '商品SKU编码';
SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_operation' AND column_name='result_json'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_operation ADD COLUMN result_json JSON NULL COMMENT ''幂等操作不可变返回快照'''));
PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt;
SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_packing_plan' AND column_name='result_labels_json'), 'SELECT 1', 'ALTER TABLE ll_return_packing_plan ADD COLUMN result_labels_json JSON NULL COMMENT ''计划完工标签快照'''));
PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt;
-- Existing outbound tables need this field even when created before the fee snapshot baseline.
USE baiyue_lastleg;
SET @return_outbound_ddl = IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_order' AND column_name='fee_coverage_json'), 'SELECT 1', 'ALTER TABLE ll_outbound_order ADD COLUMN fee_coverage_json JSON NULL COMMENT ''发运原费用及服务覆盖确认快照''');
PREPARE return_outbound_stmt FROM @return_outbound_ddl;
EXECUTE return_outbound_stmt;
DEALLOCATE PREPARE return_outbound_stmt;
@@ -1775,7 +1775,7 @@ paths:
/api/pda/v1/last-leg/return-receipts/confirm:
post:
summary: PDA 确认退货签收
description: PDA 仅能操作绑定海外仓。startedBy 记录通过退货单号或 SKU 候选进入;trackingNo 为本次包裹可选快递单号。按预报可签收数量校验、禁止超收;每次成功确认创建唯一签收单及仓储计费批次。全部预报 SKU 数量吻合时自动 COMPLETED。
description: PDA 仅能操作绑定海外仓。startedBy 记录通过退货单号或 SKU 候选进入;按 PALLET、BOX、PARCEL 选择现场实物维度。托盘必须提交托盘码、长宽高和重量并计算体积;箱子可散装,也可额外绑定托盘;包裹只需提交包裹/快递单号。按预报可签收数量校验、禁止超收;每次成功确认创建唯一签收单及仓储计费批次。全部预报 SKU 数量吻合时自动 COMPLETED。
security: [{ bearerAuth: [] }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ReturnReceiptConfirm' } } } }
responses: { '200': { description: 签收结果及自动完成状态 }, '409': { description: 超收、错绑或幂等键冲突 } }
@@ -2026,7 +2026,13 @@ components:
returnNo: { type: string, maxLength: 128 }
trackingNo: { type: string, nullable: true, maxLength: 128, description: '本次签收包裹有快递面单时扫描绑定;没有时为空' }
startedBy: { type: string, enum: [RETURN_NO, SKU], description: '本次通过退货单号查询或 SKU 候选开始签收' }
cartonMark: { type: string, nullable: true, maxLength: 128, description: PDA 实际绑定的箱号;不传时由系统生成 }
unitType: { type: string, enum: [PALLET, BOX, PARCEL], default: PALLET, description: 本次签收实物维度 }
unitCode: { type: string, nullable: true, maxLength: 128, description: 托盘码或包裹/快递单号;箱子不传时由系统生成内部箱号 }
palletCode: { type: string, nullable: true, maxLength: 128, description: 箱子可选的托盘绑定码;托盘类型下与 unitCode 相同 }
length: { type: number, nullable: true, exclusiveMinimum: true, description: 托盘长度,CM;托盘类型必填 }
width: { type: number, nullable: true, exclusiveMinimum: true, description: 托盘宽度,CM;托盘类型必填 }
height: { type: number, nullable: true, exclusiveMinimum: true, description: 托盘高度,CM;托盘类型必填 }
weight: { type: number, nullable: true, exclusiveMinimum: true, description: 托盘重量,KG;托盘类型必填 }
idempotencyKey: { type: string, maxLength: 255 }
items:
type: array
@@ -2038,6 +2044,7 @@ components:
properties:
skuCode: { type: string, maxLength: 128 }
receivedQuantity: { type: integer, minimum: 1 }
boxCount: { type: integer, nullable: true, minimum: 1, description: 托盘或箱子签收时该 SKU 对应箱数,包裹签收不填 }
ReturnInventoryMerge:
type: object
required: [returnNos, items, length, width, height, weight, idempotencyKey]
@@ -858,9 +858,11 @@ components:
remark: { type: string, maxLength: 1000, nullable: true }
DispatchPick:
type: object
required: [positionId, packages, productQuantity, idempotencyKey]
description: 普通库存提交positionId;原生退货换标箱提交cartonMark、positionId为空、packages=1,productQuantity必须与箱快照一致。两类来源互斥。
required: [packages, productQuantity, idempotencyKey]
properties:
positionId: { type: string, pattern: '^[0-9]+$' }
positionId: { type: string, pattern: '^[0-9]+$', nullable: true }
cartonMark: { type: string, maxLength: 255, description: 本排车来源的系统箱唛 }
packages: { type: integer, format: int64, minimum: 1 }
productQuantity: { type: integer, format: int64, minimum: 0 }
idempotencyKey: { type: string, minLength: 1, maxLength: 255 }
@@ -0,0 +1,157 @@
openapi: 3.0.3
info:
title: 退货合箱计划与箱级上架
version: 1.0.0
description: 散SKU查询客户全部同仓PARCEL可用库存;箱合并精确指定来源箱唛并扫描已有目标箱。托盘仅为可选载体。合箱不新增订单货量。
security: [{ bearerAuth: [] }]
paths:
/api/platform/v1/last-leg/return-packing-plans:
get:
summary: 合箱计划分页
description: return-inventory:list;page/size/status/planNo 查询,planNo 去除首尾空格后按字面包含匹配,统计与分页使用相同条件。每条计划返回 createdAt 和 completedAt(实际合箱完成时间);已完成计划 resultCartons 返回实际箱唛、SKU数量及当前库位、载体托盘。完成时间不使用箱子的 putawayAt,原箱多次合箱时仍显示本次计划完成时间。
parameters:
- { name: planNo, in: query, schema: { type: string, maxLength: 128 }, description: 合箱计划号,支持部分编号查询 }
- { name: customerId, in: query, schema: { type: string, maxLength: 64 }, description: 客户筛选 }
- { name: overseasWarehouseId, in: query, schema: { type: string, maxLength: 64 }, description: 海外仓筛选 }
- { name: skuCode, in: query, schema: { type: string, maxLength: 128 }, description: 计划箱内 SKU,支持部分编码;多条件同时匹配 }
responses: { '200': { description: 业务响应信封 code为200成功,A0400/A0404/A0409校验或状态错误,requestId及traceId保留 }, '403': { description: 权限或角色不符 } }
post:
summary: 创建并锁定来源库存
description: return-inventory:list + lastleg:inventory:manage;同客户同仓已签收订单,BOX精确锁来源箱全部SKU,SKU只锁散件指定数量。
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/Create' } } } }
responses: { '200': { description: 业务响应信封 code为200成功,A0400/A0404/A0409校验或状态错误,requestId及traceId保留 }, '403': { description: 权限或角色不符 } }
/api/platform/v1/last-leg/return-packing-plans/{planNo}/review:
post:
summary: 审核并推送PDA
description: return-inventory:list + lastleg:inventory:manage;PENDING_REVIEW→READY。
parameters: [{ name: planNo, in: path, required: true, schema: { type: string } }]
responses: { '200': { description: 业务响应信封 code为200成功,A0400/A0404/A0409校验或状态错误,requestId及traceId保留 }, '403': { description: 权限或角色不符 } }
/api/platform/v1/last-leg/return-packing-plans/{planNo}/cancel:
post:
summary: 取消并释放本计划预占
description: return-inventory:list + lastleg:inventory:manage;仅待审或READY,完成不可取消。
parameters: [{ name: planNo, in: path, required: true, schema: { type: string } }]
responses: { '200': { description: 业务响应信封 code为200成功,A0400/A0404/A0409校验或状态错误,requestId及traceId保留 }, '403': { description: 权限或角色不符 } }
/api/pda/v1/last-leg/return-inventory/packing/plans:
get:
summary: 绑定仓已审核合箱计划
description: lastleg:pda:operate + ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR;只返回READY任务。
responses: { '200': { description: 业务响应信封 code为200成功,A0400/A0404/A0409校验或状态错误,requestId及traceId保留 }, '403': { description: 权限或角色不符 } }
/api/pda/v1/last-leg/return-inventory/packing/plans/{planNo}:
get:
summary: 读取精确锁定的来源和数量
description: 绑定仓范围;sources含来源箱逐SKU锁定件数,boxes含汇总数量。
parameters: [{ name: planNo, in: path, required: true, schema: { type: string } }]
responses: { '200': { description: 业务响应信封 code为200成功,A0400/A0404/A0409校验或状态错误,requestId及traceId保留 }, '403': { description: 权限或角色不符 } }
/api/pda/v1/last-leg/return-inventory/packing/plans/{planNo}/complete:
post:
summary: 多箱分配并完成计划
description: SKU目标总量须等于锁定量。BOX先选择新增目标箱或扫描已有目标箱,sourceDeductions逐来源箱及SKU指定实际扣除量(不超过锁定量),未使用预占释放;目标原库存不扣减,新箱须填写箱规并生成系统箱唛。全事务、同载荷重放标签快照。
parameters: [{ name: planNo, in: path, required: true, schema: { type: string } }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/Complete' } } } }
responses: { '200': { description: 业务响应信封 code为200成功,A0400/A0404/A0409校验或状态错误,requestId及traceId保留 }, '403': { description: 权限或角色不符 } }
/api/pda/v1/last-leg/return-inventory/cartons/{cartonMark}:
get:
summary: 扫描可用来源或目标箱
description: 仅同仓AVAILABLE实物箱,PARCEL不作为箱选择。
parameters: [{ name: cartonMark, in: path, required: true, schema: { type: string } }]
responses: { '200': { description: 业务响应信封 code为200成功,A0400/A0404/A0409校验或状态错误,requestId及traceId保留 }, '403': { description: 权限或角色不符 } }
/api/pda/v1/last-leg/return-inventory/cartons/merge-boxes:
post:
summary: 来源箱并入已有目标箱
description: 同客户同仓,已有目标箱须与来源不同。sourceDeductions指定各来源箱SKU实际扣除量;未传则整箱移动。targetCartonMark为空时新增系统箱,必须填写箱规。保留逐退货单来源追溯。
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/BoxMerge' } } } }
responses: { '200': { description: 业务响应信封 code为200成功,A0400/A0404/A0409校验或状态错误,requestId及traceId保留 }, '403': { description: 权限或角色不符 } }
/api/pda/v1/last-leg/return-inventory/cartons/pack:
post:
summary: 散SKU分配多个新箱
description: 同客户全部散件来源,所有新箱原子提交;同键异内容拒绝。
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/Pack' } } } }
responses: { '200': { description: 业务响应信封 code为200成功,A0400/A0404/A0409校验或状态错误,requestId及traceId保留 }, '403': { description: 权限或角色不符 } }
/api/pda/v1/last-leg/return-inventory/cartons/{cartonMark}/putaway:
post:
summary: 退货箱扫码库位上架
description: 绑定仓有效非虚拟库位、AVAILABLE箱且无预占;palletCode选填。返回库位、托盘、上架时间,不计为普通头程新收货。
parameters: [{ name: cartonMark, in: path, required: true, schema: { type: string } }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/Putaway' } } } }
responses: { '200': { description: 业务响应信封 code为200成功,A0400/A0404/A0409校验或状态错误,requestId及traceId保留 }, '403': { description: 权限或角色不符 } }
components:
securitySchemes:
bearerAuth: { type: http, scheme: bearer, bearerFormat: JWT }
schemas:
CartonMergeHistory:
type: object
description: 箱号详情 mergeHistory 按时间倒序显示实际合入及合出数量,原有目标库存不计入本次合箱数量;历史未关联计划号时 planNo 为空。
properties:
planNo: { type: string, nullable: true }
sourceCartonMark: { type: string }
targetCartonMark: { type: string }
returnNo: { type: string }
skuCode: { type: string }
quantity: { type: integer, format: int64 }
createdAt: { type: string, format: date-time }
Item:
type: object
required: [skuCode, quantity]
properties:
skuCode: { type: string, maxLength: 128 }
quantity: { type: integer, format: int64, minimum: 1 }
Box:
type: object
properties:
items: { type: array, items: { $ref: '#/components/schemas/Item' }, maxItems: 100 }
targetCartonMark: { type: string, maxLength: 128, description: BOX模式已有目标箱 }
length: { type: number, exclusiveMinimum: true, minimum: 0, description: cm }
width: { type: number, exclusiveMinimum: true, minimum: 0, description: cm }
height: { type: number, exclusiveMinimum: true, minimum: 0, description: cm }
weight: { type: number, exclusiveMinimum: true, minimum: 0, description: kg }
Create:
type: object
required: [returnNos, mode, boxes, idempotencyKey]
properties:
returnNos: { type: array, minItems: 1, maxItems: 100, items: { type: string } }
mode: { type: string, enum: [SKU, BOX] }
sourceCartonMarks: { type: array, maxItems: 500, items: { type: string } }
boxes: { type: array, minItems: 1, maxItems: 100, items: { $ref: '#/components/schemas/Box' } }
remark: { type: string, maxLength: 1000 }
idempotencyKey: { type: string, maxLength: 255 }
Complete:
type: object
required: [boxes, idempotencyKey]
properties:
boxes: { type: array, minItems: 1, maxItems: 100, items: { $ref: '#/components/schemas/Box' } }
idempotencyKey: { type: string, maxLength: 255 }
sourceDeductions: { type: array, maxItems: 1000, items: { $ref: '#/components/schemas/SourceDeduction' } }
SourceDeduction:
type: object
required: [cartonMark, skuCode, quantity]
properties:
cartonMark: { type: string }
skuCode: { type: string }
quantity: { type: integer, format: int64, minimum: 1 }
Pack:
allOf:
- { $ref: '#/components/schemas/Complete' }
- type: object
required: [returnNos]
properties:
returnNos: { type: array, minItems: 1, maxItems: 100, items: { type: string } }
BoxMerge:
type: object
required: [sourceCartonMarks, idempotencyKey]
properties:
sourceCartonMarks: { type: array, minItems: 1, maxItems: 100, items: { type: string } }
targetCartonMark: { type: string }
idempotencyKey: { type: string, maxLength: 255 }
sourceDeductions: { type: array, maxItems: 1000, items: { $ref: '#/components/schemas/SourceDeduction' } }
length: { type: number, minimum: 0, exclusiveMinimum: true }
width: { type: number, minimum: 0, exclusiveMinimum: true }
height: { type: number, minimum: 0, exclusiveMinimum: true }
weight: { type: number, minimum: 0, exclusiveMinimum: true }
Putaway:
type: object
required: [locationCode, idempotencyKey]
properties:
locationCode: { type: string, maxLength: 128 }
palletCode: { type: string, maxLength: 128 }
idempotencyKey: { type: string, maxLength: 255 }
@@ -40,6 +40,11 @@ import com.baiyue.lastleg.dropship.mapper.DropshipCartonMapper;
import com.baiyue.lastleg.dropship.mapper.DropshipCartonSkuMapper;
import com.baiyue.lastleg.dropship.mapper.DropshipSkuInventoryMapper;
import com.baiyue.lastleg.dropship.model.DropshipOutboundCandidate;
import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton;
import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCartonItem;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryBalanceMapper;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -58,9 +63,11 @@ import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
@@ -113,6 +120,9 @@ class DeliveryTransferServiceTest {
@Mock DropshipCartonSkuMapper dropshipCartonSkus;
@Mock DropshipSkuInventoryMapper dropshipSkuInventory;
@Mock WarehouseTransferPalletMapper warehouseTransferPallets;
@Mock ReturnInventoryCartonMapper returnCartons;
@Mock ReturnInventoryCartonItemMapper returnCartonItems;
@Mock ReturnInventoryBalanceMapper returnBalances;
private DeliveryTransferService service;
@Mock com.baiyue.lastleg.fleet.trip.FleetDispatchBindingGuard fleetBindings;
@@ -152,6 +162,228 @@ class DeliveryTransferServiceTest {
ReflectionTestUtils.setField(service, "dropshipCartonSkus", dropshipCartonSkus);
ReflectionTestUtils.setField(service, "dropshipSkuInventory", dropshipSkuInventory);
ReflectionTestUtils.setField(service, "warehouseTransferPallets", warehouseTransferPallets);
ReflectionTestUtils.setField(service, "returnCartons", returnCartons);
ReflectionTestUtils.setField(service, "returnCartonItems", returnCartonItems);
ReflectionTestUtils.setField(service, "returnBalances", returnBalances);
}
@Test
void returnBoxOutboundCanSelectOneCartonFromAPallet() {
ReturnInventoryCarton first = returnCarton("RC-1", "PAL-1");
ReturnInventoryCarton second = returnCarton("RC-2", "PAL-1");
when(returnCartons.lockMark("RC-1")).thenReturn(first);
when(returnCartons.listActivePallet("WH-1", "PAL-1")).thenReturn(List.of(first, second));
service.createReturnOutbound("TENANT-A", "U-1",
new DeliveryRequests.ReturnOutbound("receiver", "WH-1", "RT-1", List.of("RC-1"), null, "return-1"));
ArgumentCaptor<OutboundPalletItem> item = ArgumentCaptor.forClass(OutboundPalletItem.class);
verify(outboundPallets).insert(item.capture());
assertNull(item.getValue().getPalletNo());
verify(outboundOrders).insert(any(OutboundOrder.class));
}
@Test
void receivedPalletIsEligibleForWholePalletOutboundButParcelIsNot() {
ReturnInventoryCarton pallet = returnCarton("RC-PAL", "PAL-1");
pallet.setSourceUnitType("PALLET");
ReturnInventoryCarton parcel = returnCarton("RC-PARCEL", null);
parcel.setSourceUnitType("PARCEL");
when(returnCartons.findAvailable("TENANT-A", "WH-1")).thenReturn(List.of(pallet, parcel));
var candidates = service.returnOutboundCandidates("TENANT-A", "WH-1");
assertEquals(List.of("RC-PAL"), candidates.stream().map(DeliveryResponses.ReturnOutboundCandidate::cartonMark).toList());
}
@Test
void singleSkuReturnOutboundReservesParcelAndCreatesDelivery() {
ReturnInventoryCarton parcel = returnCarton("RC-PARCEL", null);
parcel.setId(901L); parcel.setSourceUnitType("PARCEL"); parcel.setTotalQuantity(3L);
ReturnInventoryCartonItem line = new ReturnInventoryCartonItem();
line.setSkuCode("SKU-A"); line.setQuantity(3L);
when(returnCartons.lockMark("RC-PARCEL")).thenReturn(parcel);
when(returnCartonItems.lockCarton(901L)).thenReturn(List.of(line));
service.createReturnOutbound("TENANT-A", "U-1",
new DeliveryRequests.ReturnOutbound("receiver", "WH-1", "RT-1",
List.of("RC-PARCEL"), null, "sku-return-1", "SKU-A"));
ArgumentCaptor<OutboundPalletItem> item = ArgumentCaptor.forClass(OutboundPalletItem.class);
ArgumentCaptor<DeliveryOrder> delivery = ArgumentCaptor.forClass(DeliveryOrder.class);
verify(outboundPallets).insert(item.capture());
verify(deliveryOrders).insert(delivery.capture());
assertEquals("SKU-A", item.getValue().getSkuCode());
assertEquals("RC-PARCEL", item.getValue().getCartonMarks());
assertEquals("OUTBOUND_PENDING", parcel.getStatus());
assertEquals("WAIT_DELIVERY", delivery.getValue().getStatus());
assertEquals(1, delivery.getValue().getPackageCount());
}
@Test
void singleSkuReturnOutboundRejectsMixedCarton() {
ReturnInventoryCarton mixed = returnCarton("RC-MIXED", null);
mixed.setId(901L); mixed.setTotalQuantity(5L);
ReturnInventoryCartonItem skuA = new ReturnInventoryCartonItem(); skuA.setSkuCode("SKU-A"); skuA.setQuantity(3L);
ReturnInventoryCartonItem skuB = new ReturnInventoryCartonItem(); skuB.setSkuCode("SKU-B"); skuB.setQuantity(2L);
when(returnCartons.lockMark("RC-MIXED")).thenReturn(mixed);
when(returnCartonItems.lockCarton(901L)).thenReturn(List.of(skuA, skuB));
assertEquals(409, assertThrows(ResponseStatusException.class, () -> service.createReturnOutbound("TENANT-A", "U-1",
new DeliveryRequests.ReturnOutbound("receiver", "WH-1", "RT-1",
List.of("RC-MIXED"), null, "sku-return-2", "SKU-A"))).getStatusCode().value());
verify(outboundOrders, never()).insert(any(OutboundOrder.class));
}
@Test
void singleSkuReturnPickAcceptsSkuScanAndRejectsOtherSku() {
OutboundOrder outbound = outbound(301L, null, null, "OUT-301", "sku-return-1");
outbound.setOverseasWarehouseId("WH-1"); outbound.setOutboundType("RETURN_OUTBOUND"); outbound.setStatus("WAIT_PICK");
OutboundPdaTask task = new OutboundPdaTask(); task.setId(701L); task.setCustomerId("TENANT-A");
task.setOutboundOrderId(301L); task.setTaskNo("PDA-701"); task.setStatus("PENDING");
OutboundPalletItem item = new OutboundPalletItem(); item.setId(801L); item.setCustomerId("TENANT-A");
item.setOutboundOrderId(301L); item.setSkuCode("SKU-A"); item.setCartonMarks("RC-1");
item.setPackageCount(1); item.setPickedPackageCount(0);
ReturnInventoryCarton carton = returnCarton("RC-1", null); carton.setId(901L);
carton.setStatus("OUTBOUND_PENDING"); carton.setTotalQuantity(3L);
ReturnInventoryCartonItem line = new ReturnInventoryCartonItem(); line.setSkuCode("SKU-A"); line.setQuantity(3L);
when(pdaTasks.lockByNoAndWarehouse("PDA-701", "WH-1")).thenReturn(task);
when(outboundOrders.selectById(301L)).thenReturn(outbound);
when(outboundOrders.lockByNo("TENANT-A", "OUT-301")).thenReturn(outbound);
when(outboundPallets.selectById(801L)).thenReturn(item);
when(outboundPallets.findByOutbound("TENANT-A", 301L)).thenReturn(List.of(item));
when(returnCartons.lockMark("RC-1")).thenReturn(carton);
when(returnCartonItems.lockCarton(901L)).thenReturn(List.of(line));
when(returnBalances.shipCartonSku("TENANT-A", "WH-1", "SKU-A", 3L)).thenReturn(1);
assertEquals(409, assertThrows(ResponseStatusException.class, () -> service.pdaPick("WH-1", "U-1", "PDA-701",
new DeliveryRequests.PdaPick(801L, null, 1L, "wrong-sku", List.of(), "SKU-B"))).getStatusCode().value());
service.pdaPick("WH-1", "U-1", "PDA-701",
new DeliveryRequests.PdaPick(801L, null, 1L, "correct-sku", List.of(), "SKU-A"));
assertEquals("OUTBOUNDED", carton.getStatus());
verify(returnBalances).shipCartonSku("TENANT-A", "WH-1", "SKU-A", 3L);
}
@Test
void returnDeliveryDetailShowsFrozenCartonSkuAndQuantity() {
DeliveryOrder delivery = new DeliveryOrder(); delivery.setId(501L); delivery.setCustomerId("TENANT-A");
delivery.setDeliveryNo("DL-501"); delivery.setOutboundOrderId(301L);
delivery.setOutboundNo("OUT-301"); delivery.setOverseasWarehouseId("WH-1");
delivery.setDeliveryGroupKey("RETURN:RT-1"); delivery.setStatus("WAIT_DELIVERY");
OutboundOrder outbound = outbound(301L, null, null, "OUT-301", "return-1");
outbound.setOverseasWarehouseId("WH-1"); outbound.setOutboundType("RETURN_OUTBOUND");
OutboundPalletItem item = new OutboundPalletItem(); item.setCartonMarks("RC-1");
ReturnInventoryCarton carton = returnCarton("RC-1", null); carton.setId(901L);
ReturnInventoryCartonItem line = new ReturnInventoryCartonItem();
line.setSkuCode("SKU-A"); line.setProductNameZh("测试品"); line.setQuantity(3L);
when(deliveryOrders.findByNo("TENANT-A", "DL-501")).thenReturn(delivery);
when(outboundOrders.selectById(301L)).thenReturn(outbound);
when(outboundPallets.findByOutbound("TENANT-A", 301L)).thenReturn(List.of(item));
when(returnCartons.findCustomerMark("TENANT-A", "RC-1")).thenReturn(carton);
when(returnCartonItems.listCarton(901L)).thenReturn(List.of(line));
var detail = service.deliveryDetail("TENANT-A", "DL-501");
assertEquals(1, detail.skus().size());
assertEquals("SKU-A", detail.skus().get(0).skuCode());
assertEquals(3L, detail.skus().get(0).productQuantity());
assertEquals(1, detail.skus().get(0).packageCount());
}
@Test
void returnCartonPickDeductsEverySkuOnce() {
OutboundOrder outbound = outbound(301L, null, null, "OUT-301", "return-1");
outbound.setCustomerId("TENANT-A"); outbound.setOverseasWarehouseId("WH-1");
outbound.setOutboundType("RETURN_OUTBOUND"); outbound.setStatus("WAIT_PICK");
OutboundPdaTask task = new OutboundPdaTask(); task.setId(701L); task.setCustomerId("TENANT-A");
task.setOutboundOrderId(301L); task.setTaskNo("PDA-701"); task.setStatus("PENDING");
OutboundPalletItem item = new OutboundPalletItem(); item.setId(801L); item.setCustomerId("TENANT-A");
item.setOutboundOrderId(301L); item.setPackageCount(1); item.setPickedPackageCount(0);
item.setCartonMarks("RC-1"); item.setStatus("WAIT_PICK");
ReturnInventoryCarton carton = returnCarton("RC-1", null); carton.setStatus("OUTBOUND_PENDING");
carton.setId(901L);
ReturnInventoryCartonItem skuA = new ReturnInventoryCartonItem(); skuA.setSkuCode("SKU-A"); skuA.setQuantity(3L);
ReturnInventoryCartonItem skuB = new ReturnInventoryCartonItem(); skuB.setSkuCode("SKU-B"); skuB.setQuantity(2L);
when(pdaTasks.lockByNoAndWarehouse("PDA-701", "WH-1")).thenReturn(task);
when(outboundOrders.selectById(301L)).thenReturn(outbound);
when(outboundOrders.lockByNo("TENANT-A", "OUT-301")).thenReturn(outbound);
when(outboundPallets.selectById(801L)).thenReturn(item);
when(outboundPallets.findByOutbound("TENANT-A", 301L)).thenReturn(List.of(item));
when(returnCartons.lockMark("RC-1")).thenReturn(carton);
when(returnCartonItems.lockCarton(901L)).thenReturn(List.of(skuA, skuB));
when(returnBalances.shipCartonSku("TENANT-A", "WH-1", "SKU-A", 3L)).thenReturn(1);
when(returnBalances.shipCartonSku("TENANT-A", "WH-1", "SKU-B", 2L)).thenReturn(1);
service.pdaPick("WH-1", "U-1", "PDA-701",
new DeliveryRequests.PdaPick(801L, null, 1L, "scan-rc-1", List.of("RC-1")));
verify(returnBalances).shipCartonSku("TENANT-A", "WH-1", "SKU-A", 3L);
verify(returnBalances).shipCartonSku("TENANT-A", "WH-1", "SKU-B", 2L);
assertEquals("OUTBOUNDED", carton.getStatus());
assertEquals("PICKED", item.getStatus());
}
@Test
void returnPalletRejectsIndividualCartonScan() {
OutboundOrder outbound = outbound(301L, null, null, "OUT-301", "return-2");
outbound.setOverseasWarehouseId("WH-1"); outbound.setOutboundType("RETURN_OUTBOUND");
OutboundPdaTask task = new OutboundPdaTask(); task.setId(701L); task.setCustomerId("TENANT-A");
task.setOutboundOrderId(301L); task.setTaskNo("PDA-701"); task.setStatus("PENDING");
OutboundPalletItem item = new OutboundPalletItem(); item.setId(801L); item.setCustomerId("TENANT-A");
item.setOutboundOrderId(301L); item.setPalletNo("PAL-1"); item.setCartonMarks("RC-1");
item.setPackageCount(1); item.setPickedPackageCount(0);
when(pdaTasks.lockByNoAndWarehouse("PDA-701", "WH-1")).thenReturn(task);
when(outboundOrders.selectById(301L)).thenReturn(outbound);
when(outboundOrders.lockByNo("TENANT-A", "OUT-301")).thenReturn(outbound);
when(outboundPallets.selectById(801L)).thenReturn(item);
assertEquals(409, assertThrows(ResponseStatusException.class, () -> service.pdaPick("WH-1", "U-1", "PDA-701",
new DeliveryRequests.PdaPick(801L, null, 1L, "scan-rc-1", List.of("RC-1")))).getStatusCode().value());
verify(returnBalances, never()).shipCartonSku(any(), any(), any(), anyLong());
}
@Test
void returnPalletScanShipsEveryCartonAndRejectsChangedPalletContents() {
OutboundOrder outbound = outbound(301L, null, null, "OUT-301", "return-3");
outbound.setOverseasWarehouseId("WH-1"); outbound.setOutboundType("RETURN_OUTBOUND");
OutboundPdaTask task = new OutboundPdaTask(); task.setId(701L); task.setCustomerId("TENANT-A");
task.setOutboundOrderId(301L); task.setTaskNo("PDA-701"); task.setStatus("PENDING");
OutboundPalletItem item = new OutboundPalletItem(); item.setId(801L); item.setCustomerId("TENANT-A");
item.setOutboundOrderId(301L); item.setPalletNo("PAL-1"); item.setCartonMarks("RC-1,RC-2");
item.setPackageCount(2); item.setPickedPackageCount(0);
ReturnInventoryCarton first = returnCarton("RC-1", "PAL-1"); first.setId(901L); first.setStatus("OUTBOUND_PENDING");
ReturnInventoryCarton second = returnCarton("RC-2", "PAL-1"); second.setId(902L); second.setStatus("OUTBOUND_PENDING");
ReturnInventoryCartonItem firstSku = new ReturnInventoryCartonItem(); firstSku.setSkuCode("SKU-A"); firstSku.setQuantity(3L);
ReturnInventoryCartonItem secondSku = new ReturnInventoryCartonItem(); secondSku.setSkuCode("SKU-B"); secondSku.setQuantity(2L);
when(pdaTasks.lockByNoAndWarehouse("PDA-701", "WH-1")).thenReturn(task);
when(outboundOrders.selectById(301L)).thenReturn(outbound);
when(outboundOrders.lockByNo("TENANT-A", "OUT-301")).thenReturn(outbound);
when(outboundPallets.findByOutbound("TENANT-A", 301L)).thenReturn(List.of(item));
when(returnCartons.listActivePallet("WH-1", "PAL-1")).thenReturn(List.of(first));
assertEquals(409, assertThrows(ResponseStatusException.class, () -> service.pdaPickPallet("WH-1", "U-1", "PDA-701",
"PAL-1", new DeliveryRequests.PdaPalletPick("scan-pal-1"))).getStatusCode().value());
verify(returnBalances, never()).shipCartonSku(any(), any(), any(), anyLong());
when(returnCartons.listActivePallet("WH-1", "PAL-1")).thenReturn(List.of(first, second));
when(returnCartons.lockMark("RC-1")).thenReturn(first);
when(returnCartons.lockMark("RC-2")).thenReturn(second);
when(returnCartonItems.lockCarton(901L)).thenReturn(List.of(firstSku));
when(returnCartonItems.lockCarton(902L)).thenReturn(List.of(secondSku));
when(returnBalances.shipCartonSku("TENANT-A", "WH-1", "SKU-A", 3L)).thenReturn(1);
when(returnBalances.shipCartonSku("TENANT-A", "WH-1", "SKU-B", 2L)).thenReturn(1);
service.pdaPickPallet("WH-1", "U-1", "PDA-701", "PAL-1", new DeliveryRequests.PdaPalletPick("scan-pal-2"));
assertEquals("OUTBOUNDED", first.getStatus()); assertEquals("OUTBOUNDED", second.getStatus());
assertEquals(2, item.getPickedPackageCount());
verify(returnBalances).shipCartonSku("TENANT-A", "WH-1", "SKU-A", 3L);
verify(returnBalances).shipCartonSku("TENANT-A", "WH-1", "SKU-B", 2L);
}
private static ReturnInventoryCarton returnCarton(String mark, String palletCode) {
ReturnInventoryCarton carton = new ReturnInventoryCarton();
carton.setCustomerId("TENANT-A"); carton.setOverseasWarehouseId("WH-1");
carton.setSourceReturnNo("RT-1"); carton.setSourceUnitType("BOX");
carton.setCartonMark(mark); carton.setPalletCode(palletCode); carton.setStatus("AVAILABLE");
return carton;
}
@Test
@@ -0,0 +1,146 @@
package com.baiyue.lastleg.delivery.service;
import com.baiyue.lastleg.delivery.dto.DeliveryRequests;
import com.baiyue.lastleg.delivery.entity.*;
import com.baiyue.lastleg.delivery.mapper.*;
import com.baiyue.lastleg.returnreceipt.entity.*;
import com.baiyue.lastleg.returnreceipt.mapper.*;
import com.baiyue.lastleg.returnreceipt.service.ReturnInventoryService;
import com.baiyue.lastleg.service.LastlegOrderNoGenerator;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
import static org.assertj.core.api.Assertions.*;
class ReturnOutboundClosureTest {
final DeliveryTransferService service = new DeliveryTransferService();
final OutboundOrderMapper outbounds = mock(OutboundOrderMapper.class);
final OutboundPalletItemMapper items = mock(OutboundPalletItemMapper.class);
final OutboundPdaTaskMapper tasks = mock(OutboundPdaTaskMapper.class);
final DeliveryOrderMapper deliveries = mock(DeliveryOrderMapper.class);
final ReturnInventoryCartonMapper cartons = mock(ReturnInventoryCartonMapper.class);
final ReturnInventoryCartonItemMapper contents = mock(ReturnInventoryCartonItemMapper.class);
final ReturnInventoryService stock = mock(ReturnInventoryService.class);
final OutboundPdaPickMapper picks = mock(OutboundPdaPickMapper.class);
final com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper relabelApplications = mock(com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper.class);
final com.baiyue.lastleg.relabel.service.RelabelApplicationService relabelService = mock(com.baiyue.lastleg.relabel.service.RelabelApplicationService.class);
ReturnInventoryCarton carton;
@BeforeEach void setup() {
ReflectionTestUtils.setField(service, "outboundOrders", outbounds);
ReflectionTestUtils.setField(service, "outboundPallets", items);
ReflectionTestUtils.setField(service, "pdaTasks", tasks);
ReflectionTestUtils.setField(service, "deliveryOrders", deliveries);
ReflectionTestUtils.setField(service, "returnCartons", cartons);
ReflectionTestUtils.setField(service, "returnCartonItems", contents);
ReflectionTestUtils.setField(service, "returnStock", stock);
ReflectionTestUtils.setField(service, "pdaPicks", picks);
ReflectionTestUtils.setField(service, "relabelApplications", relabelApplications);
ReflectionTestUtils.setField(service, "relabelService", relabelService);
ReflectionTestUtils.setField(service, "orderNoGenerator", new LastlegOrderNoGenerator());
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules());
carton = new ReturnInventoryCarton(); carton.setId(10L); carton.setCartonMark("RC#1-1");
carton.setCustomerId("C1"); carton.setOverseasWarehouseId("WH1"); carton.setSourceReturnNo("RT1,RT2");
carton.setStatus("AVAILABLE"); carton.setSourceUnitType("BOX"); carton.setTotalQuantity(6L); carton.setSkuTypes(1);
when(cartons.lockMark(carton.getCartonMark())).thenReturn(carton);
when(stock.matchesReturnSource(carton, "RT1")).thenReturn(true);
ReturnInventoryCartonItem line = new ReturnInventoryCartonItem(); line.setSkuCode("SKU1"); line.setQuantity(6L);
when(contents.lockCarton(10L)).thenReturn(List.of(line));
}
DeliveryRequests.ReturnOutbound command(String sku) {
return new DeliveryRequests.ReturnOutbound("address", "WH1", "RT1", List.of("RC#1-1"), null, "KEY1", sku);
}
@Test void reservesWholeMergedBoxAndCreatesDelivery() {
service.createReturnOutbound("C1", "USER", command(null));
assertThat(carton.getStatus()).isEqualTo("OUTBOUND_PENDING");
verify(items).insert(argThat((OutboundPalletItem i) -> i.getPackageCount() == 1 && "RT1,RT2".equals(i.getInboundCode())));
verify(deliveries).insert(argThat((DeliveryOrder d) -> "WAIT_DELIVERY".equals(d.getStatus()) && d.getPackageCount() == 1));
}
@Test void refusesSecondCommandForReservedBox() {
service.createReturnOutbound("C1", "USER", command(null));
assertThatThrownBy(() -> service.createReturnOutbound("C1", "USER", command(null)))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("占用");
verify(outbounds, times(1)).insert(any(OutboundOrder.class));
}
@Test void preventsSingleSkuBypassingExistingBox() {
assertThatThrownBy(() -> service.createReturnOutbound("C1", "USER", command("SKU1")))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("按箱唛");
verify(outbounds, never()).insert(any(OutboundOrder.class));
}
@Test void singleSkuParcelReservesAndKeepsScanSku() {
carton.setSourceUnitType("PARCEL");
service.createReturnOutbound("C1", "USER", command("SKU1"));
verify(items).insert(argThat((OutboundPalletItem i) -> "SKU1".equals(i.getSkuCode())));
assertThat(carton.getStatus()).isEqualTo("OUTBOUND_PENDING");
}
@Test void refusesPackingReservedQuantity() {
ReturnInventoryCartonItem line = new ReturnInventoryCartonItem(); line.setSkuCode("SKU1");
line.setQuantity(6L); line.setPackingReservedQuantity(1L);
when(contents.lockCarton(10L)).thenReturn(List.of(line));
assertThatThrownBy(() -> service.createReturnOutbound("C1", "USER", command(null)))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("合箱计划");
verify(outbounds, never()).insert(any(OutboundOrder.class));
}
@Test void exactScanIsRequiredOnReplayAsWell() {
OutboundPdaTask task = new OutboundPdaTask(); task.setId(20L); task.setCustomerId("C1"); task.setOutboundOrderId(30L);
when(tasks.lockByNoAndWarehouse("TASK1", "WH1")).thenReturn(task);
OutboundPdaPick pick = new OutboundPdaPick(); pick.setTaskId(20L); pick.setOutboundPalletItemId(40L); pick.setPackages(1L);
when(picks.findByKey("C1", "PICK1")).thenReturn(pick);
OutboundOrder outbound = new OutboundOrder(); outbound.setId(30L); outbound.setCustomerId("C1"); outbound.setOutboundType("RETURN_OUTBOUND");
when(outbounds.selectById(30L)).thenReturn(outbound);
OutboundPalletItem item = new OutboundPalletItem(); item.setId(40L); item.setCustomerId("C1"); item.setOutboundOrderId(30L);
item.setPackageCount(1); item.setCartonMarks("RC#1-1"); when(items.selectById(40L)).thenReturn(item);
assertThatThrownBy(() -> service.pdaPick("WH1", "USER", "TASK1",
new DeliveryRequests.PdaPick(40L, null, 1L, "PICK1", List.of("WRONG"))))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("指定的箱唛");
verify(stock, never()).shipCarton(anyString(), anyString(), anyString(), anyString(), anyString(), anyLong());
}
@Test void cancelsUntouchedNativeRelabelWithItsOwningApplication() {
OutboundOrder outbound = new OutboundOrder(); outbound.setId(30L); outbound.setCustomerId("C1");
outbound.setOutboundNo("OUT1"); outbound.setOutboundType("RELABEL_OUTBOUND"); outbound.setStatus("WAIT_PICK");
when(outbounds.findGlobalByNo("OUT1")).thenReturn(outbound);
when(outbounds.lockGlobalByNo("OUT1")).thenReturn(outbound);
var application = new com.baiyue.lastleg.relabel.entity.RelabelApplication();
application.setId(50L); application.setCustomerId("C1"); application.setRelabelNo("REL1");
application.setOutboundOrderNo("OUT1"); application.setStatus("READY_FOR_OUTBOUND");
when(relabelApplications.lockByOutbound("C1", "OUT1")).thenReturn(application);
OutboundPalletItem item = new OutboundPalletItem(); item.setCartonMarks("RC#1-1"); item.setStatus("WAIT_PICK");
item.setPickedPackageCount(0); when(items.findByOutbound("C1", 30L)).thenReturn(List.of(item));
carton.setStatus("OUTBOUND_PENDING"); carton.setRelabelApplicationId(50L);
service.cancelOutbound("OUT1", "OP1", new DeliveryRequests.Cancel("cancel"));
assertThat(carton.getStatus()).isEqualTo("RELABEL_PENDING");
assertThat(outbound.getStatus()).isEqualTo("CANCELLED");
verify(relabelService).changeStatus(eq("C1"), eq("REL1"), eq("OP1"),
argThat(request -> "CANCELLED".equals(request.status())));
var sequence = inOrder(relabelApplications, outbounds, cartons, relabelService);
sequence.verify(relabelApplications).lockByOutbound("C1", "OUT1");
sequence.verify(outbounds).lockGlobalByNo("OUT1");
sequence.verify(cartons).lockMark("RC#1-1");
sequence.verify(relabelService).changeStatus(anyString(), anyString(), anyString(), any());
}
@Test void cancellationRejectsPickedNativeBoxWithoutReleasingItsReservation() {
OutboundOrder outbound = new OutboundOrder(); outbound.setId(30L); outbound.setCustomerId("C1");
outbound.setOutboundType("RELABEL_OUTBOUND"); outbound.setStatus("WAIT_PICK");
when(outbounds.lockGlobalByNo("OUT1")).thenReturn(outbound);
OutboundPalletItem item = new OutboundPalletItem(); item.setStatus("WAIT_PICK"); item.setPickedPackageCount(1);
when(items.findByOutbound("C1", 30L)).thenReturn(List.of(item));
assertThatThrownBy(() -> service.cancelOutbound("OUT1", "OP1", new DeliveryRequests.Cancel("cancel")))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("execution has started");
verifyNoInteractions(relabelService);
verify(cartons, never()).updateById(any(ReturnInventoryCarton.class));
}
}
@@ -108,6 +108,7 @@ class CargoDiscardServiceTest {
assertEquals(2, detail.summary().totalPackages());
assertEquals(new BigDecimal("10.00"), detail.summary().totalFee());
assertEquals(2, storedLinks.size());
verify(orders, never()).insert(any(LastlegOrder.class));
verify(inventory, never()).deductSkuInventory(any(), anyLong(), anyInt());
}
@@ -59,6 +59,7 @@ class DispatchPdaWorkflowServiceTest {
@Mock InventoryPositionMapper positions;
@Mock InventoryCommandService inventory;
@Mock DispatchPickOrderAttributionService attribution;
@Mock com.baiyue.lastleg.dispatch.service.ReturnRelabelDispatchStock returnStock;
@Mock com.baiyue.lastleg.fleet.trip.FleetDispatchBindingGuard fleetBindings;
private DispatchPdaWorkflowService service;
private ObjectMapper objectMapper;
@@ -80,6 +81,7 @@ class DispatchPdaWorkflowServiceTest {
ReflectionTestUtils.setField(service, "positions", positions);
ReflectionTestUtils.setField(service, "inventory", inventory);
ReflectionTestUtils.setField(service, "pickAttribution", attribution);
ReflectionTestUtils.setField(service, "returnRelabelStock", returnStock);
ReflectionTestUtils.setField(service, "objectMapper", objectMapper);
}
@@ -329,6 +331,31 @@ class DispatchPdaWorkflowServiceTest {
return value;
}
@Test void nativeBoxPickLocksOrderBeforeStockAndNeverUsesOrdinaryInventory() {
var order = order("100", "DSP-1", "WH-1", "PENDING_DEPARTURE");
order.setVehiclePlate("CA-100");
var task = task(11L, "100", "WH-1", "PDA_PENDING");
var item = item(21L, "100", 2);
item.setSourceStage("RETURN_RELABEL"); item.setSourceBusinessType("RELABEL_ORDER"); item.setOrderId(101L);
when(orders.findGlobalByDispatchNo("DSP-1")).thenReturn(order);
when(orders.lockById("100", 1L)).thenReturn(order);
when(tasks.lockByDispatch("100", 1L)).thenReturn(task);
when(items.selectById(21L)).thenReturn(item);
var carton = new com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton(); carton.setId(71L);
when(returnStock.pick(org.mockito.ArgumentMatchers.eq(item), org.mockito.ArgumentMatchers.eq("RC1#2-1"),
org.mockito.ArgumentMatchers.eq(1L), org.mockito.ArgumentMatchers.eq(3L), anyLong(), org.mockito.ArgumentMatchers.eq("DSP-1"))).thenReturn(carton);
var result = service.pick("WH-1", "DSP-1", 21L, "7", "作业员",
new DispatchPdaRequests.Pick(null, 1, 3, "native-pick", null, "RC1#2-1"));
assertEquals(1L, result.pickedPackages());
var sequence = org.mockito.Mockito.inOrder(attribution, returnStock, picks);
sequence.verify(attribution).prepareNativeReturn("100", 101L);
sequence.verify(returnStock).pick(org.mockito.ArgumentMatchers.eq(item), org.mockito.ArgumentMatchers.eq("RC1#2-1"),
org.mockito.ArgumentMatchers.eq(1L), org.mockito.ArgumentMatchers.eq(3L), anyLong(), org.mockito.ArgumentMatchers.eq("DSP-1"));
sequence.verify(picks).insert(org.mockito.ArgumentMatchers.argThat((DispatchPdaPick p) -> p.getReturnCartonId().equals(71L) && p.getInventoryPositionId() == null));
sequence.verify(attribution).attributeNativeReturn(org.mockito.ArgumentMatchers.eq("100"), any(), org.mockito.ArgumentMatchers.eq(101L));
org.mockito.Mockito.verifyNoInteractions(inventory, positions);
}
private static DispatchOrder order(String customerId, String dispatchNo, String warehouseId, String status) {
DispatchOrder value = new DispatchOrder();
value.setId(1L); value.setCustomerId(customerId); value.setDispatchNo(dispatchNo);
@@ -44,6 +44,14 @@ class DispatchPickOrderAttributionServiceTest {
< sql.indexOf("shipped_package_count=shipped_package_count+"));
}
@Test void nativePreparationRejectsHeldOrderBeforeAnyAttribution() {
var held = new LastlegOrder(); held.setDetained(true);
when(orders.lockById("t1", 101L)).thenReturn(held);
assertThrows(org.springframework.web.server.ResponseStatusException.class,
() -> service().prepareNativeReturn("t1", 101L));
verifyNoInteractions(attributions);
}
@Test
void attributesPackagesAcrossInboundOrdersInReceiptFifoOrder() {
DispatchPickOrderAttributionService service = service();
@@ -64,6 +64,7 @@ class DispatchServiceTest {
@Mock OutboundOrderMapper outboundOrders;
@Mock com.baiyue.lastleg.fleet.trip.FleetDispatchBindingGuard fleetBindings;
@Mock com.baiyue.lastleg.fleet.trip.FleetDispatchWorkflowQuery fleetWorkflow;
@Mock ReturnRelabelDispatchStock returnRelabelStock;
private DispatchService service;
@BeforeEach
@@ -71,6 +72,7 @@ class DispatchServiceTest {
service = new DispatchService();
ReflectionTestUtils.setField(service, "fleetWorkflow", fleetWorkflow);
ReflectionTestUtils.setField(service, "fleetBindings", fleetBindings);
ReflectionTestUtils.setField(service, "returnRelabelStock", returnRelabelStock);
ReflectionTestUtils.setField(service, "orders", orders);
ReflectionTestUtils.setField(service, "items", items);
ReflectionTestUtils.setField(service, "bolLines", bolLines);
@@ -437,24 +439,6 @@ class DispatchServiceTest {
verify(bolLines).findByDispatchOrder("200", 1L);
}
@Test
void filtersStageBeforePaginationAndPreservesTheServerTotal() {
com.baomidou.mybatisplus.core.metadata.TableInfoHelper.initTableInfo(
new org.apache.ibatis.builder.MapperBuilderAssistant(new com.baomidou.mybatisplus.core.MybatisConfiguration(), "test"), DispatchOrder.class);
Page<DispatchOrder> resultPage = new Page<>(2, 10, 47);
resultPage.setRecords(List.of());
when(orders.selectPage(any(Page.class), any(LambdaQueryWrapper.class))).thenAnswer(invocation -> {
LambdaQueryWrapper<DispatchOrder> wrapper = invocation.getArgument(1);
assertTrue(wrapper.getSqlSegment().contains("status IN"));
assertTrue(wrapper.getParamNameValuePairs().values().containsAll(
List.of("PICKING", "READY_TO_DEPART", "PARTIAL_READY_TO_DEPART")));
return resultPage;
});
var result = service.platformPage(new DispatchRequests.Query(2, 10, null, null, null, null, DispatchRequests.Stage.LOADING));
assertEquals(47, result.total());
assertEquals(List.of("DEPARTED", "PARTIAL_DEPARTED"), DispatchRequests.Stage.AFTER_LOADING.statuses());
}
@Test
void platformPageQueriesAllCustomersAndLoadsItemsInEachCustomerScope() {
DispatchOrder first = new DispatchOrder();
@@ -0,0 +1,78 @@
package com.baiyue.lastleg.dispatch.service;
import com.baiyue.lastleg.dispatch.entity.DispatchItem;
import com.baiyue.lastleg.dispatch.mapper.DispatchCandidateMapper;
import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate;
import com.baiyue.lastleg.palletops.gateway.PalletPhysicalGateway;
import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper;
import com.baiyue.lastleg.returnreceipt.service.ReturnInventoryService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class ReturnRelabelDispatchStockTest {
@Mock DispatchCandidateMapper candidates;
@Mock ReturnInventoryCartonMapper cartons;
@Mock PalletPhysicalGateway physical;
@Mock ReturnInventoryService inventory;
ReturnRelabelDispatchStock service;
DispatchItem item;
ReturnInventoryCarton carton;
@BeforeEach void setup() {
service = new ReturnRelabelDispatchStock();
ReflectionTestUtils.setField(service, "candidates", candidates);
ReflectionTestUtils.setField(service, "cartons", cartons);
ReflectionTestUtils.setField(service, "physical", physical);
ReflectionTestUtils.setField(service, "inventory", inventory);
item = new DispatchItem(); item.setCustomerId("C"); item.setOverseasWarehouseId("9");
item.setPalletNo("P1"); item.setCartonMarks("RC1#2-1,RC1#2-2"); item.setSourceBusinessNo("RL1");
carton = new ReturnInventoryCarton(); carton.setId(11L); carton.setCustomerId("C"); carton.setOverseasWarehouseId("9");
carton.setCartonMark("RC1#2-1"); carton.setPalletCode("P1"); carton.setRelabelNo("RL1");
carton.setSourceReturnNo("RT1,RT2"); carton.setTotalQuantity(6L); carton.setStatus("OUTBOUND_PENDING");
}
@Test void candidatesResolveRealCarrierWithoutCreatingOrdinaryStock() {
var row = new DispatchPalletCandidate().palletNo("P1").overseasWarehouseId("9").returnCartonSource(true);
when(candidates.nativeReturnRelabelAvailable("C", null, "RL1")).thenReturn(List.of(row));
when(physical.resolve("P1")).thenReturn(new PalletPhysicalGateway.PalletReference(55L, "P1", 9L, 4L, "OCCUPIED", "ONT8", 0));
assertEquals(55L, service.candidates("C", null, "RL1").get(0).warehousePalletId());
verifyNoInteractions(inventory, cartons);
}
@Test void wrongScanAndMultiBoxRequestNeverDeductStock() {
assertThrows(ResponseStatusException.class, () -> service.pick(item, "OTHER", 1, 6, 100, "DIS1"));
assertThrows(ResponseStatusException.class, () -> service.pick(item, "RC1#2-1", 2, 6, 100, "DIS1"));
verifyNoInteractions(inventory, cartons);
}
@Test void foreignCustomerOrChangedCarrierNeverDeductsStock() {
when(cartons.lockMark("RC1#2-1")).thenReturn(carton);
carton.setCustomerId("OTHER");
assertThrows(ResponseStatusException.class, () -> service.pick(item, "RC1#2-1", 1, 6, 100, "DIS1"));
carton.setCustomerId("C"); carton.setPalletCode("P2");
assertThrows(ResponseStatusException.class, () -> service.pick(item, "RC1#2-1", 1, 6, 100, "DIS1"));
verifyNoInteractions(inventory);
}
@Test void exactBoxShipsItsFullSourceSetAndRejectsStaleQuantity() {
when(cartons.lockMark("RC1#2-1")).thenReturn(carton);
assertThrows(ResponseStatusException.class, () -> service.pick(item, "RC1#2-1", 1, 5, 100, "DIS1"));
verifyNoInteractions(inventory);
assertEquals(11L, service.pick(item, "RC1#2-1", 1, 6, 100, "DIS1").getId());
verify(inventory).shipCarton("C", "9", "RT1,RT2", "RC1#2-1", "DIS1", 100);
}
@Test void cancelledDispatchRestoresRelabelReservationWithoutMakingItPackable() {
item.setCartonMarks("RC1#2-1");
when(cartons.lockMark("RC1#2-1")).thenReturn(carton);
when(cartons.updateById(carton)).thenReturn(1);
service.release(item);
assertEquals("RELABEL_PENDING", carton.getStatus()); assertEquals("RL1", carton.getRelabelNo());
verifyNoInteractions(inventory);
}
}
@@ -136,8 +136,8 @@ class RelabelContractTest {
void returnCartonConstraintsAllowRelabelReservationWithPositiveInventory() throws Exception {
String baseline = Files.readString(Path.of("src/main/resources/db/lastleg.sql"), StandardCharsets.UTF_8)
.replaceAll("\\s+", "").toUpperCase();
assertTrue(baseline.contains("STATUSIN('AVAILABLE','RELABEL_PENDING','OUTBOUNDED','DISCARD_PENDING')ANDSKU_TYPES>0ANDTOTAL_QUANTITY>0"));
assertTrue(baseline.contains("STATUSIN('AVAILABLE','PACKING_RESERVED','RELABEL_PENDING','OUTBOUND_PENDING','OUTBOUNDED','DISCARD_PENDING')ANDSKU_TYPES>0ANDTOTAL_QUANTITY>0"));
assertTrue(baseline.contains("STATUSIN('MERGED','DISCARDED')ANDSKU_TYPES=0ANDTOTAL_QUANTITY=0"));
assertTrue(baseline.contains("STATUSIN('AVAILABLE','RELABEL_PENDING','MERGED','OUTBOUNDED','DISCARD_PENDING','DISCARDED')"));
assertTrue(baseline.contains("STATUSIN('AVAILABLE','PACKING_RESERVED','RELABEL_PENDING','OUTBOUND_PENDING','MERGED','OUTBOUNDED','DISCARD_PENDING','DISCARDED')"));
}
}
@@ -0,0 +1,53 @@
package com.baiyue.lastleg.relabel.service;
import com.baiyue.lastleg.delivery.entity.DeliveryOrder;
import com.baiyue.lastleg.delivery.mapper.*;
import com.baiyue.lastleg.relabel.entity.*;
import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper;
import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper;
import com.baiyue.lastleg.service.LastlegOrderNoGenerator;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.LocalDateTime;
import java.util.List;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
class ReturnRelabelDeliveryTest {
@Test void directNativeRelabelCreatesTwoPhysicalBoxesAndOneDeliveryPerDestination() {
var service = new RelabelApplicationService();
var items = mock(RelabelItemMapper.class);
var cartons = mock(ReturnInventoryCartonMapper.class);
var deliveries = mock(DeliveryOrderMapper.class);
ReflectionTestUtils.setField(service, "items", items);
ReflectionTestUtils.setField(service, "returnCartons", cartons);
ReflectionTestUtils.setField(service, "deliveryOrders", deliveries);
ReflectionTestUtils.setField(service, "outboundOrders", mock(OutboundOrderMapper.class));
ReflectionTestUtils.setField(service, "outboundItems", mock(OutboundPalletItemMapper.class));
ReflectionTestUtils.setField(service, "outboundTasks", mock(OutboundPdaTaskMapper.class));
ReflectionTestUtils.setField(service, "orderNoGenerator", new LastlegOrderNoGenerator());
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper());
var application = new RelabelApplication(); application.setId(1L); application.setCustomerId("C1");
application.setRelabelNo("REL1"); application.setOverseasWarehouseId("WH1"); application.setDeliveryMethod("EXPRESS");
var a = source(10L, "A"); var b = source(11L, "B");
when(items.selectList(any())).thenReturn(List.of(a, b));
for (var item : List.of(a, b)) {
var carton = new ReturnInventoryCarton(); carton.setId(item.getReturnCartonId());
carton.setCustomerId("C1"); carton.setRelabelApplicationId(1L); carton.setStatus("RELABEL_PENDING");
when(cartons.lockId(carton.getId())).thenReturn(carton);
}
ReflectionTestUtils.invokeMethod(service, "createDirectOutbound", application, "OP1", LocalDateTime.now());
verify(deliveries, times(2)).insert(any(DeliveryOrder.class));
verify(deliveries).insert(argThat((DeliveryOrder d) -> "A".equals(d.getDestinationWarehouseCode())
&& d.getPackageCount() == 1 && "WAIT_DELIVERY".equals(d.getStatus())
&& application.getOutboundOrderNo().equals(d.getOutboundNo())));
verify(deliveries).insert(argThat((DeliveryOrder d) -> "B".equals(d.getDestinationWarehouseCode()) && d.getPackageCount() == 1));
}
private RelabelItem source(long id, String destination) {
var item = new RelabelItem(); item.setId(id); item.setReturnCartonId(id);
item.setNotifiedPackageCount(1); item.setSourceCartonMark("RC" + id + "#1-1");
item.setDestinationFbaWarehouseCode(destination); return item;
}
}
@@ -50,6 +50,9 @@ class ReturnInventoryServiceTest {
@Mock LastlegOrderNoGenerator numbers;
@Mock ReturnOrderMapper orders;
@Mock ReturnReceiptMapper receipts;
@Mock com.baiyue.lastleg.returnreceipt.mapper.ReturnPackingTraceMapper packingTrace;
@Mock com.baiyue.lastleg.returnreceipt.mapper.ReturnPackingPlanMapper packingPlans;
@Mock com.baiyue.lastleg.returnreceipt.mapper.ReturnOrderItemMapper orderItems;
@Mock com.baiyue.lastleg.palletops.gateway.PalletPhysicalGateway physicalPallets;
private ReturnInventoryService service;
@@ -66,6 +69,62 @@ class ReturnInventoryServiceTest {
ReflectionTestUtils.setField(service, "orders", orders);
ReflectionTestUtils.setField(service, "receiptMapper", receipts);
ReflectionTestUtils.setField(service, "physicalPallets", physicalPallets);
ReflectionTestUtils.setField(service, "packingTrace", packingTrace);
ReflectionTestUtils.setField(service, "packingPlans", packingPlans);
ReflectionTestUtils.setField(service, "orderItems", orderItems);
ReflectionTestUtils.setField(service, "objectMapper", new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules());
}
@Test
void platformPlanNumberFilterIsTrimmedAndUsedForCountAndPage() {
when(packingPlans.countPlatform(null, "PLAN-1", "C1", "W1", "SKU1")).thenReturn(1L);
when(packingPlans.listPlatform(null, "PLAN-1", "C1", "W1", "SKU1", 20L, 20L)).thenReturn(List.of());
var result = service.platformPackingPlans(new ReturnInventoryRequests.PackingPlanQuery(2, 20, null, " PLAN-1 ", " C1 ", " W1 ", " SKU1 "));
assertThat(result.total()).isEqualTo(1L);
verify(packingPlans).listPlatform(null, "PLAN-1", "C1", "W1", "SKU1", 20L, 20L);
}
@Test
void cartonDetailsExposeImmutableMergeHistoryWithinCustomerScope() {
ReturnInventoryCarton carton = new ReturnInventoryCarton();
carton.setId(1L); carton.setCustomerId("CUSTOMER-A"); carton.setCartonMark("TARGET-1"); carton.setStatus("AVAILABLE");
when(cartons.findCustomerMark("CUSTOMER-A", "TARGET-1")).thenReturn(carton);
when(cartonItems.listCarton(1L)).thenReturn(List.of());
var history = new com.baiyue.lastleg.returnreceipt.dto.ReturnInventoryResponses.MergeHistory("PLAN-1", "SOURCE-1", "TARGET-1", "RETURN-1", "SKU-1", 2, java.time.LocalDateTime.now());
when(packingTrace.listCartonHistory("CUSTOMER-A", "TARGET-1")).thenReturn(List.of(history));
assertThat(service.carton("CUSTOMER-A", "TARGET-1").mergeHistory()).containsExactly(history);
assertThrows(ResponseStatusException.class, () -> service.carton("CUSTOMER-B", "TARGET-1"));
}
@Test
void packingSourcesBatchItemsWithinCustomerWarehouseAndSubtractReservations() {
ReturnInventoryCarton first = new ReturnInventoryCarton();
first.setId(1L); first.setCartonMark("PARCEL-1"); first.setSourceUnitType("PARCEL"); first.setSourceReturnNo("RETURN-1");
ReturnInventoryCarton second = new ReturnInventoryCarton();
second.setId(2L); second.setCartonMark("BOX-2"); second.setSourceUnitType("BOX");
ReturnInventoryCartonItem item = new ReturnInventoryCartonItem();
item.setCartonId(1L); item.setSkuCode("SKU-1"); item.setQuantity(9L); item.setPackingReservedQuantity(7L);
when(cartons.platformCount("customer", "warehouse", null, null, "AVAILABLE", null)).thenReturn(2L);
when(cartons.platformPage("customer", "warehouse", null, null, "AVAILABLE", null, 0L, 200L)).thenReturn(List.of(first, second));
when(cartonItems.listCartons(List.of(1L, 2L))).thenReturn(List.of(item));
var response = service.platformPackingSourcePage(new ReturnInventoryRequests.PlatformQuery(1, 200, "customer", "warehouse", null, null, null, null));
assertThat(response.total()).isEqualTo(2);
assertThat(response.records().get(0).sourceUnitType()).isEqualTo("PARCEL");
assertThat(response.records().get(0).items().get(0).availableQuantity()).isEqualTo(2);
assertThat(response.records().get(1).items()).isEmpty();
verify(cartonItems).listCartons(List.of(1L, 2L));
verify(cartonItems, never()).listCarton(anyLong());
verifyNoInteractions(balances);
}
@Test
void emptyPackingSourcesSkipItemQueriesAndRequireCustomerScope() {
when(cartons.platformCount("customer", "warehouse", null, null, "AVAILABLE", null)).thenReturn(0L);
var response = service.platformPackingSourcePage(new ReturnInventoryRequests.PlatformQuery(1, 200, "customer", "warehouse", null, null, null, null));
assertThat(response.records()).isEmpty();
verifyNoInteractions(cartonItems);
assertThrows(ResponseStatusException.class, () -> service.platformPackingSourcePage(
new ReturnInventoryRequests.PlatformQuery(1, 200, null, "warehouse", null, null, null, null)));
}
@Test
@@ -82,7 +141,8 @@ class ReturnInventoryServiceTest {
when(cartons.findCustomerMark(carton.getCustomerId(), carton.getCartonMark())).thenReturn(carton);
when(cartonItems.listCarton(31L)).thenReturn(List.of(item(41L, "SKU-A", 2L)));
String html = service.label(carton.getCustomerId(), carton.getCartonMark()).printableHtml();
assertThat(html).contains("<svg", "RT-1", "RB-1,RB-2", "—", "box-sizing:border-box");
assertThat(html).contains("<svg", carton.getCartonMark(), "SKU-A", "box-sizing:border-box");
assertThat(html).doesNotContain("RT-1", "RB-1,RB-2", "—");
assertThat(html).doesNotContain("repeating-linear-gradient", "null", carton.getCustomerId(), carton.getOverseasWarehouseId());
}
@@ -110,6 +170,8 @@ class ReturnInventoryServiceTest {
receipt.setId(21L);
receipt.setReceiptBatchNo("RB-1");
receipt.setPalletCode("PAL-RETURN-1");
receipt.setUnitType("PALLET");
receipt.setUnitCode("PAL-RETURN-1");
when(numbers.next(anyString(), anyString(), anyLong())).thenReturn("RC-1");
service.createReceiptCarton(order, receipt, null,
@@ -120,6 +182,30 @@ class ReturnInventoryServiceTest {
assertThat(captor.getValue().getSourceReturnNo()).isEqualTo("RT-1");
}
@Test
void palletReceiptCreatesOnePrintableCartonPerSkuBox() {
ReturnOrder order = completedOrder();
ReturnReceipt receipt = new ReturnReceipt();
receipt.setId(21L);
receipt.setReceiptBatchNo("RB-1");
receipt.setPalletCode("PAL-RETURN-1");
receipt.setUnitType("PALLET");
receipt.setUnitCode("PAL-RETURN-1");
List<ReturnInventoryCarton> created = service.createReceiptCartons(order, receipt,
List.of(new ReturnInventoryService.ReceiptLine("SKU-A", "商品A", 5)), java.util.Map.of("SKU-A", 2L));
assertThat(created).hasSize(2);
assertThat(created).extracting(ReturnInventoryCarton::getCartonMark)
.allMatch(mark -> mark.matches("RC[A-F0-9]{16}#2-[12]"))
.doesNotHaveDuplicates();
assertThat(created.get(0).getCartonMark()).endsWith("#2-1");
assertThat(created.get(1).getCartonMark()).endsWith("#2-2");
assertThat(created).extracting(ReturnInventoryCarton::getTotalQuantity).containsExactly(3L, 2L);
assertThat(created).extracting(ReturnInventoryCarton::getPalletCode).containsOnly("PAL-RETURN-1");
verify(cartons, org.mockito.Mockito.times(2)).insert(any(ReturnInventoryCarton.class));
}
@Test
void mergeMovesItemsBetweenCartonsWithoutReducingAvailableQuantity() {
ReturnOrder order = completedOrder();
@@ -133,6 +219,7 @@ class ReturnInventoryServiceTest {
source.setSourceBatchNo("RB-1");
source.setSourceReturnNo("RT-1");
source.setStatus(ReturnInventoryService.AVAILABLE);
source.setSourceUnitType("PARCEL");
source.setSkuTypes(2);
source.setTotalQuantity(4L);
ReturnInventoryCartonItem skuA = item(41L, "SKU-A", 2L);
@@ -140,12 +227,10 @@ class ReturnInventoryServiceTest {
when(orders.findByNos("42", List.of("RT-1"))).thenReturn(List.of(order));
when(receipts.listOrder(11L)).thenReturn(List.of(receipt));
when(cartons.findReceipt(21L)).thenReturn(source);
when(cartons.listReceipt(21L)).thenReturn(List.of(source));
when(cartons.lockMark("RC-SOURCE")).thenReturn(source);
when(cartonItems.lockCarton(31L)).thenReturn(List.of(skuA, skuB));
when(numbers.next(anyString(), anyString(), anyLong())).thenReturn("RC-MERGED");
when(physicalPallets.ensureRelabelPallet(org.mockito.ArgumentMatchers.eq(42L), anyString()))
.thenReturn(new com.baiyue.lastleg.palletops.gateway.PalletPhysicalGateway.PalletReference(91L, "PLT-MERGED", 42L, null, "EMPTY", null, 0));
when(balances.adjustCartonQuantity(anyString(), anyString(), anyString(), anyLong())).thenReturn(1);
service.merge(new PdaWarehouseIdentity("operator-1", "操作员", 42L),
@@ -162,10 +247,10 @@ class ReturnInventoryServiceTest {
verify(cartons).insert(target.capture());
assertThat(target.getValue().getSourceBatchNo()).isEqualTo("RB-1");
assertThat(target.getValue().getSourceReturnNo()).isEqualTo("RT-1");
assertThat(target.getValue().getPalletCode()).isEqualTo("PLT-MERGED");
ArgumentCaptor<String> businessKey = ArgumentCaptor.forClass(String.class);
verify(physicalPallets).ensureRelabelPallet(org.mockito.ArgumentMatchers.eq(42L), businessKey.capture());
assertThat(businessKey.getValue()).matches("RETURN-MERGE:[0-9a-f]{64}");
assertThat(target.getValue().getPalletCode()).isNull();
verifyNoInteractions(physicalPallets);
verify(packingTrace, org.mockito.Mockito.times(2)).insert(anyLong(), anyString(), anyLong(), anyString(), anyString(), anyString(), anyString(), anyLong(), any());
}
@Test
@@ -198,8 +283,6 @@ class ReturnInventoryServiceTest {
ReturnInventoryCarton locked = prepareConcurrentSource();
when(cartonItems.lockCarton(31L)).thenReturn(List.of(item(41L, "SKU-A", 2L)));
when(numbers.next(anyString(), anyString(), anyLong())).thenReturn("RC-TARGET");
when(physicalPallets.ensureRelabelPallet(org.mockito.ArgumentMatchers.eq(42L), anyString()))
.thenReturn(new com.baiyue.lastleg.palletops.gateway.PalletPhysicalGateway.PalletReference(92L, "PLT-TARGET", 42L, null, "EMPTY", null, 0));
when(balances.adjustCartonQuantity(anyString(), anyString(), anyString(), anyLong())).thenReturn(1);
service.merge(new PdaWarehouseIdentity("operator-2", "操作员2", 42L), mergeRequest(2));
assertThat(locked.getTotalQuantity()).isZero();
@@ -264,16 +347,97 @@ class ReturnInventoryServiceTest {
return operation;
}
@Test
void skuPackingNeverConsumesPhysicalBoxInventory() {
ReturnInventoryCarton source = prepareConcurrentSource();
source.setSourceUnitType("BOX");
// The candidate snapshot is a parcel; the locked physical source must be rechecked.
var error = assertThrows(ResponseStatusException.class, () -> service.merge(
new PdaWarehouseIdentity("operator-2", "操作员", 42L), mergeRequest(1)));
assertThat(error.getStatusCode().value()).isEqualTo(409);
verify(cartons, never()).insert(any(ReturnInventoryCarton.class));
}
@Test
void samePrefixSkusHaveIndependentShortBoxMarksAndTheirOwnSequences() {
String a = ReflectionTestUtils.invokeMethod(service, "shortReturnMark", "RT-ORDER", 123L, "SAMEPREFIX-A", 2L, 1L);
String b = ReflectionTestUtils.invokeMethod(service, "shortReturnMark", "RT-ORDER", 123L, "SAMEPREFIX-B", 2L, 1L);
String second = ReflectionTestUtils.invokeMethod(service, "shortReturnMark", "RT-ORDER", 123L, "SAMEPREFIX-A", 2L, 2L);
assertThat(a).isNotEqualTo(b).matches("RC[A-F0-9]{16}#2-1");
assertThat(second).isEqualTo(a.substring(0, a.lastIndexOf('-') + 1) + "2");
}
@Test
void packingLabelsCountBoxesPerSkuInsteadOfCountingAllSkusTogether() {
var boxA = new ReturnInventoryRequests.PlanBox(List.of(new ReturnInventoryRequests.PlanItem("A", 2)), null, null, null, null);
var boxB = new ReturnInventoryRequests.PlanBox(List.of(new ReturnInventoryRequests.PlanItem("B", 2)), null, null, null, null);
List<String> marks = ReflectionTestUtils.invokeMethod(service, "packingMarks", List.of(boxA, boxB, boxA));
assertThat(marks).hasSize(3).doesNotHaveDuplicates();
assertThat(marks.get(0)).endsWith("#2-1"); assertThat(marks.get(1)).endsWith("#1-1");
assertThat(marks.get(2)).isEqualTo(marks.get(0).replace("#2-1", "#2-2"));
}
@Test
void reservedSkuQuantityCannotBeConsumedByDirectPacking() {
prepareConcurrentSource();
var line = item(41L, "SKU-A", 2); line.setPackingReservedQuantity(2L);
when(cartonItems.lockCarton(31L)).thenReturn(List.of(line));
var error = assertThrows(ResponseStatusException.class, () -> service.merge(
new PdaWarehouseIdentity("operator-2", "操作员", 42L), mergeRequest(1)));
assertThat(error.getStatusCode().value()).isEqualTo(409);
verify(cartonItems, never()).deleteById(anyLong());
verify(cartonItems, never()).updateById(any(ReturnInventoryCartonItem.class));
verify(cartons, never()).insert(any(ReturnInventoryCarton.class));
}
@Test
void planCreationLocksExactSkuAndCancelReleasesOnlyItsReservation() {
var source = new ReturnInventoryCarton(); source.setId(31L); source.setCartonMark("RC-SOURCE");
source.setCustomerId("10001"); source.setOverseasWarehouseId("42"); source.setSourceReturnNo("RT-1");
source.setStatus("AVAILABLE"); source.setSourceUnitType("PARCEL");
var line = item(41L, "SKU-A", 5); line.setPackingReservedQuantity(1L);
when(orders.lockNo("RT-1")).thenReturn(completedOrder());
when(cartons.packingAvailable("10001", "42")).thenReturn(List.of(source));
when(cartons.lockMark("RC-SOURCE")).thenReturn(source);
when(cartonItems.lockCarton(31L)).thenReturn(List.of(line));
when(cartonItems.lockSku(31L, "SKU-A")).thenReturn(line);
when(balances.reserveAvailable("10001", "42", "SKU-A", 2)).thenReturn(1);
when(numbers.next(anyString(), anyString(), anyLong())).thenReturn("PLAN-1");
var request = new ReturnInventoryRequests.PackingPlanCreate(List.of("RT-1"), "SKU",
List.of(new ReturnInventoryRequests.PlanBox(List.of(new ReturnInventoryRequests.PlanItem("SKU-A", 2)), null, null, null, null)), null, "plan-key");
var created = service.createPackingPlan("platform", request);
assertThat(created.status()).isEqualTo("PENDING_REVIEW"); assertThat(line.getPackingReservedQuantity()).isEqualTo(3);
var captor = ArgumentCaptor.forClass(com.baiyue.lastleg.returnreceipt.entity.ReturnPackingPlan.class);
verify(packingPlans).insert(captor.capture());
when(packingPlans.lockNo("PLAN-1")).thenReturn(captor.getValue());
assertThat(service.cancelPackingPlan("PLAN-1").status()).isEqualTo("CANCELLED");
assertThat(line.getPackingReservedQuantity()).isEqualTo(1);
verify(balances).adjust(anyLong(), org.mockito.ArgumentMatchers.eq("10001"), org.mockito.ArgumentMatchers.eq("42"), org.mockito.ArgumentMatchers.eq("SKU-A"), org.mockito.ArgumentMatchers.isNull(), org.mockito.ArgumentMatchers.eq(0L), org.mockito.ArgumentMatchers.eq(2L), org.mockito.ArgumentMatchers.eq(0L));
}
@Test
void boxPlanRequiresExplicitSourceMarksInsteadOfOrderScopeOnly() {
when(orders.lockNo("RT-1")).thenReturn(completedOrder());
var request = new ReturnInventoryRequests.PackingPlanCreate(List.of("RT-1"), "BOX",
List.of(new ReturnInventoryRequests.PlanBox(List.of(), null, null, null, null)), null, "box-key");
var error = assertThrows(ResponseStatusException.class, () -> service.createPackingPlan("platform", request));
assertThat(error.getStatusCode().value()).isEqualTo(400);
verifyNoInteractions(cartonItems, balances);
}
private ReturnInventoryCarton prepareConcurrentSource() {
ReturnReceipt receipt = new ReturnReceipt(); receipt.setId(21L);
ReturnInventoryCarton snapshot = new ReturnInventoryCarton();
snapshot.setId(31L); snapshot.setCartonMark("RC-SOURCE"); snapshot.setStatus(ReturnInventoryService.AVAILABLE);
snapshot.setSourceUnitType("PARCEL");
snapshot.setSourceReturnNo("RT-1");
ReturnInventoryCarton locked = new ReturnInventoryCarton();
locked.setId(31L); locked.setCartonMark("RC-SOURCE"); locked.setStatus(ReturnInventoryService.AVAILABLE);
locked.setSourceUnitType("PARCEL");
locked.setCustomerId("10001"); locked.setOverseasWarehouseId("42"); locked.setSourceBatchNo("RB-1"); locked.setSourceReturnNo("RT-1");
when(orders.findByNos("42", List.of("RT-1"))).thenReturn(List.of(completedOrder()));
when(receipts.listOrder(11L)).thenReturn(List.of(receipt));
when(cartons.findReceipt(21L)).thenReturn(snapshot);
when(cartons.listReceipt(21L)).thenReturn(List.of(snapshot));
when(cartons.lockMark("RC-SOURCE")).thenReturn(locked);
return locked;
}
@@ -39,6 +39,7 @@ import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
@@ -498,8 +499,25 @@ class ReturnReceiptServiceTest {
return item;
}
@Test
void combinedLabelsKeepPhysicalPageStyleAndOneSectionPerBox() {
String first = "<html><head><style>@page{size:100mm 100mm}.barcode{height:15mm}</style></head><body><div class='label'>RC#2-1</div></body></html>";
String second = first.replace("RC#2-1", "RC#2-2");
String result = ReflectionTestUtils.invokeMethod(service, "combineLabels", List.of(
new com.baiyue.lastleg.returnreceipt.dto.ReturnOrderResponses.CartonLabel("RC#2-1", first),
new com.baiyue.lastleg.returnreceipt.dto.ReturnOrderResponses.CartonLabel("RC#2-2", second)));
assertThat(result).contains("@page{size:100mm 100mm}", ".barcode{height:15mm}", "RC#2-1", "RC#2-2", ".return-label:last-child");
assertThat(result.split("<section class='return-label'>", -1)).hasSize(3);
assertThat(result.split("@page", -1)).hasSize(2);
}
private static PdaReturnReceiptRequests.Confirm confirm(String startedBy, String trackingNo, String palletCode,
String key, List<PdaReturnReceiptRequests.Item> items) {
return new PdaReturnReceiptRequests.Confirm("RT-1", trackingNo, startedBy, palletCode, items, key);
List<PdaReturnReceiptRequests.Item> boxed = items.stream()
.map(item -> new PdaReturnReceiptRequests.Item(item.skuCode(), item.receivedQuantity(), 1L)).toList();
// These focused tests exercise receipt/idempotency behavior at the
// carton level. Pallet receipts have a separate multi-carton contract.
return new PdaReturnReceiptRequests.Confirm("RT-1", trackingNo, startedBy, palletCode, boxed, key,
"BOX", palletCode, null, null, null, null, 1L);
}
}