diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java index 06c3ed9aa..7e45cc8d8 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java @@ -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 items = dispatchItems.findByDispatchOrder(customerId, delivery.getDispatchOrderId()).stream() .filter(item -> delivery.getDeliveryGroupKey().equals(deliveryGroupKey(item))).toList(); - List 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 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 orderIds = List.copyOf(sourceOrderIds); Map 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 details = orderIds.isEmpty() ? List.of() : tailOrderDetails.selectList( new LambdaQueryWrapper().eq(LastlegOrderDetail::getCustomerId, customerId).in(LastlegOrderDetail::getOrderId, orderIds)); - List skus = details.stream().map(detail -> sku(detail, ordersById.get(detail.getOrderId()))).toList(); + List 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 deliveryCargoSkus(List items, + List picked, + Map orders, List details) { + List 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.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 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); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaPickOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaPickOrderMapper.java index 14571382b..26b78cd40 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaPickOrderMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaPickOrderMapper.java @@ -11,6 +11,26 @@ import java.util.List; @Mapper public interface DispatchPdaPickOrderMapper extends BaseMapper { + /** 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 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 " + diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index 4345d8118..d1aea61fb 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -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] diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java index 01b81fe0b..5f35f3b55 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java @@ -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 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 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 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");