fix(delivery): project current dispatched cargo instead of whole order SKU quantities

This commit is contained in:
2026-10-04 02:54:52 +08:00
parent 9cd709edee
commit c46a68e32b
4 changed files with 180 additions and 3 deletions
@@ -122,6 +122,7 @@ public class DeliveryTransferService {
@Resource private LastlegOrderMapper tailOrders;
@Resource private LastlegOrderStatusHistoryMapper histories;
@Resource private LastlegOrderDetailMapper tailOrderDetails;
@Resource private com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaPickOrderMapper dispatchPickOrders;
@Resource private RelabelApplicationMapper relabelApplications;
@Resource private RelabelItemMapper relabelItems;
@Resource private RelabelApplicationService relabelService;
@@ -1354,18 +1355,61 @@ public class DeliveryTransferService {
if (delivery == null) throw notFound("Delivery order not found");
List<DispatchItem> items = dispatchItems.findByDispatchOrder(customerId, delivery.getDispatchOrderId()).stream()
.filter(item -> delivery.getDeliveryGroupKey().equals(deliveryGroupKey(item))).toList();
List<Long> orderIds = items.stream().map(DispatchItem::getOrderId).filter(Objects::nonNull).distinct().toList();
var picked = dispatchPickOrders.deliveryPickedSkus(customerId, delivery.getDispatchOrderId()).stream()
.filter(row -> items.stream().anyMatch(item -> Objects.equals(item.getId(), row.dispatchItemId()))).toList();
Set<Long> sourceOrderIds = new LinkedHashSet<>();
items.stream().map(DispatchItem::getOrderId).filter(Objects::nonNull).forEach(sourceOrderIds::add);
picked.stream().map(com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaPickOrderMapper.DeliveryPickedSku::orderId)
.filter(Objects::nonNull).forEach(sourceOrderIds::add);
List<Long> orderIds = List.copyOf(sourceOrderIds);
Map<Long, LastlegOrder> ordersById = orderIds.isEmpty() ? Map.of() : tailOrders.selectBatchIds(orderIds).stream()
.filter(order -> customerId.equals(order.getCustomerId())).collect(java.util.stream.Collectors.toMap(LastlegOrder::getId, order -> order));
List<LastlegOrderDetail> details = orderIds.isEmpty() ? List.of() : tailOrderDetails.selectList(
new LambdaQueryWrapper<LastlegOrderDetail>().eq(LastlegOrderDetail::getCustomerId, customerId).in(LastlegOrderDetail::getOrderId, orderIds));
List<DeliveryResponses.DeliverySku> skus = details.stream().map(detail -> sku(detail, ordersById.get(detail.getOrderId()))).toList();
List<DeliveryResponses.DeliverySku> skus = deliveryCargoSkus(items, picked, ordersById, details);
return new DeliveryResponses.DeliveryDetail(deliveryRow(delivery), skus,
feeSources.listForBusiness(customerId, "DELIVERY_ORDER", delivery.getId()).stream().map(this::feeRow).toList(),
deliveryOperations.findByDelivery(customerId, delivery.getId()).stream().map(this::operationRow).toList(),
feeCoverageEvidence(customerId, delivery.getOutboundNo(), items));
}
private List<DeliveryResponses.DeliverySku> deliveryCargoSkus(List<DispatchItem> items,
List<com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaPickOrderMapper.DeliveryPickedSku> picked,
Map<Long, LastlegOrder> orders, List<LastlegOrderDetail> details) {
List<DeliveryResponses.DeliverySku> result = new ArrayList<>();
for (DispatchItem item : items) {
var rows = picked.stream().filter(row -> Objects.equals(item.getId(), row.dispatchItemId())).toList();
long total = 0;
for (var row : rows) {
if (row.packages() == null || row.packages() <= 0) throw conflict("当前派送拣货数量无效");
total = Math.addExact(total, row.packages());
String skuCode = trim(row.skuCode());
if ("MIXED".equalsIgnoreCase(skuCode)) skuCode = null;
String currentSku = skuCode;
var matching = currentSku == null ? List.<LastlegOrderDetail>of() : details.stream()
.filter(detail -> Objects.equals(row.orderId(), detail.getOrderId())
&& currentSku.equals(detail.getSkuCode())).toList();
LastlegOrderDetail metadata = matching.size() == 1 ? matching.get(0) : null;
LastlegOrder order = row.orderId() == null ? null : orders.get(row.orderId());
result.add(new DeliveryResponses.DeliverySku(order == null ? item.getInboundCode() : order.getInboundCode(),
order == null ? null : order.getCustomerName(), firstText(item.getDestinationWarehouseCode(), item.getFbaWarehouseCode()),
item.getDeliveryAddress(), metadata == null ? null : metadata.getShipmentNo(),
metadata == null ? null : metadata.getFbaTrackingNo(), skuCode,
skuCode == null ? null : row.productName(), Math.toIntExact(row.packages()), null, null));
}
long snapshotPackages = value(item.getPackageCount());
if (total > snapshotPackages) throw conflict("当前派送拣货数量超过冻结排车数量");
if (total < snapshotPackages) {
// Before picking, only the selected dispatch quantity is known; no SKU allocation is invented.
LastlegOrder order = item.getOrderId() == null ? null : orders.get(item.getOrderId());
result.add(new DeliveryResponses.DeliverySku(item.getInboundCode(), order == null ? null : order.getCustomerName(),
firstText(item.getDestinationWarehouseCode(), item.getFbaWarehouseCode()), item.getDeliveryAddress(),
null, null, null, null, Math.toIntExact(snapshotPackages - total), null, null));
}
}
return List.copyOf(result);
}
private com.baiyue.lastleg.delivery.dto.DeliveryFeeCoverageModels.Evidence feeCoverageEvidence(String customerId, String outboundNo, List<DispatchItem> items) {
var outbound = outboundNo == null ? null : outboundOrders.findByNo(customerId, outboundNo);
if (outbound == null || outbound.getFeeCoverageJson() == null) return null;
@@ -1439,7 +1483,6 @@ public class DeliveryTransferService {
allocateMetric(candidate.getSourceVolumeCbm(), candidate.getSourcePackageCount(), available, 4));
}
private DeliveryResponses.DeliveryRow deliveryRow(DeliveryOrder d) { return new DeliveryResponses.DeliveryRow(d.getId(), d.getCustomerId(), d.getCustomerName(), d.getDeliveryNo(), d.getOutboundNo(), d.getDispatchNo(), d.getOverseasWarehouseId(), d.getOverseasWarehouseName(), d.getStatus(), d.getWaybillNo(), d.getDeliveryMethod(), d.getCarrierName(), d.getPickupMethod(), d.getLoadingType(), d.getVehicleType(), d.getVehicleSize(), d.getVehiclePlate(), d.getDriverName(), d.getDriverPhone(), d.getAppointmentAccount(), d.getSellerName(), value(d.getPalletCount()), value(d.getPackageCount()), d.getSignedPackages(), d.getTotalWeightKg(), d.getTotalVolumeCbm(), d.getPlannedDeliveryAt(), d.getDispatchedAt(), d.getExpectedArrivalAt(), d.getSignedAt(), d.getRemark(), d.getCreatedAt(), value(d.getVersion()), DeliveryStatusPolicy.allowedActions(d.getStatus()), d.getBolNo(), DeliveryDocumentSnapshot.summary(d.getDocumentsJson()), d.getDestinationWarehouseId(), d.getDestinationWarehouseCode(), d.getDestinationWarehouseName(), d.getDestinationType(), d.getDeliveryAddress(), d.getContactName(), d.getContactPhone(), d.getPostalCode()); }
private DeliveryResponses.DeliverySku sku(LastlegOrderDetail d, LastlegOrder order) { return new DeliveryResponses.DeliverySku(order == null ? null : order.getInboundCode(), order == null ? null : order.getCustomerName(), d.getDestinationWarehouseCode(), null, d.getShipmentNo(), d.getFbaTrackingNo(), d.getSkuCode(), firstText(d.getProductNameZh(), d.getProductNameEn()), value(d.getPackageCount()), d.getGrossWeightKg(), d.getVolumeCbm()); }
private DeliveryResponses.DeliveryFeeRow feeRow(OverseasFeeSource f) { return new DeliveryResponses.DeliveryFeeRow(f.getId(), f.getDirection(), f.getFeeItemCode(), f.getOriginalCurrency(), f.getOriginalAmount(), f.getReviewReason(), f.getReviewStatus(), f.getSourceStatus(), f.getVoidStatus()); }
private DeliveryResponses.DeliveryOperationRow operationRow(DeliveryOperation o) { return new DeliveryResponses.DeliveryOperationRow(o.getId(), o.getCreatedAt(), o.getOperationNode(), firstText(o.getOperatorName(), o.getOperatorId()), o.getResult()); }
private void recordOperation(String customerId, Long deliveryId, String operatorId, String node, String result, LocalDateTime now) { DeliveryOperation operation = new DeliveryOperation(); operation.setCustomerId(customerId); operation.setDeliveryOrderId(deliveryId); operation.setOperationNode(node); operation.setOperatorId(operatorId); operation.setOperatorName(operatorId); operation.setResult(result); operation.setCreatedAt(now); deliveryOperations.insert(operation); }
@@ -11,6 +11,26 @@ import java.util.List;
@Mapper
public interface DispatchPdaPickOrderMapper extends BaseMapper<DispatchPdaPickOrder> {
/** Current dispatch pick quantities; mixed/unknown SKU identities remain unknown. */
@Select("""
SELECT p.dispatch_item_id AS dispatchItemId,a.order_id AS orderId,
CASE WHEN a.receipt_id IS NOT NULL THEN i.sku_code ELSE b.sku_code END AS skuCode,
CASE WHEN a.receipt_id IS NOT NULL THEN i.product_name_zh ELSE b.product_name_zh END AS productName,
COALESCE(a.packages,p.packages) AS packages
FROM ll_dispatch_pda_task t
JOIN ll_dispatch_pda_pick p ON p.task_id=t.id AND BINARY p.customer_id=BINARY t.customer_id
LEFT JOIN ll_dispatch_pda_pick_order a ON a.pick_id=p.id AND BINARY a.customer_id=BINARY p.customer_id
LEFT JOIN ll_inbound_stock_receipt i ON i.id=a.receipt_id AND BINARY i.customer_id=BINARY a.customer_id
LEFT JOIN ll_inventory_position ip ON ip.id=p.inventory_position_id AND BINARY ip.customer_id=BINARY p.customer_id
LEFT JOIN ll_inventory_balance b ON b.id=ip.balance_id AND BINARY b.customer_id=BINARY ip.customer_id
WHERE BINARY t.customer_id=BINARY #{customerId} AND t.dispatch_order_id=#{dispatchId}
ORDER BY p.id,a.id
""")
List<DeliveryPickedSku> deliveryPickedSkus(@Param("customerId") String customerId,
@Param("dispatchId") Long dispatchId);
record DeliveryPickedSku(Long dispatchItemId, Long orderId, String skuCode, String productName, Long packages) {}
@Select("SELECT DISTINCT r.* FROM ll_inventory_position current_position " +
"JOIN ll_inventory_position receipt_position ON receipt_position.balance_id=current_position.balance_id " +
"JOIN ll_inbound_stock_receipt r ON r.inventory_position_id=receipt_position.id " +
@@ -1777,6 +1777,25 @@ components:
required: [order]
properties:
order: { $ref: '#/components/schemas/DeliveryOrderRow' }
skus:
type: array
description: 当前派送目的地的本次排车货物投影;数量按冻结排车与本次PDA实拣归属,不返回整单申报箱数。未实拣或MIXED库存没有单SKU归属时保留数量,SKU及品名为null;没有本次SKU测量事实时重量与体积为null,不按比例分摊整单测量。
items: { $ref: '#/components/schemas/DeliveryCargoSku' }
DeliveryCargoSku:
type: object
required: [packageCount]
properties:
inboundCode: { type: string, nullable: true }
customerName: { type: string, nullable: true }
destinationWarehouseCode: { type: string, nullable: true }
receivingAddress: { type: string, nullable: true }
shipmentNo: { type: string, nullable: true }
fbaTrackingNo: { type: string, nullable: true }
skuCode: { type: string, nullable: true }
productName: { type: string, nullable: true }
packageCount: { type: integer, minimum: 0, description: 本次排车/实拣的箱数,不是整单申报箱数 }
weightKg: { type: number, nullable: true }
volumeCbm: { type: number, nullable: true }
ReturnOrderCreate:
type: object
required: [overseasWarehouseId, needRelabel, idempotencyKey, items]
@@ -103,6 +103,7 @@ class DeliveryTransferServiceTest {
@Mock LastlegOrderMapper tailOrders;
@Mock LastlegOrderStatusHistoryMapper histories;
@Mock LastlegOrderDetailMapper tailOrderDetails;
@Mock com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaPickOrderMapper dispatchPickOrders;
@Mock OutboundPdaTaskMapper pdaTasks;
@Mock OutboundPdaPickMapper pdaPicks;
@Mock InventoryPositionMapper inventoryPositions;
@@ -141,6 +142,7 @@ class DeliveryTransferServiceTest {
ReflectionTestUtils.setField(service, "tailOrders", tailOrders);
ReflectionTestUtils.setField(service, "histories", histories);
ReflectionTestUtils.setField(service, "tailOrderDetails", tailOrderDetails);
ReflectionTestUtils.setField(service, "dispatchPickOrders", dispatchPickOrders);
ReflectionTestUtils.setField(service, "pdaTasks", pdaTasks);
ReflectionTestUtils.setField(service, "pdaPicks", pdaPicks);
ReflectionTestUtils.setField(service, "inventoryPositions", inventoryPositions);
@@ -536,11 +538,17 @@ class DeliveryTransferServiceTest {
lax9Item.setOrderId(902L);
when(deliveryOrders.findByNo("TENANT-A", "DEL-ONT8")).thenReturn(ont8);
when(dispatchItems.findByDispatchOrder("TENANT-A", 101L)).thenReturn(List.of(ont8Item, lax9Item));
when(dispatchPickOrders.deliveryPickedSkus("TENANT-A", 101L)).thenReturn(List.of(
new com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaPickOrderMapper.DeliveryPickedSku(201L, 901L, "SKU-A", "A", 12L),
new com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaPickOrderMapper.DeliveryPickedSku(202L, 902L, "SKU-B", "B", 8L)));
when(tailOrders.selectBatchIds(List.of(901L))).thenReturn(List.of());
DeliveryResponses.DeliveryDetail detail = service.deliveryDetail("TENANT-A", "DEL-ONT8");
assertEquals("ONT8", detail.order().destinationWarehouseCode());
assertEquals(1, detail.skus().size());
assertEquals(12, detail.skus().get(0).packageCount());
assertEquals("SKU-A", detail.skus().get(0).skuCode());
verify(tailOrders).selectBatchIds(List.of(901L));
}
@@ -556,6 +564,93 @@ class DeliveryTransferServiceTest {
}
}
@Test
void redispatchDetailUsesActualTwoMixedBoxesInsteadOfOriginalThreeDeclaredSkuBoxes() {
OutboundOrder outbound = outbound(301L, 101L, "DSP-101", "OUT-301", "transfer-101");
DeliveryOrder delivery = delivery(401L, outbound, "DEL-RETURN"); delivery.setPackageCount(2);
DispatchItem selected = item(201L, 501L, "RETURN-PALLET", "ONT8", "Ontario", "IN-A", 2);
selected.setOrderId(901L);
var order = new com.baiyue.lastleg.entity.LastlegOrder();
order.setId(901L); order.setCustomerId("TENANT-A"); order.setInboundCode("IN-A");
var small = new com.baiyue.lastleg.entity.LastlegOrderDetail();
small.setCustomerId("TENANT-A"); small.setOrderId(901L); small.setSkuCode("SMALL"); small.setPackageCount(2);
var large = new com.baiyue.lastleg.entity.LastlegOrderDetail();
large.setCustomerId("TENANT-A"); large.setOrderId(901L); large.setSkuCode("LARGE"); large.setPackageCount(1);
when(deliveryOrders.findByNo("TENANT-A", "DEL-RETURN")).thenReturn(delivery);
when(dispatchItems.findByDispatchOrder("TENANT-A", 101L)).thenReturn(List.of(selected));
when(dispatchPickOrders.deliveryPickedSkus("TENANT-A", 101L)).thenReturn(List.of(
new com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaPickOrderMapper.DeliveryPickedSku(201L, 901L, "MIXED", "SMALL", 2L)));
when(tailOrders.selectBatchIds(List.of(901L))).thenReturn(List.of(order));
when(tailOrderDetails.selectList(any())).thenReturn(List.of(small, large));
var detail = service.deliveryDetail("TENANT-A", "DEL-RETURN");
assertEquals(1, detail.skus().size()); assertEquals(2, detail.skus().get(0).packageCount());
assertEquals(null, detail.skus().get(0).skuCode()); assertEquals(null, detail.skus().get(0).productName());
assertEquals(null, detail.skus().get(0).weightKg()); assertEquals(null, detail.skus().get(0).volumeCbm());
assertEquals("IN-A", detail.skus().get(0).inboundCode());
}
@Test
void currentDeliveryKnownSkuUsesOnlyActualPickedQuantityAndKeepsUnpickedSkuUnknown() {
DispatchItem selected = item(201L, 501L, "PLT-501", "ONT8", "Ontario", "IN-A", 3);
var picked = List.of(new com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaPickOrderMapper.DeliveryPickedSku(
201L, 901L, "SMALL", "Actual SMALL", 1L));
var metadata = new com.baiyue.lastleg.entity.LastlegOrderDetail();
metadata.setOrderId(901L); metadata.setSkuCode("SMALL"); metadata.setPackageCount(99);
metadata.setShipmentNo("SHIP-A"); metadata.setGrossWeightKg(new BigDecimal("999"));
List<DeliveryResponses.DeliverySku> rows = ReflectionTestUtils.invokeMethod(service, "deliveryCargoSkus",
List.of(selected), picked, Map.of(), List.of(metadata));
assertEquals(2, rows.size()); assertEquals(1, rows.get(0).packageCount());
assertEquals("SMALL", rows.get(0).skuCode()); assertEquals("SHIP-A", rows.get(0).shipmentNo());
assertEquals(null, rows.get(0).weightKg()); assertEquals(2, rows.get(1).packageCount());
assertEquals(null, rows.get(1).skuCode());
}
@Test
void mixedPalletUsesActualAttributionSourceRatherThanPrimaryDispatchOrder() {
DispatchItem selected = item(201L, 501L, "PLT-501", "ONT8", "Ontario", "IN-B", 3);
var a = new com.baiyue.lastleg.entity.LastlegOrder(); a.setId(901L); a.setInboundCode("IN-A");
var b = new com.baiyue.lastleg.entity.LastlegOrder(); b.setId(902L); b.setInboundCode("IN-B");
var picked = List.of(
new com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaPickOrderMapper.DeliveryPickedSku(201L, 901L, "A-SKU", "A", 2L),
new com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaPickOrderMapper.DeliveryPickedSku(201L, 902L, "B-SKU", "B", 1L));
List<DeliveryResponses.DeliverySku> rows = ReflectionTestUtils.invokeMethod(service, "deliveryCargoSkus",
List.of(selected), picked, Map.of(901L, a, 902L, b), List.of());
assertEquals(List.of("IN-A", "IN-B"), rows.stream().map(DeliveryResponses.DeliverySku::inboundCode).toList());
assertEquals(List.of(2, 1), rows.stream().map(DeliveryResponses.DeliverySku::packageCount).toList());
}
@Test
void selectedDispatchSnapshotBeforePickingDoesNotInventSkuOrDeclaredMeasurements() {
DispatchItem selected = item(201L, 501L, "PLT-501", "ONT8", "Ontario", "IN-A", 2);
List<DeliveryResponses.DeliverySku> rows = ReflectionTestUtils.invokeMethod(service, "deliveryCargoSkus",
List.of(selected), List.of(), Map.of(), List.of());
assertEquals(1, rows.size()); assertEquals(2, rows.get(0).packageCount());
assertEquals(null, rows.get(0).skuCode()); assertEquals(null, rows.get(0).weightKg());
}
@Test
void actualPickedQuantityAboveFrozenDispatchOrNonPositiveQuantityIsRejected() {
DispatchItem selected = item(201L, 501L, "PLT-501", "ONT8", "Ontario", "IN-A", 2);
for (long quantity : new long[] {0, -1, 3}) {
var picked = List.of(new com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaPickOrderMapper.DeliveryPickedSku(
201L, 901L, "SMALL", "SMALL", quantity));
assertThrows(ResponseStatusException.class, () -> ReflectionTestUtils.invokeMethod(service, "deliveryCargoSkus",
List.of(selected), picked, Map.of(), List.of()));
}
}
@Test
void deliveryPickedSkuQueryIsScopedToActualDispatchAndFrozenReceiptSkuWithoutDeclaredPackageJoin() throws Exception {
var method = com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaPickOrderMapper.class
.getMethod("deliveryPickedSkus", String.class, Long.class);
String sql = String.join(" ", method.getAnnotation(org.apache.ibatis.annotations.Select.class).value());
assertTrue(sql.contains("t.dispatch_order_id=#{dispatchId}"));
assertTrue(sql.contains("BINARY t.customer_id=BINARY #{customerId}"));
assertTrue(sql.contains("CASE WHEN a.receipt_id IS NOT NULL THEN i.sku_code ELSE b.sku_code END"));
assertTrue(sql.contains("COALESCE(a.packages,p.packages)"));
assertTrue(!sql.contains("ll_order_detail"));
}
@Test
void globalOutboundCancelIsIdempotentForAnUnexecutedOrder() {
OutboundOrder outbound = outbound(301L, null, null, "OUT-301", "one-click-key");