贴标申请,仓储转运

This commit is contained in:
2026-10-09 11:49:17 +08:00
parent 7fd7699f6e
commit 2ec5414b39
13 changed files with 335 additions and 11 deletions
@@ -15,9 +15,12 @@ public class FleetCustomerDirectory {
this.client = client; this.token = token; this.client = client; this.token = token;
} }
public List<Customer> options(String keyword) { return load(keyword, null); } public List<Customer> options(String keyword) { return load(keyword, null); }
public Customer required(long id) { public Customer find(String id) {
var customer = load(null, id).stream().filter(row -> row.id().equals(Long.toString(id))).findFirst() return load(null, Long.parseLong(id)).stream().filter(row -> row.id().equals(id)).findFirst()
.orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "请选择有效客户")); .orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "请选择有效客户"));
}
public Customer required(long id) {
var customer = find(Long.toString(id));
try { try {
var result = client.eligibility(token, id); var result = client.eligibility(token, id);
if (result == null || !result.successful() || result.data() == null) throw unavailable(); if (result == null || !result.successful() || result.data() == null) throw unavailable();
@@ -45,6 +45,18 @@ public interface LastlegOrderMapper extends BaseMapper<LastlegOrder> {
record OrderWeightRow(Long orderId, BigDecimal grossWeightKg, BigDecimal netWeightKg) { } record OrderWeightRow(Long orderId, BigDecimal grossWeightKg, BigDecimal netWeightKg) { }
record ReturnVolumeRow(Long orderId, BigDecimal volumeCbm) { }
@Select("<script>SELECT m.orderId,SUM(m.length*m.width*m.height/1000000) AS volumeCbm FROM (" +
"SELECT o.lastleg_order_id AS orderId,r.length,r.width,r.height," +
"ROW_NUMBER() OVER (PARTITION BY r.return_order_id,r.unit_type,COALESCE(NULLIF(r.unit_code,''),r.receipt_no) " +
"ORDER BY r.signed_at DESC,r.id DESC) AS measurementNo " +
"FROM ll_return_receipt r JOIN ll_return_order o ON o.id=r.return_order_id AND o.customer_id=r.customer_id " +
"WHERE o.lastleg_order_id IN <foreach collection='orderIds' item='id' open='(' separator=',' close=')'>#{id}</foreach> " +
"AND r.unit_type IN ('PALLET','BOX') AND r.length IS NOT NULL AND r.width IS NOT NULL AND r.height IS NOT NULL" +
") m WHERE m.measurementNo=1 GROUP BY m.orderId</script>")
List<ReturnVolumeRow> findReturnVolumes(@Param("orderIds") List<Long> orderIds);
@Select("<script>SELECT o.id AS orderId," + @Select("<script>SELECT o.id AS orderId," +
"COALESCE(SUM(d.gross_weight_kg),MAX(i.gross_weight_kg),0) AS grossWeightKg," + "COALESCE(SUM(d.gross_weight_kg),MAX(i.gross_weight_kg),0) AS grossWeightKg," +
"COALESCE(SUM(d.net_weight_kg),MAX(i.net_weight_kg),0) AS netWeightKg " + "COALESCE(SUM(d.net_weight_kg),MAX(i.net_weight_kg),0) AS netWeightKg " +
@@ -265,7 +265,6 @@ public class RelabelApplicationService implements WalletResultHandler {
"INBOUND", null, null, List.of(), 0); "INBOUND", null, null, List.of(), 0);
}).filter(candidate -> candidate.availablePackages() > 0).toList()); }).filter(candidate -> candidate.availablePackages() > 0).toList());
for (ReturnInventoryCarton carton : returnCartons.findAvailable(customerId, operationWarehouseId.trim())) { for (ReturnInventoryCarton carton : returnCartons.findAvailable(customerId, operationWarehouseId.trim())) {
if ("PARCEL".equals(carton.getSourceUnitType())) continue;
String keyword = trim(inboundCode); String keyword = trim(inboundCode);
if (StringUtils.hasText(keyword) if (StringUtils.hasText(keyword)
&& !containsIgnoreCase(carton.getCartonMark(), keyword) && !containsIgnoreCase(carton.getCartonMark(), keyword)
@@ -274,7 +273,7 @@ public class RelabelApplicationService implements WalletResultHandler {
.filter(item -> value(item.getQuantity()) > 0) .filter(item -> value(item.getQuantity()) > 0)
.map(item -> { .map(item -> {
long used = returnCartons.relabelledQuantity(carton.getId(), item.getSkuCode()); long used = returnCartons.relabelledQuantity(carton.getId(), item.getSkuCode());
long remaining = value(item.getQuantity()) - used; long remaining = value(item.getQuantity()) - used - value(item.getPackingReservedQuantity());
return new RelabelResponses.SkuItem(item.getSkuCode(), null, return new RelabelResponses.SkuItem(item.getSkuCode(), null,
Math.toIntExact(Math.max(remaining, 0)), null); Math.toIntExact(Math.max(remaining, 0)), null);
}).filter(item -> item.quantity() > 0).toList(); }).filter(item -> item.quantity() > 0).toList();
@@ -288,7 +287,7 @@ public class RelabelApplicationService implements WalletResultHandler {
result.add(new RelabelResponses.InventoryCandidate(null, null, carton.getSourceReturnNo(), result.add(new RelabelResponses.InventoryCandidate(null, null, carton.getSourceReturnNo(),
null, carton.getOverseasWarehouseId(), null, null, carton.getDestinationWarehouseCode(), null, carton.getOverseasWarehouseId(), null, null, carton.getDestinationWarehouseCode(),
carton.getDestinationWarehouseName(), skuCodes, "退货入库库存", availablePackages, 0, List.of(), carton.getDestinationWarehouseName(), skuCodes, "退货入库库存", availablePackages, 0, List.of(),
"RETURN_CARTON", carton.getId(), carton.getCartonMark(), cartonSkus, relabelledPackages)); "PARCEL".equals(carton.getSourceUnitType()) ? "RETURN_PARCEL" : "RETURN_CARTON", carton.getId(), carton.getCartonMark(), cartonSkus, relabelledPackages));
} }
return result; return result;
} }
@@ -392,6 +391,21 @@ public class RelabelApplicationService implements WalletResultHandler {
if (!"AVAILABLE".equals(carton.getStatus()) || carton.getRelabelApplicationId() != null) { if (!"AVAILABLE".equals(carton.getStatus()) || carton.getRelabelApplicationId() != null) {
throw conflict("仓库唛头 " + carton.getCartonMark() + " 已不可用于换标"); throw conflict("仓库唛头 " + carton.getCartonMark() + " 已不可用于换标");
} }
if (!carton.getCartonMark().equals(item.cartonMark().trim()))
throw conflict("来源退货箱号与选择的库存不一致");
if ("PARCEL".equals(carton.getSourceUnitType()) && !"SKU_LABEL".equals(item.operationType()))
throw conflict("散件退货库存只能贴 SKU,请先合箱后申请箱唛或混贴");
final long sourceReturnCartonId = carton.getId();
Map<String, Long> availableSku = returnCartonItems.lockCarton(sourceReturnCartonId).stream()
.collect(Collectors.toMap(line -> line.getSkuCode().toUpperCase(java.util.Locale.ROOT),
line -> Math.max(0, value(line.getQuantity()) - value(line.getPackingReservedQuantity())
- returnCartons.relabelledQuantity(sourceReturnCartonId, line.getSkuCode())), Long::sum));
Map<String, Long> requestedSku = new java.util.HashMap<>();
if (item.skuItems() != null) for (var skuItem : item.skuItems()) {
long quantity = requestedSku.merge(skuItem.skuCode().toUpperCase(java.util.Locale.ROOT), (long) skuItem.quantity(), Long::sum);
if (quantity > availableSku.getOrDefault(skuItem.skuCode().toUpperCase(java.util.Locale.ROOT), 0L))
throw conflict("SKU " + skuItem.skuCode() + " 的换标数量超过来源箱可用数量");
}
if (returnCartonItems.lockCarton(carton.getId()).stream().anyMatch(line -> value(line.getPackingReservedQuantity()) > 0)) if (returnCartonItems.lockCarton(carton.getId()).stream().anyMatch(line -> value(line.getPackingReservedQuantity()) > 0))
throw conflict("退货箱SKU已被合箱计划锁定"); throw conflict("退货箱SKU已被合箱计划锁定");
if (Set.of("CARTON_LABEL", "CARTON_AND_SKU").contains(item.operationType()) if (Set.of("CARTON_LABEL", "CARTON_AND_SKU").contains(item.operationType())
@@ -477,6 +491,17 @@ public class RelabelApplicationService implements WalletResultHandler {
throw conflict("退货箱状态已变化,请刷新后重试"); throw conflict("退货箱状态已变化,请刷新后重试");
} }
} }
for (String returnNo : selectedReturnCartons.values().stream().map(ReturnInventoryCarton::getSourceReturnNo)
.filter(StringUtils::hasText).distinct().sorted().toList()) {
ReturnOrder returnOrder = returnOrders.lockCustomerNo(customerId, returnNo);
if (returnOrder != null) {
returnOrder.setRelabelApplicationId(applicationId);
returnOrder.setRelabelNo(relabelNo);
returnOrder.setRelabelStatus("PENDING_REVIEW");
returnOrder.setUpdatedAt(now);
returnOrders.updateById(returnOrder);
}
}
if (requiresWallet) { if (requiresWallet) {
String walletEventId = walletCommands.publish("FREEZE", Long.parseLong(customerId), "RELABEL_ORDER", String walletEventId = walletCommands.publish("FREEZE", Long.parseLong(customerId), "RELABEL_ORDER",
@@ -24,6 +24,11 @@ public class MerchantReturnOrderController {
@PostMapping @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RETURN_ORDER_CREATE + "')") @PostMapping @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RETURN_ORDER_CREATE + "')")
public ApiResponse<ReturnOrderResponses.Detail> create(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<ReturnOrderResponses.Detail> create(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody ReturnOrderRequests.Create request) { return ApiResponse.success(service.create(customer(user), operator(user), request)); } @Valid @RequestBody ReturnOrderRequests.Create request) { return ApiResponse.success(service.create(customer(user), operator(user), request)); }
@PutMapping("/{returnNo}") @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RETURN_ORDER_CREATE + "')")
public ApiResponse<ReturnOrderResponses.Detail> update(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String returnNo, @Valid @RequestBody ReturnOrderRequests.Create request) {
return ApiResponse.success(service.updatePending(customer(user), returnNo, request));
}
@GetMapping @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RETURN_ORDER_LIST + "')") @GetMapping @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RETURN_ORDER_LIST + "')")
public ApiResponse<PageResult<ReturnOrderResponses.Summary>> page(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<PageResult<ReturnOrderResponses.Summary>> page(@AuthenticationPrincipal AuthenticatedUser user,
@Valid ReturnOrderRequests.Query query) { return ApiResponse.success(service.page(customer(user), query)); } @Valid ReturnOrderRequests.Query query) { return ApiResponse.success(service.page(customer(user), query)); }
@@ -1,6 +1,7 @@
package com.baiyue.lastleg.returnreceipt.dto; package com.baiyue.lastleg.returnreceipt.dto;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.math.BigDecimal;
import java.util.List; import java.util.List;
public final class ReturnOrderResponses { public final class ReturnOrderResponses {
@@ -22,9 +23,10 @@ public final class ReturnOrderResponses {
String skuCode, long quantity, LocalDateTime createdAt) { } String skuCode, long quantity, LocalDateTime createdAt) { }
public record Photo(long fileId, String fileName, String contentType, long sizeBytes) { } public record Photo(long fileId, String fileName, String contentType, long sizeBytes) { }
public record PalletDetail(String palletCode, int skuTypes, long totalQuantity, List<PalletItem> items, public record PalletDetail(String palletCode, int skuTypes, long totalQuantity, List<PalletItem> items,
long totalBoxCount, String unitType, String unitCode) { long totalBoxCount, String unitType, String unitCode,
BigDecimal length, BigDecimal width, BigDecimal height, BigDecimal weight, BigDecimal volumeCbm) {
public PalletDetail(String palletCode, int skuTypes, long totalQuantity, List<PalletItem> items) { public PalletDetail(String palletCode, int skuTypes, long totalQuantity, List<PalletItem> items) {
this(palletCode, skuTypes, totalQuantity, items, 0, "PALLET", palletCode); this(palletCode, skuTypes, totalQuantity, items, 0, "PALLET", palletCode, null, null, null, null, null);
} }
} }
public record PalletItem(String skuCode, String productNameZh, long quantity, long boxCount) { public record PalletItem(String skuCode, String productNameZh, long quantity, long boxCount) {
@@ -78,6 +78,7 @@ public class ReturnReceiptService {
@Resource private RelabelItemMapper relabelItems; @Resource private RelabelItemMapper relabelItems;
@Resource private com.baiyue.lastleg.relabel.pda.gateway.RelabelPhotoFileGateway photoFiles; @Resource private com.baiyue.lastleg.relabel.pda.gateway.RelabelPhotoFileGateway photoFiles;
@Resource private com.baiyue.lastleg.service.FbaWarehouseValidator fbaWarehouseValidator; @Resource private com.baiyue.lastleg.service.FbaWarehouseValidator fbaWarehouseValidator;
@Resource private com.baiyue.lastleg.service.ReturnOrderReferenceResolver returnOrderReferences;
@Transactional @Transactional
public ReturnOrderResponses.Detail create(String customerId, String operatorId, ReturnOrderRequests.Create request) { public ReturnOrderResponses.Detail create(String customerId, String operatorId, ReturnOrderRequests.Create request) {
@@ -130,9 +131,14 @@ public class ReturnReceiptService {
tail.setSourceOrderNo(returnOrder.getReturnNo()); tail.setOverseasWarehouseId(returnOrder.getOverseasWarehouseId()); tail.setSourceOrderNo(returnOrder.getReturnNo()); tail.setOverseasWarehouseId(returnOrder.getOverseasWarehouseId());
tail.setCurrentStatus(PENDING_REVIEW); tail.setSubOrderStatus(Boolean.TRUE.equals(returnOrder.getNeedRelabel()) ? "RETURN_RELABEL:PENDING_REVIEW" : "RETURN_RELABEL:NOT_REQUIRED"); tail.setCurrentStatus(PENDING_REVIEW); tail.setSubOrderStatus(Boolean.TRUE.equals(returnOrder.getNeedRelabel()) ? "RETURN_RELABEL:PENDING_REVIEW" : "RETURN_RELABEL:NOT_REQUIRED");
tail.setRelabelStatus(Boolean.TRUE.equals(returnOrder.getNeedRelabel()) ? PENDING_REVIEW : "NOT_REQUIRED"); tail.setRelabelStatus(Boolean.TRUE.equals(returnOrder.getNeedRelabel()) ? PENDING_REVIEW : "NOT_REQUIRED");
// A return forecast is already the source of truth for the expected cargo.
// Keep the tail-leg snapshot useful before PDA receipt facts exist.
tail.setPackageCount(returnOrder.getExpectedQuantity() == null ? 0 : Math.toIntExact(returnOrder.getExpectedQuantity()));
tail.setProductQuantity(returnOrder.getExpectedQuantity() == null ? 0 : Math.toIntExact(returnOrder.getExpectedQuantity()));
tail.setCustomerRemark(returnOrder.getRemark()); tail.setSourceEventId("RETURN_ORDER:" + returnOrder.getReturnNo()); tail.setCustomerRemark(returnOrder.getRemark()); tail.setSourceEventId("RETURN_ORDER:" + returnOrder.getReturnNo());
tail.setSourceLoadingPlanId("RETURN_RELABEL"); tail.setCreatedAt(now); tail.setUpdatedAt(now); tail.setSourceLoadingPlanId("RETURN_RELABEL"); tail.setCreatedAt(now); tail.setUpdatedAt(now);
tail.setSourcePayloadHash(returnOrder.getRequestHash()); tail.setSourcePayloadHash(returnOrder.getRequestHash());
returnOrderReferences.populate(List.of(tail));
lastlegOrders.insert(tail); lastlegOrders.insert(tail);
returnOrder.setLastlegOrderId(tail.getId()); returnOrder.setLastlegOrderNo(tail.getOrderNo()); returnOrder.setLastlegOrderId(tail.getId()); returnOrder.setLastlegOrderNo(tail.getOrderNo());
returnOrder.setRelabelStatus(Boolean.TRUE.equals(returnOrder.getNeedRelabel()) ? "PENDING_BINDING" : "NOT_REQUIRED"); returnOrder.setRelabelStatus(Boolean.TRUE.equals(returnOrder.getNeedRelabel()) ? "PENDING_BINDING" : "NOT_REQUIRED");
@@ -207,12 +213,35 @@ public class ReturnReceiptService {
if (order == null) return; if (order == null) return;
Map<String, ReturnOrderItem> bySku = orderItems.listOrder(order.getId()).stream() Map<String, ReturnOrderItem> bySku = orderItems.listOrder(order.getId()).stream()
.collect(java.util.stream.Collectors.toMap(ReturnOrderItem::getSkuCode, value -> value, (left, right) -> left, LinkedHashMap::new)); .collect(java.util.stream.Collectors.toMap(ReturnOrderItem::getSkuCode, value -> value, (left, right) -> left, LinkedHashMap::new));
for (ReturnReceipt receipt : receipts.listOrder(order.getId())) { List<ReturnReceipt> returnReceipts = receipts.listOrder(order.getId());
for (ReturnReceipt receipt : returnReceipts) {
List<Allocation> allocations = receiptItems.listReceipt(receipt.getId()).stream() List<Allocation> allocations = receiptItems.listReceipt(receipt.getId()).stream()
.map(line -> new Allocation(bySku.get(line.getSkuCode()), value(line.getReceivedQuantity()))) .map(line -> new Allocation(bySku.get(line.getSkuCode()), value(line.getReceivedQuantity())))
.filter(item -> item.item() != null).toList(); .filter(item -> item.item() != null).toList();
if (!allocations.isEmpty()) syncTailCargo(order, receipt, allocations, receipt.getSignedAt() == null ? LocalDateTime.now() : receipt.getSignedAt()); if (!allocations.isEmpty()) syncTailCargo(order, receipt, allocations, receipt.getSignedAt() == null ? LocalDateTime.now() : receipt.getSignedAt());
} }
// Pending forecasts have no receipt rows yet, but their SKU forecast must
// still be visible in the platform tail-order cargo tab.
if (returnReceipts.isEmpty() && lastlegOrderDetails.selectCount(new LambdaQueryWrapper<LastlegOrderDetail>()
.eq(LastlegOrderDetail::getCustomerId, customerId).eq(LastlegOrderDetail::getOrderId, tailOrderId)) == 0) {
int total = 0;
for (ReturnOrderItem item : orderItems.listOrder(order.getId())) {
int quantity = Math.toIntExact(value(item.getExpectedQuantity()));
LastlegOrderDetail detail = new LastlegOrderDetail();
detail.setId(IdWorker.getId()); detail.setCustomerId(customerId); detail.setOrderId(tailOrderId);
detail.setSourceInboundDetailId("RETURN_ORDER:" + order.getReturnNo() + ":" + item.getSkuCode());
detail.setSkuCode(item.getSkuCode()); detail.setProductNameZh(item.getProductNameZh());
detail.setPackageCount(quantity); detail.setProductQuantity(quantity);
lastlegOrderDetails.insert(detail); total = Math.addExact(total, quantity);
}
if (total > 0) {
LastlegOrder tail = lastlegOrders.selectById(tailOrderId);
if (tail != null) {
tail.setPackageCount(total); tail.setProductQuantity(total); tail.setUpdatedAt(LocalDateTime.now());
lastlegOrders.updateById(tail);
}
}
}
} }
@Transactional(readOnly = true) @Transactional(readOnly = true)
@@ -243,12 +272,16 @@ public class ReturnReceiptService {
Map<String, Map<String, ReturnOrderResponses.PalletItem>> palletItems = new LinkedHashMap<>(); Map<String, Map<String, ReturnOrderResponses.PalletItem>> palletItems = new LinkedHashMap<>();
Map<String, String> palletTypes = new LinkedHashMap<>(); Map<String, String> palletTypes = new LinkedHashMap<>();
Map<String, Long> physicalBoxes = new LinkedHashMap<>(); Map<String, Long> physicalBoxes = new LinkedHashMap<>();
Map<String, ReturnReceipt> physicalMeasurements = new LinkedHashMap<>();
for (ReturnReceipt receipt : orderReceipts) { for (ReturnReceipt receipt : orderReceipts) {
if (!"PALLET".equals(receipt.getUnitType()) && !"BOX".equals(receipt.getUnitType())) continue; if (!"PALLET".equals(receipt.getUnitType()) && !"BOX".equals(receipt.getUnitType())) continue;
String physicalCode = text(receipt.getUnitCode()) == null ? text(receipt.getPalletCode()) : text(receipt.getUnitCode()); String physicalCode = text(receipt.getUnitCode()) == null ? text(receipt.getPalletCode()) : text(receipt.getUnitCode());
if (physicalCode == null) continue; if (physicalCode == null) continue;
String key = receipt.getUnitType() + ":" + physicalCode; String key = receipt.getUnitType() + ":" + physicalCode;
palletTypes.put(key, receipt.getUnitType()); palletTypes.put(key, receipt.getUnitType());
// A pallet is one physical carrier even when signed in several batches.
// Keep its latest measurement instead of adding the same carrier's volume twice.
if (receipt.getLength() != null && receipt.getWidth() != null && receipt.getHeight() != null) physicalMeasurements.putIfAbsent(key, receipt);
physicalBoxes.merge(key, value(receipt.getTotalBoxCount()), Long::sum); physicalBoxes.merge(key, value(receipt.getTotalBoxCount()), Long::sum);
Map<String, ReturnOrderResponses.PalletItem> bySku = palletItems.computeIfAbsent(key, ignored -> new LinkedHashMap<>()); Map<String, ReturnOrderResponses.PalletItem> bySku = palletItems.computeIfAbsent(key, ignored -> new LinkedHashMap<>());
for (ReturnReceiptItem line : receiptItems.listReceipt(receipt.getId())) bySku.merge(line.getSkuCode(), for (ReturnReceiptItem line : receiptItems.listReceipt(receipt.getId())) bySku.merge(line.getSkuCode(),
@@ -258,8 +291,12 @@ public class ReturnReceiptService {
List<ReturnOrderResponses.PalletDetail> palletViews = palletItems.entrySet().stream().map(entry -> { List<ReturnOrderResponses.PalletDetail> palletViews = palletItems.entrySet().stream().map(entry -> {
List<ReturnOrderResponses.PalletItem> palletLines = List.copyOf(entry.getValue().values()); List<ReturnOrderResponses.PalletItem> palletLines = List.copyOf(entry.getValue().values());
String[] parts = entry.getKey().split(":", 2); String[] parts = entry.getKey().split(":", 2);
ReturnReceipt measured = physicalMeasurements.get(entry.getKey());
return new ReturnOrderResponses.PalletDetail(parts.length > 1 ? parts[1] : entry.getKey(), palletLines.size(), palletLines.stream().mapToLong(ReturnOrderResponses.PalletItem::quantity).sum(), palletLines, return new ReturnOrderResponses.PalletDetail(parts.length > 1 ? parts[1] : entry.getKey(), palletLines.size(), palletLines.stream().mapToLong(ReturnOrderResponses.PalletItem::quantity).sum(), palletLines,
physicalBoxes.getOrDefault(entry.getKey(), 0L), palletTypes.get(entry.getKey()), parts.length > 1 ? parts[1] : entry.getKey()); physicalBoxes.getOrDefault(entry.getKey(), 0L), palletTypes.get(entry.getKey()), parts.length > 1 ? parts[1] : entry.getKey(),
measured == null ? null : measured.getLength(), measured == null ? null : measured.getWidth(),
measured == null ? null : measured.getHeight(), measured == null ? null : measured.getWeight(),
measured == null ? null : measured.getLength().multiply(measured.getWidth()).multiply(measured.getHeight()).divide(java.math.BigDecimal.valueOf(1_000_000)));
}).toList(); }).toList();
List<ReturnOrderResponses.Photo> photos = photoList(order).stream() List<ReturnOrderResponses.Photo> photos = photoList(order).stream()
.map(file -> new ReturnOrderResponses.Photo(file.fileId(), file.fileName(), file.contentType(), file.sizeBytes())).toList(); .map(file -> new ReturnOrderResponses.Photo(file.fileId(), file.fileName(), file.contentType(), file.sizeBytes())).toList();
@@ -662,9 +699,17 @@ public class ReturnReceiptService {
tail.setReceivedPackageCount(Math.addExact(tail.getReceivedPackageCount() == null ? 0 : tail.getReceivedPackageCount(), packageCount)); tail.setReceivedPackageCount(Math.addExact(tail.getReceivedPackageCount() == null ? 0 : tail.getReceivedPackageCount(), packageCount));
tail.setProductQuantity(Math.addExact(tail.getProductQuantity() == null ? 0 : tail.getProductQuantity(), Math.toIntExact(allocations.stream().mapToLong(Allocation::quantity).sum()))); tail.setProductQuantity(Math.addExact(tail.getProductQuantity() == null ? 0 : tail.getProductQuantity(), Math.toIntExact(allocations.stream().mapToLong(Allocation::quantity).sum())));
if ("PALLET".equals(receipt.getUnitType())) tail.setPalletCount(Math.addExact(tail.getPalletCount() == null ? 0 : tail.getPalletCount(), 1)); if ("PALLET".equals(receipt.getUnitType())) tail.setPalletCount(Math.addExact(tail.getPalletCount() == null ? 0 : tail.getPalletCount(), 1));
if (receipt.getLength() != null && receipt.getWidth() != null && receipt.getHeight() != null) { // Recalculate from physical carriers; signing another pallet must not overwrite the first pallet's volume.
tail.setVolumeCbm(receipt.getLength().multiply(receipt.getWidth()).multiply(receipt.getHeight()).divide(java.math.BigDecimal.valueOf(1_000_000))); Map<String, ReturnReceipt> measuredUnits = new LinkedHashMap<>();
List<ReturnReceipt> signedReceipts = new ArrayList<>(receipts.listOrder(order.getId()));
if (signedReceipts.stream().noneMatch(line -> Objects.equals(line.getId(), receipt.getId()))) signedReceipts.add(0, receipt);
for (ReturnReceipt signed : signedReceipts) {
if (signed.getLength() == null || signed.getWidth() == null || signed.getHeight() == null || "PARCEL".equals(signed.getUnitType())) continue;
String code = text(signed.getUnitCode()) == null ? signed.getReceiptNo() : signed.getUnitCode();
measuredUnits.putIfAbsent(signed.getUnitType() + ":" + code, signed);
} }
tail.setVolumeCbm(measuredUnits.values().stream().map(line -> line.getLength().multiply(line.getWidth()).multiply(line.getHeight())
.divide(java.math.BigDecimal.valueOf(1_000_000))).reduce(java.math.BigDecimal.ZERO, java.math.BigDecimal::add));
tail.setActualArrivalAt(tail.getActualArrivalAt() == null ? now : tail.getActualArrivalAt()); tail.setActualArrivalAt(tail.getActualArrivalAt() == null ? now : tail.getActualArrivalAt());
tail.setUpdatedAt(now); lastlegOrders.updateById(tail); tail.setUpdatedAt(now); lastlegOrders.updateById(tail);
} }
@@ -84,6 +84,7 @@ public class LastlegOrderService {
@Resource private ContainerArrivalTimeOperationMapper containerArrivalTimeOperations; @Resource private ContainerArrivalTimeOperationMapper containerArrivalTimeOperations;
@Resource private WarehouseResourceGateway warehouseResourceGateway; @Resource private WarehouseResourceGateway warehouseResourceGateway;
@Resource private ContainerShippingGateMapper containerShippingGates; @Resource private ContainerShippingGateMapper containerShippingGates;
@Resource private ReturnOrderReferenceResolver returnOrderReferences;
public PageResult<LastlegOrderSummary> page(String customerId, LastlegOrderRequests.Query query) { public PageResult<LastlegOrderSummary> page(String customerId, LastlegOrderRequests.Query query) {
return page(customerId, query, null); return page(customerId, query, null);
@@ -93,6 +94,8 @@ public class LastlegOrderService {
Page<LastlegOrder> page = orderMapper.selectPage( Page<LastlegOrder> page = orderMapper.selectPage(
new Page<>(query.page(), query.size()), filter(customerId, query, relabelNo).orderByDesc(LastlegOrder::getCreatedAt).orderByDesc(LastlegOrder::getId)); new Page<>(query.page(), query.size()), filter(customerId, query, relabelNo).orderByDesc(LastlegOrder::getCreatedAt).orderByDesc(LastlegOrder::getId));
Map<Long, List<LastlegOrderMapper.PalletMergeRelationRow>> mergeRelations = palletMergeRelations(page.getRecords()); Map<Long, List<LastlegOrderMapper.PalletMergeRelationRow>> mergeRelations = palletMergeRelations(page.getRecords());
returnOrderReferences.populateForRead(page.getRecords());
refreshReturnVolumes(page.getRecords());
Map<String, String> containerNodes = containerNodes(page.getRecords()); Map<String, String> containerNodes = containerNodes(page.getRecords());
Map<Long, String> dispatchStatuses = dispatchStatuses(page.getRecords()); Map<Long, String> dispatchStatuses = dispatchStatuses(page.getRecords());
Map<Long, LastlegOrderMapper.OrderWeightRow> weights = orderWeights(page.getRecords()); Map<Long, LastlegOrderMapper.OrderWeightRow> weights = orderWeights(page.getRecords());
@@ -331,6 +334,8 @@ public class LastlegOrderService {
public LastlegOrderDetailResponse detail(String customerId, long orderId) { public LastlegOrderDetailResponse detail(String customerId, long orderId) {
LastlegOrder order = findOwned(customerId, orderId); LastlegOrder order = findOwned(customerId, orderId);
returnOrderReferences.populateForRead(List.of(order));
refreshReturnVolumes(List.of(order));
List<LastlegOrderDetail> cargo = detailMapper.selectList(new LambdaQueryWrapper<LastlegOrderDetail>() List<LastlegOrderDetail> cargo = detailMapper.selectList(new LambdaQueryWrapper<LastlegOrderDetail>()
.eq(LastlegOrderDetail::getCustomerId, order.getCustomerId()) .eq(LastlegOrderDetail::getCustomerId, order.getCustomerId())
.eq(LastlegOrderDetail::getOrderId, orderId).orderByAsc(LastlegOrderDetail::getId)); .eq(LastlegOrderDetail::getOrderId, orderId).orderByAsc(LastlegOrderDetail::getId));
@@ -596,6 +601,14 @@ public class LastlegOrderService {
LastlegOrderMapper.DispatchStatusRow::dispatchStatus)); LastlegOrderMapper.DispatchStatusRow::dispatchStatus));
} }
private void refreshReturnVolumes(List<LastlegOrder> orders) {
List<Long> ids = orders.stream().filter(order -> "RETURN_RELABEL".equals(order.getOrderType())).map(LastlegOrder::getId).toList();
if (ids.isEmpty()) return;
Map<Long, BigDecimal> volumes = orderMapper.findReturnVolumes(ids).stream()
.collect(Collectors.toMap(LastlegOrderMapper.ReturnVolumeRow::orderId, LastlegOrderMapper.ReturnVolumeRow::volumeCbm));
for (LastlegOrder order : orders) if (volumes.containsKey(order.getId())) order.setVolumeCbm(volumes.get(order.getId()));
}
private Map<Long, LastlegOrderMapper.OrderWeightRow> orderWeights(List<LastlegOrder> orders) { private Map<Long, LastlegOrderMapper.OrderWeightRow> orderWeights(List<LastlegOrder> orders) {
if (orders.isEmpty()) return Map.of(); if (orders.isEmpty()) return Map.of();
List<LastlegOrderMapper.OrderWeightRow> rows = orderMapper.findOrderWeights( List<LastlegOrderMapper.OrderWeightRow> rows = orderMapper.findOrderWeights(
@@ -0,0 +1,62 @@
package com.baiyue.lastleg.service;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory;
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import org.springframework.web.server.ResponseStatusException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/** Return snapshots come from the owning services, never another service's database. */
@Service
public class ReturnOrderReferenceResolver {
private static final Logger log = LoggerFactory.getLogger(ReturnOrderReferenceResolver.class);
private final FleetCustomerDirectory customers;
private final WarehouseResourceGateway warehouses;
public ReturnOrderReferenceResolver(FleetCustomerDirectory customers, WarehouseResourceGateway warehouses) {
this.customers = customers;
this.warehouses = warehouses;
}
public void populate(List<LastlegOrder> orders) {
populate(orders, true);
}
public void populateForRead(List<LastlegOrder> orders) {
populate(orders, false);
}
private void populate(List<LastlegOrder> orders, boolean required) {
Map<String, FleetCustomerDirectory.Customer> customerReferences = new HashMap<>();
Map<String, WarehouseResourceGateway.WarehouseReference> warehouseReferences = new HashMap<>();
for (LastlegOrder order : orders) {
if (!"RETURN_RELABEL".equals(order.getOrderType())) continue;
if (!StringUtils.hasText(order.getCustomerName())) {
try {
var customer = customerReferences.computeIfAbsent(order.getCustomerId(), customers::find);
order.setCustomerName(customer.name());
} catch (ResponseStatusException error) {
if (required) throw error;
log.warn("Return customer reference unavailable for order {}, status {}", order.getId(), error.getStatusCode().value());
}
}
if (!StringUtils.hasText(order.getOverseasWarehouseCode()) || !StringUtils.hasText(order.getOverseasWarehouseName())) {
try {
var warehouse = warehouseReferences.computeIfAbsent(order.getOverseasWarehouseId(),
id -> warehouses.requireActiveSharedWarehouse(Long.parseLong(id)));
order.setOverseasWarehouseCode(warehouse.warehouseCode());
order.setOverseasWarehouseName(warehouse.warehouseName());
} catch (ResponseStatusException error) {
if (required) throw error;
log.warn("Return warehouse reference unavailable for order {}, status {}", order.getId(), error.getStatusCode().value());
}
}
}
}
}
@@ -895,6 +895,30 @@ class RelabelApplicationServiceTest {
return app; return app;
} }
@Test
void returnSkuRelabelRejectsQuantityBeyondSelectedSourceBox() {
var cartonMapper = org.mockito.Mockito.mock(com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper.class);
var lineMapper = org.mockito.Mockito.mock(com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper.class);
ReflectionTestUtils.setField(service, "returnCartons", cartonMapper);
ReflectionTestUtils.setField(service, "returnCartonItems", lineMapper);
var carton = new com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton();
carton.setId(901L); carton.setCustomerId("10001"); carton.setOverseasWarehouseId("WH-1");
carton.setCartonMark("RETURN-BOX-1"); carton.setStatus("AVAILABLE"); carton.setSourceUnitType("BOX");
var line = new com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCartonItem();
line.setSkuCode("SKU-A"); line.setQuantity(5L); line.setPackingReservedQuantity(0L);
when(cartonMapper.lockId(901L)).thenReturn(carton);
when(lineMapper.lockCarton(901L)).thenReturn(List.of(line));
var detail = new RelabelRequests.Item(null, null, null, "SKU-A", null, null, null,
"Return goods", 1, 1, "RT-1", null, "NEW-A", null, null,
null, null, null, "RETURN-BOX-1", null, null, "SKU_LABEL",
List.of(new RelabelRequests.SkuItem("SKU-A", "NEW-A", 6, null)), 901L);
var request = new RelabelRequests.Create("RT-1", "WH-1", LocalDateTime.now().plusDays(1),
"Li", "13800000000", null, null, "return-over-quantity", List.of(detail), null, false);
var error = assertThrows(ResponseStatusException.class, () -> service.create("10001", "operator-1", request));
assertTrue(error.getReason().contains("超过来源箱可用数量"));
verify(applications, never()).insert(any(RelabelApplication.class));
}
private void stubDetail(RelabelApplication application) { private void stubDetail(RelabelApplication application) {
when(applications.findNo(application.getCustomerId(), application.getRelabelNo())).thenReturn(application); when(applications.findNo(application.getCustomerId(), application.getRelabelNo())).thenReturn(application);
when(items.selectList(any())).thenReturn(List.of(item(31L, application.getId(), "SKU-A", 10, 6))); when(items.selectList(any())).thenReturn(List.of(item(31L, application.getId(), "SKU-A", 10, 6)));
@@ -0,0 +1,52 @@
package com.baiyue.lastleg.returnreceipt.controller;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.returnreceipt.dto.ReturnOrderRequests;
import com.baiyue.lastleg.returnreceipt.service.ReturnReceiptService;
import org.junit.jupiter.api.Test;
import org.springframework.core.MethodParameter;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.bind.support.WebDataBinderFactory;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.method.support.ModelAndViewContainer;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import java.util.Set;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
class MerchantReturnOrderControllerTest {
@Test
void putRoutesEditedForecastToAuthenticatedCustomer() throws Exception {
var service = mock(ReturnReceiptService.class);
var controller = new MerchantReturnOrderController();
ReflectionTestUtils.setField(controller, "service", service);
var user = new AuthenticatedUser(1L, 100L, 100L, 1L, "tester", "MERCHANT", "WEB", Set.of());
var mvc = MockMvcBuilders.standaloneSetup(controller).setCustomArgumentResolvers(new HandlerMethodArgumentResolver() {
public boolean supportsParameter(MethodParameter parameter) {
return parameter.getParameterType() == AuthenticatedUser.class;
}
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer container,
NativeWebRequest request, WebDataBinderFactory factory) {
return user;
}
}).build();
mvc.perform(put("/api/merchant/v1/last-leg/return-orders/RT-TEST")
.contentType("application/json")
.content("""
{"overseasWarehouseId":"WH-1","warehouseContactName":"Updated contact",
"warehouseContactPhone":"123456","customerReferenceNo":"REF-NEW",
"needRelabel":true,"remark":"Updated remark","idempotencyKey":"edit-test",
"items":[{"skuCode":"SKU-NEW","productNameZh":"Updated product","expectedQuantity":25}]}
"""))
.andExpect(status().isOk());
verify(service).updatePending(eq("100"), eq("RT-TEST"), eq(new ReturnOrderRequests.Create(
"WH-1", "Updated contact", "123456", "REF-NEW", true, "Updated remark", "edit-test",
java.util.List.of(new ReturnOrderRequests.Item("SKU-NEW", "Updated product", 25)))));
verifyNoMoreInteractions(service);
}
}
@@ -70,6 +70,7 @@ class ReturnReceiptServiceTest {
@Mock RelabelItemMapper relabelItems; @Mock RelabelItemMapper relabelItems;
@Mock FbaWarehouseValidator fbaWarehouseValidator; @Mock FbaWarehouseValidator fbaWarehouseValidator;
@Mock RelabelPhotoFileGateway photoFiles; @Mock RelabelPhotoFileGateway photoFiles;
@Mock com.baiyue.lastleg.service.ReturnOrderReferenceResolver returnOrderReferences;
ReturnReceiptService service; ReturnReceiptService service;
PdaWarehouseIdentity identity; PdaWarehouseIdentity identity;
@@ -91,6 +92,7 @@ class ReturnReceiptServiceTest {
ReflectionTestUtils.setField(service, "relabelApplications", relabelApplications); ReflectionTestUtils.setField(service, "relabelApplications", relabelApplications);
ReflectionTestUtils.setField(service, "relabelItems", relabelItems); ReflectionTestUtils.setField(service, "relabelItems", relabelItems);
ReflectionTestUtils.setField(service, "fbaWarehouseValidator", fbaWarehouseValidator); ReflectionTestUtils.setField(service, "fbaWarehouseValidator", fbaWarehouseValidator);
ReflectionTestUtils.setField(service, "returnOrderReferences", returnOrderReferences);
identity = new PdaWarehouseIdentity("operator-1", "仓管员", 10L); identity = new PdaWarehouseIdentity("operator-1", "仓管员", 10L);
} }
@@ -110,6 +112,32 @@ class ReturnReceiptServiceTest {
assertEquals(90L, result.receivedQuantity()); assertEquals(90L, result.receivedQuantity());
} }
@Test
void detailReturnsMeasurementsForBothPalletsAndDoesNotAddRepeatedCarrierVolume() {
ReturnOrder order = order("RT-TWO-PALLETS", "10", "COMPLETED", 10, 10);
when(orders.findNo("customer-1", "RT-TWO-PALLETS")).thenReturn(order);
ReturnReceipt first = measuredReceipt(1L, "PLT-A", "111", "11", "12");
ReturnReceipt second = measuredReceipt(2L, "PLT-B", "11", "21", "32");
ReturnReceipt earlier = measuredReceipt(3L, "PLT-A", "100", "10", "10");
when(receipts.listOrder(order.getId())).thenReturn(List.of(second, first, earlier));
var result = service.detail("customer-1", "RT-TWO-PALLETS");
assertEquals(2, result.pallets().size());
var palletA = result.pallets().stream().filter(row -> "PLT-A".equals(row.palletCode())).findFirst().orElseThrow();
var palletB = result.pallets().stream().filter(row -> "PLT-B".equals(row.palletCode())).findFirst().orElseThrow();
assertThat(palletA.volumeCbm()).isEqualByComparingTo("0.014652");
assertThat(palletB.volumeCbm()).isEqualByComparingTo("0.007392");
assertThat(palletA.length()).isEqualByComparingTo("111");
assertThat(palletB.weight()).isEqualByComparingTo("11");
}
private ReturnReceipt measuredReceipt(long id, String code, String length, String width, String height) {
ReturnReceipt receipt = new ReturnReceipt(); receipt.setId(id); receipt.setUnitType("PALLET"); receipt.setUnitCode(code);
receipt.setPalletCode(code); receipt.setTotalBoxCount(2L); receipt.setReceiptNo("RR-" + id);
receipt.setLength(new java.math.BigDecimal(length)); receipt.setWidth(new java.math.BigDecimal(width));
receipt.setHeight(new java.math.BigDecimal(height)); receipt.setWeight(new java.math.BigDecimal("11"));
return receipt;
}
@Test @Test
void merchantPhotoDownloadUsesAuthenticatedCustomerAndResolvedReturnOrder() { void merchantPhotoDownloadUsesAuthenticatedCustomerAndResolvedReturnOrder() {
ReflectionTestUtils.setField(service, "photoFiles", photoFiles); ReflectionTestUtils.setField(service, "photoFiles", photoFiles);
@@ -74,6 +74,7 @@ class LastlegOrderServiceTest {
@Mock ContainerArrivalTimeOperationMapper containerArrivalTimeOperations; @Mock ContainerArrivalTimeOperationMapper containerArrivalTimeOperations;
@Mock WarehouseResourceGateway warehouseResourceGateway; @Mock WarehouseResourceGateway warehouseResourceGateway;
@Mock ContainerShippingGateMapper containerShippingGates; @Mock ContainerShippingGateMapper containerShippingGates;
@Mock ReturnOrderReferenceResolver returnOrderReferences;
LastlegOrderService service; LastlegOrderService service;
@Test @Test
@@ -105,7 +106,10 @@ class LastlegOrderServiceTest {
@BeforeEach @BeforeEach
void setUp() { void setUp() {
com.baomidou.mybatisplus.core.metadata.TableInfoHelper.initTableInfo(
new org.apache.ibatis.builder.MapperBuilderAssistant(new org.apache.ibatis.session.Configuration(), ""), LastlegOrder.class);
service = new LastlegOrderService(); service = new LastlegOrderService();
ReflectionTestUtils.setField(service, "returnOrderReferences", returnOrderReferences);
ReflectionTestUtils.setField(service, "orderMapper", orders); ReflectionTestUtils.setField(service, "orderMapper", orders);
ReflectionTestUtils.setField(service, "detailMapper", details); ReflectionTestUtils.setField(service, "detailMapper", details);
ReflectionTestUtils.setField(service, "historyMapper", histories); ReflectionTestUtils.setField(service, "historyMapper", histories);
@@ -0,0 +1,49 @@
package com.baiyue.lastleg.service;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory;
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.*;
class ReturnOrderReferenceResolverTest {
@Test
void directoryFailureBlocksNewSnapshotButDoesNotHideHistoricalOrders() {
var customers = mock(FleetCustomerDirectory.class);
var warehouses = mock(WarehouseResourceGateway.class);
when(customers.find("1")).thenThrow(new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.SERVICE_UNAVAILABLE));
when(warehouses.requireActiveSharedWarehouse(10L)).thenReturn(new WarehouseResourceGateway.WarehouseReference(10L, "USLAX01", "洛杉矶仓", "ACTIVE"));
var resolver = new ReturnOrderReferenceResolver(customers, warehouses);
org.junit.jupiter.api.Assertions.assertThrows(org.springframework.web.server.ResponseStatusException.class, () -> resolver.populate(List.of(order())));
var historical = order();
resolver.populateForRead(List.of(historical));
assertEquals("洛杉矶仓", historical.getOverseasWarehouseName());
}
@Test
void resolvesMissingReturnReferencesOncePerCustomerAndWarehouseAndPreservesSnapshots() {
var customers = mock(FleetCustomerDirectory.class);
var warehouses = mock(WarehouseResourceGateway.class);
when(customers.find("1")).thenReturn(new FleetCustomerDirectory.Customer("1", "C1", "测试货主", null, null));
when(warehouses.requireActiveSharedWarehouse(10L)).thenReturn(new WarehouseResourceGateway.WarehouseReference(10L, "USLAX01", "洛杉矶仓", "ACTIVE"));
LastlegOrder first = order(), second = order(), frozen = order();
frozen.setCustomerName("原货主快照"); frozen.setOverseasWarehouseCode("OLD"); frozen.setOverseasWarehouseName("原仓库快照");
new ReturnOrderReferenceResolver(customers, warehouses).populate(List.of(first, second, frozen));
assertEquals("测试货主", first.getCustomerName());
assertEquals("USLAX01", second.getOverseasWarehouseCode());
assertEquals("洛杉矶仓", second.getOverseasWarehouseName());
assertEquals("原货主快照", frozen.getCustomerName());
assertEquals("原仓库快照", frozen.getOverseasWarehouseName());
verify(customers, times(1)).find("1");
verify(warehouses, times(1)).requireActiveSharedWarehouse(10L);
}
private LastlegOrder order() {
var order = new LastlegOrder(); order.setOrderType("RETURN_RELABEL");
order.setCustomerId("1"); order.setOverseasWarehouseId("10"); return order;
}
}