feat(lastleg): retain receipt and redispatch history on exceptions
This commit is contained in:
+6
-1
@@ -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<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,
|
||||
Long inventoryAdjustmentId, long restoredPackages,
|
||||
LocalDateTime completedAt, boolean replayed) {}
|
||||
|
||||
+39
-1
@@ -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<ExceptionOrderResponses.Row> 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<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,
|
||||
|
||||
+31
@@ -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<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")
|
||||
Long lockOrder(@Param("customerId") String customerId, @Param("id") Long id);
|
||||
record ReturnOwner(Long exceptionOrderId, Long orderId, Long inboundOrderId) { }
|
||||
|
||||
@@ -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]
|
||||
|
||||
+76
@@ -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))));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user