feat(lastleg): retain receipt and redispatch history on exceptions

This commit is contained in:
2026-10-04 10:45:05 +08:00
parent 446fad33d0
commit dc993c9713
5 changed files with 194 additions and 4 deletions
@@ -27,7 +27,12 @@ public final class ExceptionOrderResponses {
public record ReturnSource(Long outboundPalletItemId, Long inventoryPositionId, public record ReturnSource(Long outboundPalletItemId, Long inventoryPositionId,
String palletNo, String skuCode, long maxReturnPackages) {} String palletNo, String skuCode, long maxReturnPackages) {}
public record Detail(Row order, List<ReturnSource> returnSources) {} public record Detail(Row order, List<ReturnSource> returnSources, List<ReturnReceipt> returnReceipts) {}
public record ReturnReceipt(String receiptNo, long receivedPackages, LocalDateTime receivedAt,
List<Redispatch> redispatches) {}
public record Redispatch(String dispatchNo, String status, long selectedPackages, long pickedPackages,
List<RelatedDelivery> deliveries) {}
public record RelatedDelivery(String deliveryNo, String status) {}
public record CommandResult(Long id, String exceptionNo, String status, long version, public record CommandResult(Long id, String exceptionNo, String status, long version,
Long inventoryAdjustmentId, long restoredPackages, Long inventoryAdjustmentId, long restoredPackages,
LocalDateTime completedAt, boolean replayed) {} LocalDateTime completedAt, boolean replayed) {}
@@ -83,6 +83,7 @@ public class ExceptionOrderService {
@Resource private InventoryCommandService inventoryCommands; @Resource private InventoryCommandService inventoryCommands;
@Resource private ExceptionEvidenceFileGateway evidenceFiles; @Resource private ExceptionEvidenceFileGateway evidenceFiles;
@Resource private ObjectMapper objectMapper; @Resource private ObjectMapper objectMapper;
@Resource private com.baiyue.lastleg.inventory.mapper.ReturnStockReceiptMapper returnReceipts;
public PageResult<ExceptionOrderResponses.Row> page(String scopedCustomerId, ExceptionOrderRequests.Query query) { public PageResult<ExceptionOrderResponses.Row> page(String scopedCustomerId, ExceptionOrderRequests.Query query) {
String customerId = customerScope(scopedCustomerId, query.customerId()); String customerId = customerScope(scopedCustomerId, query.customerId());
@@ -114,7 +115,44 @@ public class ExceptionOrderService {
public ExceptionOrderResponses.Detail detail(String customerId, String exceptionNo) { public ExceptionOrderResponses.Detail detail(String customerId, String exceptionNo) {
ExceptionOrder order = exceptionOrders.findByNo(customerId, exceptionNo); ExceptionOrder order = exceptionOrders.findByNo(customerId, exceptionNo);
if (order == null) throw notFound("异常订单不存在"); 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<ExceptionOrderResponses.ReturnReceipt> receiptHistory(
List<com.baiyue.lastleg.inventory.mapper.ReturnStockReceiptMapper.ReturnHistoryRow> rows) {
var grouped = new LinkedHashMap<Long, List<com.baiyue.lastleg.inventory.mapper.ReturnStockReceiptMapper.ReturnHistoryRow>>();
for (var item : rows) grouped.computeIfAbsent(item.receiptId(), ignored -> new ArrayList<>()).add(item);
var receipts = new ArrayList<ExceptionOrderResponses.ReturnReceipt>();
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<String, List<com.baiyue.lastleg.inventory.mapper.ReturnStockReceiptMapper.ReturnHistoryRow>>();
for (var item : batch) if (StringUtils.hasText(item.dispatchNo()))
dispatches.computeIfAbsent(item.dispatchNo(), ignored -> new ArrayList<>()).add(item);
var redispatches = new ArrayList<ExceptionOrderResponses.Redispatch>();
for (var entry : dispatches.entrySet()) {
var distinctItems = new LinkedHashMap<Long, com.baiyue.lastleg.inventory.mapper.ReturnStockReceiptMapper.ReturnHistoryRow>();
var deliveries = new LinkedHashMap<String, ExceptionOrderResponses.RelatedDelivery>();
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, public ExceptionOrderResponses.UploadedEvidence uploadEvidence(String customerId, String operatorId,
@@ -4,9 +4,40 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.inventory.entity.ReturnStockReceipt; import com.baiyue.lastleg.inventory.entity.ReturnStockReceipt;
import org.apache.ibatis.annotations.*; import org.apache.ibatis.annotations.*;
import java.util.List; import java.util.List;
import java.time.LocalDateTime;
@Mapper @Mapper
public interface ReturnStockReceiptMapper extends BaseMapper<ReturnStockReceipt> { public interface ReturnStockReceiptMapper extends BaseMapper<ReturnStockReceipt> {
@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<ReturnHistoryRow> 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") @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); Long lockOrder(@Param("customerId") String customerId, @Param("id") Long id);
record ReturnOwner(Long exceptionOrderId, Long orderId, Long inboundOrderId) { } record ReturnOwner(Long exceptionOrderId, Long orderId, Long inboundOrderId) { }
@@ -871,10 +871,10 @@ paths:
/api/platform/v1/last-leg/exception-orders/{exceptionNo}: /api/platform/v1/last-leg/exception-orders/{exceptionNo}:
get: get:
summary: 查询异常订单详情与冻结退回明细 summary: 查询异常订单详情与冻结退回明细
description: 权限 platform:overseas-warehouse:exception-order:list;返回 version 和服务端 allowedActions。 description: 权限 platform:overseas-warehouse:exception-order:list;返回 version 和服务端 allowedActions。returnReceipts 保留当前异常全部实收批次及按冻结退回凭据关联的重派排车/派送,包括已耗尽库存;每批及每次重派分别返回数量,不能把候选库存或可变尾程当前单号当历史关联。
security: [{ bearerAuth: [] }] security: [{ bearerAuth: [] }]
parameters: [{ name: exceptionNo, in: path, required: true, schema: { type: string } }] 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: /api/platform/v1/last-leg/exception-orders/{exceptionNo}/start-processing:
post: post:
summary: 开始处理异常订单 summary: 开始处理异常订单
@@ -1748,6 +1748,46 @@ paths:
responses: { '200': { description: 退货箱唛打印数据 }, '404': { description: 当前仓没有该退货箱 } } responses: { '200': { description: 退货箱唛打印数据 }, '404': { description: 当前仓没有该退货箱 } }
components: components:
schemas: 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: DeliveryOrderRow:
type: object type: object
required: [deliveryNo, packageCount, signedPackages] required: [deliveryNo, packageCount, signedPackages]
@@ -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<ExceptionOrderResponses.ReturnReceipt> history(List<ReturnHistoryRow> 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))));
}
}