diff --git a/finance/src/main/java/com/baiyue/finance/service/FinanceService.java b/finance/src/main/java/com/baiyue/finance/service/FinanceService.java index 47097eb54..796d6ddee 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinanceService.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceService.java @@ -187,14 +187,14 @@ public class FinanceService { for (var entry : byBill.entrySet()) { BigDecimal applied = entry.getValue(); jdbc.update("INSERT INTO fin_receipt_bill(tenant_id,receipt_id,bill_id,applied_amount,created_by) VALUES(?,?,?,?,?)", tenantId, receiptId, entry.getKey(), applied, operatorId); - jdbc.update("UPDATE fin_bill SET received_amount=received_amount+?, outstanding_amount=outstanding_amount-?, status=CASE WHEN outstanding_amount-?=0 THEN 'PAID' ELSE 'PARTIALLY_PAID' END, updated_by=?, version=version+1 WHERE id=? AND tenant_id=?", applied, applied, applied, operatorId, entry.getKey(), tenantId); + jdbc.update("UPDATE fin_bill SET received_amount=received_amount+?, outstanding_amount=outstanding_amount-?, status=CASE WHEN outstanding_amount=0 THEN 'PAID' ELSE 'PARTIALLY_PAID' END, updated_by=?, version=version+1 WHERE id=? AND tenant_id=?", applied, applied, operatorId, entry.getKey(), tenantId); Integer settlementCount = jdbc.queryForObject( "SELECT COUNT(*) FROM fin_settlement WHERE tenant_id=? AND direction='RECEIVABLE' AND reconciliation_statement_id=? AND status<>'VOIDED'", Integer.class, tenantId, entry.getKey()); if (settlementCount != null && settlementCount > 0) { int settlementUpdated = jdbc.update( - "UPDATE fin_settlement SET paid_amount=paid_amount+?,outstanding_amount=outstanding_amount-?,payment_status=CASE WHEN outstanding_amount-?=0 THEN 'PAID' ELSE 'PARTIALLY_PAID' END,status=CASE WHEN outstanding_amount-?=0 THEN 'PAID' ELSE 'PARTIALLY_PAID' END,updated_by=?,version=version+1 WHERE tenant_id=? AND direction='RECEIVABLE' AND reconciliation_statement_id=? AND status IN ('ISSUED','PARTIALLY_PAID') AND outstanding_amount>=?", - applied, applied, applied, applied, operatorId, tenantId, entry.getKey(), applied); + "UPDATE fin_settlement SET paid_amount=paid_amount+?,outstanding_amount=outstanding_amount-?,payment_status=CASE WHEN outstanding_amount=0 THEN 'PAID' ELSE 'PARTIALLY_PAID' END,status=CASE WHEN outstanding_amount=0 THEN 'PAID' ELSE 'PARTIALLY_PAID' END,updated_by=?,version=version+1 WHERE tenant_id=? AND direction='RECEIVABLE' AND reconciliation_statement_id=? AND status IN ('ISSUED','PARTIALLY_PAID') AND outstanding_amount>=?", + applied, applied, operatorId, tenantId, entry.getKey(), applied); if (settlementUpdated != 1) throw new BizException(ResultCode.CONFLICT, "应收结算单状态或余额已变化"); } } diff --git a/finance/src/main/java/com/baiyue/finance/service/FinanceSettlementService.java b/finance/src/main/java/com/baiyue/finance/service/FinanceSettlementService.java index 0dadafc8e..4e84982b1 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinanceSettlementService.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceSettlementService.java @@ -201,6 +201,9 @@ public class FinanceSettlementService { "SELECT id,tenant_id,direction,currency_code,total_amount,paid_amount,outstanding_amount,status,version " + "FROM fin_settlement WHERE id=? FOR UPDATE", "结算单不存在", settlementId); long customerId = number(settlement, "tenant_id").longValue(); + Long replay = reserve(customerId, operatorId, "SETTLEMENT_PAYMENT_CREATE", key, "POST", + "/api/platform/finance/v1/settlements/" + settlementId + "/transactions", request); + if (replay != null) return getSettlement(replay); if (!"PAYABLE".equals(text(settlement, "direction"))) throw error("FIN_409", "仅应付结算单可登记付款"); if (!List.of("ISSUED", "PARTIALLY_PAID").contains(text(settlement, "status"))) { throw error("FIN_409", "结算单状态不允许付款"); @@ -209,9 +212,6 @@ public class FinanceSettlementService { if (number(settlement, "version").intValue() != request.expectedVersion()) throw error("FIN_409", "结算单版本冲突"); BigDecimal outstanding = decimal(settlement, "outstanding_amount"); if (request.amount().compareTo(outstanding) > 0) throw error("FIN_409", "付款金额不得超过未付余额"); - Long replay = reserve(customerId, operatorId, "SETTLEMENT_PAYMENT_CREATE", key, "POST", - "/api/platform/finance/v1/settlements/" + settlementId + "/transactions", request); - if (replay != null) return getSettlement(settlementId); validateVoucherFiles(customerId, request.voucherFileIds()); insertPayment(customerId, settlementId, request, operatorId); diff --git a/finance/src/test/java/com/baiyue/finance/FinanceServiceValidationTest.java b/finance/src/test/java/com/baiyue/finance/FinanceServiceValidationTest.java index 3a8be9b06..b52559300 100644 --- a/finance/src/test/java/com/baiyue/finance/FinanceServiceValidationTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinanceServiceValidationTest.java @@ -126,9 +126,11 @@ class FinanceServiceValidationTest { service.createReceipt(9L, 2L, request, "receipt-key"); - verify(jdbc).update(startsWith("UPDATE fin_settlement SET paid_amount="), - eq(new BigDecimal("40.00")), eq(new BigDecimal("40.00")), eq(new BigDecimal("40.00")), - eq(new BigDecimal("40.00")), eq(2L), eq(9L), eq(50L), eq(new BigDecimal("40.00"))); + verify(jdbc).update(contains("payment_status=CASE WHEN outstanding_amount=0"), + eq(new BigDecimal("40.00")), eq(new BigDecimal("40.00")), + eq(2L), eq(9L), eq(50L), eq(new BigDecimal("40.00"))); + verify(jdbc).update(contains("status=CASE WHEN outstanding_amount=0"), + eq(new BigDecimal("40.00")), eq(new BigDecimal("40.00")), eq(2L), eq(50L), eq(9L)); } @Test diff --git a/finance/src/test/java/com/baiyue/finance/FinanceSettlementBehaviorTest.java b/finance/src/test/java/com/baiyue/finance/FinanceSettlementBehaviorTest.java index 359b8eb4c..dd0e29726 100644 --- a/finance/src/test/java/com/baiyue/finance/FinanceSettlementBehaviorTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinanceSettlementBehaviorTest.java @@ -39,6 +39,7 @@ import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.startsWith; import org.mockito.ArgumentCaptor; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.mockito.Mockito.RETURNS_DEFAULTS; @@ -276,6 +277,47 @@ class FinanceSettlementBehaviorTest { assertThat(result.summary().outstandingAmount()).isZero(); } + @Test + void finalPaymentReplayReturnsFirstResultWithoutDuplicatePayment() { + JdbcTemplate jdbc = org.mockito.Mockito.mock(JdbcTemplate.class, invocation -> + "update".equals(invocation.getMethod().getName()) ? 1 : RETURNS_DEFAULTS.answer(invocation)); + FinanceFileClient fileClient = org.mockito.Mockito.mock(FinanceFileClient.class); + Map before = settlementRow("PAYABLE", "PARTIALLY_PAID", new BigDecimal("40.00"), + new BigDecimal("60.00"), 1); + Map after = settlementRow("PAYABLE", "PAID", new BigDecimal("100.00"), BigDecimal.ZERO, 2); + when(jdbc.queryForList(startsWith("SELECT id,tenant_id,direction"), eq(88L))) + .thenReturn(List.of(before), List.of(after)); + when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(10L), eq(7L), + eq("SETTLEMENT_PAYMENT_CREATE"), eq("final-replay-key"))).thenReturn(List.of()); + stubSettlementDetail(jdbc, after); + when(fileClient.metadata("internal-token", 10L, 10L, 91L)).thenReturn(ApiResponse.success( + new FinanceFileClient.Metadata(91L, 10L, "FINANCE_VOUCHER", "10", "pay.png", "abc", "image/png", 10L))); + FinanceSettlementService service = service(jdbc, fileClient); + + SettlementDetail first = service.createSettlementPayment(88L, 7L, "final-replay-key", + payment(new BigDecimal("60.00"), 1)); + ArgumentCaptor requestHash = ArgumentCaptor.forClass(String.class); + verify(jdbc).update(startsWith("INSERT INTO fin_idempotency_record"), eq(10L), eq(7L), + eq("SETTLEMENT_PAYMENT_CREATE"), eq("final-replay-key"), requestHash.capture()); + when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(10L), eq(7L), + eq("SETTLEMENT_PAYMENT_CREATE"), eq("final-replay-key"))).thenReturn(List.of(Map.of( + "request_hash", requestHash.getValue(), "status", "SUCCEEDED", "result_resource_id", 88L, + "updated_at", LocalDateTime.now()))); + + SettlementDetail replay = service.createSettlementPayment(88L, 7L, "final-replay-key", + payment(new BigDecimal("60.00"), 1)); + + assertThat(first.summary().status()).isEqualTo("PAID"); + assertThat(replay.summary().status()).isEqualTo("PAID"); + assertThat(replay.summary().outstandingAmount()).isZero(); + long paymentInserts = org.mockito.Mockito.mockingDetails(jdbc).getInvocations().stream() + .filter(invocation -> invocation.getMethod().getName().equals("update") + && invocation.getArguments().length > 0 + && String.valueOf((Object) invocation.getArgument(0)).startsWith("INSERT INTO fin_settlement_payment")) + .count(); + assertThat(paymentInserts).isEqualTo(1); + } + @Test void receivableSettlementDetailIncludesAppliedReceiptHistory() { JdbcTemplate jdbc = org.mockito.Mockito.mock(JdbcTemplate.class); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingPdaController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingPdaController.java index 056b9a958..872bbfdf1 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingPdaController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingPdaController.java @@ -46,9 +46,11 @@ public class PlatformLoadingPdaController { @PreAuthorize("hasAuthority('platform:firstleg:pda:view')") public ApiResponse task(@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String containerNo, + @RequestParam(required = false) String cabinetNo, + @RequestParam(required = false) Long loadingPlanId, @RequestParam @Positive long warehouseId) { FirstlegScopeGuard.platform(user); - return ApiResponse.success(service.task(containerNo, warehouseId)); + return ApiResponse.success(service.task(containerNo, cabinetNo, loadingPlanId, warehouseId)); } @GetMapping("/tasks/pending") diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickTaskMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickTaskMapper.java index 391715a89..d41367757 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickTaskMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickTaskMapper.java @@ -31,6 +31,16 @@ public interface LoadingPickTaskMapper extends BaseMapper LoadingPickTaskEntity selectLoadingByContainerAndAccount(@Param("containerNo") String containerNo, @Param("warehouseId") long warehouseId); + @Select("SELECT task.* FROM fl_loading_pick_task task JOIN fl_loading_plan plan ON plan.id=task.loading_plan_id " + + "JOIN fl_container_booking booking ON booking.id=plan.container_booking_id " + + "WHERE task.task_status='LOADING' AND booking.cabinet_no=#{cabinetNo} AND booking.warehouse_id=#{warehouseId} LIMIT 1") + LoadingPickTaskEntity selectLoadingByCabinet(@Param("cabinetNo") String cabinetNo, @Param("warehouseId") long warehouseId); + + @Select("SELECT task.* FROM fl_loading_pick_task task JOIN fl_loading_plan plan ON plan.id=task.loading_plan_id " + + "JOIN fl_container_booking booking ON booking.id=plan.container_booking_id " + + "WHERE task.task_status='LOADING' AND task.loading_plan_id=#{loadingPlanId} AND booking.warehouse_id=#{warehouseId} LIMIT 1") + LoadingPickTaskEntity selectLoadingByPlan(@Param("loadingPlanId") long loadingPlanId, @Param("warehouseId") long warehouseId); + @Select("""