diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnOrderMapper.java index 41557810a..717ef9ac0 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnOrderMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnOrderMapper.java @@ -9,6 +9,10 @@ import java.util.List; @Mapper public interface ReturnOrderMapper extends BaseMapper { + @Select("SELECT * FROM ll_return_order WHERE customer_id=#{customerId} AND lastleg_order_id=#{tailOrderId} LIMIT 1 FOR UPDATE") + ReturnOrder lockTailOrder(@Param("customerId") String customerId, @Param("tailOrderId") long tailOrderId); + @Select("SELECT o.* FROM ll_return_order o WHERE o.id=(SELECT r.return_order_id FROM ll_return_receipt r WHERE r.receipt_no=#{receiptNo} LIMIT 1) FOR UPDATE") + ReturnOrder lockReceiptOrder(@Param("receiptNo") String receiptNo); @Select("SELECT * FROM ll_return_order WHERE customer_id=#{customerId} AND overseas_warehouse_id=#{warehouseId} AND status='COMPLETED' ORDER BY created_at,id") List completedCustomer(@Param("customerId") String customerId, @Param("warehouseId") String warehouseId); @Select("SELECT * FROM ll_return_order WHERE customer_id=#{customerId} AND idempotency_key=#{key} LIMIT 1") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnCartonCountService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnCartonCountService.java index 57879c8ea..2e6d3cd04 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnCartonCountService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnCartonCountService.java @@ -19,6 +19,8 @@ import java.util.*; @Service public class ReturnCartonCountService { @Resource private ReturnReceiptMapper receipts; + @Resource private ReturnOrderMapper orders; + @Resource private ReturnReceiptService returnReceiptService; @Resource private ReturnReceiptItemMapper receiptItems; @Resource private ReturnInventoryCartonMapper cartons; @Resource private ReturnInventoryCartonItemMapper items; @@ -100,6 +102,7 @@ public class ReturnCartonCountService { @Transactional public Task confirm(PdaWarehouseIdentity identity, String receiptNo, Confirm request) { var session = lockSession(identity); + orders.lockReceiptOrder(receiptNo); ReturnReceipt receipt = receipts.lockCountReceipt(receiptNo); if (receipt == null) throw error(HttpStatus.NOT_FOUND, "托盘签收任务不存在"); List boxes = cartons.lockReceipt(receipt.getId()); @@ -148,6 +151,11 @@ public class ReturnCartonCountService { long delta = newCounts.getOrDefault(sku, 0L) - oldCounts.getOrDefault(sku, 0L); if (delta != 0 && balances.adjustCartonQuantity(receipt.getCustomerId(), boxes.get(0).getOverseasWarehouseId(), sku, delta) != 1) throw error(HttpStatus.CONFLICT, "SKU 箱数余额已变化,请重新核对"); } + for (ReturnReceiptItem line : receiptItems.listReceipt(receipt.getId())) { + line.setBoxCount(newCounts.getOrDefault(line.getSkuCode(), 0L)); + receiptItems.updateById(line); + } + returnReceiptService.refreshCountedTailCargo(receipt); if (sessions.release(identity.operatorKey(), receipt.getId()) != 1) throw error(HttpStatus.CONFLICT, "清点会话已变化,请重新核对"); Task result = task(receipt, boxes); ReturnInventoryOperation operation = new ReturnInventoryOperation(); operation.setId(IdWorker.getId()); operation.setOperatorId(identity.operatorKey()); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptService.java index accdef6c8..ae6c0c841 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptService.java @@ -201,15 +201,11 @@ public class ReturnReceiptService { result.getSize(), result.getTotal(), result.getPages()); } - /** Backfill tail-leg cargo for receipts created before tail synchronization was added. */ + /** Reconcile legacy forecast totals and receipt details without adding the facts twice. */ @Transactional public void ensureTailCargo(String customerId, long tailOrderId) { if (lastlegOrderDetails == null) return; - long existing = lastlegOrderDetails.selectCount(new LambdaQueryWrapper() - .eq(LastlegOrderDetail::getCustomerId, customerId).eq(LastlegOrderDetail::getOrderId, tailOrderId)); - if (existing > 0) return; - ReturnOrder order = orders.selectOne(new LambdaQueryWrapper() - .eq(ReturnOrder::getCustomerId, customerId).eq(ReturnOrder::getLastlegOrderId, tailOrderId)); + ReturnOrder order = orders.lockTailOrder(customerId, tailOrderId); if (order == null) return; Map bySku = orderItems.listOrder(order.getId()).stream() .collect(java.util.stream.Collectors.toMap(ReturnOrderItem::getSkuCode, value -> value, (left, right) -> left, LinkedHashMap::new)); @@ -665,10 +661,25 @@ public class ReturnReceiptService { } /** Keep the platform tail-leg order detail in sync with PDA receipt facts. */ + @Transactional + public void refreshCountedTailCargo(ReturnReceipt receipt) { + ReturnOrder order = orders.selectById(receipt.getReturnOrderId()); + if (order == null) return; + Map bySku = orderItems.listOrder(order.getId()).stream() + .collect(java.util.stream.Collectors.toMap(ReturnOrderItem::getSkuCode, item -> item)); + List allocations = receiptItems.listReceipt(receipt.getId()).stream() + .map(line -> new Allocation(bySku.get(line.getSkuCode()), value(line.getReceivedQuantity()))) + .filter(line -> line.item() != null).toList(); + syncTailCargo(order, receipt, allocations, LocalDateTime.now()); + } + private void syncTailCargo(ReturnOrder order, ReturnReceipt receipt, List allocations, LocalDateTime now) { if (lastlegOrderDetails == null || order.getLastlegOrderId() == null) return; LastlegOrder tail = lastlegOrders.selectById(order.getLastlegOrderId()); if (tail == null || !Objects.equals(order.getCustomerId(), tail.getCustomerId())) return; + lastlegOrderDetails.delete(new LambdaQueryWrapper() + .eq(LastlegOrderDetail::getCustomerId, order.getCustomerId()).eq(LastlegOrderDetail::getOrderId, tail.getId()) + .likeRight(LastlegOrderDetail::getSourceInboundDetailId, "RETURN_ORDER:")); Map quantities = new LinkedHashMap<>(); Map boxes = new LinkedHashMap<>(); Map names = new LinkedHashMap<>(); @@ -680,31 +691,40 @@ public class ReturnReceiptService { for (ReturnReceiptItem line : receiptItems.listReceipt(receipt.getId())) { boxes.merge(line.getSkuCode(), value(line.getBoxCount()), Math::addExact); } - int packageCount = 0; boolean firstSku = true; for (Map.Entry entry : quantities.entrySet()) { int skuBoxes = Math.toIntExact("PARCEL".equals(receipt.getUnitType()) ? 1 : boxes.getOrDefault(entry.getKey(), 0L)); if (skuBoxes <= 0) skuBoxes = 1; - LastlegOrderDetail detail = new LastlegOrderDetail(); - detail.setId(IdWorker.getId()); detail.setCustomerId(order.getCustomerId()); detail.setOrderId(tail.getId()); - detail.setSourceInboundDetailId("RETURN_RECEIPT:" + receipt.getId() + ":" + entry.getKey()); + String sourceKey = "RETURN_RECEIPT:" + receipt.getId() + ":" + entry.getKey(); + LastlegOrderDetail detail = lastlegOrderDetails.selectOne(new LambdaQueryWrapper() + .eq(LastlegOrderDetail::getCustomerId, order.getCustomerId()).eq(LastlegOrderDetail::getOrderId, tail.getId()) + .eq(LastlegOrderDetail::getSourceInboundDetailId, sourceKey).last("LIMIT 1")); + boolean newDetail = detail == null; + if (newDetail) { detail = new LastlegOrderDetail(); detail.setId(IdWorker.getId()); } + detail.setCustomerId(order.getCustomerId()); detail.setOrderId(tail.getId()); + detail.setSourceInboundDetailId(sourceKey); detail.setSkuCode(entry.getKey()); detail.setProductNameZh(names.get(entry.getKey())); detail.setPackageCount(skuBoxes); detail.setProductQuantity(Math.toIntExact(entry.getValue())); detail.setPalletCount("PALLET".equals(receipt.getUnitType()) ? 1 : 0); detail.setGrossWeightKg(firstSku ? receipt.getWeight() : null); detail.setVolumeCbm(firstSku && receipt.getLength() != null && receipt.getWidth() != null && receipt.getHeight() != null ? receipt.getLength().multiply(receipt.getWidth()).multiply(receipt.getHeight()).divide(java.math.BigDecimal.valueOf(1_000_000)) : null); detail.setShippingMark(receipt.getCartonMark()); - lastlegOrderDetails.insert(detail); - packageCount = Math.addExact(packageCount, skuBoxes); + if (newDetail) lastlegOrderDetails.insert(detail); else lastlegOrderDetails.updateById(detail); firstSku = false; } - tail.setPackageCount(Math.addExact(tail.getPackageCount() == null ? 0 : tail.getPackageCount(), packageCount)); - tail.setReceivedPackageCount(Math.addExact(tail.getReceivedPackageCount() == null ? 0 : tail.getReceivedPackageCount(), packageCount)); - tail.setProductQuantity(Math.addExact(tail.getProductQuantity() == null ? 0 : tail.getProductQuantity(), Math.toIntExact(allocations.stream().mapToLong(Allocation::quantity).sum()))); - if ("PALLET".equals(receipt.getUnitType())) tail.setPalletCount(Math.addExact(tail.getPalletCount() == null ? 0 : tail.getPalletCount(), 1)); // Recalculate from physical carriers; signing another pallet must not overwrite the first pallet's volume. Map measuredUnits = new LinkedHashMap<>(); List signedReceipts = new ArrayList<>(receipts.listOrder(order.getId())); if (signedReceipts.stream().noneMatch(line -> Objects.equals(line.getId(), receipt.getId()))) signedReceipts.add(0, receipt); + int actualBoxes = 0, actualUnits = 0; + Set palletCodes = new LinkedHashSet<>(); + for (ReturnReceipt signed : signedReceipts) { + actualBoxes = Math.addExact(actualBoxes, "PARCEL".equals(signed.getUnitType()) ? 1 : Math.toIntExact(value(signed.getTotalBoxCount()))); + actualUnits = Math.addExact(actualUnits, Math.toIntExact(receiptItems.listReceipt(signed.getId()).stream() + .mapToLong(line -> value(line.getReceivedQuantity())).sum())); + if ("PALLET".equals(signed.getUnitType())) palletCodes.add(text(signed.getUnitCode()) == null ? signed.getReceiptNo() : signed.getUnitCode()); + } + tail.setPackageCount(actualBoxes); tail.setReceivedPackageCount(actualBoxes); + tail.setProductQuantity(actualUnits); tail.setPalletCount(palletCodes.size()); for (ReturnReceipt signed : signedReceipts) { if (signed.getLength() == null || signed.getWidth() == null || signed.getHeight() == null || "PARCEL".equals(signed.getUnitType())) continue; String code = text(signed.getUnitCode()) == null ? signed.getReceiptNo() : signed.getUnitCode(); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnCartonCountServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnCartonCountServiceTest.java index 721aae72d..bbbb95cf8 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnCartonCountServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnCartonCountServiceTest.java @@ -20,6 +20,8 @@ import static org.mockito.ArgumentMatchers.*; @ExtendWith(MockitoExtension.class) class ReturnCartonCountServiceTest { @Mock ReturnReceiptMapper receipts; + @Mock ReturnOrderMapper orders; + @Mock ReturnReceiptService returnReceiptService; @Mock ReturnReceiptItemMapper receiptItems; @Mock ReturnInventoryCartonMapper cartons; @Mock ReturnInventoryCartonItemMapper items; @@ -33,6 +35,8 @@ class ReturnCartonCountServiceTest { @BeforeEach void setup() { service = new ReturnCartonCountService(); ReflectionTestUtils.setField(service,"receipts",receipts); + ReflectionTestUtils.setField(service,"orders",orders); + ReflectionTestUtils.setField(service,"returnReceiptService",returnReceiptService); ReflectionTestUtils.setField(service,"receiptItems",receiptItems); ReflectionTestUtils.setField(service,"cartons",cartons); ReflectionTestUtils.setField(service,"items",items); @@ -76,11 +80,16 @@ class ReturnCartonCountServiceTest { assertThat(a.getCountedBy()).isEqualTo("op"); assertThat(a.getCountedAt()).isNotNull(); verify(items).deleteCarton(2L); verify(items).deleteCarton(3L); verify(items,times(2)).insert(any(ReturnInventoryCartonItem.class)); verifyNoInteractions(balances); + verify(returnReceiptService).refreshCountedTailCargo(receipt); + var countedLine = org.mockito.ArgumentCaptor.forClass(ReturnReceiptItem.class); + verify(receiptItems).updateById(countedLine.capture()); + assertThat(countedLine.getValue().getBoxCount()).isEqualTo(2); var audit = org.mockito.ArgumentCaptor.forClass(ReturnInventoryOperation.class); verify(operations).insert(audit.capture()); when(operations.lockKey("op","retry")).thenReturn(audit.getValue()); assertThat(service.confirm(identity,"RR-1",unequal(3,7))).isEqualTo(result); assertThatThrownBy(() -> service.confirm(identity,"RR-1",unequal(4,6))).isInstanceOf(ResponseStatusException.class).hasMessageContaining("幂等键"); verify(items,times(2)).deleteCarton(anyLong()); + verify(returnReceiptService, times(1)).refreshCountedTailCargo(receipt); } @Test void mixedBoxesAdjustOnlyContainingBoxCounts() { prepare(List.of(expected("SKU1",6),expected("SKU2",4))); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptServiceTest.java index 2f7592dc2..d01ba1164 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptServiceTest.java @@ -56,6 +56,32 @@ import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class ReturnReceiptServiceTest { + @Test void countedCargoReplacesForecastTotalsAndRepeatedSyncUpdatesTheSameDetail() { + var details = org.mockito.Mockito.mock(com.baiyue.lastleg.mapper.LastlegOrderDetailMapper.class); + ReflectionTestUtils.setField(service, "lastlegOrderDetails", details); + ReturnOrder order = new ReturnOrder(); order.setId(1L); order.setCustomerId("C1"); order.setLastlegOrderId(9L); + ReturnOrderItem sku = new ReturnOrderItem(); sku.setSkuCode("SKU1"); + ReturnReceipt first = new ReturnReceipt(); first.setId(2L); first.setReturnOrderId(1L); first.setUnitType("PALLET"); first.setUnitCode("PLT1"); first.setTotalBoxCount(5L); + ReturnReceipt second = new ReturnReceipt(); second.setId(3L); second.setUnitType("PALLET"); second.setUnitCode("PLT2"); second.setTotalBoxCount(2L); + var counted = new com.baiyue.lastleg.returnreceipt.entity.ReturnReceiptItem(); counted.setSkuCode("SKU1"); counted.setReceivedQuantity(10L); counted.setBoxCount(5L); + var other = new com.baiyue.lastleg.returnreceipt.entity.ReturnReceiptItem(); other.setSkuCode("SKU1"); other.setReceivedQuantity(4L); other.setBoxCount(2L); + LastlegOrder tail = new LastlegOrder(); tail.setId(9L); tail.setCustomerId("C1"); tail.setPackageCount(107); tail.setProductQuantity(202); + when(orders.selectById(1L)).thenReturn(order); when(orderItems.listOrder(1L)).thenReturn(List.of(sku)); + when(lastlegOrders.selectById(9L)).thenReturn(tail); when(receipts.listOrder(1L)).thenReturn(List.of(first, second)); + when(receiptItems.listReceipt(2L)).thenReturn(List.of(counted)); when(receiptItems.listReceipt(3L)).thenReturn(List.of(other)); + var stored = new AtomicReference(); + when(details.selectOne(any())).thenAnswer(invocation -> stored.get()); + when(details.insert(any(com.baiyue.lastleg.entity.LastlegOrderDetail.class))).thenAnswer(invocation -> { stored.set(invocation.getArgument(0)); return 1; }); + service.refreshCountedTailCargo(first); + Long detailId = stored.get().getId(); + service.refreshCountedTailCargo(first); + assertEquals(7, tail.getPackageCount()); assertEquals(7, tail.getReceivedPackageCount()); + assertEquals(14, tail.getProductQuantity()); assertEquals(2, tail.getPalletCount()); + assertEquals(5, stored.get().getPackageCount()); assertEquals(10, stored.get().getProductQuantity()); + assertEquals(detailId, stored.get().getId()); + verify(details, times(1)).insert(any(com.baiyue.lastleg.entity.LastlegOrderDetail.class)); + verify(details, times(1)).updateById(any(com.baiyue.lastleg.entity.LastlegOrderDetail.class)); + } @Mock ReturnOrderMapper orders; @Mock ReturnOrderItemMapper orderItems; @Mock ReturnReceiptMapper receipts;