修改提交

This commit is contained in:
2026-09-08 17:58:42 +08:00
parent f0939c91c3
commit 2a27e28f26
67 changed files with 2590 additions and 110 deletions
@@ -7,6 +7,8 @@ import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import jakarta.validation.Valid;
import java.util.List;
import java.util.Map;
import java.util.Collections;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@@ -28,7 +30,19 @@ public final class DeliveryRequests {
@Size(max = 128) String appointmentAccount,
@Size(max = 128) String sellerName,
@Size(max = 1000) String remark,
@NotNull @Valid @Size(min = 1, max = 20) List<FeeItem> fees) {}
@NotNull @Valid @Size(min = 1, max = 20) List<FeeItem> fees,
Map<String, List<@NotBlank @Size(max = 128) String>> palletCartonMarks) {
public Transfer(String idempotencyKey, LocalDateTime plannedDeliveryAt, String deliveryMethod, String waybillNo,
String carrierName, String pickupMethod, String loadingType, String vehicleType, String vehicleSize,
String driverName, String driverPhone, String vehiclePlate, String appointmentAccount,
String sellerName, String remark, List<FeeItem> fees) {
this(idempotencyKey, plannedDeliveryAt, deliveryMethod, waybillNo, carrierName, pickupMethod, loadingType,
vehicleType, vehicleSize, driverName, driverPhone, vehiclePlate, appointmentAccount, sellerName, remark, fees, Map.of());
}
}
public record PalletCartonSelection(@NotBlank @Size(max = 128) String palletNo,
@NotNull @Size(min = 1, max = 500) List<@NotBlank @Size(max = 128) String> cartonMarks) {}
public record FeeItem(@NotBlank @Size(max = 64) String feeType,
@NotBlank @Size(max = 8) String currency,
@@ -41,12 +55,6 @@ public final class DeliveryRequests {
String driverName, String deliveryMethod,
LocalDateTime plannedFrom, LocalDateTime plannedTo) {
public Query { if (page == 0) page = 1; if (size == 0) size = 20; }
public Query(long page, long size, String documentNo, String warehouseId, String status,
String dispatchNo, String waybillNo, String vehiclePlate, String driverName,
String deliveryMethod, LocalDateTime plannedFrom, LocalDateTime plannedTo) {
this(page, size, documentNo, null, warehouseId, status, dispatchNo, waybillNo, vehiclePlate,
driverName, deliveryMethod, plannedFrom, plannedTo);
}
}
public record PalletQuery(@Min(1) long page, @Min(1) @Max(200) long size,
@@ -59,7 +67,12 @@ public final class DeliveryRequests {
@Size(max=1000) String remark,
@NotBlank @Size(max=255) String idempotencyKey) {}
public record OneClickItem(@NotNull Long inventoryPositionId, @NotNull @Min(1) Long packages,
@NotBlank @Size(max=128) String sku) {}
@NotBlank @Size(max=128) String sku,
@Size(max=200) List<@NotBlank @Size(max=128) String> cartonMarks) {
public OneClickItem(Long inventoryPositionId, Long packages, String sku) {
this(inventoryPositionId, packages, sku, Collections.emptyList());
}
}
public record PdaPick(@NotNull Long outboundPalletItemId, @NotNull Long inventoryPositionId,
@NotNull @Min(1) Long packages, @NotBlank @Size(max=255) String idempotencyKey) {}
public record PdaComplete(@NotBlank @Size(max=255) String idempotencyKey) {}
@@ -40,7 +40,9 @@ public final class DeliveryResponses {
public record DeliveryDetail(DeliveryRow order, List<DeliverySku> skus,
List<DeliveryFeeRow> fees, List<DeliveryOperationRow> operations) {}
public record OutboundDetail(OutboundRow outbound, DeliveryRow delivery) {}
public record OutboundPalletRow(Long id, String palletNo, String cartonMarks, String skuCode, String inboundCode,
Integer packageCount, Integer pickedPackageCount, String status) {}
public record OutboundDetail(OutboundRow outbound, DeliveryRow delivery, List<OutboundPalletRow> pallets) {}
public record PdaTask(Long id, String taskNo, String status, String outboundNo, LocalDateTime pushedAt, LocalDateTime completedAt) {}
public record PdaPickPosition(Long inventoryPositionId, String palletNo, String cartonMark,
String skuCode, String inboundCode, long availablePackages,
@@ -24,6 +24,7 @@ public class OutboundPalletItem {
private Integer pickedPackageCount;
private String status;
private String skuCode;
private String cartonMarks;
private String orderRefs;
private String inboundCode;
private LocalDateTime createdAt;
@@ -26,6 +26,7 @@ import com.baiyue.lastleg.entity.LastlegOrderDetail;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanLineMapper;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanCartonMapper;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletAllocationMapper;
import com.baiyue.lastleg.delivery.entity.OutboundPdaTask;
import com.baiyue.lastleg.delivery.entity.OutboundPdaPick;
@@ -78,6 +79,7 @@ public class DeliveryTransferService {
@Resource private DropshipSkuInventoryMapper dropshipSkuInventory;
@Resource private InboundPlanPalletAllocationMapper planAllocations;
@Resource private InboundPlanLineMapper planLines;
@Resource private InboundPlanCartonMapper inboundCartons;
@Resource private LastlegOrderMapper tailOrders;
@Resource private LastlegOrderStatusHistoryMapper histories;
@Resource private LastlegOrderDetailMapper tailOrderDetails;
@@ -156,6 +158,15 @@ public class DeliveryTransferService {
item.setDestinationWarehouseCode(source.getDestinationWarehouseCode());
item.setDestinationWarehouseName(source.getDestinationWarehouseName());
item.setPackageCount(Objects.requireNonNullElse(source.getPackageCount(), 0)); item.setPickedPackageCount(0); item.setStatus("WAIT_PICK");
List<String> requestedMarks = request.palletCartonMarks() == null ? List.of() : request.palletCartonMarks().getOrDefault(source.getPalletNo(), List.of());
if (!requestedMarks.isEmpty()) {
if (requestedMarks.size() != Objects.requireNonNullElse(source.getPackageCount(), 0)) throw conflict("Selected carton mark count must equal pallet package count");
item.setCartonMarks(String.join(",", requestedMarks.stream().distinct().toList()));
} else if (source.getWarehousePalletId() != null) {
String marks = inboundCartons.listByPallet(customerId, source.getWarehousePalletId()).stream()
.map(c -> c.getCartonMark()).filter(StringUtils::hasText).distinct().collect(java.util.stream.Collectors.joining(","));
item.setCartonMarks(StringUtils.hasText(marks) ? marks : null);
}
item.setOrderRefs(source.getOrderRefs()); item.setInboundCode(source.getInboundCode()); item.setCreatedAt(now);
outboundPallets.insert(item);
}
@@ -230,7 +241,7 @@ public class DeliveryTransferService {
InventoryPosition position = inventoryPositions.selectById(input.inventoryPositionId());
if (position == null || !customerId.equals(position.getCustomerId())) throw notFound("Inventory position not found");
OutboundPalletItem item = new OutboundPalletItem(); item.setId(IdWorker.getId()); item.setCustomerId(customerId); item.setOutboundOrderId(outbound.getId());
item.setWarehousePalletId(position.getPalletId()); item.setPalletNo(position.getPalletNo()); item.setPackageCount(input.packages().intValue()); item.setPickedPackageCount(0); item.setStatus("WAIT_PICK"); item.setSkuCode(input.sku().trim()); item.setCreatedAt(now);
item.setWarehousePalletId(position.getPalletId()); item.setPalletNo(position.getPalletNo()); item.setPackageCount(input.packages().intValue()); item.setPickedPackageCount(0); item.setStatus("WAIT_PICK"); item.setSkuCode(input.sku().trim()); item.setCartonMarks(String.join(",", input.cartonMarks() == null ? List.of() : input.cartonMarks())); item.setCreatedAt(now);
outboundPallets.insert(item);
}
outbound.setOverseasWarehouseId(selectedWarehouseId); outboundOrders.updateById(outbound);
@@ -258,6 +269,15 @@ public class DeliveryTransferService {
String key = profile.getId() + "\u0000" + input.sku().trim();
requestedPackages.merge(key, input.packages(), Long::sum);
profiles.put(key, profile);
if (input.cartonMarks() != null && !input.cartonMarks().isEmpty()) {
if (input.cartonMarks().size() != input.packages() || new HashSet<>(input.cartonMarks()).size() != input.cartonMarks().size()) {
throw conflict("Specified carton marks must be unique and equal the package count");
}
List<DropshipCarton> selected = dropshipCartons.listProfile(customerId, profile.getId()).stream()
.filter(carton -> "AVAILABLE".equals(carton.getStatus()) && input.sku().trim().equals(carton.getSkuCode())
&& input.cartonMarks().contains(carton.getCartonMark())).toList();
if (selected.size() != input.cartonMarks().size()) throw conflict("Specified carton marks are unavailable");
}
}
for (Map.Entry<String, Long> requested : requestedPackages.entrySet()) {
String sku = requested.getKey().substring(requested.getKey().indexOf('\u0000') + 1);
@@ -305,7 +325,11 @@ public class DeliveryTransferService {
}
DropshipOrderProfile profile = dropshipProfiles.findPosition(customerId, request.inventoryPositionId());
if (profile == null) throw conflict("Scanned pallet is not one-click dropship inventory");
List<DropshipCarton> cartons = dropshipCartons.lockAvailable(customerId, profile.getId(), item.getSkuCode(), request.packages());
List<DropshipCarton> cartons = StringUtils.hasText(item.getCartonMarks())
? Arrays.stream(item.getCartonMarks().split(",")).map(String::trim).filter(StringUtils::hasText)
.map(mark -> dropshipCartons.findByProfileAndMark(customerId, profile.getId(), mark)).filter(Objects::nonNull)
.filter(carton -> "AVAILABLE".equals(carton.getStatus()) && item.getSkuCode().equals(carton.getSkuCode())).toList()
: dropshipCartons.lockAvailable(customerId, profile.getId(), item.getSkuCode(), request.packages());
if (cartons.size() != request.packages()) throw conflict("Insufficient available one-click dropship cartons");
InventoryCommandRequests.Move move = new InventoryCommandRequests.Move(request.inventoryPositionId(), request.packages(), outbound.getOutboundNo(), outbound.getOutboundNo(), null, request.idempotencyKey() + ":ALLOCATE", null);
inventoryCommands.allocate(customerId, operatorId, move);
@@ -481,7 +505,9 @@ public class DeliveryTransferService {
if (outbound == null) throw notFound("Outbound order not found");
String customerId = outbound.getCustomerId();
DeliveryOrder delivery = requireDelivery(customerId, outbound.getId());
return new DeliveryResponses.OutboundDetail(outboundRow(outbound), deliveryRow(delivery));
List<DeliveryResponses.OutboundPalletRow> pallets = outboundPallets.findByOutbound(customerId, outbound.getId()).stream()
.map(item -> new DeliveryResponses.OutboundPalletRow(item.getId(), item.getPalletNo(), item.getCartonMarks(), item.getSkuCode(), item.getInboundCode(), item.getPackageCount(), item.getPickedPackageCount(), item.getStatus())).toList();
return new DeliveryResponses.OutboundDetail(outboundRow(outbound), deliveryRow(delivery), pallets);
}
public PageResult<DeliveryResponses.DeliveryRow> deliveryPage(String customerId, DeliveryRequests.Query query) {
@@ -9,6 +9,8 @@ import java.util.List;
public interface DropshipCartonMapper extends BaseMapper<DropshipCarton> {
@Select("SELECT * FROM ll_dropship_carton WHERE customer_id=#{customerId} AND order_id=#{orderId} AND carton_mark=#{cartonMark} LIMIT 1")
DropshipCarton find(@Param("customerId") String customerId, @Param("orderId") Long orderId, @Param("cartonMark") String cartonMark);
@Select("SELECT * FROM ll_dropship_carton WHERE customer_id=#{customerId} AND profile_id=#{profileId} AND carton_mark=#{cartonMark} LIMIT 1 FOR UPDATE")
DropshipCarton findByProfileAndMark(@Param("customerId") String customerId, @Param("profileId") Long profileId, @Param("cartonMark") String cartonMark);
@Select("SELECT * FROM ll_dropship_carton WHERE customer_id=#{customerId} AND profile_id=#{profileId} ORDER BY carton_sequence")
List<DropshipCarton> listProfile(@Param("customerId") String customerId, @Param("profileId") Long profileId);
@@ -28,15 +28,6 @@ public final class InboundPlanRequests {
LocalDateTime createdFrom,
LocalDateTime createdTo
) {
public Query(long page, long size, String planNo, String inboundCode, String status,
String overseasWarehouseId, String destinationWarehouseCode, String warehouse,
String fbaWarehouseCode, String appointmentStatus, String dispatchStatus,
LocalDateTime createdFrom, LocalDateTime createdTo) {
this(page, size, planNo, inboundCode, null, status, overseasWarehouseId,
destinationWarehouseCode, warehouse, fbaWarehouseCode, appointmentStatus,
dispatchStatus, createdFrom, createdTo);
}
public Query {
if (page == 0) page = 1;
if (size == 0) size = 20;
@@ -10,6 +10,8 @@ import java.util.List;
@Mapper
public interface InboundPlanCartonMapper extends BaseMapper<InboundPlanCarton> {
@Select("SELECT * FROM ll_inbound_plan_carton WHERE customer_id=#{customerId} AND (warehouse_pallet_id=#{palletId} OR plan_pallet_id=#{palletId}) ORDER BY id")
List<InboundPlanCarton> listByPallet(@Param("customerId") String customerId, @Param("palletId") Long palletId);
@Select("""
SELECT l.* FROM ll_inbound_plan_line l
JOIN ll_order_detail d ON d.order_id=l.order_id
@@ -16,6 +16,10 @@ public interface InventoryPositionMapper extends BaseMapper<InventoryPosition> {
List<InventoryPosition> listBalance(@Param("customerId") String customerId,
@Param("balanceId") Long balanceId);
@Select("SELECT * FROM ll_inventory_position WHERE id=#{positionId} AND customer_id=#{customerId} LIMIT 1")
InventoryPosition findTenantPosition(@Param("customerId") String customerId,
@Param("positionId") Long positionId);
@Select("SELECT * FROM ll_inventory_position WHERE customer_id=#{customerId} AND pallet_id=#{palletId} " +
"ORDER BY balance_id,batch_no")
List<InventoryPosition> listPallet(@Param("customerId") String customerId,
@@ -12,11 +12,19 @@ import org.springframework.http.HttpStatus;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
@RestController
@RequestMapping("/api/merchant/v1/last-leg/relabel-applications")
public class MerchantRelabelApplicationController {
@Resource private RelabelApplicationService service;
@GetMapping("/inventory-candidates")
public ApiResponse<List<RelabelResponses.InventoryCandidate>> candidates(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam String operationWarehouseId,
@RequestParam(required = false) String inboundCode) {
return ApiResponse.success(service.candidates(customer(user), operationWarehouseId, inboundCode));
}
@PostMapping
public ApiResponse<RelabelResponses.Detail> create(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody RelabelRequests.Create request) { return ApiResponse.success(service.create(customer(user), operator(user), request)); }
@@ -19,7 +19,7 @@ public final class RelabelRequests {
public record Create(
@NotBlank @Size(max = 128) String customerOrderNo,
@Size(max = 128) String overseasWarehouseId,
@NotBlank @Size(max = 128) String operationWarehouseId,
@NotNull LocalDateTime labelDeadline,
@NotBlank @Size(max = 128) String contactName,
@NotBlank @Size(max = 64) String contactPhone,
@@ -30,6 +30,9 @@ public final class RelabelRequests {
) {}
public record Item(
@NotNull Long sourceInventoryBalanceId,
@NotNull Long sourceInventoryPositionId,
@NotNull Long sourceInboundOrderId,
@Size(max = 128) String skuCode,
@Size(max = 128) String productImageFileId,
@Size(max = 255) String productImageFileName,
@@ -39,26 +42,61 @@ public final class RelabelRequests {
@Positive int notifiedPackageCount,
@NotBlank @Size(max = 128) String shipmentNo,
@Size(max = 255) String trackingCode,
@NotBlank @Size(max = 128) String newSkuCode,
@Size(max = 128) String newSkuCode,
@Size(max = 1000) String labelRequirement,
@Size(max = 1000) String remark,
@Size(max = 128) String relabelFileId,
@Size(max = 255) String relabelFileName,
@Size(max = 1000) String relabelFileUrl,
@NotBlank @Size(max = 255) String cartonMark,
@NotBlank @Size(max = 255) String fbaCartonMark,
@Size(max = 128) String destinationWarehouseId
@Size(max = 255) String fbaCartonMark,
@Size(max = 128) String destinationFbaWarehouseCode,
@Size(max = 32) String operationType,
@Size(max = 100) List<@Valid SkuItem> skuItems
) {
/** 保持既有箱唛换标调用方的二进制参数形态。 */
public Item(Long sourceInventoryBalanceId, Long sourceInventoryPositionId, Long sourceInboundOrderId,
String skuCode, String productImageFileId, String productImageFileName, String productImageUrl,
String productNameZh, int totalPackageCount, int notifiedPackageCount, String shipmentNo,
String trackingCode, String newSkuCode, String labelRequirement, String remark,
String relabelFileId, String relabelFileName, String relabelFileUrl, String cartonMark,
String fbaCartonMark, String destinationFbaWarehouseCode) {
this(sourceInventoryBalanceId, sourceInventoryPositionId, sourceInboundOrderId, skuCode,
productImageFileId, productImageFileName, productImageUrl, productNameZh, totalPackageCount,
notifiedPackageCount, shipmentNo, trackingCode, newSkuCode, labelRequirement, remark,
relabelFileId, relabelFileName, relabelFileUrl, cartonMark, fbaCartonMark,
destinationFbaWarehouseCode, "CARTON_LABEL", List.of());
}
public Item(String skuCode, String productImageFileId, String productImageFileName,
String productImageUrl, String productNameZh, int totalPackageCount,
int notifiedPackageCount, String shipmentNo, String trackingCode, String newSkuCode,
String labelRequirement, String remark, String relabelFileId, String relabelFileName,
String relabelFileUrl, String cartonMark, String fbaCartonMark,
String destinationFbaWarehouseCode) {
this(101L, 201L, 301L, skuCode, productImageFileId, productImageFileName, productImageUrl,
productNameZh, totalPackageCount, notifiedPackageCount, shipmentNo, trackingCode,
newSkuCode, labelRequirement, remark, relabelFileId, relabelFileName, relabelFileUrl,
cartonMark, fbaCartonMark, destinationFbaWarehouseCode, "CARTON_LABEL", List.of());
}
public Item(String skuCode, String productImageFileId, String productImageFileName,
String productImageUrl, String productNameZh, int totalPackageCount,
int notifiedPackageCount, String shipmentNo, String trackingCode, String newSkuCode,
String labelRequirement, String relabelFileId, String relabelFileName, String relabelFileUrl) {
this(skuCode, productImageFileId, productImageFileName, productImageUrl, productNameZh,
this(101L, 201L, 301L, skuCode, productImageFileId, productImageFileName, productImageUrl, productNameZh,
totalPackageCount, notifiedPackageCount, shipmentNo, trackingCode, newSkuCode,
labelRequirement, null, relabelFileId, relabelFileName, relabelFileUrl, skuCode, shipmentNo, null);
labelRequirement, null, relabelFileId, relabelFileName, relabelFileUrl, skuCode, shipmentNo, "ONT8",
"CARTON_LABEL", List.of());
}
}
/** 同一我方箱唛内可同时更换的多个 SKU;数量以件为单位。 */
public record SkuItem(
@NotBlank @Size(max = 128) String skuCode,
@NotBlank @Size(max = 128) String newSkuCode,
@Positive int quantity,
@Size(max = 1000) String remark
) {}
public record Query(@Min(1) long page, @Min(1) @Max(200) long size, String relabelNo,
String customerOrderNo, String skuCode, String status,
String overseasWarehouseId, LocalDateTime createdFrom, LocalDateTime createdTo) {
@@ -19,7 +19,19 @@ public final class RelabelResponses {
int notifiedPackageCount, String shipmentNo, String trackingCode,
String newSkuCode, String labelRequirement, String remark, String relabelFileId,
String relabelFileName, String relabelFileUrl,
String cartonMark, String fbaCartonMark, String destinationWarehouseId) {}
String cartonMark, String fbaCartonMark, String destinationFbaWarehouseCode,
String operationType, List<SkuItem> skuItems,
Long sourceInventoryBalanceId, Long sourceInventoryPositionId, Long sourceInboundOrderId,
String sourceInboundOrderNo, String sourceInboundCode, String sourceWarehouseId,
String sourceWarehouseCode, String sourceWarehouseName) {}
public record SkuItem(String skuCode, String newSkuCode, int quantity, String remark) {}
public record InventoryPosition(Long positionId, String palletNo, String batchNo,
String warehouseLocationCode, long availablePackages) {}
public record InventoryCandidate(Long inventoryBalanceId, Long inboundOrderId, String inboundOrderNo,
String inboundCode, String overseasWarehouseId, String overseasWarehouseCode,
String overseasWarehouseName, String destinationFbaWarehouseCode,
String destinationFbaWarehouseName, String skuCode, String productNameZh,
long availablePackages, long version, List<InventoryPosition> positions) {}
public record TailOrder(Long id, String orderNo, String overseasWarehouseId, int packageCount,
String status) {}
public record History(String fromStatus, String toStatus, String operatorId,
@@ -31,5 +31,16 @@ public class RelabelItem {
private String cartonMark;
private String fbaCartonMark;
private String destinationWarehouseId;
private String destinationFbaWarehouseCode;
private String operationType;
private String skuRelabelItemsJson;
private Long sourceInventoryBalanceId;
private Long sourceInventoryPositionId;
private Long sourceInboundOrderId;
private String sourceInboundOrderNo;
private String sourceInboundCode;
private String sourceWarehouseId;
private String sourceWarehouseCode;
private String sourceWarehouseName;
private LocalDateTime createdAt;
}
@@ -14,9 +14,9 @@ public final class PdaRelabelRequests {
public record Claim(@NotBlank @Size(max = 255) String idempotencyKey) { }
public record Scan(@NotNull @Positive Long itemId,
public record Scan(@Positive Long itemId,
@NotBlank @Size(max = 255) String oldLabel,
@NotBlank @Size(max = 255) String newLabel,
@Size(max = 255) String newLabel,
@NotNull @Min(0) Integer expectedVersion,
@NotBlank @Size(max = 255) String idempotencyKey) { }
@@ -23,8 +23,11 @@ public final class PdaRelabelResponses {
public record Item(Long itemId, String skuCode, String productNameZh, String shipmentNo,
String trackingCode, String newSkuCode, String labelRequirement,
String relabelFileId, String relabelFileName, String relabelFileUrl,
String cartonMark, String fbaCartonMark, String operationType,
List<SkuItem> skuItems,
int expectedPackages, int relabelledPackages, int skippedPackages,
int exceptionPackages) { }
public record SkuItem(String skuCode, String newSkuCode, int quantity, String remark) { }
public record UnitFact(Long factId, Long itemId, String oldLabel, String newLabel,
String result, String exceptionCode, String reason,
@@ -135,13 +135,23 @@ public class PdaRelabelService {
@Transactional
public PdaRelabelResponses.Detail scan(String operatorKey, String operatorName, long overseasWarehouseId,
String relabelNo, PdaRelabelRequests.Scan request) {
Long itemId = request.itemId();
if (itemId == null) {
RelabelApplication application = requireApplication(relabelNo, overseasWarehouseId, false);
itemId = requireItemByCartonMark(application, request.oldLabel()).getId();
}
return unitAction(operatorKey, operatorName, overseasWarehouseId, relabelNo, "RELABEL", request.idempotencyKey(),
request, request.itemId(), request.oldLabel(), request.newLabel(), "RELABELLED", null, null,
request, itemId, request.oldLabel(), request.newLabel(), "RELABELLED", null, null,
request.expectedVersion(), item -> {
if (!item.getNewSkuCode().equalsIgnoreCase(request.newLabel().trim())) {
throw conflict("Scanned new label does not match the approved new SKU");
if (StringUtils.hasText(item.getCartonMark()) && !item.getCartonMark().trim().equalsIgnoreCase(request.oldLabel().trim())) {
throw conflict("Scanned old carton mark does not match the relabel item");
}
if (request.oldLabel().trim().equalsIgnoreCase(request.newLabel().trim())) {
String expectedFbaLabel = trim(item.getFbaCartonMark());
if (StringUtils.hasText(request.newLabel()) && StringUtils.hasText(expectedFbaLabel)
&& !expectedFbaLabel.equalsIgnoreCase(request.newLabel().trim())) {
throw conflict("Scanned new label does not match the approved FBA carton mark");
}
if (StringUtils.hasText(expectedFbaLabel) && request.oldLabel().trim().equalsIgnoreCase(expectedFbaLabel)) {
throw conflict("Old and new labels must be different");
}
});
@@ -277,7 +287,11 @@ public class PdaRelabelService {
PdaRelabelUnitFact fact = new PdaRelabelUnitFact();
fact.setId(IdWorker.getId()); fact.setTaskId(task.getId()); fact.setApplicationId(application.getId());
fact.setItemId(itemId); fact.setCustomerId(application.getCustomerId());
fact.setOldLabel(normalizedOldLabel); fact.setNewLabel(trim(newLabel)); fact.setResult(resultCode);
String resolvedNewLabel = trim(newLabel);
if ("RELABELLED".equals(resultCode) && !StringUtils.hasText(resolvedNewLabel)) {
resolvedNewLabel = trim(item.getFbaCartonMark());
}
fact.setOldLabel(normalizedOldLabel); fact.setNewLabel(resolvedNewLabel); fact.setResult(resultCode);
fact.setExceptionCode(exceptionCode); fact.setReason(trim(reason));
fact.setOperatorKey(operatorKey); fact.setOperatorName(operatorName); fact.setOccurredAt(LocalDateTime.now());
try { unitFacts.insert(fact); }
@@ -328,6 +342,14 @@ public class PdaRelabelService {
if (item == null) throw notFound("Relabel item does not belong to the current application");
return item;
}
private RelabelItem requireItemByCartonMark(RelabelApplication application, String cartonMark) {
if (!StringUtils.hasText(cartonMark)) throw badRequest("Carton mark is required when itemId is omitted");
List<RelabelItem> matches = items.selectList(new LambdaQueryWrapper<RelabelItem>()
.eq(RelabelItem::getApplicationId, application.getId()).eq(RelabelItem::getCustomerId, application.getCustomerId())
.eq(RelabelItem::getCartonMark, cartonMark.trim()));
if (matches.size() != 1) throw notFound("Scanned carton mark does not belong to the current relabel application");
return matches.get(0);
}
private void requireVersion(PdaRelabelTask task, Integer expectedVersion) {
if (!Objects.equals(task.getVersion(), expectedVersion)) {
@@ -380,6 +402,7 @@ public class PdaRelabelService {
return new PdaRelabelResponses.Item(item.getId(), item.getSkuCode(), item.getProductNameZh(),
item.getShipmentNo(), item.getTrackingCode(), item.getNewSkuCode(), item.getLabelRequirement(),
item.getRelabelFileId(), item.getRelabelFileName(), item.getRelabelFileUrl(),
item.getCartonMark(), item.getFbaCartonMark(), operationType(item), readSkuItems(item.getSkuRelabelItemsJson()),
value(item.getNotifiedPackageCount()), count(itemFacts, "RELABELLED"), count(itemFacts, "SKIPPED"),
count(itemFacts, "EXCEPTION"));
}).toList();
@@ -462,6 +485,16 @@ public class PdaRelabelService {
return objectMapper.readValue(value, objectMapper.getTypeFactory().constructCollectionType(List.class, Long.class));
} catch (JsonProcessingException exception) { throw new IllegalStateException("Cannot read relabel photo IDs", exception); }
}
private List<PdaRelabelResponses.SkuItem> readSkuItems(String value) {
if (!StringUtils.hasText(value)) return List.of();
try {
return objectMapper.readValue(value, objectMapper.getTypeFactory().constructCollectionType(
List.class, PdaRelabelResponses.SkuItem.class));
} catch (JsonProcessingException exception) { throw new IllegalStateException("Cannot read relabel SKU details", exception); }
}
private static String operationType(RelabelItem item) {
return StringUtils.hasText(item.getOperationType()) ? item.getOperationType() : "CARTON_LABEL";
}
private static int count(List<PdaRelabelUnitFact> facts, String result) {
return (int) facts.stream().filter(fact -> result.equals(fact.getResult())).count();
@@ -8,6 +8,12 @@ import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper;
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests;
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
import com.baiyue.lastleg.inventory.entity.InventoryBalance;
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper;
import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper;
import com.baiyue.lastleg.relabel.dto.RelabelRequests;
import com.baiyue.lastleg.relabel.dto.RelabelResponses;
import com.baiyue.lastleg.relabel.entity.RelabelApplication;
@@ -38,6 +44,7 @@ import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.HashSet;
import java.util.stream.Collectors;
@Service
@@ -53,35 +60,90 @@ public class RelabelApplicationService implements WalletResultHandler {
@Resource private LastlegOrderNoGenerator orderNoGenerator;
@Resource private ObjectMapper objectMapper;
@Resource private WalletCommandOutboxService walletCommands;
@Resource private InventoryCommandService inventoryCommands;
@Resource private InventoryBalanceMapper inventoryBalances;
@Resource private InventoryPositionMapper inventoryPositions;
@Transactional(readOnly = true)
public List<RelabelResponses.InventoryCandidate> candidates(String customerId, String operationWarehouseId,
String inboundCode) {
if (!StringUtils.hasText(operationWarehouseId)) throw badRequest("Operation warehouse is required");
List<InventoryBalance> balances = inventoryBalances.selectList(new LambdaQueryWrapper<InventoryBalance>()
.eq(InventoryBalance::getCustomerId, customerId)
.eq(InventoryBalance::getOverseasWarehouseId, operationWarehouseId.trim())
.gt(InventoryBalance::getAvailablePackages, 0)
.like(StringUtils.hasText(inboundCode), InventoryBalance::getInboundCode, trim(inboundCode))
.orderByDesc(InventoryBalance::getUpdatedAt));
return balances.stream().map(balance -> {
List<RelabelResponses.InventoryPosition> positions = inventoryPositions.listBalance(customerId, balance.getId())
.stream().filter(position -> value(position.getAvailablePackages()) > 0)
.map(position -> new RelabelResponses.InventoryPosition(position.getId(), position.getPalletNo(),
position.getBatchNo(), position.getWarehouseLocationCode(), value(position.getAvailablePackages())))
.toList();
long available = positions.stream().mapToLong(RelabelResponses.InventoryPosition::availablePackages).sum();
return new RelabelResponses.InventoryCandidate(balance.getId(), balance.getInboundOrderId(),
balance.getInboundOrderNo(), balance.getInboundCode(), balance.getOverseasWarehouseId(),
balance.getOverseasWarehouseCode(), balance.getOverseasWarehouseName(),
balance.getDestinationWarehouseCode(), balance.getDestinationWarehouseName(), balance.getSkuCode(),
balance.getProductNameZh(), available, value(balance.getVersion()), positions);
}).filter(candidate -> candidate.availablePackages() > 0).toList();
}
@Transactional
public RelabelResponses.Detail create(String customerId, String operatorId, RelabelRequests.Create request) {
int total = 0;
int notified = 0;
Map<String, Integer> packagesByWarehouse = new LinkedHashMap<>();
for (RelabelRequests.Item item : request.items()) {
if (item.notifiedPackageCount() > item.totalPackageCount()) {
throw badRequest("Notified relocation packages cannot exceed total packages for SKU " + item.skuCode());
}
total = Math.addExact(total, item.totalPackageCount());
notified = Math.addExact(notified, item.notifiedPackageCount());
String warehouseId = destination(item, request.overseasWarehouseId());
packagesByWarehouse.merge(warehouseId, item.totalPackageCount(), Math::addExact);
}
if (!StringUtils.hasText(request.operationWarehouseId())) throw badRequest("Operation warehouse is required");
String requestHash = hash(request);
RelabelApplication replay = applications.findKey(customerId, request.idempotencyKey().trim());
if (replay != null) {
if (!requestHash.equals(replay.getRequestHash())) throw conflict("Idempotency key was reused with different relabel data");
return detail(customerId, replay.getRelabelNo());
}
int total = 0;
int notified = 0;
List<SourceInventory> sources = new java.util.ArrayList<>();
Set<Long> selectedPositions = new HashSet<>();
Set<String> selectedCartonMarks = new HashSet<>();
for (RelabelRequests.Item item : request.items()) {
validateOperation(item);
if (item.notifiedPackageCount() > item.totalPackageCount()) {
throw badRequest("Notified relocation packages cannot exceed total packages for SKU " + item.skuCode());
}
total = Math.addExact(total, item.totalPackageCount());
notified = Math.addExact(notified, item.notifiedPackageCount());
if (!selectedPositions.add(item.sourceInventoryPositionId())) {
throw badRequest("The same inventory position cannot be selected more than once");
}
if (!selectedCartonMarks.add(item.cartonMark().trim().toUpperCase())) {
throw badRequest("The same carton mark cannot create more than one relabel task");
}
InventoryPosition position = inventoryPositions.findTenantPosition(customerId, item.sourceInventoryPositionId());
if (position == null || !Objects.equals(position.getBalanceId(), item.sourceInventoryBalanceId())) {
throw conflict("Selected inventory position is no longer available");
}
InventoryBalance balance = inventoryBalances.selectById(item.sourceInventoryBalanceId());
if (balance == null || !customerId.equals(balance.getCustomerId())
|| !request.operationWarehouseId().trim().equals(balance.getOverseasWarehouseId())
|| !Objects.equals(balance.getInboundOrderId(), item.sourceInboundOrderId())) {
throw conflict("Selected inventory does not belong to this customer, operation warehouse and inbound order");
}
if (value(position.getAvailablePackages()) < item.totalPackageCount()) {
throw conflict("Available inventory changed for inbound " + balance.getInboundOrderNo());
}
sources.add(new SourceInventory(item, balance));
}
LocalDateTime now = LocalDateTime.now();
long applicationId = IdWorker.getId();
String primaryWarehouseId = packagesByWarehouse.keySet().iterator().next();
String relabelNo = orderNoGenerator.next("RELABEL", primaryWarehouseId, applicationId);
List<LastlegOrder> tailOrders = packagesByWarehouse.entrySet().stream()
.map(entry -> insertTailOrder(customerId, request, requestHash, relabelNo,
entry.getKey(), entry.getValue(), now))
.toList();
String operationWarehouseId = request.operationWarehouseId().trim();
String relabelNo = orderNoGenerator.next("RELABEL", operationWarehouseId, applicationId);
for (SourceInventory source : sources) {
inventoryCommands.hold(customerId, operatorId, new InventoryCommandRequests.Move(
source.request().sourceInventoryPositionId(), source.request().totalPackageCount(), relabelNo,
null, "RELABEL:" + relabelNo, request.idempotencyKey().trim() + ":HOLD:"
+ source.request().sourceInventoryPositionId(), "贴标申请冻结原入仓单库存"));
}
LastlegOrder tailOrder = insertTailOrder(customerId, request, requestHash, relabelNo,
operationWarehouseId, total, now);
List<LastlegOrder> tailOrders = List.of(tailOrder);
RelabelApplication application = new RelabelApplication();
application.setId(applicationId);
@@ -89,7 +151,7 @@ public class RelabelApplicationService implements WalletResultHandler {
application.setRelabelNo(relabelNo);
application.setLastlegOrderId(tailOrders.get(0).getId());
application.setCustomerOrderNo(request.customerOrderNo().trim());
application.setOverseasWarehouseId(primaryWarehouseId);
application.setOverseasWarehouseId(operationWarehouseId);
application.setLabelDeadline(request.labelDeadline());
application.setContactName(request.contactName().trim());
application.setContactPhone(request.contactPhone().trim());
@@ -113,8 +175,8 @@ public class RelabelApplicationService implements WalletResultHandler {
application.setWalletCommandEventId(walletEventId);
applications.updateById(application);
for (RelabelRequests.Item source : request.items()) {
insertItem(customerId, applicationId, source, request.overseasWarehouseId(), now);
for (SourceInventory source : sources) {
insertItem(customerId, applicationId, source, now);
}
insertHistory(customerId, applicationId, null, PENDING_PROCESSING, operatorId, "Relabel application submitted", now);
tailOrders.forEach(order -> insertOrderHistory(customerId, order.getId(), null,
@@ -132,9 +194,8 @@ public class RelabelApplicationService implements WalletResultHandler {
.like(StringUtils.hasText(query.relabelNo()), RelabelApplication::getRelabelNo, query.relabelNo())
.like(StringUtils.hasText(query.customerOrderNo()), RelabelApplication::getCustomerOrderNo, query.customerOrderNo())
.eq(StringUtils.hasText(query.status()), RelabelApplication::getStatus, query.status())
.apply(StringUtils.hasText(query.overseasWarehouseId()),
"EXISTS (SELECT 1 FROM ll_relabel_item rw WHERE rw.application_id=ll_relabel_application.id AND rw.customer_id=ll_relabel_application.customer_id AND rw.destination_warehouse_id={0})",
query.overseasWarehouseId())
.eq(StringUtils.hasText(query.overseasWarehouseId()), RelabelApplication::getOverseasWarehouseId,
trim(query.overseasWarehouseId()))
.ge(query.createdFrom() != null, RelabelApplication::getCreatedAt, query.createdFrom())
.le(query.createdTo() != null, RelabelApplication::getCreatedAt, query.createdTo())
.apply(StringUtils.hasText(query.skuCode()),
@@ -185,13 +246,17 @@ public class RelabelApplicationService implements WalletResultHandler {
application.setUpdatedAt(now);
applications.updateById(application);
if ("COMPLETED".equals(next)) {
releaseInventory(customerId, operatorId, application);
walletCommands.publish("SETTLE", Long.parseLong(customerId), "RELABEL_ORDER", relabelNo, null,
"换标完成,结算费用", 2L);
application.setWalletStatus("SETTLE_PENDING"); applications.updateById(application);
} else if (Set.of("REJECTED", "CANCELLED").contains(next) && "FROZEN".equals(application.getWalletStatus())) {
walletCommands.publish("RELEASE", Long.parseLong(customerId), "RELABEL_ORDER", relabelNo, null,
"换标取消,释放冻结", 2L);
application.setWalletStatus("RELEASE_PENDING"); applications.updateById(application);
} else if (Set.of("REJECTED", "CANCELLED").contains(next)) {
releaseInventory(customerId, operatorId, application);
if ("FROZEN".equals(application.getWalletStatus())) {
walletCommands.publish("RELEASE", Long.parseLong(customerId), "RELABEL_ORDER", relabelNo, null,
"换标取消,释放冻结", 2L);
application.setWalletStatus("RELEASE_PENDING"); applications.updateById(application);
}
}
List<LastlegOrder> linkedOrders = tailOrders(application);
if (linkedOrders.isEmpty()) throw conflict("Linked tail-leg order is missing");
@@ -217,30 +282,41 @@ public class RelabelApplicationService implements WalletResultHandler {
return detail(customerId, relabelNo);
}
private void insertItem(String customerId, long applicationId, RelabelRequests.Item source,
String defaultWarehouseId, LocalDateTime now) {
private void insertItem(String customerId, long applicationId, SourceInventory sourceInventory,
LocalDateTime now) {
RelabelRequests.Item source = sourceInventory.request();
InventoryBalance balance = sourceInventory.balance();
RelabelItem item = new RelabelItem();
item.setId(IdWorker.getId()); item.setApplicationId(applicationId); item.setCustomerId(customerId);
item.setSkuCode(trim(source.skuCode())); item.setProductImageFileId(trim(source.productImageFileId()));
item.setProductImageFileName(trim(source.productImageFileName())); item.setProductImageUrl(trim(source.productImageUrl()));
item.setProductNameZh(source.productNameZh().trim()); item.setTotalPackageCount(source.totalPackageCount());
item.setNotifiedPackageCount(source.notifiedPackageCount()); item.setShipmentNo(source.shipmentNo().trim());
item.setTrackingCode(trim(source.trackingCode())); item.setNewSkuCode(source.newSkuCode().trim());
item.setTrackingCode(trim(source.trackingCode())); item.setNewSkuCode(trim(source.newSkuCode()));
item.setLabelRequirement(trim(source.labelRequirement())); item.setRemark(trim(source.remark())); item.setRelabelFileId(trim(source.relabelFileId()));
item.setRelabelFileName(trim(source.relabelFileName())); item.setRelabelFileUrl(trim(source.relabelFileUrl()));
item.setCartonMark(source.cartonMark().trim()); item.setFbaCartonMark(source.fbaCartonMark().trim());
item.setDestinationWarehouseId(destination(source, defaultWarehouseId));
item.setCartonMark(source.cartonMark().trim()); item.setFbaCartonMark(trim(source.fbaCartonMark()));
item.setDestinationWarehouseId(trim(source.destinationFbaWarehouseCode()));
item.setDestinationFbaWarehouseCode(trim(source.destinationFbaWarehouseCode()));
item.setOperationType(operationType(source));
item.setSkuRelabelItemsJson(writeSkuItems(source.skuItems()));
item.setSourceInventoryBalanceId(balance.getId());
item.setSourceInventoryPositionId(source.sourceInventoryPositionId());
item.setSourceInboundOrderId(balance.getInboundOrderId());
item.setSourceInboundOrderNo(balance.getInboundOrderNo());
item.setSourceInboundCode(balance.getInboundCode());
item.setSourceWarehouseId(balance.getOverseasWarehouseId());
item.setSourceWarehouseCode(balance.getOverseasWarehouseCode());
item.setSourceWarehouseName(balance.getOverseasWarehouseName());
item.setCreatedAt(now); items.insert(item);
}
private RelabelResponses.Summary summary(RelabelApplication value, List<RelabelItem> detailItems) {
LastlegOrder order = orders.selectById(value.getLastlegOrderId());
String skuCodes = detailItems.stream().map(RelabelItem::getSkuCode).filter(Objects::nonNull).distinct().collect(Collectors.joining(","));
String warehouses = detailItems.stream().map(RelabelItem::getDestinationWarehouseId)
.filter(StringUtils::hasText).distinct().collect(Collectors.joining(","));
return new RelabelResponses.Summary(value.getId(), value.getRelabelNo(), value.getLastlegOrderId(),
order == null ? null : order.getOrderNo(), value.getCustomerOrderNo(),
StringUtils.hasText(warehouses) ? warehouses : value.getOverseasWarehouseId(),
value.getOverseasWarehouseId(),
value.getLabelDeadline(), value.getContactName(), value.getContactPhone(), value.getStatus(),
Objects.requireNonNullElse(value.getItemCount(), detailItems.size()),
Objects.requireNonNullElse(value.getTotalPackageCount(), 0),
@@ -267,6 +343,9 @@ public class RelabelApplicationService implements WalletResultHandler {
} else {
value.setWalletStatus(result.operation() + "_FAILED"); value.setWalletErrorCode(result.errorCode());
value.setWalletErrorMessage(result.errorMessage());
if ("FREEZE".equals(result.operation())) {
releaseInventory(value.getCustomerId(), "WALLET_CALLBACK", value);
}
}
value.setUpdatedAt(LocalDateTime.now()); applications.updateById(value);
}
@@ -277,7 +356,11 @@ public class RelabelApplicationService implements WalletResultHandler {
value.getTotalPackageCount(), value.getNotifiedPackageCount(), value.getShipmentNo(),
value.getTrackingCode(), value.getNewSkuCode(), value.getLabelRequirement(), value.getRemark(), value.getRelabelFileId(),
value.getRelabelFileName(), value.getRelabelFileUrl(), value.getCartonMark(), value.getFbaCartonMark(),
value.getDestinationWarehouseId());
StringUtils.hasText(value.getDestinationFbaWarehouseCode()) ? value.getDestinationFbaWarehouseCode() : value.getDestinationWarehouseId(),
operationType(value), readSkuItems(value.getSkuRelabelItemsJson()),
value.getSourceInventoryBalanceId(), value.getSourceInventoryPositionId(), value.getSourceInboundOrderId(),
value.getSourceInboundOrderNo(), value.getSourceInboundCode(), value.getSourceWarehouseId(),
value.getSourceWarehouseCode(), value.getSourceWarehouseName());
}
private LastlegOrder insertTailOrder(String customerId, RelabelRequests.Create request, String requestHash,
@@ -310,18 +393,61 @@ public class RelabelApplicationService implements WalletResultHandler {
return legacy == null ? List.of() : List.of(legacy);
}
private static String destination(RelabelRequests.Item item, String fallback) {
String value = StringUtils.hasText(item.destinationWarehouseId())
? item.destinationWarehouseId().trim()
: trim(fallback);
if (!StringUtils.hasText(value)) throw badRequest("Destination warehouse is required for every carton");
return value;
private record SourceInventory(RelabelRequests.Item request, InventoryBalance balance) {}
private static long value(Long value) { return value == null ? 0L : value; }
private void releaseInventory(String customerId, String operatorId, RelabelApplication application) {
for (RelabelItem item : itemList(application.getId())) {
if (item.getSourceInventoryPositionId() == null || item.getTotalPackageCount() == null) continue;
inventoryCommands.release(customerId, operatorId, new InventoryCommandRequests.Move(
item.getSourceInventoryPositionId(), item.getTotalPackageCount(), application.getRelabelNo(), null,
"RELABEL:" + application.getRelabelNo(), application.getIdempotencyKey() + ":RELEASE:"
+ item.getSourceInventoryPositionId(), "贴标结束释放库存至原入仓单"));
}
}
private List<RelabelItem> itemList(Long applicationId) {
return items.selectList(new LambdaQueryWrapper<RelabelItem>().eq(RelabelItem::getApplicationId, applicationId)
.orderByAsc(RelabelItem::getId));
}
private void validateOperation(RelabelRequests.Item item) {
String type = operationType(item);
if (!Set.of("CARTON_LABEL", "SKU_LABEL", "CARTON_AND_SKU").contains(type)) {
throw badRequest("Unsupported relabel operation type");
}
if (item.notifiedPackageCount() > item.totalPackageCount()) {
throw badRequest("Notified relocation packages cannot exceed total packages for carton " + item.cartonMark());
}
if (("CARTON_LABEL".equals(type) || "CARTON_AND_SKU".equals(type))
&& (!StringUtils.hasText(item.fbaCartonMark()) || !StringUtils.hasText(item.destinationFbaWarehouseCode()))) {
throw badRequest("Carton label changes require both FBA carton mark and destination warehouse");
}
if (("SKU_LABEL".equals(type) || "CARTON_AND_SKU".equals(type))
&& (item.skuItems() == null || item.skuItems().isEmpty())) {
throw badRequest("SKU label changes require at least one SKU detail");
}
if ("CARTON_LABEL".equals(type) && !StringUtils.hasText(item.newSkuCode())) {
throw badRequest("Carton label changes require the new SKU");
}
}
private static String operationType(RelabelRequests.Item item) {
return StringUtils.hasText(item.operationType()) ? item.operationType().trim().toUpperCase() : "CARTON_LABEL";
}
private static String operationType(RelabelItem item) {
return StringUtils.hasText(item.getOperationType()) ? item.getOperationType() : "CARTON_LABEL";
}
private String writeSkuItems(List<RelabelRequests.SkuItem> values) {
if (values == null || values.isEmpty()) return null;
try { return objectMapper.writeValueAsString(values); }
catch (Exception exception) { throw new IllegalStateException("Cannot save relabel SKU details", exception); }
}
private List<RelabelResponses.SkuItem> readSkuItems(String value) {
if (!StringUtils.hasText(value)) return List.of();
try {
return objectMapper.readValue(value, objectMapper.getTypeFactory().constructCollectionType(
List.class, RelabelResponses.SkuItem.class));
} catch (Exception exception) { throw new IllegalStateException("Cannot read relabel SKU details", exception); }
}
private RelabelApplication require(String customerId, String relabelNo) {
RelabelApplication value = StringUtils.hasText(customerId)
? applications.findNo(customerId, relabelNo)
@@ -0,0 +1,59 @@
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.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
@RestController
@RequestMapping("/api/merchant/v1/last-leg/return-inventory")
public class MerchantReturnInventoryController {
public static final String LIST_PERMISSION = "merchant:overseas-warehouse:return-inventory:list";
@Resource private ReturnInventoryService service;
@GetMapping
@PreAuthorize("hasAuthority('" + LIST_PERMISSION + "')")
public ApiResponse<PageResult<ReturnInventoryResponses.InventoryItem>> page(@AuthenticationPrincipal AuthenticatedUser user,
@Valid ReturnInventoryRequests.Query request) {
return ApiResponse.success(service.page(customer(user), request));
}
@GetMapping("/summary")
@PreAuthorize("hasAuthority('" + LIST_PERMISSION + "')")
public ApiResponse<PageResult<ReturnInventoryResponses.SummaryItem>> summary(@AuthenticationPrincipal AuthenticatedUser user,
@Valid ReturnInventoryRequests.SummaryQuery request) {
return ApiResponse.success(service.summaryPage(customer(user), request));
}
@GetMapping("/cartons/{cartonMark}")
@PreAuthorize("hasAuthority('" + LIST_PERMISSION + "')")
public ApiResponse<ReturnInventoryResponses.CartonDetail> carton(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String cartonMark) {
return ApiResponse.success(service.carton(customer(user), cartonMark));
}
@GetMapping("/cartons/{cartonMark}/label")
@PreAuthorize("hasAuthority('" + LIST_PERMISSION + "')")
public ApiResponse<ReturnInventoryResponses.Label> label(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String cartonMark) {
return ApiResponse.success(service.label(customer(user), cartonMark));
}
private String customer(AuthenticatedUser user) {
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if ("PLATFORM".equals(user.appCode()) || user.tenantId() == 0L) throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Merchant identity is required");
return String.valueOf(user.tenantId());
}
}
@@ -0,0 +1,32 @@
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.ReturnOrderRequests;
import com.baiyue.lastleg.returnreceipt.dto.ReturnOrderResponses;
import com.baiyue.lastleg.returnreceipt.service.ReturnReceiptService;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
@RestController
@RequestMapping("/api/merchant/v1/last-leg/return-orders")
public class MerchantReturnOrderController {
@Resource private ReturnReceiptService service;
@PostMapping public ApiResponse<ReturnOrderResponses.Detail> create(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody ReturnOrderRequests.Create request) { return ApiResponse.success(service.create(customer(user), operator(user), request)); }
@GetMapping public ApiResponse<PageResult<ReturnOrderResponses.Summary>> page(@AuthenticationPrincipal AuthenticatedUser user,
@Valid ReturnOrderRequests.Query query) { return ApiResponse.success(service.page(customer(user), query)); }
@GetMapping("/{returnNo}") public ApiResponse<ReturnOrderResponses.Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String returnNo) { return ApiResponse.success(service.detail(customer(user), returnNo)); }
private String customer(AuthenticatedUser user) {
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if ("PLATFORM".equals(user.appCode()) || user.tenantId() == 0L) throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Merchant identity is required");
return String.valueOf(user.tenantId());
}
private String operator(AuthenticatedUser user) { return user.userId() == null ? user.username() : String.valueOf(user.userId()); }
}
@@ -0,0 +1,43 @@
package com.baiyue.lastleg.returnreceipt.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
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.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.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/pda/v1/last-leg/return-inventory")
@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR')")
public class PdaReturnInventoryController {
@Resource private ReturnInventoryService service;
@GetMapping("/packing/orders")
public ApiResponse<ReturnInventoryResponses.PackingPreview> packingOrders(@AuthenticationPrincipal AuthenticatedUser user,
@Valid ReturnInventoryRequests.PackingQuery request) {
return ApiResponse.success(service.packingPreview(PdaWarehouseIdentity.require(user), request));
}
@PostMapping("/cartons/merge")
public ApiResponse<ReturnInventoryResponses.MergeResult> merge(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody ReturnInventoryRequests.Merge request) {
return ApiResponse.success(service.merge(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));
}
}
@@ -0,0 +1,33 @@
package com.baiyue.lastleg.returnreceipt.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity;
import com.baiyue.lastleg.returnreceipt.dto.PdaReturnReceiptRequests;
import com.baiyue.lastleg.returnreceipt.dto.PdaReturnReceiptResponses;
import com.baiyue.lastleg.returnreceipt.service.ReturnReceiptService;
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/pda/v1/last-leg/return-receipts")
@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR')")
public class PdaReturnReceiptController {
@Resource private ReturnReceiptService service;
@PostMapping("/start") public ApiResponse<PdaReturnReceiptResponses.Start> start(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody PdaReturnReceiptRequests.Start request) {
return ApiResponse.success(service.start(PdaWarehouseIdentity.require(user), request));
}
@PostMapping("/sku-candidates") public ApiResponse<PdaReturnReceiptResponses.SkuCandidates> skuCandidates(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody PdaReturnReceiptRequests.SkuLookup request) {
return ApiResponse.success(service.skuCandidates(PdaWarehouseIdentity.require(user), request));
}
@PostMapping("/confirm") public ApiResponse<PdaReturnReceiptResponses.Confirmed> confirm(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody PdaReturnReceiptRequests.Confirm request) {
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
return ApiResponse.success(service.confirm(identity, request));
}
}
@@ -0,0 +1,44 @@
package com.baiyue.lastleg.returnreceipt.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
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.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/platform/v1/last-leg/return-inventory")
public class PlatformReturnInventoryController {
@Resource private ReturnInventoryService service;
@GetMapping
@PreAuthorize("hasAuthority('platform:overseas-warehouse:return-inventory:list')")
public ApiResponse<PageResult<ReturnInventoryResponses.PlatformStock>> stock(@Valid ReturnInventoryRequests.PlatformQuery request) {
return ApiResponse.success(service.platformStockPage(request));
}
@GetMapping("/cartons")
@PreAuthorize("hasAuthority('platform:overseas-warehouse:return-cartons:list')")
public ApiResponse<PageResult<ReturnInventoryResponses.PlatformCarton>> cartons(@Valid ReturnInventoryRequests.PlatformQuery request) {
return ApiResponse.success(service.platformCartonPage(request));
}
@GetMapping("/cartons/{cartonMark}")
@PreAuthorize("hasAuthority('platform:overseas-warehouse:return-cartons:list')")
public ApiResponse<ReturnInventoryResponses.CartonDetail> carton(@PathVariable String cartonMark) {
return ApiResponse.success(service.platformCarton(cartonMark));
}
@GetMapping("/cartons/{cartonMark}/label")
@PreAuthorize("hasAuthority('platform:overseas-warehouse:return-cartons:list')")
public ApiResponse<ReturnInventoryResponses.Label> label(@PathVariable String cartonMark) {
return ApiResponse.success(service.platformLabel(cartonMark));
}
}
@@ -0,0 +1,22 @@
package com.baiyue.lastleg.returnreceipt.dto;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;
import java.util.List;
public final class PdaReturnReceiptRequests {
private PdaReturnReceiptRequests() { }
public record Start(@NotBlank @Size(max = 128) String returnNo) { }
public record SkuLookup(@NotBlank @Size(max = 128) String skuCode) { }
public record Confirm(@NotBlank @Size(max = 128) String returnNo,
@Size(max = 128) String trackingNo,
@NotBlank @Size(max = 16) String startedBy,
@Size(max = 128) String cartonMark,
@NotEmpty @Size(max = 500) List<@Valid Item> items,
@NotBlank @Size(max = 255) String idempotencyKey) { }
public record Item(@NotBlank @Size(max = 128) String skuCode, @Positive long receivedQuantity) { }
}
@@ -0,0 +1,17 @@
package com.baiyue.lastleg.returnreceipt.dto;
import java.time.LocalDateTime;
import java.util.List;
public final class PdaReturnReceiptResponses {
private PdaReturnReceiptResponses() { }
public record Start(String returnNo, String status, List<Item> expectedItems) { }
public record SkuCandidates(String skuCode, List<Candidate> candidates) { }
public record Candidate(String returnNo, String customerId, String customerReferenceNo, String status,
long skuExpectedQuantity, long skuReceivedQuantity, long orderExpectedQuantity,
long orderReceivedQuantity) { }
public record Item(String skuCode, String productNameZh, long expectedQuantity, long receivedQuantity) { }
public record Confirmed(String returnNo, String receiptNo, String receiptBatchNo, String cartonMark, String orderStatus,
boolean fullyMatched, long expectedQuantity, long receivedQuantity,
LocalDateTime signedAt) { }
}
@@ -0,0 +1,45 @@
package com.baiyue.lastleg.returnreceipt.dto;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;
import jakarta.validation.Valid;
import java.math.BigDecimal;
import java.util.List;
public final class ReturnInventoryRequests {
private ReturnInventoryRequests() { }
public record Query(@Min(1) long page, @Min(1) @Max(200) long size, String overseasWarehouseId,
String skuCode, String cartonMark, String status, String returnNo) {
public Query { if (page == 0) page = 1; if (size == 0) size = 20; }
}
public record SummaryQuery(@Min(1) long page, @Min(1) @Max(200) long size, String overseasWarehouseId,
String skuCode) {
public SummaryQuery { if (page == 0) page = 1; if (size == 0) size = 20; }
}
public record PlatformQuery(@Min(1) long page, @Min(1) @Max(200) long size, String customerId,
String overseasWarehouseId, String skuCode, String cartonMark,
String returnNo, String status) {
public PlatformQuery { if (page == 0) page = 1; if (size == 0) size = 20; }
}
public record PackingQuery(@Size(max = 128) String returnNo) { }
public record Merge(@NotEmpty @Size(min = 1, max = 100) List<@NotBlank @Size(max = 128) String> returnNos,
@NotEmpty @Size(max = 500) List<@Valid ValidItem> items,
@NotNull @Positive BigDecimal length,
@NotNull @Positive BigDecimal width,
@NotNull @Positive BigDecimal height,
@NotNull @Positive BigDecimal weight,
@NotBlank @Size(max = 255) String idempotencyKey) { }
public record ValidItem(@NotBlank @Size(max = 128) String skuCode, @Positive long quantity) { }
}
@@ -0,0 +1,32 @@
package com.baiyue.lastleg.returnreceipt.dto;
import java.time.LocalDateTime;
import java.math.BigDecimal;
import java.util.List;
public final class ReturnInventoryResponses {
private ReturnInventoryResponses() { }
public record InventoryItem(String overseasWarehouseId, String cartonMark, String sourceBatchNo, String status, String skuCode,
String productNameZh, long quantity, int cartonSkuTypes, long cartonTotalQuantity,
LocalDateTime receivedAt) { }
public record SummaryItem(String overseasWarehouseId, String skuCode, String productNameZh,
long availableQuantity, long cartonQuantity, LocalDateTime updatedAt) { }
public record CartonDetail(String cartonMark, String overseasWarehouseId, String sourceBatchNo, String sourceReturnNo, String status,
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) { }
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 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 PlatformStock(String customerId, String overseasWarehouseId, String skuCode, String productNameZh,
long availableQuantity, long cartonQuantity, LocalDateTime updatedAt) { }
public record PlatformCarton(String customerId, String overseasWarehouseId, String cartonMark, String sourceReturnNo,
String sourceBatchNo, String status, int skuTypes, long totalQuantity,
BigDecimal length, BigDecimal width, BigDecimal height, BigDecimal weight,
LocalDateTime createdAt) { }
}
@@ -0,0 +1,27 @@
package com.baiyue.lastleg.returnreceipt.dto;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;
import java.util.List;
public final class ReturnOrderRequests {
private ReturnOrderRequests() { }
public record Create(@NotBlank @Size(max = 128) String overseasWarehouseId,
@Size(max = 128) String customerReferenceNo,
@Size(max = 1000) String remark,
@NotBlank @Size(max = 255) String idempotencyKey,
@NotEmpty @Size(max = 500) List<@Valid Item> items) { }
public record Item(@NotBlank @Size(max = 128) String skuCode,
@Size(max = 255) String productNameZh,
@Positive long expectedQuantity) { }
public record Query(@Min(1) long page, @Min(1) @Max(200) long size, String returnNo,
String trackingNo, String skuCode, String status) {
public Query { if (page == 0) page = 1; if (size == 0) size = 20; }
}
}
@@ -0,0 +1,15 @@
package com.baiyue.lastleg.returnreceipt.dto;
import java.time.LocalDateTime;
import java.util.List;
public final class ReturnOrderResponses {
private ReturnOrderResponses() { }
public record Summary(String returnNo, String overseasWarehouseId, String customerReferenceNo, String status,
int skuTypes, long expectedQuantity, long receivedQuantity, LocalDateTime createdAt) { }
public record Item(String skuCode, String productNameZh, long expectedQuantity,
long receivedQuantity) { }
public record Detail(Summary order, String remark, List<Item> items, List<Receipt> receipts) { }
public record Receipt(String receiptNo, String receiptBatchNo, String cartonMark, String trackingNo, String startedBy,
long receivedQuantity, String operatorName, LocalDateTime signedAt) { }
}
@@ -0,0 +1,23 @@
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;
/** Customer-facing available return-inventory balance. */
@Data
@TableName("ll_return_inventory_balance")
public class ReturnInventoryBalance {
@TableId(type = IdType.INPUT) private Long id;
private String customerId;
private String overseasWarehouseId;
private String skuCode;
private String productNameZh;
private Long availableQuantity;
private Long cartonQuantity;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,32 @@
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;
import java.math.BigDecimal;
/** Physical carton that holds return inventory. */
@Data
@TableName("ll_return_inventory_carton")
public class ReturnInventoryCarton {
@TableId(type = IdType.INPUT) private Long id;
private String customerId;
private String overseasWarehouseId;
private Long sourceReceiptId;
private String sourceBatchNo;
private String sourceReturnNo;
private String cartonMark;
private String status;
private Integer skuTypes;
private Long totalQuantity;
private BigDecimal length;
private BigDecimal width;
private BigDecimal height;
private BigDecimal weight;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private LocalDateTime mergedAt;
}
@@ -0,0 +1,17 @@
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;
@Data
@TableName("ll_return_inventory_carton_item")
public class ReturnInventoryCartonItem {
@TableId(type = IdType.INPUT) private Long id;
private Long cartonId;
private String customerId;
private String skuCode;
private String productNameZh;
private Long quantity;
}
@@ -0,0 +1,21 @@
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_inventory_operation")
public class ReturnInventoryOperation {
@TableId(type = IdType.INPUT) private Long id;
private String operatorId;
private String idempotencyKey;
private String requestHash;
private String operationType;
private Long resultCartonId;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,28 @@
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_order")
public class ReturnOrder {
@TableId(type = IdType.INPUT) private Long id;
private String customerId;
private String returnNo;
private String overseasWarehouseId;
private String customerReferenceNo;
private String status;
private Integer expectedSkuTypes;
private Long expectedQuantity;
private Long receivedQuantity;
private String remark;
private String idempotencyKey;
private String requestHash;
private String createdBy;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,22 @@
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_order_item")
public class ReturnOrderItem {
@TableId(type = IdType.INPUT) private Long id;
private Long returnOrderId;
private String customerId;
private String skuCode;
private String productNameZh;
private Long expectedQuantity;
private Long receivedQuantity;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,29 @@
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_receipt")
public class ReturnReceipt {
@TableId(type = IdType.INPUT) private Long id;
private Long returnOrderId;
private String customerId;
private String receiptNo;
private String receiptBatchNo;
private String cartonMark;
private String trackingNo;
private String boundBy;
private String status;
private Long receivedQuantity;
private String operatorId;
private String operatorName;
private String idempotencyKey;
private String requestHash;
private LocalDateTime signedAt;
private LocalDateTime createdAt;
}
@@ -0,0 +1,23 @@
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;
/** A completed receipt starts the storage-billing clock, even when the whole return order remains partial. */
@Data
@TableName("ll_return_receipt_batch")
public class ReturnReceiptBatch {
@TableId(type = IdType.INPUT) private Long id;
private Long returnOrderId;
private Long receiptId;
private String customerId;
private String batchNo;
private String overseasWarehouseId;
private String storageBillingStatus;
private LocalDateTime storageStartedAt;
private LocalDateTime createdAt;
}
@@ -0,0 +1,17 @@
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;
@Data
@TableName("ll_return_receipt_item")
public class ReturnReceiptItem {
@TableId(type = IdType.INPUT) private Long id;
private Long receiptId;
private Long returnOrderItemId;
private String customerId;
private String skuCode;
private Long receivedQuantity;
}
@@ -0,0 +1,48 @@
package com.baiyue.lastleg.returnreceipt.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryBalance;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface ReturnInventoryBalanceMapper extends BaseMapper<ReturnInventoryBalance> {
@Insert("INSERT INTO ll_return_inventory_balance (id,customer_id,overseas_warehouse_id,sku_code,product_name_zh,available_quantity,carton_quantity,created_at,updated_at) " +
"VALUES (#{id},#{customerId},#{warehouseId},#{skuCode},#{productNameZh},#{quantity},#{cartons},NOW(3),NOW(3)) " +
"ON DUPLICATE KEY UPDATE available_quantity=available_quantity+VALUES(available_quantity),carton_quantity=carton_quantity+VALUES(carton_quantity), " +
"product_name_zh=COALESCE(NULLIF(VALUES(product_name_zh),''),product_name_zh),updated_at=NOW(3)")
int adjust(@Param("id") long id, @Param("customerId") String customerId, @Param("warehouseId") String warehouseId,
@Param("skuCode") String skuCode, @Param("productNameZh") String productNameZh,
@Param("quantity") long quantity, @Param("cartons") long cartons);
@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} " +
"<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>")
List<ReturnInventoryBalance> page(@Param("customerId") String customerId, @Param("warehouseId") String warehouseId,
@Param("skuCode") String skuCode, @Param("offset") long offset, @Param("size") long size);
@Select("<script>SELECT COUNT(*) FROM ll_return_inventory_balance 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></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 " +
"<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>" +
"ORDER BY updated_at DESC,id DESC LIMIT #{offset},#{size}</script>")
List<ReturnInventoryBalance> platformPage(@Param("customerId") String customerId, @Param("warehouseId") String warehouseId,
@Param("skuCode") String skuCode, @Param("offset") long offset, @Param("size") long size);
}
@@ -0,0 +1,15 @@
package com.baiyue.lastleg.returnreceipt.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCartonItem;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface ReturnInventoryCartonItemMapper extends BaseMapper<ReturnInventoryCartonItem> {
@Select("SELECT * FROM ll_return_inventory_carton_item WHERE carton_id=#{cartonId} ORDER BY sku_code")
List<ReturnInventoryCartonItem> listCarton(@Param("cartonId") long cartonId);
}
@@ -0,0 +1,45 @@
package com.baiyue.lastleg.returnreceipt.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface ReturnInventoryCartonMapper extends BaseMapper<ReturnInventoryCarton> {
@Select("SELECT * FROM ll_return_inventory_carton WHERE carton_mark=#{cartonMark} LIMIT 1")
ReturnInventoryCarton findMark(@Param("cartonMark") String cartonMark);
@Select("SELECT * FROM ll_return_inventory_carton WHERE carton_mark=#{cartonMark} LIMIT 1 FOR UPDATE")
ReturnInventoryCarton lockMark(@Param("cartonMark") String cartonMark);
@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 customer_id=#{customerId} AND carton_mark=#{cartonMark} LIMIT 1")
ReturnInventoryCarton findCustomerMark(@Param("customerId") String customerId, @Param("cartonMark") String cartonMark);
@Select("<script>SELECT COUNT(*) FROM ll_return_inventory_carton c WHERE 1=1 " +
"<if test='customerId != null and customerId != \"\"'>AND c.customer_id LIKE CONCAT('%',#{customerId},'%') </if>" +
"<if test='warehouseId != null and warehouseId != \"\"'>AND c.overseas_warehouse_id=#{warehouseId} </if>" +
"<if test='cartonMark != null and cartonMark != \"\"'>AND c.carton_mark LIKE CONCAT('%',#{cartonMark},'%') </if>" +
"<if test='returnNo != null and returnNo != \"\"'>AND c.source_return_no LIKE CONCAT('%',#{returnNo},'%') </if>" +
"<if test='status != null and status != \"\"'>AND c.status=#{status} </if>" +
"<if test='skuCode != null and skuCode != \"\"'>AND EXISTS(SELECT 1 FROM ll_return_inventory_carton_item i WHERE i.carton_id=c.id AND i.sku_code LIKE CONCAT('%',#{skuCode},'%')) </if></script>")
long platformCount(@Param("customerId") String customerId, @Param("warehouseId") String warehouseId,
@Param("cartonMark") String cartonMark, @Param("returnNo") String returnNo, @Param("status") String status, @Param("skuCode") String skuCode);
@Select("<script>SELECT c.* FROM ll_return_inventory_carton c WHERE 1=1 " +
"<if test='customerId != null and customerId != \"\"'>AND c.customer_id LIKE CONCAT('%',#{customerId},'%') </if>" +
"<if test='warehouseId != null and warehouseId != \"\"'>AND c.overseas_warehouse_id=#{warehouseId} </if>" +
"<if test='cartonMark != null and cartonMark != \"\"'>AND c.carton_mark LIKE CONCAT('%',#{cartonMark},'%') </if>" +
"<if test='returnNo != null and returnNo != \"\"'>AND c.source_return_no LIKE CONCAT('%',#{returnNo},'%') </if>" +
"<if test='status != null and status != \"\"'>AND c.status=#{status} </if>" +
"<if test='skuCode != null and skuCode != \"\"'>AND EXISTS(SELECT 1 FROM ll_return_inventory_carton_item i WHERE i.carton_id=c.id AND i.sku_code LIKE CONCAT('%',#{skuCode},'%')) </if>" +
"ORDER BY c.created_at DESC,c.id DESC LIMIT #{offset},#{size}</script>")
List<ReturnInventoryCarton> platformPage(@Param("customerId") String customerId, @Param("warehouseId") String warehouseId,
@Param("cartonMark") String cartonMark, @Param("returnNo") String returnNo, @Param("status") String status,
@Param("skuCode") String skuCode, @Param("offset") long offset, @Param("size") long size);
}
@@ -0,0 +1,13 @@
package com.baiyue.lastleg.returnreceipt.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryOperation;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface ReturnInventoryOperationMapper extends BaseMapper<ReturnInventoryOperation> {
@Select("SELECT * FROM ll_return_inventory_operation WHERE operator_id=#{operatorId} AND idempotency_key=#{key} LIMIT 1")
ReturnInventoryOperation findKey(@Param("operatorId") String operatorId, @Param("key") String key);
}
@@ -0,0 +1,36 @@
package com.baiyue.lastleg.returnreceipt.mapper;
import com.baiyue.lastleg.returnreceipt.model.ReturnInventoryRow;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface ReturnInventoryQueryMapper {
@Select("<script>SELECT COUNT(*) FROM ll_return_inventory_carton c JOIN ll_return_inventory_carton_item i ON i.carton_id=c.id " +
"WHERE c.customer_id=#{customerId} " +
"<if test='status != null and status != \"\"'>AND c.status=#{status} </if>" +
"<if test='returnNo != null and returnNo != \"\"'>AND (c.source_return_no LIKE CONCAT('%',#{returnNo},'%') OR EXISTS (SELECT 1 FROM ll_return_receipt r JOIN ll_return_order o ON o.id=r.return_order_id WHERE r.id=c.source_receipt_id AND o.return_no LIKE CONCAT('%',#{returnNo},'%'))) </if>" +
"<if test='warehouseId != null and warehouseId != \"\"'>AND c.overseas_warehouse_id=#{warehouseId} </if>" +
"<if test='skuCode != null and skuCode != \"\"'>AND i.sku_code LIKE CONCAT('%',#{skuCode},'%') </if>" +
"<if test='cartonMark != null and cartonMark != \"\"'>AND c.carton_mark LIKE CONCAT('%',#{cartonMark},'%') </if></script>")
long count(@Param("customerId") String customerId, @Param("warehouseId") String warehouseId,
@Param("skuCode") String skuCode, @Param("cartonMark") String cartonMark,
@Param("status") String status, @Param("returnNo") String returnNo);
@Select("<script>SELECT c.id carton_id,c.overseas_warehouse_id,c.carton_mark,c.source_batch_no,c.status,c.sku_types,c.total_quantity,c.created_at, " +
"i.sku_code,i.product_name_zh,i.quantity FROM ll_return_inventory_carton c JOIN ll_return_inventory_carton_item i ON i.carton_id=c.id " +
"WHERE c.customer_id=#{customerId} " +
"<if test='status != null and status != \"\"'>AND c.status=#{status} </if>" +
"<if test='returnNo != null and returnNo != \"\"'>AND (c.source_return_no LIKE CONCAT('%',#{returnNo},'%') OR EXISTS (SELECT 1 FROM ll_return_receipt r JOIN ll_return_order o ON o.id=r.return_order_id WHERE r.id=c.source_receipt_id AND o.return_no LIKE CONCAT('%',#{returnNo},'%'))) </if>" +
"<if test='warehouseId != null and warehouseId != \"\"'>AND c.overseas_warehouse_id=#{warehouseId} </if>" +
"<if test='skuCode != null and skuCode != \"\"'>AND i.sku_code LIKE CONCAT('%',#{skuCode},'%') </if>" +
"<if test='cartonMark != null and cartonMark != \"\"'>AND c.carton_mark LIKE CONCAT('%',#{cartonMark},'%') </if>" +
"ORDER BY c.created_at DESC,c.id DESC,i.sku_code LIMIT #{offset},#{size}</script>")
List<ReturnInventoryRow> page(@Param("customerId") String customerId, @Param("warehouseId") String warehouseId,
@Param("skuCode") String skuCode, @Param("cartonMark") String cartonMark,
@Param("status") String status, @Param("returnNo") String returnNo,
@Param("offset") long offset, @Param("size") long size);
}
@@ -0,0 +1,22 @@
package com.baiyue.lastleg.returnreceipt.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.returnreceipt.entity.ReturnOrderItem;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface ReturnOrderItemMapper extends BaseMapper<ReturnOrderItem> {
@Select("SELECT * FROM ll_return_order_item WHERE return_order_id=#{orderId} ORDER BY sku_code,id")
List<ReturnOrderItem> listOrder(@Param("orderId") Long orderId);
@Select("SELECT * FROM ll_return_order_item WHERE return_order_id=#{orderId} ORDER BY sku_code,id FOR UPDATE")
List<ReturnOrderItem> lockOrder(@Param("orderId") Long orderId);
@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 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);
}
@@ -0,0 +1,27 @@
package com.baiyue.lastleg.returnreceipt.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.returnreceipt.entity.ReturnOrder;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface ReturnOrderMapper extends BaseMapper<ReturnOrder> {
@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")
ReturnOrder findNo(@Param("customerId") String customerId, @Param("returnNo") String returnNo);
@Select("SELECT * FROM ll_return_order WHERE return_no=#{returnNo} LIMIT 1 FOR UPDATE")
ReturnOrder lockNo(@Param("returnNo") String returnNo);
@Select("SELECT * FROM ll_return_order WHERE return_no=#{returnNo} AND overseas_warehouse_id=#{warehouseId} " +
"AND status IN ('PENDING_RECEIPT','PARTIALLY_RECEIVED') LIMIT 1")
ReturnOrder findActiveNo(@Param("warehouseId") String warehouseId, @Param("returnNo") String returnNo);
@Select("SELECT * FROM ll_return_order WHERE overseas_warehouse_id=#{warehouseId} AND status='COMPLETED' " +
"AND return_no LIKE CONCAT('%',#{returnNo},'%') ORDER BY created_at DESC,id DESC LIMIT 100")
List<ReturnOrder> findCompletedLike(@Param("warehouseId") String warehouseId, @Param("returnNo") String returnNo);
@Select("<script>SELECT * FROM ll_return_order WHERE overseas_warehouse_id=#{warehouseId} AND return_no IN " +
"<foreach collection='returnNos' item='returnNo' open='(' separator=',' close=')'>#{returnNo}</foreach></script>")
List<ReturnOrder> findByNos(@Param("warehouseId") String warehouseId, @Param("returnNos") List<String> returnNos);
}
@@ -0,0 +1,8 @@
package com.baiyue.lastleg.returnreceipt.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.returnreceipt.entity.ReturnReceiptBatch;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface ReturnReceiptBatchMapper extends BaseMapper<ReturnReceiptBatch> { }
@@ -0,0 +1,8 @@
package com.baiyue.lastleg.returnreceipt.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.returnreceipt.entity.ReturnReceiptItem;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface ReturnReceiptItemMapper extends BaseMapper<ReturnReceiptItem> { }
@@ -0,0 +1,17 @@
package com.baiyue.lastleg.returnreceipt.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.returnreceipt.entity.ReturnReceipt;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface ReturnReceiptMapper extends BaseMapper<ReturnReceipt> {
@Select("SELECT * FROM ll_return_receipt WHERE operator_id=#{operatorId} AND idempotency_key=#{key} LIMIT 1")
ReturnReceipt findKey(@Param("operatorId") String operatorId, @Param("key") String key);
@Select("SELECT * FROM ll_return_receipt WHERE return_order_id=#{orderId} ORDER BY signed_at DESC,id DESC")
List<ReturnReceipt> listOrder(@Param("orderId") Long orderId);
}
@@ -0,0 +1,20 @@
package com.baiyue.lastleg.returnreceipt.model;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class ReturnInventoryRow {
private Long cartonId;
private String overseasWarehouseId;
private String cartonMark;
private String sourceBatchNo;
private String status;
private Integer skuTypes;
private Long totalQuantity;
private LocalDateTime createdAt;
private String skuCode;
private String productNameZh;
private Long quantity;
}
@@ -0,0 +1,320 @@
package com.baiyue.lastleg.returnreceipt.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
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.entity.ReturnInventoryCarton;
import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCartonItem;
import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryOperation;
import com.baiyue.lastleg.returnreceipt.entity.ReturnOrder;
import com.baiyue.lastleg.returnreceipt.entity.ReturnReceipt;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryBalanceMapper;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryOperationMapper;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryQueryMapper;
import com.baiyue.lastleg.returnreceipt.model.ReturnInventoryRow;
import com.baiyue.lastleg.service.LastlegOrderNoGenerator;
import jakarta.annotation.Resource;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.LocalDateTime;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
/** Return stock is deliberately separate from normal inbound stock because it has no putaway location yet. */
@Service
public class ReturnInventoryService {
public static final String AVAILABLE = "AVAILABLE";
public static final String MERGED = "MERGED";
@Resource private ReturnInventoryCartonMapper cartons;
@Resource private ReturnInventoryCartonItemMapper cartonItems;
@Resource private ReturnInventoryBalanceMapper balances;
@Resource private ReturnInventoryOperationMapper operations;
@Resource private ReturnInventoryQueryMapper queries;
@Resource private LastlegOrderNoGenerator numbers;
@Resource private com.baiyue.lastleg.returnreceipt.mapper.ReturnOrderMapper orders;
@Resource private com.baiyue.lastleg.returnreceipt.mapper.ReturnReceiptMapper receiptMapper;
@Transactional
public ReturnInventoryCarton createReceiptCarton(ReturnOrder order, ReturnReceipt receipt,
String requestedMark, List<ReceiptLine> lines) {
if (lines.isEmpty()) throw new IllegalArgumentException("退货签收库存明细不能为空");
String cartonMark = text(requestedMark);
long cartonId = IdWorker.getId();
if (cartonMark == null) cartonMark = numbers.next("RETURN_CARTON", order.getOverseasWarehouseId(), cartonId);
if (cartons.findMark(cartonMark) != null) throw conflict("箱号已存在: " + cartonMark);
LocalDateTime now = LocalDateTime.now();
long total = lines.stream().mapToLong(ReceiptLine::quantity).sum();
ReturnInventoryCarton carton = new ReturnInventoryCarton();
carton.setId(cartonId); carton.setCustomerId(order.getCustomerId()); carton.setOverseasWarehouseId(order.getOverseasWarehouseId());
carton.setSourceReceiptId(receipt.getId()); carton.setSourceBatchNo(receipt.getReceiptBatchNo()); carton.setCartonMark(cartonMark);
carton.setStatus(AVAILABLE); carton.setSkuTypes(lines.size()); carton.setTotalQuantity(total); carton.setCreatedAt(now); carton.setUpdatedAt(now);
cartons.insert(carton);
for (ReceiptLine line : lines) {
ReturnInventoryCartonItem item = new ReturnInventoryCartonItem();
item.setId(IdWorker.getId()); item.setCartonId(cartonId); item.setCustomerId(order.getCustomerId());
item.setSkuCode(line.skuCode()); item.setProductNameZh(text(line.productNameZh())); item.setQuantity(line.quantity());
cartonItems.insert(item);
balances.adjust(IdWorker.getId(), order.getCustomerId(), order.getOverseasWarehouseId(), line.skuCode(),
text(line.productNameZh()), line.quantity(), 1);
}
return carton;
}
@Transactional(readOnly = true)
public PageResult<ReturnInventoryResponses.InventoryItem> page(String customerId, ReturnInventoryRequests.Query request) {
String warehouseId = text(request.overseasWarehouseId());
String sku = text(request.skuCode());
String carton = text(request.cartonMark());
long total = queries.count(customerId, warehouseId, sku, carton, text(request.status()), text(request.returnNo()));
List<ReturnInventoryResponses.InventoryItem> records = total == 0 ? List.of() : queries.page(customerId, warehouseId, sku,
carton, text(request.status()), text(request.returnNo()), (request.page() - 1) * request.size(), request.size()).stream().map(this::item).toList();
return new PageResult<>(records, request.page(), request.size(), total, (total + request.size() - 1) / request.size());
}
@Transactional(readOnly = true)
public PageResult<ReturnInventoryResponses.SummaryItem> summaryPage(String customerId, ReturnInventoryRequests.SummaryQuery request) {
String warehouseId = text(request.overseasWarehouseId());
String sku = text(request.skuCode());
long total = balances.countPage(customerId, warehouseId, sku);
List<ReturnInventoryResponses.SummaryItem> records = total == 0 ? List.of() : balances.page(customerId, warehouseId, sku,
(request.page() - 1) * request.size(), request.size()).stream().map(this::summary).toList();
return new PageResult<>(records, request.page(), request.size(), total, (total + request.size() - 1) / request.size());
}
@Transactional(readOnly = true)
public PageResult<ReturnInventoryResponses.PlatformStock> platformStockPage(ReturnInventoryRequests.PlatformQuery request) {
String customerId = text(request.customerId()); String warehouseId = text(request.overseasWarehouseId()); String sku = text(request.skuCode());
long total = balances.platformCount(customerId, warehouseId, sku);
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.getAvailableQuantity()), value(balance.getCartonQuantity()), balance.getUpdatedAt())).toList();
return new PageResult<>(records, request.page(), request.size(), total, (total + request.size() - 1) / request.size());
}
@Transactional(readOnly = true)
public PageResult<ReturnInventoryResponses.PlatformCarton> platformCartonPage(ReturnInventoryRequests.PlatformQuery request) {
String customerId = text(request.customerId()); String warehouseId = text(request.overseasWarehouseId());
String cartonMark = text(request.cartonMark()); String returnNo = text(request.returnNo()); String status = normStatus(request.status()); String sku = text(request.skuCode());
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.getStatus(), carton.getSkuTypes() == null ? 0 : carton.getSkuTypes(), value(carton.getTotalQuantity()), carton.getLength(),
carton.getWidth(), carton.getHeight(), carton.getWeight(), carton.getCreatedAt())).toList();
return new PageResult<>(records, request.page(), request.size(), total, (total + request.size() - 1) / request.size());
}
@Transactional(readOnly = true)
public ReturnInventoryResponses.CartonDetail platformCarton(String cartonMark) {
ReturnInventoryCarton carton = cartons.findMark(required(cartonMark, "箱号不能为空"));
if (carton == null) throw notFound("退货箱不存在");
return cartonDetail(carton);
}
@Transactional(readOnly = true)
public ReturnInventoryResponses.Label platformLabel(String cartonMark) {
ReturnInventoryCarton carton = cartons.findMark(required(cartonMark, "箱号不能为空"));
if (carton == null) throw notFound("退货箱不存在");
return label(carton);
}
@Transactional(readOnly = true)
public ReturnInventoryResponses.CartonDetail carton(String customerId, String cartonMark) {
ReturnInventoryCarton carton = cartons.findCustomerMark(customerId, required(cartonMark, "箱号不能为空"));
if (carton == null) throw notFound("退货箱不存在");
return cartonDetail(carton);
}
@Transactional(readOnly = true)
public ReturnInventoryResponses.Label label(String customerId, String cartonMark) {
ReturnInventoryCarton carton = cartons.findCustomerMark(customerId, required(cartonMark, "箱号不能为空"));
if (carton == null) throw notFound("退货箱不存在");
return label(carton);
}
@Transactional(readOnly = true)
public ReturnInventoryResponses.Label label(PdaWarehouseIdentity identity, String cartonMark) {
ReturnInventoryCarton carton = cartons.findMark(required(cartonMark, "箱号不能为空"));
if (carton == null || !String.valueOf(identity.overseasWarehouseId()).equals(carton.getOverseasWarehouseId())) throw notFound("当前仓没有该退货箱");
return label(carton);
}
@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()));
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) {
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());
}
}
}
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());
}
@Transactional
public ReturnInventoryResponses.MergeResult merge(PdaWarehouseIdentity identity, ReturnInventoryRequests.Merge request) {
String requestHash = hash(request);
String key = text(request.idempotencyKey());
ReturnInventoryOperation replay = operations.findKey(identity.operatorKey(), key);
if (replay != null) return replayMerge(replay, requestHash);
LocalDateTime now = LocalDateTime.now();
ReturnInventoryOperation operation = new ReturnInventoryOperation();
operation.setId(IdWorker.getId()); operation.setOperatorId(identity.operatorKey()); operation.setIdempotencyKey(key);
operation.setRequestHash(requestHash); operation.setOperationType("MERGE"); operation.setCreatedAt(now); operation.setUpdatedAt(now);
try { operations.insert(operation); }
catch (DuplicateKeyException concurrent) {
ReturnInventoryOperation existing = operations.findKey(identity.operatorKey(), key);
if (existing != null) return replayMerge(existing, requestHash);
throw concurrent;
}
List<String> returnNos = request.returnNos().stream().map(this::requiredReturnNo).distinct().toList();
List<ReturnOrder> selectedOrders = orders.findByNos(String.valueOf(identity.overseasWarehouseId()), returnNos);
if (selectedOrders.size() != returnNos.size()) throw notFound("存在不属于当前仓或不存在的退货单");
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("不同客户的退货单禁止合箱");
List<ReturnInventoryCarton> sources = new ArrayList<>();
for (ReturnOrder order : selectedOrders) for (ReturnReceipt receipt : receiptMapper.listOrder(order.getId())) {
ReturnInventoryCarton carton = cartons.findReceipt(receipt.getId());
if (carton != null && AVAILABLE.equals(carton.getStatus())) sources.add(cartons.lockMark(carton.getCartonMark()));
}
Map<String, Long> requested = new LinkedHashMap<>();
Map<String, String> productNames = new LinkedHashMap<>();
for (ReturnInventoryRequests.ValidItem item : request.items()) {
String sku = required(item.skuCode(), "SKU 不能为空").toUpperCase(java.util.Locale.ROOT);
if (requested.put(sku, item.quantity()) != null) throw badRequest("合箱 SKU 不能重复");
}
Map<String, Long> remaining = new LinkedHashMap<>(requested);
Map<String, Integer> emptiedBySku = new LinkedHashMap<>();
long total = 0;
for (ReturnInventoryCarton source : sources) {
List<ReturnInventoryCartonItem> sourceItems = cartonItems.listCarton(source.getId());
List<String> originalSkus = sourceItems.stream().map(item -> item.getSkuCode().toUpperCase(java.util.Locale.ROOT)).toList();
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()));
if (take > 0) {
remaining.put(sku, remaining.get(sku) - take); total = Math.addExact(total, take);
productNames.putIfAbsent(sku, item.getProductNameZh());
item.setQuantity(item.getQuantity() - take);
if (item.getQuantity() == 0) cartonItems.deleteById(item.getId()); else cartonItems.updateById(item);
}
if (item.getQuantity() > 0) { sourceTotal = Math.addExact(sourceTotal, item.getQuantity()); sourceTypes++; }
}
if (sourceTotal == 0) {
source.setStatus(MERGED); source.setMergedAt(now);
originalSkus.forEach(sku -> emptiedBySku.merge(sku, 1, Integer::sum));
}
source.setTotalQuantity(sourceTotal); source.setSkuTypes(sourceTypes); source.setUpdatedAt(now); cartons.updateById(source);
}
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();
target.setId(targetId); target.setCustomerId(customerId); target.setOverseasWarehouseId(sources.get(0).getOverseasWarehouseId());
target.setCartonMark(numbers.next("RETURN_CARTON", target.getOverseasWarehouseId(), targetId)); target.setSourceBatchNo("MERGED"); target.setSourceReturnNo(String.join(",", returnNos)); 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);
balances.adjust(IdWorker.getId(), customerId, target.getOverseasWarehouseId(), entry.getKey(), productNames.get(entry.getKey()), 0,
1L - emptiedBySku.getOrDefault(entry.getKey(), 0));
}
operation.setResultCartonId(targetId); operation.setUpdatedAt(now); operations.updateById(operation);
return new ReturnInventoryResponses.MergeResult(target.getCartonMark(), requested.keySet().iterator().next(), total, label(target));
}
private ReturnInventoryResponses.MergeResult replayMerge(ReturnInventoryOperation operation, String requestHash) {
if (!requestHash.equals(operation.getRequestHash())) throw conflict("幂等键已用于不同的合箱请求");
if (operation.getResultCartonId() == null) throw conflict("合箱请求正在处理");
ReturnInventoryCarton carton = cartons.selectById(operation.getResultCartonId());
if (carton == null) throw new IllegalStateException("合箱结果箱不存在");
List<ReturnInventoryCartonItem> items = cartonItems.listCarton(carton.getId());
return new ReturnInventoryResponses.MergeResult(carton.getCartonMark(), items.get(0).getSkuCode(), carton.getTotalQuantity(), label(carton));
}
private ReturnInventoryResponses.Label label(ReturnInventoryCarton carton) {
List<ReturnInventoryResponses.CartonItem> items = cartonItems.listCarton(carton.getId()).stream()
.map(item -> new ReturnInventoryResponses.CartonItem(item.getSkuCode(), item.getProductNameZh(), item.getQuantity())).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));
}
private ReturnInventoryResponses.InventoryItem item(ReturnInventoryRow row) {
return new ReturnInventoryResponses.InventoryItem(row.getOverseasWarehouseId(), row.getCartonMark(), row.getSourceBatchNo(), row.getStatus(),
row.getSkuCode(), row.getProductNameZh(), value(row.getQuantity()), row.getSkuTypes() == null ? 0 : row.getSkuTypes(),
value(row.getTotalQuantity()), row.getCreatedAt());
}
private ReturnInventoryResponses.SummaryItem summary(com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryBalance balance) {
return new ReturnInventoryResponses.SummaryItem(balance.getOverseasWarehouseId(), balance.getSkuCode(), balance.getProductNameZh(),
value(balance.getAvailableQuantity()), value(balance.getCartonQuantity()), balance.getUpdatedAt());
}
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();
return new ReturnInventoryResponses.CartonDetail(carton.getCartonMark(), carton.getOverseasWarehouseId(), carton.getSourceBatchNo(), carton.getSourceReturnNo(),
carton.getStatus(), carton.getSkuTypes() == null ? 0 : carton.getSkuTypes(), value(carton.getTotalQuantity()), carton.getLength(), carton.getWidth(), carton.getHeight(), carton.getWeight(), carton.getCreatedAt(), items);
}
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>");
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{border:2px solid #111;padding:5mm;min-height:136mm}.brand{font-size:16px;font-weight:700}.title{font-size:22px;font-weight:700;margin:3mm 0 5mm}.mark{font:700 18px monospace;letter-spacing:1px;word-break:break-all}.meta{font-size:11px;margin:4mm 0;border-top:1px solid #111;border-bottom:1px solid #111;padding:3mm 0}.barcode{height:11mm;margin:3mm 0;background:repeating-linear-gradient(90deg,#111 0 2px,#fff 2px 4px,#111 4px 5px,#fff 5px 8px)}table{width:100%;border-collapse:collapse;font-size:11px}th,td{border:1px solid #111;padding:2mm;text-align:left}.qty{text-align:right;font-weight:700}.total{font-size:14px;font-weight:700;text-align:right;margin-top:4mm}</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\" aria-label=\"" + escape(carton.getCartonMark()) + "\"></div>"
+ "<div class=\"meta\">仓库:" + escape(carton.getOverseasWarehouseId()) + "<br>客户:" + escape(carton.getCustomerId())
+ "<br>箱规:" + escape(String.valueOf(carton.getLength())) + " × " + escape(String.valueOf(carton.getWidth())) + " × " + escape(String.valueOf(carton.getHeight())) + " cm / " + escape(String.valueOf(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>";
}
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); } }
private static String escape(String value) { return text(value) == null ? "" : text(value).replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("\"", "&quot;"); }
private static long value(Long number) { return number == null ? 0 : number; }
private static String required(String value, String message) { String result = text(value); if (result == null) throw badRequest(message); return result; }
private String requiredReturnNo(String value) { return required(value, "退货单号不能为空"); }
private static String text(String value) { return StringUtils.hasText(value) ? value.trim() : null; }
private static String normStatus(String value) { String result = text(value); return result == null ? null : result.toUpperCase(java.util.Locale.ROOT); }
private static ResponseStatusException badRequest(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); }
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
private static ResponseStatusException notFound(String message) { return new ResponseStatusException(HttpStatus.NOT_FOUND, message); }
public record ReceiptLine(String skuCode, String productNameZh, long quantity) { }
}
@@ -0,0 +1,292 @@
package com.baiyue.lastleg.returnreceipt.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity;
import com.baiyue.lastleg.returnreceipt.dto.PdaReturnReceiptRequests;
import com.baiyue.lastleg.returnreceipt.dto.PdaReturnReceiptResponses;
import com.baiyue.lastleg.returnreceipt.dto.ReturnOrderRequests;
import com.baiyue.lastleg.returnreceipt.dto.ReturnOrderResponses;
import com.baiyue.lastleg.returnreceipt.entity.ReturnOrder;
import com.baiyue.lastleg.returnreceipt.entity.ReturnOrderItem;
import com.baiyue.lastleg.returnreceipt.entity.ReturnReceipt;
import com.baiyue.lastleg.returnreceipt.entity.ReturnReceiptBatch;
import com.baiyue.lastleg.returnreceipt.entity.ReturnReceiptItem;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnOrderItemMapper;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnOrderMapper;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnReceiptBatchMapper;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnReceiptItemMapper;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnReceiptMapper;
import com.baiyue.lastleg.service.LastlegOrderNoGenerator;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
/** Customer return forecasts and PDA receipt facts. A receipt batch is the storage-fee start fact. */
@Service
public class ReturnReceiptService {
public static final String PENDING_RECEIPT = "PENDING_RECEIPT";
public static final String PARTIALLY_RECEIVED = "PARTIALLY_RECEIVED";
public static final String COMPLETED = "COMPLETED";
@Resource private ReturnOrderMapper orders;
@Resource private ReturnOrderItemMapper orderItems;
@Resource private ReturnReceiptMapper receipts;
@Resource private ReturnReceiptItemMapper receiptItems;
@Resource private ReturnReceiptBatchMapper batches;
@Resource private ReturnInventoryService inventory;
@Resource private LastlegOrderNoGenerator numbers;
@Resource private ObjectMapper objectMapper;
@Transactional
public ReturnOrderResponses.Detail create(String customerId, String operatorId, ReturnOrderRequests.Create request) {
String key = text(request.idempotencyKey());
String requestHash = hash(request);
ReturnOrder replay = orders.findKey(customerId, key);
if (replay != null) return replayOrder(customerId, replay, requestHash);
Set<String> skuCodes = new LinkedHashSet<>();
long expected = 0;
for (ReturnOrderRequests.Item item : request.items()) {
String skuCode = text(item.skuCode());
if (!skuCodes.add(norm(skuCode))) throw badRequest("同一退货单不能重复预报相同 SKU: " + skuCode);
expected = Math.addExact(expected, item.expectedQuantity());
}
LocalDateTime now = LocalDateTime.now();
ReturnOrder order = new ReturnOrder();
order.setId(IdWorker.getId()); order.setCustomerId(customerId);
order.setOverseasWarehouseId(text(request.overseasWarehouseId()));
order.setReturnNo(numbers.next("RETURN", order.getOverseasWarehouseId(), order.getId()));
order.setCustomerReferenceNo(text(request.customerReferenceNo())); order.setStatus(PENDING_RECEIPT);
order.setExpectedSkuTypes(skuCodes.size()); order.setExpectedQuantity(expected); order.setReceivedQuantity(0L);
order.setRemark(text(request.remark())); order.setIdempotencyKey(key); order.setRequestHash(requestHash);
order.setCreatedBy(text(operatorId)); order.setCreatedAt(now); order.setUpdatedAt(now);
try { orders.insert(order); }
catch (DuplicateKeyException concurrent) {
ReturnOrder existing = orders.findKey(customerId, key);
if (existing != null) return replayOrder(customerId, existing, requestHash);
throw concurrent;
}
for (ReturnOrderRequests.Item input : request.items()) {
ReturnOrderItem item = new ReturnOrderItem();
item.setId(IdWorker.getId()); item.setReturnOrderId(order.getId()); item.setCustomerId(customerId);
item.setSkuCode(text(input.skuCode()));
item.setProductNameZh(text(input.productNameZh())); item.setExpectedQuantity(input.expectedQuantity());
item.setReceivedQuantity(0L); item.setCreatedAt(now); item.setUpdatedAt(now); orderItems.insert(item);
}
return detail(customerId, order.getReturnNo());
}
@Transactional(readOnly = true)
public PageResult<ReturnOrderResponses.Summary> page(String customerId, ReturnOrderRequests.Query query) {
LambdaQueryWrapper<ReturnOrder> w = new LambdaQueryWrapper<ReturnOrder>()
.eq(ReturnOrder::getCustomerId, customerId)
.like(StringUtils.hasText(query.returnNo()), ReturnOrder::getReturnNo, text(query.returnNo()))
.eq(StringUtils.hasText(query.status()), ReturnOrder::getStatus, norm(query.status()))
.orderByDesc(ReturnOrder::getCreatedAt).orderByDesc(ReturnOrder::getId);
if (StringUtils.hasText(query.trackingNo()) || StringUtils.hasText(query.skuCode())) {
Set<Long> ids = null;
if (StringUtils.hasText(query.skuCode())) {
ids = new LinkedHashSet<>(orderItems.selectList(new LambdaQueryWrapper<ReturnOrderItem>()
.eq(ReturnOrderItem::getCustomerId, customerId)
.like(ReturnOrderItem::getSkuCode, text(query.skuCode())))
.stream().map(ReturnOrderItem::getReturnOrderId).toList());
}
if (StringUtils.hasText(query.trackingNo())) {
Set<Long> trackingIds = new LinkedHashSet<>(receipts.selectList(new LambdaQueryWrapper<ReturnReceipt>()
.eq(ReturnReceipt::getCustomerId, customerId)
.like(ReturnReceipt::getTrackingNo, text(query.trackingNo())))
.stream().map(ReturnReceipt::getReturnOrderId).toList());
if (ids == null) ids = trackingIds; else ids.retainAll(trackingIds);
}
if (ids.isEmpty()) return new PageResult<>(List.of(), query.page(), query.size(), 0, 0);
w.in(ReturnOrder::getId, ids);
}
Page<ReturnOrder> result = orders.selectPage(Page.of(query.page(), query.size()), w);
return new PageResult<>(result.getRecords().stream().map(this::summary).toList(), result.getCurrent(),
result.getSize(), result.getTotal(), result.getPages());
}
@Transactional(readOnly = true)
public ReturnOrderResponses.Detail detail(String customerId, String returnNo) {
ReturnOrder order = orders.findNo(customerId, text(returnNo));
if (order == null) throw notFound("退货单不存在");
List<ReturnOrderResponses.Item> items = orderItems.listOrder(order.getId()).stream().map(this::item).toList();
List<ReturnOrderResponses.Receipt> receiptViews = receipts.listOrder(order.getId()).stream().map(receipt ->
new ReturnOrderResponses.Receipt(receipt.getReceiptNo(), receipt.getReceiptBatchNo(), receipt.getCartonMark(), receipt.getTrackingNo(),
receipt.getBoundBy(), value(receipt.getReceivedQuantity()), receipt.getOperatorName(), receipt.getSignedAt())).toList();
return new ReturnOrderResponses.Detail(summary(order), order.getRemark(), items, receiptViews);
}
@Transactional(readOnly = true)
public PdaReturnReceiptResponses.Start start(PdaWarehouseIdentity identity, PdaReturnReceiptRequests.Start request) {
ReturnOrder order = activeOrder(String.valueOf(identity.overseasWarehouseId()), request.returnNo());
return new PdaReturnReceiptResponses.Start(order.getReturnNo(), order.getStatus(), orderItems.listOrder(order.getId()).stream()
.filter(item -> value(item.getReceivedQuantity()) < value(item.getExpectedQuantity())).map(this::pdaItem).toList());
}
@Transactional(readOnly = true)
public PdaReturnReceiptResponses.SkuCandidates skuCandidates(PdaWarehouseIdentity identity, PdaReturnReceiptRequests.SkuLookup request) {
String skuCode = text(request.skuCode());
List<ReturnOrderItem> matchingItems = orderItems.activeSkuCandidates(String.valueOf(identity.overseasWarehouseId()), skuCode);
Map<Long, ReturnOrder> candidateOrders = new LinkedHashMap<>();
if (!matchingItems.isEmpty()) {
for (ReturnOrder order : orders.selectBatchIds(matchingItems.stream().map(ReturnOrderItem::getReturnOrderId).distinct().toList())) {
candidateOrders.put(order.getId(), order);
}
}
List<PdaReturnReceiptResponses.Candidate> candidates = matchingItems.stream().map(item -> {
ReturnOrder order = candidateOrders.get(item.getReturnOrderId());
if (order == null) throw new IllegalStateException("SKU 候选关联退货单不存在");
return new PdaReturnReceiptResponses.Candidate(order.getReturnNo(), order.getCustomerId(), order.getCustomerReferenceNo(),
order.getStatus(), value(item.getExpectedQuantity()), value(item.getReceivedQuantity()),
value(order.getExpectedQuantity()), value(order.getReceivedQuantity()));
}).toList();
return new PdaReturnReceiptResponses.SkuCandidates(skuCode, candidates);
}
@Transactional
public PdaReturnReceiptResponses.Confirmed confirm(PdaWarehouseIdentity identity, PdaReturnReceiptRequests.Confirm request) {
String key = text(request.idempotencyKey());
String requestHash = hash(request);
ReturnReceipt replay = receipts.findKey(identity.operatorKey(), key);
if (replay != null) return replayReceipt(replay, requestHash);
String startedBy = norm(request.startedBy());
if (!Set.of("RETURN_NO", "SKU").contains(startedBy)) throw badRequest("startedBy 必须为 RETURN_NO 或 SKU");
String trackingNo = text(request.trackingNo());
ReturnOrder order = orders.lockNo(text(request.returnNo()));
if (order == null || !String.valueOf(identity.overseasWarehouseId()).equals(order.getOverseasWarehouseId())) {
throw notFound("当前绑定仓没有该退货单");
}
if (!Set.of(PENDING_RECEIPT, PARTIALLY_RECEIVED).contains(order.getStatus())) {
throw conflict("仅待签收或签收中的退货单允许 PDA 签收");
}
List<ReturnOrderItem> lockedItems = orderItems.lockOrder(order.getId());
Map<String, Long> receivedBySku = aggregate(request.items());
List<Allocation> allocations = allocate(lockedItems, receivedBySku);
long receiptQuantity = allocations.stream().mapToLong(Allocation::quantity).sum();
LocalDateTime now = LocalDateTime.now();
long receiptId = IdWorker.getId();
ReturnReceipt receipt = new ReturnReceipt();
receipt.setId(receiptId); receipt.setReturnOrderId(order.getId()); receipt.setCustomerId(order.getCustomerId());
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.setReceivedQuantity(receiptQuantity); receipt.setOperatorId(identity.operatorKey());
receipt.setOperatorName(identity.operatorName()); receipt.setIdempotencyKey(key); receipt.setRequestHash(requestHash);
receipt.setSignedAt(now); receipt.setCreatedAt(now);
try { receipts.insert(receipt); }
catch (DuplicateKeyException concurrent) {
ReturnReceipt existing = receipts.findKey(identity.operatorKey(), key);
if (existing != null) return replayReceipt(existing, requestHash);
throw concurrent;
}
for (Allocation allocation : allocations) {
ReturnOrderItem item = allocation.item();
item.setReceivedQuantity(value(item.getReceivedQuantity()) + allocation.quantity()); item.setUpdatedAt(now);
orderItems.updateById(item);
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);
}
Map<String, ReturnInventoryService.ReceiptLine> inventoryLines = new LinkedHashMap<>();
for (Allocation allocation : allocations) {
ReturnOrderItem item = allocation.item();
inventoryLines.merge(item.getSkuCode(), new ReturnInventoryService.ReceiptLine(item.getSkuCode(),
item.getProductNameZh(), allocation.quantity()), (left, right) -> new ReturnInventoryService.ReceiptLine(
left.skuCode(), left.productNameZh() == null ? right.productNameZh() : left.productNameZh(),
Math.addExact(left.quantity(), right.quantity())));
}
receipt.setCartonMark(inventory.createReceiptCarton(order, receipt, request.cartonMark(),
new ArrayList<>(inventoryLines.values())).getCartonMark());
receipts.updateById(receipt);
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()));
order.setReceivedQuantity(received); order.setStatus(completed ? COMPLETED : PARTIALLY_RECEIVED); order.setUpdatedAt(now); orders.updateById(order);
ReturnReceiptBatch batch = new ReturnReceiptBatch();
batch.setId(IdWorker.getId()); batch.setReturnOrderId(order.getId()); batch.setReceiptId(receiptId);
batch.setCustomerId(order.getCustomerId()); batch.setBatchNo(receipt.getReceiptBatchNo());
batch.setOverseasWarehouseId(order.getOverseasWarehouseId()); batch.setStorageBillingStatus("PENDING_PRICING");
batch.setStorageStartedAt(now); batch.setCreatedAt(now); batches.insert(batch);
return confirmed(order, receipt, completed);
}
private ReturnOrder activeOrder(String warehouseId, String returnNo) {
ReturnOrder order = orders.findActiveNo(warehouseId, text(returnNo));
if (order == null) throw notFound("当前仓没有待签收或签收中的退货单");
return order;
}
private List<Allocation> allocate(List<ReturnOrderItem> source, Map<String, Long> requested) {
List<Allocation> allocations = new ArrayList<>();
for (Map.Entry<String, Long> entry : requested.entrySet()) {
long remaining = entry.getValue();
for (ReturnOrderItem item : source) {
if (!entry.getKey().equals(item.getSkuCode())) continue;
long capacity = value(item.getExpectedQuantity()) - value(item.getReceivedQuantity());
long accepted = Math.min(remaining, capacity);
if (accepted > 0) { allocations.add(new Allocation(item, accepted)); remaining -= accepted; }
if (remaining == 0) break;
}
if (remaining > 0) throw conflict("SKU 未预报或超出预报可签收数量: " + entry.getKey());
}
return allocations;
}
private Map<String, Long> aggregate(List<PdaReturnReceiptRequests.Item> inputs) {
Map<String, Long> result = new LinkedHashMap<>();
for (PdaReturnReceiptRequests.Item item : inputs) result.merge(text(item.skuCode()), item.receivedQuantity(), Math::addExact);
return result;
}
private ReturnOrderResponses.Detail replayOrder(String customerId, ReturnOrder order, String requestHash) {
if (!requestHash.equals(order.getRequestHash())) throw conflict("幂等键已用于不同的退货预报");
return detail(customerId, order.getReturnNo());
}
private PdaReturnReceiptResponses.Confirmed replayReceipt(ReturnReceipt receipt, String requestHash) {
if (!requestHash.equals(receipt.getRequestHash())) throw conflict("幂等键已用于不同的签收请求");
ReturnOrder order = orders.selectById(receipt.getReturnOrderId());
if (order == null) throw new IllegalStateException("签收单关联退货单不存在");
return confirmed(order, receipt, COMPLETED.equals(order.getStatus()));
}
private PdaReturnReceiptResponses.Confirmed confirmed(ReturnOrder order, ReturnReceipt receipt, boolean completed) {
return new PdaReturnReceiptResponses.Confirmed(order.getReturnNo(), receipt.getReceiptNo(), receipt.getReceiptBatchNo(), receipt.getCartonMark(),
order.getStatus(), completed, value(order.getExpectedQuantity()), value(order.getReceivedQuantity()), receipt.getSignedAt());
}
private ReturnOrderResponses.Summary summary(ReturnOrder order) { return new ReturnOrderResponses.Summary(order.getReturnNo(),
order.getOverseasWarehouseId(), order.getCustomerReferenceNo(), order.getStatus(),
order.getExpectedSkuTypes() == null ? 0 : order.getExpectedSkuTypes(), value(order.getExpectedQuantity()),
value(order.getReceivedQuantity()), order.getCreatedAt()); }
private ReturnOrderResponses.Item item(ReturnOrderItem item) { return new ReturnOrderResponses.Item(item.getSkuCode(),
item.getProductNameZh(), value(item.getExpectedQuantity()), value(item.getReceivedQuantity())); }
private PdaReturnReceiptResponses.Item pdaItem(ReturnOrderItem item) { return new PdaReturnReceiptResponses.Item(item.getSkuCode(),
item.getProductNameZh(), value(item.getExpectedQuantity()), value(item.getReceivedQuantity())); }
private static long value(Long value) { return value == null ? 0 : value; }
private static String text(String value) { return StringUtils.hasText(value) ? value.trim() : null; }
private static String norm(String value) { return text(value) == null ? null : text(value).toUpperCase(java.util.Locale.ROOT); }
private String hash(Object value) { try { return java.util.HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(objectMapper.writeValueAsString(value).getBytes(StandardCharsets.UTF_8))); }
catch (Exception exception) { throw new IllegalStateException("无法生成请求哈希", exception); } }
private static ResponseStatusException badRequest(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); }
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
private static ResponseStatusException notFound(String message) { return new ResponseStatusException(HttpStatus.NOT_FOUND, message); }
private record Allocation(ReturnOrderItem item, long quantity) { }
}
@@ -54,6 +54,10 @@ public class LastlegOrderNoGenerator {
case "DISCARD" -> "CD";
case "PROCUREMENT" -> "PR";
case "INBOUND_PLAN" -> "IP";
case "RETURN" -> "RT";
case "RETURN_RECEIPT" -> "RR";
case "RETURN_BATCH" -> "RB";
case "RETURN_CARTON" -> "RC";
default -> {
String normalized = code.replaceAll("[^A-Z0-9]", "");
yield normalized.isEmpty() ? "LMO" : normalized.substring(0, Math.min(4, normalized.length()));