From b371fab53d5a689678ffaa2a3b4f6b94fb2e931b Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 5 Oct 2026 02:47:22 +0800 Subject: [PATCH] fix(transfer): settle linked relabel fees after final receipt --- .../service/RelabelApplicationService.java | 69 +++++++++++---- .../service/WarehouseTransferService.java | 4 +- .../RelabelApplicationServiceTest.java | 86 +++++++++++++++++++ .../service/WarehouseTransferServiceTest.java | 10 +++ 4 files changed, 151 insertions(+), 18 deletions(-) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java index a793cae54..0051f4775 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java @@ -614,22 +614,7 @@ public class RelabelApplicationService implements WalletResultHandler { && !preDispatched) { releaseInventory(customerId, operatorId, application); } - if (!"NOT_REQUIRED".equals(application.getWalletStatus())) { - BigDecimal quantity = BigDecimal.valueOf(Objects.requireNonNullElse(application.getTotalPackageCount(), 0)); - feeSources.publishApproved(operatorId, "RELABEL:" + relabelNo, - new OverseasFeeSourceDtos.SaveRequest(customerId, linkedOrders.get(0).getCustomerName(), - "OVERSEAS_RELABEL_FEE", "RELABEL_ORDER", application.getId(), relabelNo, - application.getLastlegOrderId(), null, null, null, null, null, - parseLong(application.getOverseasWarehouseId()), "RELABEL_SERVICE", "换标费", "RECEIVABLE", - null, null, null, null, null, null, null, List.of(), LocalDate.now(), - application.getChargeCurrency(), application.getChargeAmount(), - quantity.signum() > 0 ? quantity : null, quantity.signum() > 0 ? "BOX" : null, - quantity.signum() > 0 ? application.getChargeAmount().divide(quantity, 6, java.math.RoundingMode.HALF_UP) : null, - null, null)); - walletCommands.publish("SETTLE", Long.parseLong(customerId), "RELABEL_ORDER", relabelNo, null, - "换标完成,结算费用", 2L); - application.setWalletStatus("SETTLE_PENDING"); applications.updateById(application); - } + publishCompletionFeesAndSettlement(application, operatorId, linkedOrders); } else if (Set.of("REJECTED", "CANCELLED").contains(next)) { releaseInventory(customerId, operatorId, application); if ("FROZEN".equals(application.getWalletStatus())) { @@ -795,6 +780,58 @@ public class RelabelApplicationService implements WalletResultHandler { insertHistory(customerId, application.getId(), before, "PROCESSING", operatorId, remark, LocalDateTime.now()); } + @Transactional + public void completeWarehouseTransferFulfillment(String customerId, String relabelNo, Long transferId, + String operatorId) { + RelabelApplication application = applications.lockNo(customerId, relabelNo); + WarehouseTransferOrder transfer = warehouseTransfers.lockByRelabelNo(customerId, relabelNo); + if (application == null || transfer == null || !customerId.equals(application.getCustomerId()) + || !customerId.equals(transfer.getCustomerId()) || !Objects.equals(transfer.getId(), transferId) + || !Boolean.TRUE.equals(transfer.getNeedRelabel()) || !"RECEIVED".equals(transfer.getStatus()) + || !Objects.equals(application.getId(), transfer.getRelabelApplicationId()) + || !Objects.equals(application.getLastlegOrderId(), transfer.getLastlegOrderId()) + || !Objects.equals(application.getCustomerOrderNo(), transfer.getTransferNo()) + || application.getCustomerConfirmedAt() == null || transfer.getRelabelCompletedAt() == null) { + throw conflict("本票换标完成归属或客户确认不一致"); + } + // Completion replay has no side effects, including for an old TEST row missing settlement. + if ("COMPLETED".equals(application.getStatus())) return; + if (!READY_FOR_OUTBOUND.equals(application.getStatus()) + || !"OUTBOUNDED".equals(application.getFulfillmentStatus()) + || !Set.of("FROZEN", "NOT_REQUIRED").contains(application.getWalletStatus())) { + throw conflict("本票换标尚未完成出库或资金冻结"); + } + List linkedOrders = tailOrders(application); + if (linkedOrders.isEmpty()) throw conflict("Linked tail-leg order is missing"); + LocalDateTime now = LocalDateTime.now(); + application.setStatus("COMPLETED"); application.setFulfillmentStatus("COMPLETED"); + application.setUpdatedAt(now); applications.updateById(application); + // The original transfer already released/transformed/shipped its own stock; never release it again. + publishCompletionFeesAndSettlement(application, operatorId, linkedOrders); + insertHistory(customerId, application.getId(), READY_FOR_OUTBOUND, "COMPLETED", operatorId, + "Warehouse transfer received; relabel fulfillment completed", now); + } + + private void publishCompletionFeesAndSettlement(RelabelApplication application, String operatorId, + List linkedOrders) { + if ("NOT_REQUIRED".equals(application.getWalletStatus())) return; + BigDecimal quantity = BigDecimal.valueOf(Objects.requireNonNullElse(application.getTotalPackageCount(), 0)); + String customerId = application.getCustomerId(), relabelNo = application.getRelabelNo(); + feeSources.publishApproved(operatorId, "RELABEL:" + relabelNo, + new OverseasFeeSourceDtos.SaveRequest(customerId, linkedOrders.get(0).getCustomerName(), + "OVERSEAS_RELABEL_FEE", "RELABEL_ORDER", application.getId(), relabelNo, + application.getLastlegOrderId(), null, null, null, null, null, + parseLong(application.getOverseasWarehouseId()), "RELABEL_SERVICE", "换标费", "RECEIVABLE", + null, null, null, null, null, null, null, List.of(), LocalDate.now(), + application.getChargeCurrency(), application.getChargeAmount(), + quantity.signum() > 0 ? quantity : null, quantity.signum() > 0 ? "BOX" : null, + quantity.signum() > 0 ? application.getChargeAmount().divide(quantity, 6, java.math.RoundingMode.HALF_UP) : null, + null, null)); + walletCommands.publish("SETTLE", Long.parseLong(customerId), "RELABEL_ORDER", relabelNo, null, + "换标完成,结算费用", 2L); + application.setWalletStatus("SETTLE_PENDING"); applications.updateById(application); + } + @Transactional public RelabelResponses.Detail confirmCompletion(String customerId, String relabelNo, String operatorId, RelabelRequests.CustomerConfirm request) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java index 0d13ee870..eb3f5a7e8 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java @@ -1283,8 +1283,8 @@ public class WarehouseTransferService { RelabelApplication linked = relabelApplications.lockNo(customerId, transfer.getRelabelNo()); if (linked == null || !Objects.equals(linked.getId(), transfer.getRelabelApplicationId())) throw conflict("本票换标完成归属不一致"); - linked.setStatus("COMPLETED"); linked.setFulfillmentStatus("COMPLETED"); - linked.setUpdatedAt(now); relabelApplications.updateById(linked); + relabelService.completeWarehouseTransferFulfillment(customerId, transfer.getRelabelNo(), + transfer.getId(), operatorId); } return detail(customerId, transferNo); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java index d585b8d87..a86a0e1ff 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java @@ -54,7 +54,9 @@ import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.never; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.verify; @@ -809,6 +811,90 @@ class RelabelApplicationServiceTest { return application; } + @Test + void receivedTransferPublishesFrozenQuoteFeeAndSettleOnceWithoutReleasingStockAgain() { + RelabelApplication app = transferCompletionFixture(); + service.completeWarehouseTransferFulfillment("10001", app.getRelabelNo(), 91L, "operator-1"); + service.completeWarehouseTransferFulfillment("10001", app.getRelabelNo(), 91L, "operator-1"); + ArgumentCaptor fee = + ArgumentCaptor.forClass(com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.SaveRequest.class); + verify(feeSources).publishApproved(eq("operator-1"), eq("RELABEL:FBR-CLOSURE"), fee.capture()); + assertEquals(new BigDecimal("0.01"), fee.getValue().amount()); + assertEquals("CNY", fee.getValue().currency()); assertEquals("RELABEL_ORDER", fee.getValue().businessType()); + assertEquals("FBR-CLOSURE", fee.getValue().businessNo()); assertEquals(BigDecimal.ONE, fee.getValue().chargeableQuantity()); + verify(walletCommands).publish(eq("SETTLE"), eq(10001L), eq("RELABEL_ORDER"), eq("FBR-CLOSURE"), + isNull(), eq("换标完成,结算费用"), eq(2L)); + assertEquals("COMPLETED", app.getStatus()); assertEquals("COMPLETED", app.getFulfillmentStatus()); + assertEquals("SETTLE_PENDING", app.getWalletStatus()); assertEquals(new BigDecimal("0.01"), app.getChargeAmount()); + assertEquals("CNY", app.getChargeCurrency()); + verify(inventoryCommands, never()).release(any(), any(), any()); + verify(inventoryCommands, never()).releaseTransferRelabel(any(), any(), any(), any()); + verify(transferRelabelCompletion, never()).complete(any(), any(), any(), any(), any()); + verify(histories, times(1)).insert(any(RelabelStatusHistory.class)); + } + + @Test + void receivedTransferWithNoRequiredFundsCompletesWithoutWalletOrFeeCommands() { + RelabelApplication app = transferCompletionFixture(); app.setWalletStatus("NOT_REQUIRED"); app.setChargeAmount(BigDecimal.ZERO); + service.completeWarehouseTransferFulfillment("10001", app.getRelabelNo(), 91L, "operator-1"); + assertEquals("COMPLETED", app.getStatus()); assertEquals("NOT_REQUIRED", app.getWalletStatus()); + verify(feeSources, never()).publishApproved(any(), any(), any()); + verify(walletCommands, never()).publish(any(), anyLong(), any(), any(), any(), any(), anyLong()); + verify(inventoryCommands, never()).release(any(), any(), any()); + } + + @Test + void transferCompletionRejectsWrongLinkUnreceivedStockMissingConfirmationOrFailedFreeze() { + RelabelApplication app = transferCompletionFixture(); + assertThrows(ResponseStatusException.class, () -> service.completeWarehouseTransferFulfillment("10001", app.getRelabelNo(), 92L, "operator-1")); + app.setCustomerConfirmedAt(null); + assertThrows(ResponseStatusException.class, () -> service.completeWarehouseTransferFulfillment("10001", app.getRelabelNo(), 91L, "operator-1")); + app.setCustomerConfirmedAt(LocalDateTime.now()); app.setWalletStatus("FREEZE_FAILED"); + assertThrows(ResponseStatusException.class, () -> service.completeWarehouseTransferFulfillment("10001", app.getRelabelNo(), 91L, "operator-1")); + app.setWalletStatus("FROZEN"); + WarehouseTransferOrder transfer = warehouseTransfers.lockByRelabelNo("10001", app.getRelabelNo()); + transfer.setStatus("PARTIALLY_RECEIVED"); + assertThrows(ResponseStatusException.class, () -> service.completeWarehouseTransferFulfillment("10001", app.getRelabelNo(), 91L, "operator-1")); + transfer.setStatus("RECEIVED"); transfer.setRelabelApplicationId(999L); + assertThrows(ResponseStatusException.class, () -> service.completeWarehouseTransferFulfillment("10001", app.getRelabelNo(), 91L, "operator-1")); + verify(feeSources, never()).publishApproved(any(), any(), any()); + verify(walletCommands, never()).publish(any(), anyLong(), any(), any(), any(), any(), anyLong()); + verify(applications, never()).updateById(any(RelabelApplication.class)); + } + + @Test + void alreadyCompletedFrozenTestRowIsNotBackfilledByCompletionReplay() { + RelabelApplication app = transferCompletionFixture(); app.setStatus("COMPLETED"); app.setFulfillmentStatus("COMPLETED"); + service.completeWarehouseTransferFulfillment("10001", app.getRelabelNo(), 91L, "operator-1"); + assertEquals("FROZEN", app.getWalletStatus()); + verify(feeSources, never()).publishApproved(any(), any(), any()); + verify(walletCommands, never()).publish(any(), anyLong(), any(), any(), any(), any(), anyLong()); + verify(applications, never()).updateById(any(RelabelApplication.class)); + } + + @Test + void feePublicationFailureNeverQueuesSettlement() { + RelabelApplication app = transferCompletionFixture(); + when(feeSources.publishApproved(any(), any(), any())).thenThrow(new IllegalStateException("fee unavailable")); + assertThrows(IllegalStateException.class, () -> service.completeWarehouseTransferFulfillment("10001", app.getRelabelNo(), 91L, "operator-1")); + verify(walletCommands, never()).publish(any(), anyLong(), any(), any(), any(), any(), anyLong()); + } + + private RelabelApplication transferCompletionFixture() { + RelabelApplication app = application(11L, "FBR-CLOSURE", "10001", "key", null, RelabelApplicationService.READY_FOR_OUTBOUND); + app.setLastlegOrderId(21L); app.setCustomerOrderNo("WTR-CLOSURE"); app.setOverseasWarehouseId("900"); + app.setCustomerConfirmedAt(LocalDateTime.now()); app.setFulfillmentStatus("OUTBOUNDED"); + app.setTotalPackageCount(1); app.setNotifiedPackageCount(1); app.setChargeAmount(new BigDecimal("0.01")); + WarehouseTransferOrder transfer = new WarehouseTransferOrder(); transfer.setId(91L); transfer.setCustomerId("10001"); + transfer.setTransferNo("WTR-CLOSURE"); transfer.setLastlegOrderId(21L); transfer.setRelabelApplicationId(11L); + transfer.setNeedRelabel(true); transfer.setStatus("RECEIVED"); transfer.setRelabelCompletedAt(LocalDateTime.now()); + when(applications.lockNo("10001", "FBR-CLOSURE")).thenReturn(app); + when(warehouseTransfers.lockByRelabelNo("10001", "FBR-CLOSURE")).thenReturn(transfer); + LastlegOrder order = order(21L, "10001", "LMO-CLOSURE", "COMPLETED"); order.setCustomerName("测试客户"); + lenient().when(orders.selectById(21L)).thenReturn(order); + return app; + } + private void stubDetail(RelabelApplication application) { when(applications.findNo(application.getCustomerId(), application.getRelabelNo())).thenReturn(application); when(items.selectList(any())).thenReturn(List.of(item(31L, application.getId(), "SKU-A", 10, 6))); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java index 113176d0b..ee4ba4044 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java @@ -74,6 +74,7 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.clearInvocations; import static org.mockito.Mockito.when; import static org.mockito.Mockito.times; @@ -853,10 +854,19 @@ class WarehouseTransferServiceTest { verify(inventory,never()).ship(anyString(),anyString(),any());assertEquals("OUTBOUNDED",app.getFulfillmentStatus()); var mixed=balance(601,101,"IN-1");mixed.setSkuCode("MIXED");when(inventoryCommands.lockBalance("TENANT-A",601L)).thenReturn(mixed); when(inventory.stockIn(eq("TENANT-A"),eq("USER-1"),any())).thenReturn(new InventoryCommandResponse(1L,"STOCK_IN",801L,901L,5,0,0,0,false,false)); + doAnswer(call -> { app.setStatus("COMPLETED"); app.setFulfillmentStatus("COMPLETED"); + app.setWalletStatus("SETTLE_PENDING"); return null; }).when(relabelService) + .completeWarehouseTransferFulfillment("TENANT-A", "RL-OWN", 21L, "USER-1"); service.receive("TENANT-A","WTR-1","USER-1",receiptRequest()); var received=ArgumentCaptor.forClass(com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests.StockIn.class); verify(inventory).stockIn(eq("TENANT-A"),eq("USER-1"),received.capture());assertEquals("NEW",received.getValue().skuCode()); assertEquals("RECEIVED",transfer.getStatus());assertEquals("COMPLETED",app.getStatus());assertEquals("COMPLETED",app.getFulfillmentStatus()); + var savedReceipt = ArgumentCaptor.forClass(com.baiyue.lastleg.transfer.entity.WarehouseTransferReceipt.class); + verify(transferReceipts).insert(savedReceipt.capture()); + when(transferReceipts.findSource("TENANT-A", 21L, current.getInventoryPositionId())).thenReturn(savedReceipt.getValue()); + service.receive("TENANT-A","WTR-1","USER-1",receiptRequest()); + verify(relabelService, times(1)).completeWarehouseTransferFulfillment("TENANT-A", "RL-OWN", 21L, "USER-1"); + assertEquals("SETTLE_PENDING", app.getWalletStatus()); } @Test