diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/dto/ExceptionOrderResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/dto/ExceptionOrderResponses.java index f7fa3ddf8..dd2aa3c41 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/dto/ExceptionOrderResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/dto/ExceptionOrderResponses.java @@ -27,7 +27,12 @@ public final class ExceptionOrderResponses { public record ReturnSource(Long outboundPalletItemId, Long inventoryPositionId, String palletNo, String skuCode, long maxReturnPackages) {} - public record Detail(Row order, List returnSources) {} + public record Detail(Row order, List returnSources, List returnReceipts) {} + public record ReturnReceipt(String receiptNo, long receivedPackages, LocalDateTime receivedAt, + List redispatches) {} + public record Redispatch(String dispatchNo, String status, long selectedPackages, long pickedPackages, + List deliveries) {} + public record RelatedDelivery(String deliveryNo, String status) {} public record CommandResult(Long id, String exceptionNo, String status, long version, Long inventoryAdjustmentId, long restoredPackages, LocalDateTime completedAt, boolean replayed) {} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderService.java index 3a0594bf1..9a8c9f9a7 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderService.java @@ -83,6 +83,7 @@ public class ExceptionOrderService { @Resource private InventoryCommandService inventoryCommands; @Resource private ExceptionEvidenceFileGateway evidenceFiles; @Resource private ObjectMapper objectMapper; + @Resource private com.baiyue.lastleg.inventory.mapper.ReturnStockReceiptMapper returnReceipts; public PageResult page(String scopedCustomerId, ExceptionOrderRequests.Query query) { String customerId = customerScope(scopedCustomerId, query.customerId()); @@ -114,7 +115,44 @@ public class ExceptionOrderService { public ExceptionOrderResponses.Detail detail(String customerId, String exceptionNo) { ExceptionOrder order = exceptionOrders.findByNo(customerId, exceptionNo); if (order == null) throw notFound("异常订单不存在"); - return new ExceptionOrderResponses.Detail(row(order), storedReturnSources(order)); + return new ExceptionOrderResponses.Detail(row(order), storedReturnSources(order), + receiptHistory(returnReceipts.history(customerId, order.getId()))); + } + + private List receiptHistory( + List rows) { + var grouped = new LinkedHashMap>(); + for (var item : rows) grouped.computeIfAbsent(item.receiptId(), ignored -> new ArrayList<>()).add(item); + var receipts = new ArrayList(); + for (var batch : grouped.values()) { + var first = batch.get(0); + if (first.receiptId() == null || !StringUtils.hasText(first.receiptNo()) + || first.receivedPackages() == null || first.receivedPackages() <= 0) + throw conflict("退回实收凭据缺少有效业务编号或数量"); + var dispatches = new LinkedHashMap>(); + for (var item : batch) if (StringUtils.hasText(item.dispatchNo())) + dispatches.computeIfAbsent(item.dispatchNo(), ignored -> new ArrayList<>()).add(item); + var redispatches = new ArrayList(); + for (var entry : dispatches.entrySet()) { + var distinctItems = new LinkedHashMap(); + var deliveries = new LinkedHashMap(); + for (var item : entry.getValue()) { + if (item.dispatchItemId() == null || item.selectedPackages() == null || item.selectedPackages() < 0 + || item.pickedPackages() == null || item.pickedPackages() < 0) + throw conflict("退回重派关联缺少有效排车数量"); + distinctItems.putIfAbsent(item.dispatchItemId(), item); + if (StringUtils.hasText(item.deliveryNo())) deliveries.putIfAbsent(item.deliveryNo(), + new ExceptionOrderResponses.RelatedDelivery(item.deliveryNo(), item.deliveryStatus())); + } + long selected = distinctItems.values().stream().mapToLong(item -> item.selectedPackages()).sum(); + long picked = distinctItems.values().stream().mapToLong(item -> item.pickedPackages()).sum(); + redispatches.add(new ExceptionOrderResponses.Redispatch(entry.getKey(), entry.getValue().get(0).dispatchStatus(), + selected, picked, List.copyOf(deliveries.values()))); + } + receipts.add(new ExceptionOrderResponses.ReturnReceipt(first.receiptNo(), first.receivedPackages(), first.receivedAt(), + List.copyOf(redispatches))); + } + return List.copyOf(receipts); } public ExceptionOrderResponses.UploadedEvidence uploadEvidence(String customerId, String operatorId, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/ReturnStockReceiptMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/ReturnStockReceiptMapper.java index 7183b318f..deb20a909 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/ReturnStockReceiptMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/ReturnStockReceiptMapper.java @@ -4,9 +4,40 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.lastleg.inventory.entity.ReturnStockReceipt; import org.apache.ibatis.annotations.*; import java.util.List; +import java.time.LocalDateTime; @Mapper public interface ReturnStockReceiptMapper extends BaseMapper { + @Select(""" + SELECT r.id AS receiptId,r.receipt_no AS receiptNo,r.received_packages AS receivedPackages, + r.received_at AS receivedAt,di.id AS dispatchItemId,d.dispatch_no AS dispatchNo, + d.status AS dispatchStatus,di.package_count AS selectedPackages, + COALESCE((SELECT SUM(a.packages) FROM ll_dispatch_pda_pick_order a + JOIN ll_dispatch_pda_pick p ON p.id=a.pick_id AND BINARY p.customer_id=BINARY a.customer_id + WHERE BINARY a.customer_id=BINARY r.customer_id AND a.return_receipt_id=r.id + AND p.dispatch_item_id=di.id),0) AS pickedPackages, + delivery.delivery_no AS deliveryNo,delivery.status AS deliveryStatus + FROM ll_return_stock_receipt r + LEFT JOIN ll_dispatch_item di ON di.return_receipt_id=r.id AND BINARY di.customer_id=BINARY r.customer_id + LEFT JOIN ll_dispatch_order d ON d.id=di.dispatch_order_id AND BINARY d.customer_id=BINARY r.customer_id + LEFT JOIN ll_delivery_order delivery ON delivery.dispatch_order_id=d.id + AND BINARY delivery.customer_id=BINARY r.customer_id + AND BINARY delivery.delivery_group_key=BINARY CASE + WHEN di.destination_type='PRIVATE' AND NULLIF(TRIM(di.delivery_address),'') IS NOT NULL + THEN CONCAT('PRIVATE:',TRIM(di.delivery_address),':',TRIM(COALESCE(di.postal_code,''))) + WHEN COALESCE(NULLIF(TRIM(di.destination_warehouse_code),''),NULLIF(TRIM(di.fba_warehouse_code),'')) IS NOT NULL + THEN CONCAT('CODE:',UPPER(COALESCE(NULLIF(TRIM(di.destination_warehouse_code),''),NULLIF(TRIM(di.fba_warehouse_code),'')))) + WHEN NULLIF(TRIM(di.destination_warehouse_id),'') IS NOT NULL THEN CONCAT('ID:',TRIM(di.destination_warehouse_id)) + ELSE NULL END + WHERE BINARY r.customer_id=BINARY #{customerId} AND r.exception_order_id=#{exceptionId} + ORDER BY r.received_at,r.id,d.id,di.id,delivery.id + """) + List history(@Param("customerId") String customerId, @Param("exceptionId") Long exceptionId); + + record ReturnHistoryRow(Long receiptId, String receiptNo, Long receivedPackages, LocalDateTime receivedAt, + Long dispatchItemId, String dispatchNo, String dispatchStatus, Integer selectedPackages, + Long pickedPackages, String deliveryNo, String deliveryStatus) {} + @Select("SELECT id FROM ll_order WHERE customer_id=#{customerId} AND id=#{id} FOR UPDATE") Long lockOrder(@Param("customerId") String customerId, @Param("id") Long id); record ReturnOwner(Long exceptionOrderId, Long orderId, Long inboundOrderId) { } 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 d1aea61fb..c0ce7627f 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -871,10 +871,10 @@ paths: /api/platform/v1/last-leg/exception-orders/{exceptionNo}: get: summary: 查询异常订单详情与冻结退回明细 - description: 权限 platform:overseas-warehouse:exception-order:list;返回 version 和服务端 allowedActions。 + description: 权限 platform:overseas-warehouse:exception-order:list;返回 version 和服务端 allowedActions。returnReceipts 保留当前异常全部实收批次及按冻结退回凭据关联的重派排车/派送,包括已耗尽库存;每批及每次重派分别返回数量,不能把候选库存或可变尾程当前单号当历史关联。 security: [{ bearerAuth: [] }] parameters: [{ name: exceptionNo, in: path, required: true, schema: { type: string } }] - responses: { '200': { description: 异常订单详情 }, '404': { description: 异常订单不存在 } } + responses: { '200': { description: 异常订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/ExceptionOrderDetailResponse' } } } }, '404': { description: 异常订单不存在 } } /api/platform/v1/last-leg/exception-orders/{exceptionNo}/start-processing: post: summary: 开始处理异常订单 @@ -1748,6 +1748,46 @@ paths: responses: { '200': { description: 退货箱唛打印数据 }, '404': { description: 当前仓没有该退货箱 } } components: schemas: + ExceptionOrderDetailResponse: + type: object + properties: + code: { type: string } + data: + type: object + required: [order, returnSources, returnReceipts] + properties: + order: { type: object } + returnSources: { type: array, items: { type: object }, description: 当前异常剩余可收来源,不是实收历史 } + returnReceipts: + type: array + description: 全部真实退回实收批次;无实收时为空数组,不因库存耗尽而隐藏 + items: + type: object + required: [receiptNo, receivedPackages, receivedAt, redispatches] + properties: + receiptNo: { type: string } + receivedPackages: { type: string, pattern: '^[0-9]+$', description: 本批实际实收箱数,Java long按统一JSON合同返回字符串 } + receivedAt: { type: string, format: date-time } + redispatches: + type: array + description: 本凭据关联的全部重派排车;尚未关联时为空数组 + items: + type: object + required: [dispatchNo, status, selectedPackages, pickedPackages, deliveries] + properties: + dispatchNo: { type: string } + status: { type: string } + selectedPackages: { type: string, pattern: '^[0-9]+$', description: 本凭据在该排车冻结选定箱数,按排车项去重 } + pickedPackages: { type: string, pattern: '^[0-9]+$', description: 本凭据在该排车的实际PDA拣货箱数,未拣为0 } + deliveries: + type: array + description: 仅关联该退回排车项目的地的派送,不返回同排车其他目的地单据 + items: + type: object + required: [deliveryNo, status] + properties: + deliveryNo: { type: string } + status: { type: string } DeliveryOrderRow: type: object required: [deliveryNo, packageCount, signedPackages] diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionReturnHistoryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionReturnHistoryTest.java new file mode 100644 index 000000000..c90f3ba17 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionReturnHistoryTest.java @@ -0,0 +1,76 @@ +package com.baiyue.lastleg.exceptionorder.service; + +import com.baiyue.lastleg.exceptionorder.dto.ExceptionOrderResponses; +import com.baiyue.lastleg.exceptionorder.entity.ExceptionOrder; +import com.baiyue.lastleg.exceptionorder.mapper.ExceptionOrderMapper; +import com.baiyue.lastleg.exceptionorder.mapper.ExceptionReturnItemMapper; +import com.baiyue.lastleg.inventory.mapper.ReturnStockReceiptMapper; +import com.baiyue.lastleg.inventory.mapper.ReturnStockReceiptMapper.ReturnHistoryRow; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.server.ResponseStatusException; +import java.time.LocalDateTime; +import java.util.List; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class ExceptionReturnHistoryTest { + private final ExceptionOrderService service = new ExceptionOrderService(); + private final LocalDateTime time = LocalDateTime.of(2026, 10, 3, 23, 24); + + private List history(List rows) { + return ReflectionTestUtils.invokeMethod(service, "receiptHistory", rows); + } + private ReturnHistoryRow row(long receipt, String dispatch, long item, String delivery, int quantity) { + return new ReturnHistoryRow(receipt, "RSR-" + receipt, 2L, time, item, dispatch, "DEPARTED", + quantity, (long) quantity, delivery, delivery == null ? null : "DELIVERED"); + } + @Test void retainsAllBatchesAndMultipleRedispatchesWithoutMultiplyingItemQuantitiesByDeliveryRows() { + var result = history(List.of(row(1, "DS-A", 11, "DL-A", 1), row(1, "DS-A", 11, "DL-B", 1), + row(1, "DS-B", 12, "DL-C", 1), new ReturnHistoryRow(2L, "RSR-2", 1L, time.plusMinutes(1), + null, null, null, null, 0L, null, null))); + assertEquals(2, result.size()); assertEquals(2, result.get(0).redispatches().size()); + var first = result.get(0).redispatches().get(0); + assertEquals(1, first.selectedPackages()); assertEquals(1, first.pickedPackages()); + assertEquals(List.of("DL-A", "DL-B"), first.deliveries().stream().map(ExceptionOrderResponses.RelatedDelivery::deliveryNo).toList()); + assertEquals(1, result.get(1).receivedPackages()); assertTrue(result.get(1).redispatches().isEmpty()); + } + @Test void sameDispatchAcrossDistinctReceiptBatchesRemainsSeparate() { + var result = history(List.of(row(1, "DS-A", 11, "DL-A", 1), row(2, "DS-A", 12, "DL-A", 1))); + assertEquals(2, result.size()); assertEquals("RSR-1", result.get(0).receiptNo()); + assertEquals("RSR-2", result.get(1).receiptNo()); + assertEquals(1, result.get(0).redispatches().get(0).pickedPackages()); + } + @Test void currentDetailReadsOnlyResolvedCustomerAndExceptionWithoutStateCommands() { + var orders = mock(ExceptionOrderMapper.class); var returns = mock(ExceptionReturnItemMapper.class); + var receipts = mock(ReturnStockReceiptMapper.class); + ReflectionTestUtils.setField(service, "exceptionOrders", orders); + ReflectionTestUtils.setField(service, "returnItems", returns); + ReflectionTestUtils.setField(service, "returnReceipts", receipts); + var order = new ExceptionOrder(); order.setId(10L); order.setCustomerId("C"); + order.setExceptionNo("EX-10"); order.setExceptionType("RETURN"); order.setStatus("COMPLETED"); + when(orders.findByNo("C", "EX-10")).thenReturn(order); + when(returns.findByException("C", 10L)).thenReturn(List.of()); + when(receipts.history("C", 10L)).thenReturn(List.of(row(1, "DS-A", 11, "DL-A", 2))); + var result = service.detail("C", "EX-10"); + assertEquals("COMPLETED", result.order().status()); assertTrue(result.order().allowedActions().isEmpty()); + assertEquals(2, result.returnReceipts().get(0).receivedPackages()); + verify(receipts).history("C", 10L); verify(orders, never()).updateById(any(ExceptionOrder.class)); + assertThrows(ResponseStatusException.class, () -> service.detail("OTHER", "EX-10")); + verify(receipts, never()).history(eq("OTHER"), any()); + } + @Test void historyQueryKeepsConsumedReceiptsAndFiltersDeliveryByCustomerAndItemDestination() throws Exception { + String sql = String.join(" ", ReturnStockReceiptMapper.class.getMethod("history", String.class, Long.class) + .getAnnotation(org.apache.ibatis.annotations.Select.class).value()); + assertTrue(sql.contains("r.exception_order_id=#{exceptionId}")); + assertTrue(sql.contains("BINARY r.customer_id=BINARY #{customerId}")); + assertTrue(sql.contains("BINARY delivery.delivery_group_key=BINARY CASE")); + assertTrue(sql.contains("WHEN di.destination_type='PRIVATE'")); + assertTrue(sql.contains("a.return_receipt_id=r.id")); + assertFalse(sql.contains("available_packages")); assertFalse(sql.contains("ll_order o")); + } + @Test void receiptBusinessNumberAndPositiveReceivedQuantityCannotBeGuessed() { + assertThrows(ResponseStatusException.class, () -> history(List.of(new ReturnHistoryRow(1L, null, 2L, time, + null, null, null, null, 0L, null, null)))); + } +}