贴标申请,仓储转运

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;
}
public List<Customer> options(String keyword) { return load(keyword, null); }
public Customer required(long id) {
var customer = load(null, id).stream().filter(row -> row.id().equals(Long.toString(id))).findFirst()
public Customer find(String id) {
return load(null, Long.parseLong(id)).stream().filter(row -> row.id().equals(id)).findFirst()
.orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "请选择有效客户"));
}
public Customer required(long id) {
var customer = find(Long.toString(id));
try {
var result = client.eligibility(token, id);
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 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," +
"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 " +
@@ -265,7 +265,6 @@ public class RelabelApplicationService implements WalletResultHandler {
"INBOUND", null, null, List.of(), 0);
}).filter(candidate -> candidate.availablePackages() > 0).toList());
for (ReturnInventoryCarton carton : returnCartons.findAvailable(customerId, operationWarehouseId.trim())) {
if ("PARCEL".equals(carton.getSourceUnitType())) continue;
String keyword = trim(inboundCode);
if (StringUtils.hasText(keyword)
&& !containsIgnoreCase(carton.getCartonMark(), keyword)
@@ -274,7 +273,7 @@ public class RelabelApplicationService implements WalletResultHandler {
.filter(item -> value(item.getQuantity()) > 0)
.map(item -> {
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,
Math.toIntExact(Math.max(remaining, 0)), null);
}).filter(item -> item.quantity() > 0).toList();
@@ -288,7 +287,7 @@ public class RelabelApplicationService implements WalletResultHandler {
result.add(new RelabelResponses.InventoryCandidate(null, null, carton.getSourceReturnNo(),
null, carton.getOverseasWarehouseId(), null, null, carton.getDestinationWarehouseCode(),
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;
}
@@ -392,6 +391,21 @@ public class RelabelApplicationService implements WalletResultHandler {
if (!"AVAILABLE".equals(carton.getStatus()) || carton.getRelabelApplicationId() != null) {
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))
throw conflict("退货箱SKU已被合箱计划锁定");
if (Set.of("CARTON_LABEL", "CARTON_AND_SKU").contains(item.operationType())
@@ -477,6 +491,17 @@ public class RelabelApplicationService implements WalletResultHandler {
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) {
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 + "')")
public ApiResponse<ReturnOrderResponses.Detail> create(@AuthenticationPrincipal AuthenticatedUser user,
@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 + "')")
public ApiResponse<PageResult<ReturnOrderResponses.Summary>> page(@AuthenticationPrincipal AuthenticatedUser user,
@Valid ReturnOrderRequests.Query query) { return ApiResponse.success(service.page(customer(user), query)); }
@@ -1,6 +1,7 @@
package com.baiyue.lastleg.returnreceipt.dto;
import java.time.LocalDateTime;
import java.math.BigDecimal;
import java.util.List;
public final class ReturnOrderResponses {
@@ -22,9 +23,10 @@ public final class ReturnOrderResponses {
String skuCode, long quantity, LocalDateTime createdAt) { }
public record Photo(long fileId, String fileName, String contentType, long sizeBytes) { }
public record PalletDetail(String palletCode, int skuTypes, long totalQuantity, List<PalletItem> items,
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) {
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) {
@@ -78,6 +78,7 @@ public class ReturnReceiptService {
@Resource private RelabelItemMapper relabelItems;
@Resource private com.baiyue.lastleg.relabel.pda.gateway.RelabelPhotoFileGateway photoFiles;
@Resource private com.baiyue.lastleg.service.FbaWarehouseValidator fbaWarehouseValidator;
@Resource private com.baiyue.lastleg.service.ReturnOrderReferenceResolver returnOrderReferences;
@Transactional
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.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");
// 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.setSourceLoadingPlanId("RETURN_RELABEL"); tail.setCreatedAt(now); tail.setUpdatedAt(now);
tail.setSourcePayloadHash(returnOrder.getRequestHash());
returnOrderReferences.populate(List.of(tail));
lastlegOrders.insert(tail);
returnOrder.setLastlegOrderId(tail.getId()); returnOrder.setLastlegOrderNo(tail.getOrderNo());
returnOrder.setRelabelStatus(Boolean.TRUE.equals(returnOrder.getNeedRelabel()) ? "PENDING_BINDING" : "NOT_REQUIRED");
@@ -207,12 +213,35 @@ public class ReturnReceiptService {
if (order == null) return;
Map<String, ReturnOrderItem> bySku = orderItems.listOrder(order.getId()).stream()
.collect(java.util.stream.Collectors.toMap(ReturnOrderItem::getSkuCode, value -> value, (left, right) -> left, LinkedHashMap::new));
for (ReturnReceipt receipt : receipts.listOrder(order.getId())) {
List<ReturnReceipt> returnReceipts = receipts.listOrder(order.getId());
for (ReturnReceipt receipt : returnReceipts) {
List<Allocation> allocations = receiptItems.listReceipt(receipt.getId()).stream()
.map(line -> new Allocation(bySku.get(line.getSkuCode()), value(line.getReceivedQuantity())))
.filter(item -> item.item() != null).toList();
if (!allocations.isEmpty()) syncTailCargo(order, receipt, allocations, receipt.getSignedAt() == null ? LocalDateTime.now() : receipt.getSignedAt());
}
// 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)
@@ -243,12 +272,16 @@ public class ReturnReceiptService {
Map<String, Map<String, ReturnOrderResponses.PalletItem>> palletItems = new LinkedHashMap<>();
Map<String, String> palletTypes = new LinkedHashMap<>();
Map<String, Long> physicalBoxes = new LinkedHashMap<>();
Map<String, ReturnReceipt> physicalMeasurements = new LinkedHashMap<>();
for (ReturnReceipt receipt : orderReceipts) {
if (!"PALLET".equals(receipt.getUnitType()) && !"BOX".equals(receipt.getUnitType())) continue;
String physicalCode = text(receipt.getUnitCode()) == null ? text(receipt.getPalletCode()) : text(receipt.getUnitCode());
if (physicalCode == null) continue;
String key = receipt.getUnitType() + ":" + physicalCode;
palletTypes.put(key, receipt.getUnitType());
// 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);
Map<String, ReturnOrderResponses.PalletItem> bySku = palletItems.computeIfAbsent(key, ignored -> new LinkedHashMap<>());
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.PalletItem> palletLines = List.copyOf(entry.getValue().values());
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,
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();
List<ReturnOrderResponses.Photo> photos = photoList(order).stream()
.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.setProductQuantity(Math.addExact(tail.getProductQuantity() == null ? 0 : tail.getProductQuantity(), Math.toIntExact(allocations.stream().mapToLong(Allocation::quantity).sum())));
if ("PALLET".equals(receipt.getUnitType())) tail.setPalletCount(Math.addExact(tail.getPalletCount() == null ? 0 : tail.getPalletCount(), 1));
if (receipt.getLength() != null && receipt.getWidth() != null && receipt.getHeight() != null) {
tail.setVolumeCbm(receipt.getLength().multiply(receipt.getWidth()).multiply(receipt.getHeight()).divide(java.math.BigDecimal.valueOf(1_000_000)));
// Recalculate from physical carriers; signing another pallet must not overwrite the first pallet's volume.
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.setUpdatedAt(now); lastlegOrders.updateById(tail);
}
@@ -84,6 +84,7 @@ public class LastlegOrderService {
@Resource private ContainerArrivalTimeOperationMapper containerArrivalTimeOperations;
@Resource private WarehouseResourceGateway warehouseResourceGateway;
@Resource private ContainerShippingGateMapper containerShippingGates;
@Resource private ReturnOrderReferenceResolver returnOrderReferences;
public PageResult<LastlegOrderSummary> page(String customerId, LastlegOrderRequests.Query query) {
return page(customerId, query, null);
@@ -93,6 +94,8 @@ public class LastlegOrderService {
Page<LastlegOrder> page = orderMapper.selectPage(
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());
returnOrderReferences.populateForRead(page.getRecords());
refreshReturnVolumes(page.getRecords());
Map<String, String> containerNodes = containerNodes(page.getRecords());
Map<Long, String> dispatchStatuses = dispatchStatuses(page.getRecords());
Map<Long, LastlegOrderMapper.OrderWeightRow> weights = orderWeights(page.getRecords());
@@ -331,6 +334,8 @@ public class LastlegOrderService {
public LastlegOrderDetailResponse detail(String customerId, long orderId) {
LastlegOrder order = findOwned(customerId, orderId);
returnOrderReferences.populateForRead(List.of(order));
refreshReturnVolumes(List.of(order));
List<LastlegOrderDetail> cargo = detailMapper.selectList(new LambdaQueryWrapper<LastlegOrderDetail>()
.eq(LastlegOrderDetail::getCustomerId, order.getCustomerId())
.eq(LastlegOrderDetail::getOrderId, orderId).orderByAsc(LastlegOrderDetail::getId));
@@ -596,6 +601,14 @@ public class LastlegOrderService {
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) {
if (orders.isEmpty()) return Map.of();
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;
}
@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) {
when(applications.findNo(application.getCustomerId(), application.getRelabelNo())).thenReturn(application);
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 FbaWarehouseValidator fbaWarehouseValidator;
@Mock RelabelPhotoFileGateway photoFiles;
@Mock com.baiyue.lastleg.service.ReturnOrderReferenceResolver returnOrderReferences;
ReturnReceiptService service;
PdaWarehouseIdentity identity;
@@ -91,6 +92,7 @@ class ReturnReceiptServiceTest {
ReflectionTestUtils.setField(service, "relabelApplications", relabelApplications);
ReflectionTestUtils.setField(service, "relabelItems", relabelItems);
ReflectionTestUtils.setField(service, "fbaWarehouseValidator", fbaWarehouseValidator);
ReflectionTestUtils.setField(service, "returnOrderReferences", returnOrderReferences);
identity = new PdaWarehouseIdentity("operator-1", "仓管员", 10L);
}
@@ -110,6 +112,32 @@ class ReturnReceiptServiceTest {
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
void merchantPhotoDownloadUsesAuthenticatedCustomerAndResolvedReturnOrder() {
ReflectionTestUtils.setField(service, "photoFiles", photoFiles);
@@ -74,6 +74,7 @@ class LastlegOrderServiceTest {
@Mock ContainerArrivalTimeOperationMapper containerArrivalTimeOperations;
@Mock WarehouseResourceGateway warehouseResourceGateway;
@Mock ContainerShippingGateMapper containerShippingGates;
@Mock ReturnOrderReferenceResolver returnOrderReferences;
LastlegOrderService service;
@Test
@@ -105,7 +106,10 @@ class LastlegOrderServiceTest {
@BeforeEach
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();
ReflectionTestUtils.setField(service, "returnOrderReferences", returnOrderReferences);
ReflectionTestUtils.setField(service, "orderMapper", orders);
ReflectionTestUtils.setField(service, "detailMapper", details);
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;
}
}