From 83f2fda4cd2a432c7516018f6ce3b1b05eb1d2d7 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 27 Oct 2026 12:08:35 +0800 Subject: [PATCH] fix(finance): reconcile payment progress audit amounts and customer names --- .../finance/dto/BusinessFeeSourceVersion.java | 4 + .../mapper/BusinessFeeSourceMapper.java | 27 ++ .../mapper/FinanceFeeSourceReadMapper.java | 20 +- .../BusinessFeeSourceRepository.java | 20 ++ .../FinanceFeeSourceReadRepository.java | 16 ++ .../BusinessFeeSourceEventService.java | 53 +++- .../finance/service/FinanceCustomerNames.java | 71 ++++++ .../service/FinanceExportProviderService.java | 55 ++-- .../FinancePhase1OperationService.java | 29 +-- .../finance/service/FinanceQueryService.java | 13 +- .../service/FinanceSettlementService.java | 18 +- .../service/ReceivableStatisticsService.java | 7 +- .../ReconciliationStatementService.java | 23 +- .../BusinessFeeSourceEventServiceTest.java | 91 ++++++- ...nessFeeSourceReceptionPersistenceTest.java | 238 ++++++++++++++++++ .../CustomerAccountSummaryBehaviorTest.java | 8 +- .../finance/FinanceCustomerNamesTest.java | 53 ++++ .../FinanceCustomerNamesTestSupport.java | 22 ++ .../FinanceDirectPaidCostReadTest.java | 2 +- .../FinanceFeePoolDirectionBehaviorTest.java | 6 +- .../FinanceFeeSourceReadMapperTest.java | 50 +++- .../FinancePhase1OperationBehaviorTest.java | 81 +++--- ...ancePlatformOptionalCustomerQueryTest.java | 6 +- .../FinanceSettlementBehaviorTest.java | 3 +- .../ReceivableStatisticsServiceTest.java | 27 +- .../ReconciliationStatementBehaviorTest.java | 33 ++- .../FinanceExportProviderServiceTest.java | 20 ++ 27 files changed, 820 insertions(+), 176 deletions(-) create mode 100644 finance/src/main/java/com/baiyue/finance/dto/BusinessFeeSourceVersion.java create mode 100644 finance/src/main/java/com/baiyue/finance/mapper/BusinessFeeSourceMapper.java create mode 100644 finance/src/main/java/com/baiyue/finance/repository/BusinessFeeSourceRepository.java create mode 100644 finance/src/main/java/com/baiyue/finance/service/FinanceCustomerNames.java create mode 100644 finance/src/test/java/com/baiyue/finance/BusinessFeeSourceReceptionPersistenceTest.java create mode 100644 finance/src/test/java/com/baiyue/finance/FinanceCustomerNamesTest.java create mode 100644 finance/src/test/java/com/baiyue/finance/FinanceCustomerNamesTestSupport.java diff --git a/finance/src/main/java/com/baiyue/finance/dto/BusinessFeeSourceVersion.java b/finance/src/main/java/com/baiyue/finance/dto/BusinessFeeSourceVersion.java new file mode 100644 index 000000000..c4d34898b --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/dto/BusinessFeeSourceVersion.java @@ -0,0 +1,4 @@ +package com.baiyue.finance.dto; + +/** 接收事务锁定的来源版本及其不可变客户归属。 */ +public record BusinessFeeSourceVersion(int sourceVersion, long customerId) { } diff --git a/finance/src/main/java/com/baiyue/finance/mapper/BusinessFeeSourceMapper.java b/finance/src/main/java/com/baiyue/finance/mapper/BusinessFeeSourceMapper.java new file mode 100644 index 000000000..60407b524 --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/mapper/BusinessFeeSourceMapper.java @@ -0,0 +1,27 @@ +package com.baiyue.finance.mapper; + +import com.baiyue.finance.dto.BusinessFeeSourceVersion; +import org.apache.ibatis.annotations.Arg; +import org.apache.ibatis.annotations.ConstructorArgs; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Options; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; + +/** 费用接收时锁定稳定来源,防止新版本改写其他客户的费用。 */ +@Mapper +public interface BusinessFeeSourceMapper { + /** 使用完整来源键锁定全部版本;锁定读取必须绕过缓存并参与接收事务。 */ + @Select("SELECT source_fee_version,customer_id FROM fin_fee_source " + + "WHERE source_system=#{system} AND source_type=#{type} AND source_fee_id=#{feeId} " + + "ORDER BY source_fee_version FOR UPDATE") + @ConstructorArgs({ + @Arg(column = "source_fee_version", javaType = int.class), + @Arg(column = "customer_id", javaType = long.class) + }) + @Options(useCache = false, flushCache = Options.FlushCachePolicy.TRUE) + List lockVersions(@Param("system") String system, + @Param("type") String type, @Param("feeId") long feeId); +} diff --git a/finance/src/main/java/com/baiyue/finance/mapper/FinanceFeeSourceReadMapper.java b/finance/src/main/java/com/baiyue/finance/mapper/FinanceFeeSourceReadMapper.java index 4fa153600..bc415481b 100644 --- a/finance/src/main/java/com/baiyue/finance/mapper/FinanceFeeSourceReadMapper.java +++ b/finance/src/main/java/com/baiyue/finance/mapper/FinanceFeeSourceReadMapper.java @@ -3,14 +3,23 @@ package com.baiyue.finance.mapper; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Options; import java.util.List; import java.util.Map; /** 费用池按稳定来源读取当前版本,历史快照保留在同一费用的操作记录中。 */ @Mapper public interface FinanceFeeSourceReadMapper { - String GENERATED = "(EXISTS(SELECT 1 FROM fin_bill_line bl WHERE bl.source_id=f.id AND bl.source_version=f.source_fee_version))"; - String STATUS = "CASE WHEN f.status='AVAILABLE' AND " + GENERATED + " THEN 'BILLED' ELSE f.status END"; + String GENERATED = "(EXISTS(SELECT 1 FROM fin_bill_line bl JOIN fin_bill b ON b.id=bl.bill_id AND b.tenant_id=bl.tenant_id" + + " WHERE bl.tenant_id=f.customer_id AND bl.source_id=f.id AND bl.source_version=f.source_fee_version AND b.status<>'VOIDED'))"; + // 应付进度取同客户的有效结算单;作废单释放来源,直接已付使用来源中的付款事实。 + String PAYABLE_STATUS = "(SELECT CASE s.payment_status WHEN 'PAID' THEN 'PAID' WHEN 'PARTIALLY_PAID' THEN 'PARTIALLY_PAID'" + + " ELSE 'PAYABLE_BILLED' END FROM fin_settlement_line sl JOIN fin_settlement s ON s.id=sl.settlement_id AND s.tenant_id=sl.tenant_id" + + " WHERE sl.tenant_id=f.customer_id AND sl.fee_source_id=f.id AND s.direction='PAYABLE' AND s.status<>'VOIDED' ORDER BY s.id DESC LIMIT 1)"; + String STATUS = "CASE WHEN f.status IN ('VOIDED','SUPERSEDED','SETTLED') THEN f.status" + + " WHEN f.direction='PAYABLE' THEN COALESCE(" + PAYABLE_STATUS + ",CASE WHEN f.settlement_mode='DIRECT_PAID'" + + " AND f.review_status='APPROVED' AND f.paid_at IS NOT NULL THEN 'PAID' ELSE f.status END)" + + " WHEN f.status='AVAILABLE' AND " + GENERATED + " THEN 'BILLED' ELSE f.status END"; String BUSINESS_DATE = "CAST(JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.businessDate')) AS DATE)"; String FILTER = " FROM fin_fee_source f WHERE 1=1 " + "" + @@ -41,6 +50,13 @@ public interface FinanceFeeSourceReadMapper { " ORDER BY " + BUSINESS_DATE + " DESC,f.id DESC LIMIT #{limit} OFFSET #{offset}") List> list(Map filters); + /** 详情与列表共用状态投影,仍允许通过精确 ID 查看历史版本。 */ + @Select("") + // 同事务的审核/调整仍含 JDBC 写入,读取必须清空 MyBatis 会话缓存才能返回变更后状态。 + @Options(useCache = false, flushCache = Options.FlushCachePolicy.TRUE) + Map detail(@Param("id") long id); + String LINEAGE = " FROM fin_fee_source anchor JOIN fin_fee_source f ON f.source_system=anchor.source_system" + " AND f.source_type=anchor.source_type AND f.source_fee_id=anchor.source_fee_id" + " AND f.customer_id=anchor.customer_id AND f.direction=anchor.direction" + diff --git a/finance/src/main/java/com/baiyue/finance/repository/BusinessFeeSourceRepository.java b/finance/src/main/java/com/baiyue/finance/repository/BusinessFeeSourceRepository.java new file mode 100644 index 000000000..4c08e3b38 --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/repository/BusinessFeeSourceRepository.java @@ -0,0 +1,20 @@ +package com.baiyue.finance.repository; + +import com.baiyue.finance.dto.BusinessFeeSourceVersion; +import com.baiyue.finance.mapper.BusinessFeeSourceMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.List; + +/** 为接收服务提供同一事务中的费用来源版本与归属事实。 */ +@Repository +@RequiredArgsConstructor +public class BusinessFeeSourceRepository { + private final BusinessFeeSourceMapper mapper; + + /** 原来源的客户不能被新版本替换,查询范围不得按传入客户提前过滤。 */ + public List lockVersions(String system, String type, long feeId) { + return mapper.lockVersions(system, type, feeId); + } +} diff --git a/finance/src/main/java/com/baiyue/finance/repository/FinanceFeeSourceReadRepository.java b/finance/src/main/java/com/baiyue/finance/repository/FinanceFeeSourceReadRepository.java index c5862944e..5dde48e22 100644 --- a/finance/src/main/java/com/baiyue/finance/repository/FinanceFeeSourceReadRepository.java +++ b/finance/src/main/java/com/baiyue/finance/repository/FinanceFeeSourceReadRepository.java @@ -1,6 +1,7 @@ package com.baiyue.finance.repository; import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceSummary; +import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceDetail; import com.baiyue.finance.dto.FinancePhase1Dtos.OperationLog; import com.baiyue.finance.mapper.FinanceFeeSourceReadMapper; import com.baiyue.finance.service.FinanceEventException; @@ -51,6 +52,21 @@ public class FinanceFeeSourceReadRepository { return mapper.list(filters).stream().map(this::summary).toList(); } + /** 精确版本详情使用与列表相同的金额和状态,并保留审核信息及操作记录。 */ + public FeeSourceDetail detail(long id) { + Map raw = mapper.detail(id); + if (raw == null || raw.isEmpty()) throw new FinanceEventException("FIN-FEE-404", "费用来源不存在"); + Map row = row(raw); + FeeSourceSummary s = summary(row); + return new FeeSourceDetail(s.feeSourceId(), s.sourceSystem(), s.sourceType(), s.sourceBusinessId(), s.sourceFeeId(), + s.sourceVersion(), s.direction(), s.customerId(), s.customerName(), s.supplierId(), s.supplierName(), s.orderId(), + s.inboundId(), s.inboundCode(), s.containerId(), s.containerNo(), s.businessDate(), s.feeItemName(), + s.quantity(), s.quantityUnit(), s.unitPrice(), s.periodStart(), s.periodEnd(), s.amount(), s.currency(), + s.originalCurrency(), s.originalAmount(), s.exchangeRate(), s.exchangeRateMonth(), s.reviewStatus(), + s.sourceStatus(), s.statementGenerated(), text(row.get("review_reason")), text(row.get("source_hash")), + ((Number) row.get("operation_version")).intValue(), logs(s.customerId(), id)); + } + /** 由不可变版本追加同步历史,并串联同一费用所有既有审计记录。 */ public List logs(long customerId, long id) { List result = new ArrayList<>(); diff --git a/finance/src/main/java/com/baiyue/finance/service/BusinessFeeSourceEventService.java b/finance/src/main/java/com/baiyue/finance/service/BusinessFeeSourceEventService.java index 9796e4820..20c27e713 100644 --- a/finance/src/main/java/com/baiyue/finance/service/BusinessFeeSourceEventService.java +++ b/finance/src/main/java/com/baiyue/finance/service/BusinessFeeSourceEventService.java @@ -6,6 +6,7 @@ import com.baiyue.common.event.DeliveryAck; import com.baiyue.common.event.EventPayloadCanonicalizer; import com.baiyue.common.event.EventPayloadHash; import com.baiyue.common.event.EventTypes; +import com.baiyue.finance.repository.BusinessFeeSourceRepository; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; @@ -13,11 +14,13 @@ import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.math.BigDecimal; +import java.math.RoundingMode; import java.util.List; import java.util.Map; import java.util.Set; -/** Consumes complete overseas business fee snapshots without a cross-region synchronous callback. */ +/** 接收海外费用不可变快照,在同一事务内完成来源版本与接收记录。 */ @Service @RequiredArgsConstructor public class BusinessFeeSourceEventService { @@ -26,7 +29,9 @@ public class BusinessFeeSourceEventService { private final JdbcTemplate jdbc; private final ObjectMapper objectMapper; private final FinanceSupplierValidator supplierValidator; + private final BusinessFeeSourceRepository sources; + /** 先核对不可变事件,再恢复已提交结果;仅新事件检查外部主档。 */ @Transactional public DeliveryAck consume(EventEnvelope envelope) { try { @@ -56,6 +61,8 @@ public class BusinessFeeSourceEventService { return DeliveryAck.of(envelope, DeliveryAck.Outcome.DUPLICATE); throw error("FIN-EVT-003", "相同事件正在处理或上次处理失败"); } + // 回执丢失后的原事件重放不能依赖供应商当前是否可用,否则已入账事件无法确认。 + validateSupplier(source); jdbc.update("INSERT INTO fin_inbox_event(event_id,source_system,event_type,payload,payload_hash,status) VALUES (?,?,?,?,?,'RECEIVED')", envelope.eventId(), sourceSystem, envelope.eventType(), eventJson, envelope.payloadHash()); long result = apply(sourceSystem, source); @@ -64,6 +71,7 @@ public class BusinessFeeSourceEventService { return DeliveryAck.of(envelope, DeliveryAck.Outcome.APPLIED); } + /** 锁定同一来源的历史版本,拒绝跨客户替代,并保留已出账来源冻结规则。 */ private long apply(String system, BusinessFeeSourceChangedV1 source) { long feeId = positive(source.sourceFeeId(), "sourceFeeId"); List> same = jdbc.queryForList( @@ -83,10 +91,12 @@ public class BusinessFeeSourceEventService { } if ("VOIDED".equals(source.action())) throw error("FIN-EVT-005", "作废事件对应的费用来源版本不存在"); - List> versions = jdbc.queryForList( - "SELECT source_fee_version FROM fin_fee_source WHERE source_system=? AND source_type=? AND source_fee_id=? FOR UPDATE", - system, source.sourceType(), feeId); - if (versions.stream().anyMatch(row -> ((Number) row.get("source_fee_version")).intValue() > source.sourceVersion())) + var versions = sources.lockVersions(system, source.sourceType(), feeId); + long customerId = positive(source.customerId(), "customerId"); + // 来源身份跨版本保持客户归属;检查失败时 Inbox 插入也随当前事务回滚。 + if (versions.stream().anyMatch(row -> row.customerId() != customerId)) + throw error("FIN-EVT-018", "费用来源客户归属不允许变更"); + if (versions.stream().anyMatch(row -> row.sourceVersion() > source.sourceVersion())) throw error("FIN-EVT-008", "费用来源版本已过期"); requireSourceNotFrozen(system, source.sourceType(), feeId); jdbc.update("UPDATE fin_fee_source SET status='SUPERSEDED' WHERE source_system=? AND source_type=? AND source_fee_id=? AND status='AVAILABLE'", @@ -110,6 +120,7 @@ public class BusinessFeeSourceEventService { return id; } + /** 校验载荷自身的身份、金额和摘要,不依赖会随时间变化的外部数据。 */ private void validate(EventEnvelope envelope, BusinessFeeSourceChangedV1 value) { if (value == null || !ACTIONS.contains(value.action()) || blank(value.sourceSystem()) || "FIRSTLEG".equalsIgnoreCase(value.sourceSystem()) || blank(value.sourceType()) @@ -130,6 +141,7 @@ public class BusinessFeeSourceEventService { } positive(value.sourceFeeId(), "sourceFeeId"); positive(value.customerId(), "customerId"); + validateAmounts(value); if (value.walletSettlement() != null) { if (!"RECEIVABLE".equals(value.direction())) throw error("FIN-EVT-012", "钱包消费只能关联应收费用"); positive(value.walletSettlement().holdId(), "walletSettlement.holdId"); @@ -149,10 +161,8 @@ public class BusinessFeeSourceEventService { throw error("FIN-EVT-015", "费用来源尚未发布或已失效"); } if ("PAYABLE".equals(value.direction())) { - long supplierId = positive(value.supplierId(), "supplierId"); - var supplier = supplierValidator.requireActive(supplierId, trim(value.supplierCategory())); - if (blank(value.supplierName()) || !value.supplierName().equals(supplier.name())) - throw error("FIN-EVT-012", "应付来源供应商快照不一致"); + positive(value.supplierId(), "supplierId"); + if (blank(value.supplierName())) throw error("FIN-EVT-012", "应付来源供应商快照不完整"); validateSettlement(value); } else if (!blank(value.supplierId()) || !blank(value.supplierName()) || !blank(value.supplierCategory()) || value.settlementMode() != null && !Set.of("BILL", "WALLET").contains(value.settlementMode()) || value.paidAt() != null @@ -174,6 +184,31 @@ public class BusinessFeeSourceEventService { if (!expected.equals(value.sourceHash())) throw error("FIN-EVT-004", "费用来源快照哈希不一致"); } + /** 使用来源冻结汇率复核人民币金额,与海外生产端的分位四舍五入口径一致。 */ + private void validateAmounts(BusinessFeeSourceChangedV1 value) { + try { + // 不在接收端静默修正金额或截断汇率;无效精度须由生产端重新审核。 + value.originalAmount().setScale(2, RoundingMode.UNNECESSARY); + value.totalAmount().setScale(2, RoundingMode.UNNECESSARY); + value.exchangeRate().setScale(6, RoundingMode.UNNECESSARY); + } catch (ArithmeticException invalidPrecision) { + throw error("FIN-EVT-019", "费用金额或汇率精度无效"); + } + if ("CNY".equals(value.originalCurrency()) && value.exchangeRate().compareTo(BigDecimal.ONE) != 0) + throw error("FIN-EVT-019", "人民币原币费用汇率必须为1"); + BigDecimal expected = value.originalAmount().multiply(value.exchangeRate()).setScale(2, RoundingMode.HALF_UP); + if (expected.compareTo(value.totalAmount()) != 0) + throw error("FIN-EVT-019", "费用人民币金额与原币金额、汇率不一致"); + } + + /** 首次接收应付仍须校验启用供应商与名称快照,依赖不可用时失败关闭。 */ + private void validateSupplier(BusinessFeeSourceChangedV1 value) { + if (!"PAYABLE".equals(value.direction())) return; + var supplier = supplierValidator.requireActive(positive(value.supplierId(), "supplierId"), trim(value.supplierCategory())); + if (!value.supplierName().equals(supplier.name())) + throw error("FIN-EVT-012", "应付来源供应商快照不一致"); + } + private void validateSettlement(BusinessFeeSourceChangedV1 value) { boolean files = value.paymentVoucherFileIds() != null && !value.paymentVoucherFileIds().isEmpty(); if ("ON_ACCOUNT".equals(value.settlementMode())) { diff --git a/finance/src/main/java/com/baiyue/finance/service/FinanceCustomerNames.java b/finance/src/main/java/com/baiyue/finance/service/FinanceCustomerNames.java new file mode 100644 index 000000000..d802e61f9 --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceCustomerNames.java @@ -0,0 +1,71 @@ +package com.baiyue.finance.service; + +import com.baiyue.finance.client.IamCustomerClient; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.util.HashMap; +import java.util.function.BiFunction; + +/** 单据客户名称取真实主档或有效快照,内部引用不能作为名称展示。 */ +@Service +public class FinanceCustomerNames { + private final IamCustomerClient customers; + private final String token; + + /** 复用已存在的 IAM 客户查询,不跨库读取客户资料。 */ + public FinanceCustomerNames(IamCustomerClient customers, + @Value("${baiyue.security.internal-service-token}") String token) { + this.customers = customers; + this.token = token; + } + + /** 新建单据必须取得真实客户名称,依赖故障或客户不存在时停止建单。 */ + public String required(long customerId) { + String name = lookup(customerId); + if (name == null) throw new FinanceEventException("FIN-CUSTOMER-404", "客户名称未提供,无法生成财务单据"); + return name; + } + + /** 应收结算沿用有效的对账单名称,缺失时必须取得真实主档后才能建单。 */ + public String requiredSnapshot(long customerId, String snapshot) { + return valid(customerId, snapshot) ? snapshot.trim() : required(customerId); + } + + /** 每次列表/详情请求内复用同客户查询结果,保留有效的历史名称快照。 */ + public BiFunction resolver() { + var names = new HashMap(); + return (customerId, snapshot) -> { + if (valid(customerId, snapshot)) return snapshot.trim(); + return names.computeIfAbsent(customerId, id -> { + String name = lookup(id); + return name == null ? "—" : name; + }); + }; + } + + /** 仅接受所请求客户的主档,不能拿其他客户记录填充当前单据。 */ + private String lookup(long customerId) { + try { + var response = customers.page(token, 1, 1, null, null, customerId); + if (response == null || !response.successful() || response.data() == null || response.data().records() == null) + throw new FinanceEventException("FIN_503", "客户主数据暂不可用"); + var rows = response.data().records(); + if (rows.isEmpty()) return null; + if (rows.size() != 1 || rows.get(0).id() != customerId) + throw new FinanceEventException("FIN_503", "客户主数据返回范围异常"); + String name = rows.get(0).tenantName(); + return valid(customerId, name) ? name.trim() : null; + } catch (FinanceEventException failure) { + throw failure; + } catch (RuntimeException failure) { + throw new FinanceEventException("FIN_503", "客户主数据暂不可用"); + } + } + + /** 空名称与内部编号占位不是有效的业务名称。 */ + private static boolean valid(long customerId, String name) { + return name != null && !name.isBlank() && !name.trim().equals("—") + && !name.trim().equals(Long.toString(customerId)) && !name.trim().matches("客户\\s*(?:ID)?\\s*\\d+"); + } +} diff --git a/finance/src/main/java/com/baiyue/finance/service/FinanceExportProviderService.java b/finance/src/main/java/com/baiyue/finance/service/FinanceExportProviderService.java index ce9af6f84..020b496f1 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinanceExportProviderService.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceExportProviderService.java @@ -7,13 +7,12 @@ import com.baiyue.common.core.export.ExportContracts.ExportBatchResponse; import com.baiyue.common.core.export.ExportContracts.ExportColumn; import com.baiyue.common.core.export.ExportContracts.ExportColumnType; import com.baiyue.finance.dto.FinancePhase1Dtos.AccountSummary; -import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceDetail; import com.baiyue.finance.dto.FinancePhase1Dtos.LedgerEntry; import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementDetail; import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementLine; import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementSummary; import com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationDetail; -import com.baiyue.finance.dto.FinanceReadModels.FeePoolItem; +import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceSummary; import com.baiyue.finance.dto.ReceivableStatisticsDtos.ReceivableStatisticsRow; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.annotation.PostConstruct; @@ -199,41 +198,16 @@ public class FinanceExportProviderService { .map(value -> receivableStatisticsRow(month, value)).toList(); } + /** 导出与列表使用相同当前版本、状态及筛选,已付款来源不能被可选结算池遗漏。 */ private List> feeRows(Map p) { - Long customerId = longValue(p, "customerId", false); - String direction = text(p, "direction"); - String status = text(p, "status"); - String reviewStatus = text(p, "reviewStatus"); - Boolean generated = bool(p, "statementGenerated"); - LocalDate from = date(p, "businessDateFrom"), to = date(p, "businessDateTo"); - String keyword = lower(text(p, "keyword")); List> rows = new ArrayList<>(); - for (FeePoolItem item : queryService.feePool(customerId, direction)) { - FeeSourceDetail detail = detail(item); - String sourceStatus = detail == null ? item.status() : detail.sourceStatus(); - String actualReview = detail == null ? null : detail.reviewStatus(); - Boolean actualGenerated = detail == null ? null : detail.statementGenerated(); - LocalDate businessDate = detail == null || detail.businessDate() == null - ? (item.createdAt() == null ? null : item.createdAt().toLocalDate()) : detail.businessDate(); - if (status != null && !status.equalsIgnoreCase(sourceStatus)) continue; - if (reviewStatus != null && !reviewStatus.equalsIgnoreCase(actualReview)) continue; - if (generated != null && !generated.equals(actualGenerated)) continue; - if (from != null && (businessDate == null || businessDate.isBefore(from))) continue; - if (to != null && (businessDate == null || businessDate.isAfter(to))) continue; - String searchable = lower(String.join(" ", value(item.poolItemId()), value(item.sourceId()), value(item.feeItemName()), - detail == null ? "" : value(detail.inboundCode()), detail == null ? "" : value(detail.containerNo()))); - if (keyword != null && !searchable.contains(keyword)) continue; - rows.add(feeRow(item, detail, businessDate)); + for (int page = 1; ; page++) { + var result = operationService.listFinanceFeeSources(page, 200, longValue(p, "customerId", false), + text(p, "direction"), text(p, "status"), text(p, "reviewStatus"), bool(p, "statementGenerated"), + date(p, "businessDateFrom"), date(p, "businessDateTo"), text(p, "keyword")); + result.data().forEach(source -> rows.add(feeRow(source))); + if ((long) page * 200 >= result.page().total()) return rows; } - return rows; - } - - private FeeSourceDetail detail(FeePoolItem item) { - if ("MANUAL_COST".equals(item.sourceType())) return null; - String[] parts = item.poolItemId().split(":", -1); - if (parts.length != 2) return null; - try { return operationService.getFinanceFeeSource(Long.parseLong(parts[1])); } - catch (RuntimeException ignored) { return null; } } private List> statementRows(Map p) { @@ -376,13 +350,12 @@ public class FinanceExportProviderService { "documentNo", value.documentNo(), "receivableChange", value.receivableChange(), "payableChange", value.payableChange(), "outstandingReceivableAfter", value.outstandingReceivableAfter(), "operatorName", value.operatorName(), "remark", value.remark()); } - private Map feeRow(FeePoolItem item, FeeSourceDetail detail, LocalDate businessDate) { - return row("customerName", detail == null ? null : detail.customerName(), "direction", item.direction(), "sourceType", item.sourceType(), - "sourceFeeId", String.valueOf(item.sourceId()), "sourceVersion", item.sourceVersion(), "inboundCode", detail == null ? null : detail.inboundCode(), - "containerNo", detail == null ? null : detail.containerNo(), "businessDate", businessDate, "feeItemName", item.feeItemName(), - "quantity", detail == null ? null : detail.quantity(), "unitPrice", detail == null ? null : detail.unitPrice(), "amount", item.amount(), - "reviewStatus", detail == null ? null : detail.reviewStatus(), - "sourceStatus", detail == null ? item.status() : detail.sourceStatus(), "statementGenerated", detail == null ? null : detail.statementGenerated()); + private Map feeRow(FeeSourceSummary source) { + return row("customerName", source.customerName(), "direction", source.direction(), "sourceType", source.sourceType(), + "sourceFeeId", String.valueOf(source.sourceFeeId()), "sourceVersion", source.sourceVersion(), "inboundCode", source.inboundCode(), + "containerNo", source.containerNo(), "businessDate", source.businessDate(), "feeItemName", source.feeItemName(), + "quantity", source.quantity(), "unitPrice", source.unitPrice(), "amount", source.amount(), + "reviewStatus", source.reviewStatus(), "sourceStatus", source.sourceStatus(), "statementGenerated", source.statementGenerated()); } private Map statementRow(ReconciliationDetail value) { return row("statementNo", value.statementNo(), "customerName", value.customerName(), "periodStart", value.periodStart(), diff --git a/finance/src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java b/finance/src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java index 856ae61d7..81bfd5be0 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java @@ -178,7 +178,7 @@ public class FinancePhase1OperationService { String normalizedReview = normalized(reviewStatus, List.of("DRAFT", "PENDING", "APPROVED", "REJECTED"), "审核状态无效"); String normalizedSourceStatus = normalized(sourceStatus, - List.of("AVAILABLE", "LOCKED", "BILLED", "SETTLED", "VOIDED", "SUPERSEDED"), "费用来源状态无效"); + List.of("AVAILABLE", "LOCKED", "BILLED", "SETTLED", "PAYABLE_BILLED", "PARTIALLY_PAID", "PAID", "VOIDED", "SUPERSEDED"), "费用来源状态无效"); Map filters = feeSources.filters(customerId, normalizedDirection, normalizedSourceStatus, normalizedReview, statementGenerated, businessDateFrom, businessDateTo, blankToNull(keyword), page, pageSize); return PageEnvelope.success(feeSources.list(filters), page, pageSize, feeSources.count(filters)); @@ -186,9 +186,7 @@ public class FinancePhase1OperationService { public FeeSourceDetail getFinanceFeeSource(long feeSourceId) { if (feeSourceId <= 0) throw error("FIN-REQ-400", "费用来源ID无效"); - List rows = jdbc.query("SELECT * FROM fin_fee_source WHERE id=?", (rs, n) -> detail(rs), feeSourceId); - if (rows.isEmpty()) throw error("FIN-FEE-404", "费用来源不存在"); - return rows.get(0); + return feeSources.detail(feeSourceId); } @Transactional @@ -225,6 +223,7 @@ public class FinancePhase1OperationService { adjustment ? "POST" : "PUT", path, request); if (replay != null) return getFinanceFeeSource(replay); if (before.statementGenerated()) throw error("FIN-FEE-409", "已生成单据的费用来源不可编辑或调整"); + if (!"AVAILABLE".equals(before.sourceStatus())) throw error("FIN-FEE-409", "费用已被占用、结清或作废,不可编辑或调整"); String requiredStatus = adjustment ? "APPROVED" : before.reviewStatus(); if (!adjustment && !List.of("DRAFT", "REJECTED").contains(requiredStatus)) { throw error("FIN-FEE-409", "只有草稿或已驳回费用可编辑"); @@ -302,6 +301,7 @@ public class FinancePhase1OperationService { "/api/platform/finance/v1/fee-sources/" + feeSourceId + (voidSource ? "/void" : "/submit"), request); if (replay != null) return getFinanceFeeSource(replay); if (before.statementGenerated()) throw error("FIN-FEE-409", "已生成单据的费用来源不可操作"); + if (!"AVAILABLE".equals(before.sourceStatus())) throw error("FIN-FEE-409", "费用已被占用、结清或作废,不可操作"); if (!allowedReviews.contains(before.reviewStatus())) throw error("FIN-FEE-409", "费用来源当前状态不允许该操作"); int changed = voidSource ? jdbc.update("UPDATE fin_fee_source SET status='VOIDED',operation_version=operation_version+1 WHERE id=? AND operation_version=? AND status='AVAILABLE' AND review_status=?", @@ -315,27 +315,6 @@ public class FinancePhase1OperationService { return after; } - /** 读取指定不可变版本,并附带同客户同方向的费用调整历史。 */ - private FeeSourceDetail detail(ResultSet rs) throws SQLException { - JsonNode snapshot = parse(rs.getString("source_snapshot")); - long id = rs.getLong("id"); - boolean generated = Boolean.TRUE.equals(jdbc.queryForObject("SELECT COUNT(*)>0 FROM fin_bill_line WHERE source_id=? AND source_version=?", Boolean.class, id, rs.getInt("source_fee_version"))); - String status = rs.getString("status"); - if (generated && "AVAILABLE".equals(status)) status = "BILLED"; - BigDecimal quantity = decimal(snapshot, "chargeableQuantity"); - BigDecimal amount = rs.getBigDecimal("amount"); - BigDecimal originalAmount = firstDecimal(decimal(snapshot, "originalAmount"), amount); - BigDecimal unitPrice = firstDecimal(decimal(snapshot, "unitPrice"), - quantity != null && quantity.signum() > 0 ? originalAmount.divide(quantity, 6, java.math.RoundingMode.HALF_UP) : null); - return new FeeSourceDetail(id, rs.getString("source_system"), rs.getString("source_type"), firstText(rs.getString("source_no"), text(snapshot, "sourceBusinessId")), rs.getLong("source_fee_id"), rs.getInt("source_fee_version"), rs.getString("direction"), rs.getLong("customer_id"), text(snapshot, "customerName"), jsonLong(snapshot, "supplierId"), text(snapshot, "supplierName"), - nullableLong(rs, "order_id"), nullableLong(rs, "inbound_id"), text(snapshot, "inboundCode"), nullableLong(rs, "container_id"), text(snapshot, "containerNo"), date(snapshot, "businessDate"), rs.getString("fee_item_name"), - quantity, text(snapshot, "quantityUnit"), unitPrice, nullableDate(snapshot, "periodStart"), nullableDate(snapshot, "periodEnd"), - amount, rs.getString("currency_code"), firstText(text(snapshot, "originalCurrency"), rs.getString("currency_code")), originalAmount, - firstDecimal(decimal(snapshot, "exchangeRate"), BigDecimal.ONE), text(snapshot, "exchangeRateMonth"), - defaultString(rs.getString("review_status"), "DRAFT"), status, generated, - rs.getString("review_reason"), rs.getString("source_hash"), rs.getInt("operation_version"), feeSources.logs(rs.getLong("customer_id"), id)); - } - private Long reserve(long customerId, long operatorId, String operation, String key, String method, String path, Object body) { if (!StringUtils.hasText(key) || key.length() > 128) throw error("FIN-IDEM-400", "必须提供有效的Idempotency-Key"); String hash = hash(Map.of("method", method, "path", path, "customerId", customerId, "body", body)); diff --git a/finance/src/main/java/com/baiyue/finance/service/FinanceQueryService.java b/finance/src/main/java/com/baiyue/finance/service/FinanceQueryService.java index 9d41b2eb9..435c89856 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinanceQueryService.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceQueryService.java @@ -40,10 +40,12 @@ public class FinanceQueryService { private static final String CNY = "CNY"; private final JdbcTemplate jdbc; private final ObjectMapper objectMapper; + private final FinanceCustomerNames customerNames; - public FinanceQueryService(JdbcTemplate jdbc, ObjectMapper objectMapper) { + public FinanceQueryService(JdbcTemplate jdbc, ObjectMapper objectMapper, FinanceCustomerNames customerNames) { this.jdbc = jdbc; this.objectMapper = objectMapper; + this.customerNames = customerNames; } public CustomerAccount customerAccount(long customerId) { @@ -131,12 +133,13 @@ public class FinanceQueryService { public List statements(Long customerId, boolean clientVisibleOnly) { validateOptionalCustomer(customerId); + var names = customerNames.resolver(); String visible = clientVisibleOnly ? " AND status IN ('ISSUED','PARTIALLY_PAID','PAID')" : ""; String sql = "SELECT id,tenant_id,bill_no,customer_name_snapshot,bill_date,due_date,total_amount,received_amount,outstanding_amount,currency_code,status,version,customer_confirmation_status,customer_confirmed_by,customer_confirmed_by_name,customer_confirmed_at,created_by,created_by_name,created_at,updated_by,updated_by_name,updated_at FROM fin_bill WHERE 1=1"; Object[] args = new Object[0]; if (customerId != null) { sql += " AND tenant_id=?"; args = new Object[]{customerId}; } return jdbc.query(sql + visible + " ORDER BY bill_date DESC,id DESC", - (rs, rowNum) -> new Statement(rs.getLong("id"), rs.getLong("tenant_id"), rs.getString("bill_no"), rs.getString("customer_name_snapshot"), + (rs, rowNum) -> new Statement(rs.getLong("id"), rs.getLong("tenant_id"), rs.getString("bill_no"), names.apply(rs.getLong("tenant_id"), rs.getString("customer_name_snapshot")), rs.getDate("bill_date").toLocalDate(), rs.getDate("due_date").toLocalDate(), rs.getBigDecimal("total_amount"), rs.getBigDecimal("received_amount"), rs.getBigDecimal("outstanding_amount"), rs.getString("currency_code"), rs.getString("status"), rs.getInt("version"), rs.getString("customer_confirmation_status"), nullableLong(rs, "customer_confirmed_by"), @@ -147,6 +150,7 @@ public class FinanceQueryService { public List reconciliationStatements(Long customerId) { validateOptionalCustomer(customerId); + var names = customerNames.resolver(); String sql = "SELECT b.id,b.tenant_id,b.bill_no,b.customer_name_snapshot,b.currency_code,b.status,b.version,b.remark," + "b.total_amount,b.received_amount,b.outstanding_amount,b.issued_at,b.customer_confirmation_status,b.customer_confirmed_by,b.customer_confirmed_by_name,b.customer_confirmed_at,b.created_by,b.created_by_name,b.created_at,b.updated_by,b.updated_by_name,b.updated_at," + "COALESCE(p.credit_days,30) credit_days," + @@ -163,7 +167,7 @@ public class FinanceQueryService { Object[] args = new Object[0]; if (customerId != null) { sql += " AND b.tenant_id=?"; args = new Object[]{customerId}; } return jdbc.query(sql + " ORDER BY b.bill_date DESC,b.id DESC", (rs, rowNum) -> new ReconciliationSummary( - rs.getLong("id"), rs.getString("bill_no"), rs.getLong("tenant_id"), rs.getString("customer_name_snapshot"), + rs.getLong("id"), rs.getString("bill_no"), rs.getLong("tenant_id"), names.apply(rs.getLong("tenant_id"), rs.getString("customer_name_snapshot")), date(rs, "period_start"), date(rs, "period_end"), rs.getInt("credit_days"), rs.getString("currency_code"), rs.getInt("line_count"), rs.getInt("source_count"), rs.getBigDecimal("source_amount"), rs.getBigDecimal("adjustment_amount"), rs.getBigDecimal("total_amount"), rs.getBigDecimal("received_amount"), rs.getBigDecimal("outstanding_amount"), @@ -178,9 +182,10 @@ public class FinanceQueryService { public StatementDetail statement(long customerId, long billId, boolean clientVisibleOnly) { requireCustomer(customerId); + var names = customerNames.resolver(); String visible = clientVisibleOnly ? " AND status IN ('ISSUED','PARTIALLY_PAID','PAID')" : ""; List rows = jdbc.query("SELECT id,tenant_id,bill_no,customer_name_snapshot,bill_date,due_date,total_amount,received_amount,outstanding_amount,currency_code,status,version,customer_confirmation_status,customer_confirmed_by,customer_confirmed_by_name,customer_confirmed_at,created_by,created_by_name,created_at,updated_by,updated_by_name,updated_at FROM fin_bill WHERE tenant_id=? AND id=?" + visible, - (rs, rowNum) -> new Statement(rs.getLong("id"), rs.getLong("tenant_id"), rs.getString("bill_no"), rs.getString("customer_name_snapshot"), + (rs, rowNum) -> new Statement(rs.getLong("id"), rs.getLong("tenant_id"), rs.getString("bill_no"), names.apply(rs.getLong("tenant_id"), rs.getString("customer_name_snapshot")), rs.getDate("bill_date").toLocalDate(), rs.getDate("due_date").toLocalDate(), rs.getBigDecimal("total_amount"), rs.getBigDecimal("received_amount"), rs.getBigDecimal("outstanding_amount"), rs.getString("currency_code"), rs.getString("status"), rs.getInt("version"), rs.getString("customer_confirmation_status"), nullableLong(rs, "customer_confirmed_by"), 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 3f1e8a1fa..4259966d4 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinanceSettlementService.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceSettlementService.java @@ -52,19 +52,21 @@ public class FinanceSettlementService { private final FinanceVoucherValidator voucherValidator; private final FinanceSupplierValidator supplierValidator; private final FinanceDocumentNumberGenerator documentNumbers; + private final FinanceCustomerNames customerNames; /** 注入统一编号组件,确保所有业务入口共用受事务保护的日流水。 */ @Autowired public FinanceSettlementService(JdbcTemplate jdbc, ObjectMapper objectMapper, FinanceVoucherValidator voucherValidator, FinanceSupplierValidator supplierValidator, FinanceDocumentNumberGenerator documentNumbers, - com.baiyue.finance.repository.FinanceSettlementDetailRepository details) { + com.baiyue.finance.repository.FinanceSettlementDetailRepository details, FinanceCustomerNames customerNames) { this.jdbc = jdbc; this.details = details; this.objectMapper = objectMapper; this.voucherValidator = voucherValidator; this.supplierValidator = supplierValidator; this.documentNumbers = documentNumbers; + this.customerNames = customerNames; } /** 创建应收结算单,幂等请求恢复原单,首次建单分配 AR 日流水。 */ @@ -98,7 +100,7 @@ public class FinanceSettlementService { LocalDate generatedDate = LocalDate.now(); long settlementId = insertSettlement(customerId, documentNumbers.next("AR"), "RECEIVABLE", - text(bill, "customer_name_snapshot"), null, null, request.reconciliationStatementId(), + customerNames.requiredSnapshot(customerId, text(bill, "customer_name_snapshot")), null, null, request.reconciliationStatementId(), date(bill, "bill_date").format(DateTimeFormatter.ofPattern("yyyy-MM")), CNY, total, generatedDate, date(bill, "due_date"), operatorId); int inserted = jdbc.update( @@ -155,8 +157,7 @@ public class FinanceSettlementService { if (total.signum() <= 0) throw error("FIN_400", "应付结算金额必须大于0"); String supplierCategory = supplierCategoryConstraint(sources); String supplierName = supplierValidator.requireActive(request.supplierId(), supplierCategory).name(); - String customerName = sources.stream().map(row -> jsonText(parseTree(text(row, "source_snapshot")), "customerName")) - .filter(StringUtils::hasText).findFirst().orElse("客户" + customerId); + String customerName = customerNames.required(customerId); LocalDate generatedDate = LocalDate.now(); long settlementId = insertSettlement(customerId, documentNumbers.next("AP"), "PAYABLE", customerName, request.supplierId(), supplierName, null, generatedDate.format(DateTimeFormatter.ofPattern("yyyy-MM")), @@ -176,7 +177,7 @@ public class FinanceSettlementService { if (settlementId <= 0) throw error("FIN_404", "结算单不存在"); List> records = details.summary(settlementId); if (records.isEmpty()) throw error("FIN_404", "结算单不存在"); - SettlementSummary summary = summary(records.get(0)); + SettlementSummary summary = summary(records.get(0), customerNames.resolver()); List lines = details.lines(summary.customerId(), settlementId) .stream().map(source -> line(source, summary)).toList(); List transactions = new ArrayList<>(details.payments(summary.customerId(), settlementId) @@ -206,7 +207,8 @@ public class FinanceSettlementService { args.add(customerId); } sql += " ORDER BY s.generated_date DESC,s.id DESC"; - return jdbc.queryForList(sql, args.toArray()).stream().map(this::summary).toList(); + var names = customerNames.resolver(); + return jdbc.queryForList(sql, args.toArray()).stream().map(row -> summary(row, names)).toList(); } @Transactional @@ -419,9 +421,9 @@ public class FinanceSettlementService { MDC.get("requestId"), MDC.get("traceId")); } - private SettlementSummary summary(Map row) { + private SettlementSummary summary(Map row, java.util.function.BiFunction names) { return new SettlementSummary(number(row, "id").longValue(), text(row, "settlement_no"), - text(row, "direction"), number(row, "tenant_id").longValue(), text(row, "customer_name_snapshot"), + text(row, "direction"), number(row, "tenant_id").longValue(), names.apply(number(row, "tenant_id").longValue(), text(row, "customer_name_snapshot")), nullableNumber(row, "supplier_id"), text(row, "supplier_name_snapshot"), nullableNumber(row, "reconciliation_statement_id"), text(row, "reconciliation_statement_no"), text(row, "business_period"), text(row, "fee_item_summary"), diff --git a/finance/src/main/java/com/baiyue/finance/service/ReceivableStatisticsService.java b/finance/src/main/java/com/baiyue/finance/service/ReceivableStatisticsService.java index 07ce5c305..2d1940454 100644 --- a/finance/src/main/java/com/baiyue/finance/service/ReceivableStatisticsService.java +++ b/finance/src/main/java/com/baiyue/finance/service/ReceivableStatisticsService.java @@ -15,9 +15,11 @@ import java.util.List; public class ReceivableStatisticsService { private static final String CURRENCY = "CNY"; private final JdbcTemplate jdbc; + private final FinanceCustomerNames customerNames; - public ReceivableStatisticsService(JdbcTemplate jdbc) { + public ReceivableStatisticsService(JdbcTemplate jdbc, FinanceCustomerNames customerNames) { this.jdbc = jdbc; + this.customerNames = customerNames; } public ReceivableStatisticsResponse query(YearMonth month, Long customerId, int page, int pageSize) { @@ -61,8 +63,9 @@ public class ReceivableStatisticsService { args.add(customerId); } sql.append(" GROUP BY s.tenant_id,p.customer_name_snapshot ORDER BY customer_name_snapshot,s.tenant_id"); + var names = customerNames.resolver(); return jdbc.query(sql.toString(), (rs, rowNum) -> checked(new ReceivableStatisticsRow( - rs.getLong("tenant_id"), rs.getString("customer_name_snapshot"), CURRENCY, + rs.getLong("tenant_id"), names.apply(rs.getLong("tenant_id"), rs.getString("customer_name_snapshot")), CURRENCY, rs.getBigDecimal("total_receivable"), rs.getBigDecimal("total_received"), rs.getBigDecimal("total_outstanding"))), args.toArray()); } diff --git a/finance/src/main/java/com/baiyue/finance/service/ReconciliationStatementService.java b/finance/src/main/java/com/baiyue/finance/service/ReconciliationStatementService.java index 34c5c62eb..e4ef8262f 100644 --- a/finance/src/main/java/com/baiyue/finance/service/ReconciliationStatementService.java +++ b/finance/src/main/java/com/baiyue/finance/service/ReconciliationStatementService.java @@ -46,14 +46,16 @@ public class ReconciliationStatementService { private final JdbcTemplate jdbc; private final ObjectMapper objectMapper; private final FinanceDocumentNumberGenerator documentNumbers; + private final FinanceCustomerNames customerNames; /** 注入统一编号组件,确保所有业务入口共用受事务保护的日流水。 */ @Autowired public ReconciliationStatementService(JdbcTemplate jdbc, ObjectMapper objectMapper, - FinanceDocumentNumberGenerator documentNumbers) { + FinanceDocumentNumberGenerator documentNumbers, FinanceCustomerNames customerNames) { this.jdbc = jdbc; this.objectMapper = objectMapper; this.documentNumbers = documentNumbers; + this.customerNames = customerNames; } /** 创建对账单并分配 REC 日流水,重复请求返回已创建记录。 */ @@ -195,7 +197,7 @@ public class ReconciliationStatementService { List revisions = jdbc.query( "SELECT action,reason,before_data,after_data,operator_id,operator_name_snapshot,created_at FROM fin_audit_log WHERE tenant_id=? AND resource_type='RECONCILIATION_STATEMENT' AND resource_id=? AND action<>'RECONCILIATION_CUSTOMER_CONFIRM' AND before_data IS NOT NULL ORDER BY created_at,id", (rs, row) -> revision(rs, row + 1), customerId, statementId); - return new ReconciliationDetail(bill.id(), bill.number(), bill.customerId(), bill.customerName(), + return new ReconciliationDetail(bill.id(), bill.number(), bill.customerId(), customerNames.resolver().apply(bill.customerId(), bill.customerName()), metadata.periodStart(), metadata.periodEnd(), bill.creditDays(), "CNY", bill.total(), bill.received(), bill.outstanding(), bill.outstanding().signum() == 0, reconciliationStatus(bill.status()), bill.version(), bill.customerConfirmationStatus(), bill.customerConfirmedBy(), bill.customerConfirmedByName(), @@ -278,7 +280,7 @@ public class ReconciliationStatementService { private CustomerProfile customer(long customerId) { List rows = jdbc.query("SELECT customer_name_snapshot,credit_days FROM fin_customer_settlement_profile WHERE tenant_id=? AND status='ACTIVE'", (rs, row) -> new CustomerProfile(rs.getString("customer_name_snapshot"), rs.getInt("credit_days")), customerId); - return rows.isEmpty() ? new CustomerProfile("客户" + customerId, 30) : rows.get(0); + return new CustomerProfile(customerNames.required(customerId), rows.isEmpty() ? 30 : rows.get(0).creditDays()); } private long activeSubject() { @@ -488,18 +490,19 @@ public class ReconciliationStatementService { } private ReconciliationRevision revision(ResultSet rs, int revisionNo) throws SQLException { - return new ReconciliationRevision(revisionNo, rs.getString("action").replaceFirst("^RECONCILIATION_", ""), amountFromJson(rs.getString("before_data")), - amountFromJson(rs.getString("after_data")), rs.getString("reason"), rs.getLong("operator_id"), + return new ReconciliationRevision(revisionNo, rs.getString("action").replaceFirst("^RECONCILIATION_", ""), amountFromJson(rs.getString("before_data"), "total"), + amountFromJson(rs.getString("after_data"), "totalReceivable"), rs.getString("reason"), rs.getLong("operator_id"), rs.getString("operator_name_snapshot"), rs.getTimestamp("created_at").toLocalDateTime()); } - private BigDecimal amountFromJson(String value) { - if (!StringUtils.hasText(value)) return BigDecimal.ZERO; + /** 变更前为 Bill,变更后为 ReconciliationDetail;缺失金额属于损坏快照,不能伪装为零。 */ + private BigDecimal amountFromJson(String value, String field) { try { - JsonNode node = objectMapper.readTree(value); - JsonNode amount = node.has("totalAmount") ? node.get("totalAmount") : node.get("total"); - return amount == null || amount.isNull() ? BigDecimal.ZERO : amount.decimalValue(); + JsonNode node = objectMapper.reader().with(com.fasterxml.jackson.databind.DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS).readTree(value); + JsonNode amount = node.get(field); + if (amount == null || !amount.isNumber()) throw new IllegalArgumentException("缺失金额"); + return amount.decimalValue(); } catch (Exception exception) { throw error("FIN_001", "对账单修订快照损坏"); } diff --git a/finance/src/test/java/com/baiyue/finance/BusinessFeeSourceEventServiceTest.java b/finance/src/test/java/com/baiyue/finance/BusinessFeeSourceEventServiceTest.java index b99700bb0..de880e17a 100644 --- a/finance/src/test/java/com/baiyue/finance/BusinessFeeSourceEventServiceTest.java +++ b/finance/src/test/java/com/baiyue/finance/BusinessFeeSourceEventServiceTest.java @@ -7,9 +7,16 @@ import com.baiyue.common.event.EventPayloadHash; import com.baiyue.common.event.EventTypes; import com.baiyue.finance.service.BusinessFeeSourceEventService; import com.baiyue.finance.service.FinanceSupplierValidator; +import com.baiyue.finance.service.FinanceEventException; +import com.baiyue.finance.repository.BusinessFeeSourceRepository; +import com.baiyue.finance.dto.BusinessFeeSourceVersion; +import com.baiyue.common.event.DeliveryAck; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; import org.mockito.ArgumentCaptor; import org.springframework.jdbc.core.JdbcTemplate; @@ -25,6 +32,7 @@ import static org.mockito.Mockito.never; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.eq; @@ -37,7 +45,8 @@ class BusinessFeeSourceEventServiceTest { private final JdbcTemplate jdbc = mock(JdbcTemplate.class); private final ObjectMapper json = new ObjectMapper().findAndRegisterModules(); private final FinanceSupplierValidator suppliers = mock(FinanceSupplierValidator.class); - private final BusinessFeeSourceEventService service = new BusinessFeeSourceEventService(jdbc, json, suppliers); + private final BusinessFeeSourceRepository sources = mock(BusinessFeeSourceRepository.class); + private final BusinessFeeSourceEventService service = new BusinessFeeSourceEventService(jdbc, json, suppliers, sources); @Test void importsOverseasFeeWithoutInventingInboundReference() { @@ -116,19 +125,93 @@ class BusinessFeeSourceEventServiceTest { verify(jdbc, never()).update(anyString(), any(Object[].class)); } + /** 旧版本不能覆盖已有较新版本,客户相同时仍保留原乱序门禁。 */ @Test void lateOlderVersionCannotSupersedeNewerSourceOrCost() { when(suppliers.requireActive(71L, null)).thenReturn( new com.baiyue.finance.client.WarehouseSupplierClient.SupplierSnapshot(71L, "供应商", null, "ACTIVE")); - when(jdbc.queryForList(startsWith("SELECT source_fee_version"), eq("LASTLEG"), - eq("OVERSEAS_CONTAINER_FEE"), eq(701L))) - .thenReturn(List.of(Map.of("source_fee_version", 2))); + when(sources.lockVersions("LASTLEG", "OVERSEAS_CONTAINER_FEE", 701L)) + .thenReturn(List.of(new BusinessFeeSourceVersion(2, 10001L))); assertThatThrownBy(() -> service.consume(containerEvent("CNY", "61", "PUBLISHED", 1))) .hasMessageContaining("版本已过期"); verify(jdbc, never()).update(startsWith("UPDATE fin_fee_source"), any(Object[].class)); verify(jdbc, never()).update(startsWith("UPDATE fin_cost"), any(Object[].class)); } + /** 已完成的同一事件不再查询会停用、改名或暂时不可用的供应商主档。 */ + @ParameterizedTest + @ValueSource(strings = {"FIN_409", "FIN_503", "RENAMED"}) + void completedReplayDoesNotDependOnCurrentSupplier(String change) { + EventEnvelope event = containerEvent("CNY", "61", "PUBLISHED", 1); + when(jdbc.queryForList(startsWith("SELECT payload_hash,status"), eq("LASTLEG"), eq(event.eventId()))) + .thenReturn(List.of(Map.of("payload_hash", event.payloadHash(), "status", "PROCESSED", "result_resource_id", 901L))); + if ("RENAMED".equals(change)) { + when(suppliers.requireActive(71L, null)).thenReturn( + new com.baiyue.finance.client.WarehouseSupplierClient.SupplierSnapshot(71L, "新供应商名称", null, "ACTIVE")); + } else { + when(suppliers.requireActive(71L, null)).thenThrow(new FinanceEventException(change, "供应商不可用")); + } + assertThat(service.consume(event).outcome()).isEqualTo(DeliveryAck.Outcome.DUPLICATE); + verify(suppliers, never()).requireActive(anyLong(), any()); + verify(jdbc, never()).update(anyString(), any(Object[].class)); + } + + /** 首次接收仍须失败关闭,不能为修复重放而绕过供应商校验。 */ + @ParameterizedTest + @ValueSource(strings = {"FIN_409", "FIN_503"}) + void newPayableStillRejectsUnavailableSupplier(String code) { + EventEnvelope event = containerEvent("CNY", "61", "PUBLISHED", 1); + when(suppliers.requireActive(71L, null)).thenThrow(new FinanceEventException(code, "供应商不可用")); + assertThatThrownBy(() -> service.consume(event)).hasMessageContaining("供应商不可用"); + verify(jdbc, never()).update(anyString(), any(Object[].class)); + } + + /** 两个摘要均正确也不能绕过金额算术、人民币汇率及精度约束。 */ + @ParameterizedTest + @CsvSource({"USD,1.00,8.000000,120.00", "CNY,120.00,2.000000,240.00", + "USD,1.00,8.000000,8.001", "USD,1.001,8.000000,8.01", "USD,1.00,8.0000001,8.00"}) + void rejectsInconsistentAmountOrPrecision(String currency, String original, String rate, String total) { + EventEnvelope event = convertedEvent(currency, original, rate, total); + assertThatThrownBy(() -> service.consume(event)).isInstanceOfSatisfying(FinanceEventException.class, + error -> assertThat(error.code()).isEqualTo("FIN-EVT-019")); + verify(jdbc, never()).update(anyString(), any(Object[].class)); + } + + /** 分位舍入与数值相等的尾零均遵循生产端规则,不错误拒收合法费用。 */ + @ParameterizedTest + @CsvSource({"USD,0.04,0.125000,0.01", "USD,16.85,7.122000,120.01", "CNY,120.0000,1.00000000,120.0000"}) + void acceptsProducerRoundingAndEquivalentTrailingZeros(String currency, String original, String rate, String total) { + when(jdbc.queryForObject(startsWith("SELECT id FROM fin_fee_source"), eq(Long.class), + eq("LASTLEG"), eq("OVERSEAS_WAREHOUSE_FEE"), eq(701L), eq(1))).thenReturn(901L); + assertThat(service.consume(convertedEvent(currency, original, rate, total)).outcome()) + .isEqualTo(DeliveryAck.Outcome.APPLIED); + } + + /** 调整只允许原客户,错误归属不得替代旧费用或旧成本。 */ + @Test + void crossCustomerRevisionCannotReplaceExistingSource() { + var event = payableEvent("ADJUSTED", 2); + when(sources.lockVersions("LASTLEG", "OVERSEAS_CONTAINER_FEE", 701L)) + .thenReturn(List.of(new BusinessFeeSourceVersion(1, 10002L))); + assertThatThrownBy(() -> service.consume(event)).isInstanceOfSatisfying(FinanceEventException.class, + error -> assertThat(error.code()).isEqualTo("FIN-EVT-018")); + noFinancialWrites(); + } + + /** 创建金额测试快照时同时更新正式来源摘要与事件摘要。 */ + private EventEnvelope convertedEvent(String currency, String original, String rate, String total) { + EventEnvelope base = eventWithoutInbound(); + ObjectNode payload = base.payload().deepCopy(); + payload.put("originalCurrency", currency).put("originalAmount", new BigDecimal(original)) + .put("exchangeRate", new BigDecimal(rate)).put("totalAmount", new BigDecimal(total)); + payload.putNull("action"); payload.putNull("sourceHash"); + payload.put("sourceHash", EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload))); + payload.put("action", "PUBLISHED"); + return new EventEnvelope(base.eventId(), base.eventType(), base.eventVersion(), base.occurredAt(), base.tenantId(), + base.aggregateType(), base.aggregateId(), base.aggregateVersion(), null, base.traceId(), base.correlationId(), null, + payload, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload))); + } + @Test void replayReturnsOriginalSourceWithoutDuplicatingCost() { EventEnvelope event = containerEvent("CNY", "61", "PUBLISHED", 1); diff --git a/finance/src/test/java/com/baiyue/finance/BusinessFeeSourceReceptionPersistenceTest.java b/finance/src/test/java/com/baiyue/finance/BusinessFeeSourceReceptionPersistenceTest.java new file mode 100644 index 000000000..138aadf89 --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/BusinessFeeSourceReceptionPersistenceTest.java @@ -0,0 +1,238 @@ +package com.baiyue.finance; + +import com.baiyue.common.event.DeliveryAck; +import com.baiyue.common.event.BusinessFeeSourceChangedV1; +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.EventPayloadCanonicalizer; +import com.baiyue.common.event.EventPayloadHash; +import com.baiyue.common.event.EventTypes; +import com.baiyue.finance.client.WarehouseSupplierClient.SupplierSnapshot; +import com.baiyue.finance.mapper.BusinessFeeSourceMapper; +import com.baiyue.finance.repository.BusinessFeeSourceRepository; +import com.baiyue.finance.service.BusinessFeeSourceEventService; +import com.baiyue.finance.service.FinanceEventException; +import com.baiyue.finance.service.FinanceSupplierValidator; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.apache.ibatis.session.Configuration; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mybatis.spring.SqlSessionFactoryBean; +import org.mybatis.spring.SqlSessionTemplate; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.springframework.transaction.support.TransactionTemplate; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** 隔离数据库执行真实 Mapper 和接收事务,验证失败不留下收件箱或费用半成品。 */ +class BusinessFeeSourceReceptionPersistenceTest { + private final ObjectMapper json = new ObjectMapper().findAndRegisterModules(); + private final FinanceSupplierValidator suppliers = mock(FinanceSupplierValidator.class); + private JdbcTemplate jdbc; + private TransactionTemplate transaction; + private BusinessFeeSourceEventService service; + + /** MyBatis 与既有接收写入共用 Spring 事务连接,不连接共享 TEST 数据库。 */ + @BeforeEach + void setUp() throws Exception { + var data = new DriverManagerDataSource("jdbc:h2:mem:fee-reception-" + UUID.randomUUID() + + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""); + jdbc = new JdbcTemplate(data); + jdbc.execute(""" + CREATE TABLE fin_fee_source(id BIGINT AUTO_INCREMENT PRIMARY KEY, + source_system VARCHAR(32),source_type VARCHAR(32),source_fee_id BIGINT,source_fee_version INT, + customer_id BIGINT,source_no VARCHAR(64),business_type VARCHAR(32),business_id BIGINT, + business_no VARCHAR(128),inbound_code VARCHAR(64),warehouse_id BIGINT,direction VARCHAR(16), + order_id BIGINT,inbound_id BIGINT,container_id BIGINT,container_booking_id BIGINT,container_no VARCHAR(64), + fee_item_code VARCHAR(64),fee_item_name VARCHAR(200),amount DECIMAL(20,6),currency_code VARCHAR(3), + settlement_mode VARCHAR(16),paid_at TIMESTAMP,payment_method VARCHAR(32),payment_reference_no VARCHAR(128), + payment_voucher_file_ids VARCHAR(2000),source_hash VARCHAR(71),source_snapshot VARCHAR(10000), + review_status VARCHAR(16),status VARCHAR(16),wallet_hold_id BIGINT,operation_version INT DEFAULT 0, + UNIQUE(source_system,source_type,source_fee_id,source_fee_version)) + """); + jdbc.execute(""" + CREATE TABLE fin_inbox_event(event_id VARCHAR(128),source_system VARCHAR(32),event_type VARCHAR(64), + payload VARCHAR(15000),payload_hash VARCHAR(71),status VARCHAR(16),result_resource_id BIGINT, + processed_at TIMESTAMP,error_code VARCHAR(64),PRIMARY KEY(source_system,event_id)) + """); + jdbc.execute(""" + CREATE TABLE fin_cost(id BIGINT AUTO_INCREMENT PRIMARY KEY,tenant_id BIGINT,cost_no VARCHAR(200), + cost_type VARCHAR(32),cost_origin VARCHAR(32),source_system VARCHAR(32),source_type VARCHAR(32), + source_fee_id BIGINT,source_fee_version INT,source_hash VARCHAR(71),order_id BIGINT,inbound_id BIGINT, + container_id BIGINT,warehouse_id BIGINT,supplier_id BIGINT,supplier_name_snapshot VARCHAR(128), + fee_item_code VARCHAR(64),fee_item_name VARCHAR(200),currency_code VARCHAR(3),cost_amount DECIMAL(20,6), + voucher_file_ids VARCHAR(2000),paid_at TIMESTAMP,payment_method VARCHAR(32),payment_reference_no VARCHAR(128), + status VARCHAR(16),confirmed_at TIMESTAMP,created_by BIGINT,created_by_name VARCHAR(64),updated_by BIGINT, + voided_at TIMESTAMP,void_reason VARCHAR(200),version INT DEFAULT 0, + UNIQUE(source_system,source_type,source_fee_id,source_fee_version)) + """); + jdbc.execute("CREATE TABLE fin_bill(id BIGINT,tenant_id BIGINT,status VARCHAR(16))"); + jdbc.execute("CREATE TABLE fin_bill_line(bill_id BIGINT,tenant_id BIGINT,source_id BIGINT,source_version INT)"); + jdbc.execute("CREATE TABLE fin_settlement(id BIGINT,tenant_id BIGINT,status VARCHAR(16))"); + jdbc.execute("CREATE TABLE fin_settlement_line(settlement_id BIGINT,tenant_id BIGINT,fee_source_id BIGINT)"); + var configuration = new Configuration(); + configuration.addMapper(BusinessFeeSourceMapper.class); + var factory = new SqlSessionFactoryBean(); + factory.setDataSource(data); + factory.setConfiguration(configuration); + var mapper = new SqlSessionTemplate(factory.getObject()).getMapper(BusinessFeeSourceMapper.class); + service = new BusinessFeeSourceEventService(jdbc, json, suppliers, new BusinessFeeSourceRepository(mapper)); + transaction = new TransactionTemplate(new DataSourceTransactionManager(data)); + when(suppliers.requireActive(71L, null)).thenReturn(new SupplierSnapshot(71L, "供应商", null, "ACTIVE")); + } + + /** 释放每个用例独占的内存数据库。 */ + @AfterEach + void tearDown() { + if (jdbc != null) jdbc.execute("SHUTDOWN"); + } + + /** 已提交后模拟丢失回执,供应商停用或故障都不产生第二笔费用和成本。 */ + @ParameterizedTest + @ValueSource(strings = {"FIN_409", "FIN_503"}) + void committedDirectPaidReplayKeepsExactlyOneSourceAndCost(String errorCode) { + var event = event(701, 1, 10001, "DIRECT_PAID", "CNY", "12.00", "1", "12.00"); + assertThat(consume(event).outcome()).isEqualTo(DeliveryAck.Outcome.APPLIED); + reset(suppliers); + when(suppliers.requireActive(71L, null)).thenThrow(new FinanceEventException(errorCode, "供应商不可用")); + + assertThat(consume(event).outcome()).isEqualTo(DeliveryAck.Outcome.DUPLICATE); + verifyNoInteractions(suppliers); + assertCounts(1, 1, 1); + assertThat(jdbc.queryForObject("SELECT status FROM fin_inbox_event", String.class)).isEqualTo("PROCESSED"); + assertThat(jdbc.queryForObject("SELECT cost_amount FROM fin_cost", BigDecimal.class)).isEqualByComparingTo("12.00"); + } + + /** 不同客户的升版失败时回滚已插入 Inbox,原费用与原直付成本保持有效。 */ + @Test + void foreignCustomerRevisionRollsBackAndSameCustomerCanStillAdjust() { + consume(event(701, 1, 10001, "DIRECT_PAID", "CNY", "12.00", "1", "12.00")); + var wrong = event(701, 2, 10002, "DIRECT_PAID", "CNY", "18.00", "1", "18.00"); + assertThatThrownBy(() -> consume(wrong)).isInstanceOfSatisfying(FinanceEventException.class, + error -> assertThat(error.code()).isEqualTo("FIN-EVT-018")); + assertCounts(1, 1, 1); + assertThat(jdbc.queryForObject("SELECT status FROM fin_fee_source", String.class)).isEqualTo("AVAILABLE"); + assertThat(jdbc.queryForObject("SELECT status FROM fin_cost", String.class)).isEqualTo("CONFIRMED"); + assertThat(jdbc.queryForObject("SELECT customer_id FROM fin_fee_source", Long.class)).isEqualTo(10001L); + + consume(event(701, 2, 10001, "DIRECT_PAID", "CNY", "18.00", "1", "18.00")); + assertCounts(2, 2, 2); + assertThat(jdbc.queryForList("SELECT status FROM fin_fee_source ORDER BY source_fee_version", String.class)) + .containsExactly("SUPERSEDED", "AVAILABLE"); + assertThat(jdbc.queryForList("SELECT status FROM fin_cost ORDER BY source_fee_version", String.class)) + .containsExactly("VOIDED", "CONFIRMED"); + } + + /** 错误换算拒收后不残留收件记录,已存在版本也不会被替代。 */ + @Test + void invalidConversionLeavesOriginalAmountsAndReceiptIntact() { + consume(event(701, 1, 10001, "ON_ACCOUNT", "CNY", "12.00", "1", "12.00")); + assertThatThrownBy(() -> consume(event(701, 2, 10001, "ON_ACCOUNT", "USD", "1.00", "8", "120.00"))) + .isInstanceOfSatisfying(FinanceEventException.class, + error -> assertThat(error.code()).isEqualTo("FIN-EVT-019")); + assertCounts(1, 1, 0); + assertThat(jdbc.queryForObject("SELECT amount FROM fin_fee_source", BigDecimal.class)).isEqualByComparingTo("12.00"); + assertThat(jdbc.queryForObject("SELECT status FROM fin_fee_source", String.class)).isEqualTo("AVAILABLE"); + } + + /** 只锁定相同系统、来源类型和费用号,其他客户的独立费用不会阻塞正常升版。 */ + @Test + void independentSourcesKeepSeparateCustomerIdentity() { + consume(event(701, 1, 10001, "ON_ACCOUNT", "CNY", "12.00", "1", "12.00")); + consume(event(702, 1, 10002, "ON_ACCOUNT", "CNY", "9.00", "1", "9.00")); + consume(event(701, 2, 10001, "ON_ACCOUNT", "CNY", "18.00", "1", "18.00")); + assertCounts(3, 3, 0); + assertThat(jdbc.queryForObject("SELECT customer_id FROM fin_fee_source WHERE source_fee_id=702", Long.class)) + .isEqualTo(10002L); + assertThat(jdbc.queryForObject("SELECT status FROM fin_fee_source WHERE source_fee_id=702", String.class)) + .isEqualTo("AVAILABLE"); + } + + /** 提前恢复幂等结果不放宽事件冲突校验,异载荷仍须拒绝。 */ + @Test + void changedPayloadCannotUseProcessedEventIdentity() { + var first = event(701, 1, 10001, "ON_ACCOUNT", "CNY", "12.00", "1", "12.00"); + consume(first); + var changed = event(701, 1, 10001, "ON_ACCOUNT", "CNY", "18.00", "1", "18.00"); + var conflict = new EventEnvelope(first.eventId(), changed.eventType(), changed.eventVersion(), changed.occurredAt(), + changed.tenantId(), changed.aggregateType(), changed.aggregateId(), changed.aggregateVersion(), null, + changed.traceId(), changed.correlationId(), null, changed.payload(), changed.payloadHash()); + reset(suppliers); + assertThatThrownBy(() -> consume(conflict)).isInstanceOfSatisfying(FinanceEventException.class, + error -> assertThat(error.code()).isEqualTo("FIN-EVT-002")); + verifyNoInteractions(suppliers); + assertCounts(1, 1, 0); + } + + /** 即使失败发生在费用插入之后,收件箱、费用及成本也必须一起回滚。 */ + @Test + void costWriteFailureRollsBackReceiptAndSource() { + jdbc.execute("ALTER TABLE fin_cost ADD CONSTRAINT test_cost_limit CHECK(cost_amount <= 10)"); + assertThatThrownBy(() -> consume(event(701, 1, 10001, "DIRECT_PAID", "CNY", "12.00", "1", "12.00"))) + .isInstanceOf(org.springframework.dao.DataIntegrityViolationException.class); + assertCounts(0, 0, 0); + } + + /** 以正式服务事务边界执行,Mapper 与遗留 JDBC 写入必须共进退。 */ + private DeliveryAck consume(EventEnvelope event) { + return transaction.execute(status -> service.consume(event)); + } + + /** 校验整笔接收事实数量,防止幂等通过但产生额外财务明细。 */ + private void assertCounts(int inbox, int sources, int costs) { + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fin_inbox_event", Integer.class)).isEqualTo(inbox); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fin_fee_source", Integer.class)).isEqualTo(sources); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fin_cost", Integer.class)).isEqualTo(costs); + } + + /** 构造经过两个正式摘要计算的费用快照,金额错误用例不会被摘要错误提前拦截。 */ + private EventEnvelope event(long feeId, int version, long customer, String mode, String currency, + String original, String rate, String total) { + ObjectNode payload = json.createObjectNode(); + payload.putNull("action"); + payload.put("sourceSystem", "LASTLEG").put("sourceType", "OVERSEAS_WAREHOUSE_FEE") + .put("sourceFeeId", String.valueOf(feeId)).put("sourceVersion", version) + .put("customerId", String.valueOf(customer)).put("customerName", "验收客户") + .put("sourceNo", "ZY-RECEPTION").put("businessType", "WAREHOUSE_SERVICE") + .put("businessId", "801").put("businessNo", "ZY-RECEPTION"); + for (String field : new String[]{"orderId", "inboundId", "inboundCode", "containerId", "containerBookingId", "containerNo"}) + payload.putNull(field); + payload.put("warehouseId", "91").put("feeItemCode", "FEE_TYPE_003").put("feeItemName", "人工费") + .put("direction", "PAYABLE").put("supplierId", "71").put("supplierName", "供应商"); + payload.putNull("supplierCategory"); payload.put("settlementMode", mode); + if ("DIRECT_PAID".equals(mode)) { + payload.set("paidAt", json.valueToTree(Instant.parse("2026-10-27T01:00:00Z"))); + payload.put("paymentMethod", "BANK_TRANSFER").put("paymentReferenceNo", "TEST-RECEPTION"); + } else { + payload.putNull("paidAt"); payload.putNull("paymentMethod"); payload.putNull("paymentReferenceNo"); + } + payload.putArray("paymentVoucherFileIds"); + payload.put("businessDate", "2026-10-27").put("currency", "CNY").put("totalAmount", new BigDecimal(total)) + .put("originalCurrency", currency).put("originalAmount", new BigDecimal(original)) + .put("exchangeRate", new BigDecimal(rate)).put("exchangeRateMonth", "2026-10"); + for (String field : new String[]{"chargeableQuantity", "quantityUnit", "unitPrice", "periodStart", "periodEnd"}) + payload.putNull(field); + payload.put("reviewStatus", "APPROVED").put("publicationStatus", "PUBLISHED").put("sourceStatus", "ACTIVE"); + payload.putNull("sourceHash"); payload.putNull("walletSettlement"); + // 使用正式 DTO 规范化日期等类型,和生产者的序列化输入保持一致。 + payload = json.valueToTree(json.convertValue(payload, BusinessFeeSourceChangedV1.class)); + payload.put("sourceHash", EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload))); + payload.put("action", version == 1 ? "PUBLISHED" : "ADJUSTED"); + return new EventEnvelope(UUID.randomUUID().toString(), EventTypes.BUSINESS_FEE_SOURCE_CHANGED, 1, + Instant.parse("2026-10-27T01:00:00Z"), String.valueOf(customer), "BUSINESS_FEE", String.valueOf(feeId), version, + null, UUID.randomUUID().toString(), "TEST:FEE:" + feeId + ':' + version, null, payload, + EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload))); + } +} diff --git a/finance/src/test/java/com/baiyue/finance/CustomerAccountSummaryBehaviorTest.java b/finance/src/test/java/com/baiyue/finance/CustomerAccountSummaryBehaviorTest.java index 7e4737ea7..690edc56e 100644 --- a/finance/src/test/java/com/baiyue/finance/CustomerAccountSummaryBehaviorTest.java +++ b/finance/src/test/java/com/baiyue/finance/CustomerAccountSummaryBehaviorTest.java @@ -37,7 +37,7 @@ class CustomerAccountSummaryBehaviorTest { when(jdbc.query(anyString(), any(RowMapper.class), org.mockito.ArgumentMatchers.eq(9L))) .thenReturn(List.of()); - var result = new FinanceQueryService(jdbc, new ObjectMapper()).customerAccount(9L); + var result = new FinanceQueryService(jdbc, new ObjectMapper(), FinanceCustomerNamesTestSupport.names()).customerAccount(9L); assertThat(result.accountMode()).isEqualTo("MANUAL_RECEIVABLE_LEDGER"); var amountSql = org.mockito.ArgumentCaptor.forClass(String.class); @@ -60,7 +60,7 @@ class CustomerAccountSummaryBehaviorTest { ResultSet rs = summaryRow("300.00", "140.00", "160.00", "25.00", "260.00", "180.00", "80.00"); when(jdbc.queryForObject(anyString(), any(RowMapper.class), any(), any(), any(), any(), any(), any(), any(), any(), any(), any())).thenAnswer(invocation -> ((RowMapper) invocation.getArgument(1)).mapRow(rs, 0)); - FinanceQueryService service = new FinanceQueryService(jdbc, new ObjectMapper()); + FinanceQueryService service = new FinanceQueryService(jdbc, new ObjectMapper(), FinanceCustomerNamesTestSupport.names()); CustomerAccountSummary result = service.customerAccountSummary(9L); @@ -99,7 +99,7 @@ class CustomerAccountSummaryBehaviorTest { when(jdbc.queryForObject(anyString(), any(RowMapper.class), any(), any(), any(), any(), any(), any(), any(), any(), any(), any())).thenAnswer(invocation -> ((RowMapper) invocation.getArgument(1)).mapRow(rs, 0)); - CustomerAccountSummary result = new FinanceQueryService(jdbc, new ObjectMapper()).customerAccountSummary(77L); + CustomerAccountSummary result = new FinanceQueryService(jdbc, new ObjectMapper(), FinanceCustomerNamesTestSupport.names()).customerAccountSummary(77L); assertThat(result.totalReceivable()).isZero(); assertThat(result.receivedAmount()).isZero(); @@ -116,7 +116,7 @@ class CustomerAccountSummaryBehaviorTest { when(jdbc.queryForObject(anyString(), any(RowMapper.class), any(), any(), any(), any(), any(), any(), any(), any(), any(), any())) .thenThrow(new BadSqlGrammarException("customer summary", "SELECT ...", null)); - assertThatThrownBy(() -> new FinanceQueryService(jdbc, new ObjectMapper()).customerAccountSummary(88L)) + assertThatThrownBy(() -> new FinanceQueryService(jdbc, new ObjectMapper(), FinanceCustomerNamesTestSupport.names()).customerAccountSummary(88L)) .isInstanceOf(BadSqlGrammarException.class); } diff --git a/finance/src/test/java/com/baiyue/finance/FinanceCustomerNamesTest.java b/finance/src/test/java/com/baiyue/finance/FinanceCustomerNamesTest.java new file mode 100644 index 000000000..57cc7c4a4 --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/FinanceCustomerNamesTest.java @@ -0,0 +1,53 @@ +package com.baiyue.finance; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.finance.client.IamCustomerClient; +import com.baiyue.finance.service.FinanceCustomerNames; +import com.baiyue.finance.service.FinanceEventException; +import org.junit.jupiter.api.Test; +import java.util.List; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class FinanceCustomerNamesTest { + private final IamCustomerClient iam = mock(IamCustomerClient.class); + private final FinanceCustomerNames names = new FinanceCustomerNames(iam, "internal-token"); + + @Test + void resolvesInternalLabelsAndMissingNamesOncePerCustomerWithoutReplacingValidSnapshots() { + respond(9, "当前公司名称"); + var resolver = names.resolver(); + assertThat(resolver.apply(9L, "客户9")).isEqualTo("当前公司名称"); + assertThat(resolver.apply(9L, "9")).isEqualTo("当前公司名称"); + assertThat(resolver.apply(9L, null)).isEqualTo("当前公司名称"); + assertThat(resolver.apply(9L, "原单公司名称")).isEqualTo("原单公司名称"); + verify(iam, times(1)).page("internal-token", 1, 1, null, null, 9L); + } + + @Test + void missingCustomerDisplaysDashButCannotCreateNewDocument() { + when(iam.page("internal-token", 1, 1, null, null, 9L)) + .thenReturn(ApiResponse.success(new PageResult<>(List.of(), 1, 1, 0, 0))); + assertThat(names.resolver().apply(9L, "客户9")).isEqualTo("—"); + assertThatThrownBy(() -> names.requiredSnapshot(9, "客户9")).isInstanceOf(FinanceEventException.class).hasMessageContaining("无法生成"); + } + + @Test + void rejectsMismatchedCustomerInsteadOfLeakingAnotherCompanyName() { + respond(10, "其他公司"); + assertThatThrownBy(() -> names.required(9)).isInstanceOf(FinanceEventException.class).hasMessageContaining("范围异常"); + } + + @Test + void dependencyFailureCannotFallBackToInternalIdOrSilentlyCreateDocument() { + when(iam.page("internal-token", 1, 1, null, null, 9L)).thenThrow(new IllegalStateException("offline")); + assertThatThrownBy(() -> names.required(9)).isInstanceOf(FinanceEventException.class).hasMessageContaining("暂不可用"); + assertThat(names.resolver().apply(9L, "已记录公司名称")).isEqualTo("已记录公司名称"); + } + + private void respond(long returnedId, String name) { + when(iam.page("internal-token", 1, 1, null, null, 9L)).thenReturn(ApiResponse.success( + new PageResult<>(List.of(new IamCustomerClient.Customer(returnedId, "TEST", name, "ACTIVE", null, null)), 1, 1, 1, 1))); + } +} diff --git a/finance/src/test/java/com/baiyue/finance/FinanceCustomerNamesTestSupport.java b/finance/src/test/java/com/baiyue/finance/FinanceCustomerNamesTestSupport.java new file mode 100644 index 000000000..18b6d5d8f --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/FinanceCustomerNamesTestSupport.java @@ -0,0 +1,22 @@ +package com.baiyue.finance; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.finance.client.IamCustomerClient; +import com.baiyue.finance.service.FinanceCustomerNames; +import java.util.List; +import static org.mockito.Mockito.mock; + +/** 财务服务单测使用真实名称解析器及隔离的 IAM 主档,不请求测试环境。 */ +public final class FinanceCustomerNamesTestSupport { + private FinanceCustomerNamesTestSupport() {} + + public static FinanceCustomerNames names() { + var iam = mock(IamCustomerClient.class, invocation -> { + if (!invocation.getMethod().getName().equals("page")) return org.mockito.Mockito.RETURNS_DEFAULTS.answer(invocation); + long id = invocation.getArgument(5); + return ApiResponse.success(new PageResult<>(List.of(new IamCustomerClient.Customer(id, "TEST", "测试客户公司", "ACTIVE", null, null)), 1, 1, 1, 1)); + }); + return new FinanceCustomerNames(iam, "internal-token"); + } +} diff --git a/finance/src/test/java/com/baiyue/finance/FinanceDirectPaidCostReadTest.java b/finance/src/test/java/com/baiyue/finance/FinanceDirectPaidCostReadTest.java index 11c995fd1..53171d97c 100644 --- a/finance/src/test/java/com/baiyue/finance/FinanceDirectPaidCostReadTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinanceDirectPaidCostReadTest.java @@ -31,7 +31,7 @@ class FinanceDirectPaidCostReadTest { jdbc.execute("CREATE TABLE fin_cost_allocation(id BIGINT,tenant_id BIGINT,cost_id BIGINT,order_id BIGINT," + "inbound_id BIGINT,volume_cbm DECIMAL(18,6),total_volume_cbm DECIMAL(18,6),allocation_rate DECIMAL(18,6)," + "allocated_amount DECIMAL(18,2),rounding_adjustment BOOLEAN)"); - service = new FinanceQueryService(jdbc, new ObjectMapper()); + service = new FinanceQueryService(jdbc, new ObjectMapper(), FinanceCustomerNamesTestSupport.names()); } /** 头程与海外直付均已付款,列表和详情不能再次显示未付余额。 */ diff --git a/finance/src/test/java/com/baiyue/finance/FinanceFeePoolDirectionBehaviorTest.java b/finance/src/test/java/com/baiyue/finance/FinanceFeePoolDirectionBehaviorTest.java index 8806fff05..953de366f 100644 --- a/finance/src/test/java/com/baiyue/finance/FinanceFeePoolDirectionBehaviorTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinanceFeePoolDirectionBehaviorTest.java @@ -18,7 +18,7 @@ class FinanceFeePoolDirectionBehaviorTest { @Test void receivablePoolNeverContainsPayableSource() { DirectionJdbc jdbc = new DirectionJdbc(); - FinanceQueryService service = new FinanceQueryService(jdbc, new ObjectMapper()); + FinanceQueryService service = new FinanceQueryService(jdbc, new ObjectMapper(), FinanceCustomerNamesTestSupport.names()); List result = service.feePool(7L, "RECEIVABLE"); @@ -30,7 +30,7 @@ class FinanceFeePoolDirectionBehaviorTest { @Test void payablePoolNeverContainsReceivableSource() { DirectionJdbc jdbc = new DirectionJdbc(); - FinanceQueryService service = new FinanceQueryService(jdbc, new ObjectMapper()); + FinanceQueryService service = new FinanceQueryService(jdbc, new ObjectMapper(), FinanceCustomerNamesTestSupport.names()); List result = service.feePool(7L, "PAYABLE"); @@ -42,7 +42,7 @@ class FinanceFeePoolDirectionBehaviorTest { @Test void payablePoolOnlyQueriesApprovedUnsettledSourcesForTheSelectedSupplier() { DirectionJdbc jdbc = new DirectionJdbc(); - FinanceQueryService service = new FinanceQueryService(jdbc, new ObjectMapper()); + FinanceQueryService service = new FinanceQueryService(jdbc, new ObjectMapper(), FinanceCustomerNamesTestSupport.names()); service.feePool(7L, "PAYABLE", 81L); diff --git a/finance/src/test/java/com/baiyue/finance/FinanceFeeSourceReadMapperTest.java b/finance/src/test/java/com/baiyue/finance/FinanceFeeSourceReadMapperTest.java index c48e46ab7..bbde7617a 100644 --- a/finance/src/test/java/com/baiyue/finance/FinanceFeeSourceReadMapperTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinanceFeeSourceReadMapperTest.java @@ -15,6 +15,7 @@ import org.springframework.jdbc.datasource.DriverManagerDataSource; import java.math.BigDecimal; import java.time.LocalDate; import java.util.UUID; +import java.util.List; import static org.assertj.core.api.Assertions.assertThat; @@ -30,8 +31,11 @@ public class FinanceFeeSourceReadMapperTest { try (var connection = data.getConnection()) { jdbc.execute("CREATE ALIAS JSON_EXTRACT FOR 'com.baiyue.finance.FinanceFeeSourceReadMapperTest.jsonExtract'"); jdbc.execute("CREATE ALIAS JSON_UNQUOTE FOR 'com.baiyue.finance.FinanceFeeSourceReadMapperTest.jsonUnquote'"); - jdbc.execute("CREATE TABLE fin_fee_source(id BIGINT PRIMARY KEY,source_system VARCHAR(32),source_type VARCHAR(32),source_fee_id BIGINT,source_fee_version INT,customer_id BIGINT,direction VARCHAR(16),source_no VARCHAR(64),fee_item_name VARCHAR(100),amount DECIMAL(20,6),currency_code VARCHAR(3),review_status VARCHAR(16),status VARCHAR(16),source_snapshot VARCHAR(2000),order_id BIGINT,inbound_id BIGINT,container_id BIGINT,created_at TIMESTAMP)"); - jdbc.execute("CREATE TABLE fin_bill_line(source_id BIGINT,source_version INT)"); + jdbc.execute("CREATE TABLE fin_fee_source(id BIGINT PRIMARY KEY,source_system VARCHAR(32),source_type VARCHAR(32),source_fee_id BIGINT,source_fee_version INT,customer_id BIGINT,direction VARCHAR(16),source_no VARCHAR(64),fee_item_name VARCHAR(100),amount DECIMAL(20,6),currency_code VARCHAR(3),review_status VARCHAR(16),status VARCHAR(16),source_snapshot VARCHAR(2000),order_id BIGINT,inbound_id BIGINT,container_id BIGINT,created_at TIMESTAMP,settlement_mode VARCHAR(32),paid_at TIMESTAMP,operation_version INT DEFAULT 0,review_reason VARCHAR(128),source_hash VARCHAR(64))"); + jdbc.execute("CREATE TABLE fin_bill_line(source_id BIGINT,source_version INT,tenant_id BIGINT,bill_id BIGINT)"); + jdbc.execute("CREATE TABLE fin_bill(id BIGINT PRIMARY KEY,tenant_id BIGINT,status VARCHAR(24))"); + jdbc.execute("CREATE TABLE fin_settlement(id BIGINT PRIMARY KEY,tenant_id BIGINT,direction VARCHAR(16),status VARCHAR(24),payment_status VARCHAR(24))"); + jdbc.execute("CREATE TABLE fin_settlement_line(settlement_id BIGINT,tenant_id BIGINT,fee_source_id BIGINT)"); jdbc.execute("CREATE TABLE fin_audit_log(id BIGINT PRIMARY KEY,tenant_id BIGINT,resource_type VARCHAR(32),resource_id BIGINT,action VARCHAR(64),operator_name_snapshot VARCHAR(64),reason VARCHAR(100),before_data VARCHAR(1000),after_data VARCHAR(1000),created_at TIMESTAMP)"); insert(jdbc, 1, "FIRSTLEG", "INBOUND_FEE", 700, 1, 9, direction, "SUPERSEDED", "100.00"); insert(jdbc, 2, "FIRSTLEG", "INBOUND_FEE", 700, 2, 9, direction, "AVAILABLE", "120.00"); @@ -103,10 +107,44 @@ public class FinanceFeeSourceReadMapperTest { assertThat(repository.logs(9, 1)).extracting(log -> log.action()).containsExactly( "FEE_SOURCE_PUBLISHED", "FEE_SOURCE_REVIEW_APPROVE"); assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fin_fee_source", Long.class)).isEqualTo(8); - jdbc.update("INSERT INTO fin_bill_line VALUES(2,2)"); - filters.put("status", "BILLED"); filters.put("statementGenerated", true); - assertThat(repository.count(filters)).isEqualTo(1); - assertThat(repository.list(filters)).extracting(row -> row.feeSourceId()).containsExactly(2L); + if ("RECEIVABLE".equals(direction)) { + jdbc.update("INSERT INTO fin_bill VALUES(91,9,'ISSUED')"); + jdbc.update("INSERT INTO fin_bill_line VALUES(2,2,9,91)"); + filters.put("status", "BILLED"); filters.put("statementGenerated", true); + session.clearCache(); + assertThat(repository.count(filters)).isEqualTo(1); + assertThat(repository.list(filters)).extracting(row -> row.feeSourceId()).containsExactly(2L); + assertThat(repository.detail(2).sourceStatus()).isEqualTo("BILLED"); + jdbc.update("UPDATE fin_bill SET status='VOIDED' WHERE id=91"); + session.clearCache(); + assertThat(repository.detail(2).statementGenerated()).isFalse(); + assertThat(repository.detail(2).sourceStatus()).isEqualTo("AVAILABLE"); + } else { + jdbc.update("INSERT INTO fin_settlement VALUES(91,9,'PAYABLE','ISSUED','UNPAID')"); + jdbc.update("INSERT INTO fin_settlement_line VALUES(91,9,2)"); + for (String payment : List.of("UNPAID", "PARTIALLY_PAID", "PAID")) { + jdbc.update("UPDATE fin_settlement SET payment_status=? WHERE id=91", payment); + String expected = "UNPAID".equals(payment) ? "PAYABLE_BILLED" : payment; + filters.put("status", expected); + assertThat(repository.count(filters)).isEqualTo(1); + assertThat(repository.list(filters)).singleElement().satisfies(row -> { + assertThat(row.feeSourceId()).isEqualTo(2); + assertThat(row.sourceStatus()).isEqualTo(expected); + assertThat(row.statementGenerated()).isFalse(); + assertThat(row.amount()).isEqualByComparingTo("120"); + }); + assertThat(repository.detail(2).sourceStatus()).isEqualTo(expected); + } + jdbc.update("UPDATE fin_settlement SET status='VOIDED' WHERE id=91"); + assertThat(repository.detail(2).sourceStatus()).isEqualTo("AVAILABLE"); + jdbc.update("UPDATE fin_settlement SET status='PAID',tenant_id=10 WHERE id=91"); + jdbc.update("UPDATE fin_settlement_line SET tenant_id=10 WHERE settlement_id=91"); + assertThat(repository.detail(2).sourceStatus()).isEqualTo("AVAILABLE"); + jdbc.update("UPDATE fin_fee_source SET settlement_mode='DIRECT_PAID',paid_at='2026-10-21 10:00:00' WHERE id=2"); + assertThat(repository.detail(2).sourceStatus()).isEqualTo("PAID"); + jdbc.update("UPDATE fin_fee_source SET status='VOIDED' WHERE id=2"); + assertThat(repository.detail(2).sourceStatus()).isEqualTo("VOIDED"); + } // 财务内部费用原地改金额,其历史只取真实审计,不能伪造旧时间的同步金额。 insert(jdbc, 9, "FINANCE", "MANUAL", 700, 1, 9, direction, "AVAILABLE", "120.00"); diff --git a/finance/src/test/java/com/baiyue/finance/FinancePhase1OperationBehaviorTest.java b/finance/src/test/java/com/baiyue/finance/FinancePhase1OperationBehaviorTest.java index fa4cdff2d..43187b5eb 100644 --- a/finance/src/test/java/com/baiyue/finance/FinancePhase1OperationBehaviorTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinancePhase1OperationBehaviorTest.java @@ -189,8 +189,8 @@ class FinancePhase1OperationBehaviorTest { /** 保留费用审核状态读取。 */ @Test void approvedFeeCannotBeEditedAsDraft() throws Exception { - JdbcTemplate jdbc = feeSourceJdbc("REJECTED", false, 2); - FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper(), null, "", mock(FinanceFeeSourceReadRepository.class)); + JdbcTemplate jdbc = mock(JdbcTemplate.class); + FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper(), null, "", feeSources("REJECTED", false, 2)); assertThat(service.getFinanceFeeSource(88L).reviewStatus()).isEqualTo("REJECTED"); } @@ -199,7 +199,7 @@ class FinancePhase1OperationBehaviorTest { @Test void feeSourceDetailUsesFixedDtoAndRealCustomerScope() throws Exception { FinancePhase1OperationService service = new FinancePhase1OperationService( - feeSourceJdbc("APPROVED", true, 2), objectMapper(), null, "", mock(FinanceFeeSourceReadRepository.class)); + mock(JdbcTemplate.class), objectMapper(), null, "", feeSources("APPROVED", true, 2)); FeeSourceDetail detail = service.getFinanceFeeSource(88L); @@ -212,8 +212,8 @@ class FinancePhase1OperationBehaviorTest { /** 业务来源费用必须回到所属系统调整。 */ @Test void firstlegFeeEditAndAdjustAreBlockedInsteadOfMutatingFinanceCopy() throws Exception { - JdbcTemplate jdbc = feeSourceJdbc("REJECTED", false, 1); - FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper(), null, "", mock(FinanceFeeSourceReadRepository.class)); + JdbcTemplate jdbc = mock(JdbcTemplate.class); + FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper(), null, "", feeSources("REJECTED", false, 1)); var request = new FinancePhase1Dtos.FeeSourceEditRequest( new BigDecimal("120.00"), "更正金额", 1); @@ -228,7 +228,7 @@ class FinancePhase1OperationBehaviorTest { /** 头程作废等待权威来源事件,不能直接改财务副本。 */ @Test void voidingNyexx2608180003DelegatesToFirstlegAndWaitsForEvent() throws Exception { - JdbcTemplate jdbc = feeSourceJdbc("APPROVED", false, 2); + JdbcTemplate jdbc = mock(JdbcTemplate.class); when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(9L), eq(71L), eq("FEE_SOURCE_VOID"), eq("void-key"))).thenReturn(List.of()); FirstlegFeeSourceClient firstleg = mock(FirstlegFeeSourceClient.class); @@ -236,7 +236,7 @@ class FinancePhase1OperationBehaviorTest { .thenReturn(ApiResponse.success(new FirstlegFeeSourceClient.CommandResult( "INBOUND_FEE", 700L, 3, "VOIDED", false))); FinancePhase1OperationService service = new FinancePhase1OperationService( - jdbc, objectMapper(), firstleg, "internal-token", mock(FinanceFeeSourceReadRepository.class)); + jdbc, objectMapper(), firstleg, "internal-token", feeSources("APPROVED", false, 2)); FeeSourceDetail result = service.voidFinanceFeeSource(88L, 71L, "finance", "void-key", new FinancePhase1Dtos.FeeSourceActionRequest("测试作废", 2)); @@ -262,13 +262,9 @@ class FinancePhase1OperationBehaviorTest { void reviewUsesExpectedVersionAndReturnsUpdatedDetail() throws Exception { JdbcTemplate jdbc = mock(JdbcTemplate.class); AtomicInteger reads = new AtomicInteger(); - when(jdbc.query(startsWith("SELECT * FROM fin_fee_source"), any(RowMapper.class), (Object[]) any())).thenAnswer(invocation -> { - @SuppressWarnings("unchecked") RowMapper mapper = invocation.getArgument(1); - String review = reads.getAndIncrement() == 0 ? "PENDING" : "APPROVED"; - int version = "PENDING".equals(review) ? 0 : 1; - return List.of(mapper.mapRow(resultSet(review, false, version), 0)); - }); - when(jdbc.queryForObject(startsWith("SELECT COUNT(*)>0"), eq(Boolean.class), eq(88L), eq(3))).thenReturn(false); + var sources = mock(FinanceFeeSourceReadRepository.class); + when(sources.detail(88L)).thenAnswer(invocation -> reads.getAndIncrement() == 0 + ? feeDetail("PENDING", false, 0) : feeDetail("APPROVED", false, 1)); when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(9L), eq(71L), eq("FEE_SOURCE_REVIEW"), eq("review-key"))).thenReturn(List.of()); when(jdbc.update(startsWith("INSERT INTO fin_idempotency_record"), eq(9L), eq(71L), @@ -280,7 +276,7 @@ class FinancePhase1OperationBehaviorTest { eq(71L), eq("操作人 71"), any(), any())).thenReturn(1); when(jdbc.update(startsWith("UPDATE fin_idempotency_record SET status='SUCCEEDED'"), eq(88L), eq(9L), eq(71L), eq("FEE_SOURCE_REVIEW"), eq("review-key"))).thenReturn(1); - FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper(), null, "", mock(FinanceFeeSourceReadRepository.class)); + FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper(), null, "", sources); FeeSourceDetail result = service.reviewFinanceFeeSource(88L, 71L, "review-key", new FinancePhase1Dtos.ReviewRequest("APPROVE", "核对无误", 0)); @@ -332,45 +328,30 @@ class FinancePhase1OperationBehaviorTest { .when(service).getFinanceFeeSource(88L); assertThatThrownBy(() -> service.editFinanceFeeSource(88, 71, "frozen", request, true)) .isInstanceOf(FinanceEventException.class).hasMessageContaining("已生成单据"); + for (String occupied : List.of("PAYABLE_BILLED", "PARTIALLY_PAID", "PAID")) { + doReturn(objectMapper().treeToValue(beforeJson.deepCopy().put("sourceStatus", occupied), FeeSourceDetail.class)) + .when(service).getFinanceFeeSource(88L); + assertThatThrownBy(() -> service.editFinanceFeeSource(88, 71, "occupied-" + occupied, request, true)) + .isInstanceOf(FinanceEventException.class).hasMessageContaining("费用已被占用、结清或作废"); + } } - /** 准备指定来源版本的费用查询,不再模拟已迁入 Mapper 的审计查询。 */ - private static JdbcTemplate feeSourceJdbc(String reviewStatus, boolean included, int version) throws Exception { - JdbcTemplate jdbc = mock(JdbcTemplate.class); - when(jdbc.query(startsWith("SELECT * FROM fin_fee_source"), any(RowMapper.class), (Object[]) any())).thenAnswer(invocation -> { - @SuppressWarnings("unchecked") RowMapper mapper = invocation.getArgument(1); - return List.of(mapper.mapRow(resultSet(reviewStatus, included, version), 0)); - }); - when(jdbc.queryForObject(startsWith("SELECT COUNT(*)>0"), eq(Boolean.class), eq(88L), eq(3))).thenReturn(false); - lenient().when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(9L), eq(71L), - eq("FEE_SOURCE_SETTLEMENT"), eq("include-key"))).thenReturn(List.of()); - return jdbc; + /** 服务行为测试由 Repository 返回明确快照;正式 SQL 与 DTO 映射由 Mapper 数据库测试覆盖。 */ + private static FinanceFeeSourceReadRepository feeSources(String review, boolean generated, int version) throws Exception { + var repository = mock(FinanceFeeSourceReadRepository.class); + when(repository.detail(88L)).thenReturn(feeDetail(review, generated, version)); + return repository; } - private static ResultSet resultSet(String reviewStatus, boolean included, int version) throws Exception { - ResultSet rs = mock(ResultSet.class); - when(rs.getLong("id")).thenReturn(88L); - when(rs.getLong("source_fee_id")).thenReturn(700L); - when(rs.getLong("customer_id")).thenReturn(9L); - when(rs.getLong("order_id")).thenReturn(90L); - when(rs.getLong("inbound_id")).thenReturn(100L); - when(rs.getLong("container_id")).thenReturn(200L); - when(rs.wasNull()).thenReturn(false, false, true, false, false, true); - when(rs.getInt("source_fee_version")).thenReturn(3); - when(rs.getString("source_system")).thenReturn("FIRSTLEG"); - when(rs.getString("source_no")).thenReturn("IN-100"); - when(rs.getString("source_type")).thenReturn("INBOUND_FEE"); - when(rs.getString("direction")).thenReturn("RECEIVABLE"); - when(rs.getString("fee_item_name")).thenReturn("头程运费"); - when(rs.getString("currency_code")).thenReturn("CNY"); - when(rs.getString("status")).thenReturn("AVAILABLE"); - when(rs.getString("source_hash")).thenReturn("a".repeat(64)); - when(rs.getString("review_status")).thenReturn(reviewStatus); - when(rs.getString("review_reason")).thenReturn(null); - when(rs.getInt("operation_version")).thenReturn(version); - when(rs.getBigDecimal("amount")).thenReturn(new BigDecimal("100.00")); - when(rs.getString("source_snapshot")).thenReturn("{\"customerName\":\"客户9\",\"inboundCode\":\"NYEXX2608180003\",\"businessDate\":\"2026-08-11\",\"chargeableQuantity\":\"10\",\"reviewStatus\":\"" + reviewStatus + "\",\"publicationStatus\":\"PUBLISHED\",\"operationVersion\":" + version + "}"); - return rs; + private static FeeSourceDetail feeDetail(String review, boolean generated, int version) throws Exception { + var node = objectMapper().createObjectNode().put("feeSourceId",88).put("sourceSystem","FIRSTLEG") + .put("sourceType","INBOUND_FEE").put("sourceFeeId",700).put("sourceVersion",3).put("customerId",9) + .put("direction","RECEIVABLE").put("orderId",90).put("inboundId",100).put("containerId",200) + .put("inboundCode","NYEXX2608180003").put("sourceBusinessId","IN-100").put("feeItemName","头程运费") + .put("amount",100).put("currency","CNY").put("operationVersion",version).put("reviewStatus",review) + .put("sourceStatus",generated ? "BILLED" : "AVAILABLE").put("statementGenerated",generated) + .put("sourceHash","a".repeat(64)).put("businessDate","2026-08-11"); + return objectMapper().treeToValue(node,FeeSourceDetail.class); } private static ObjectMapper objectMapper() { diff --git a/finance/src/test/java/com/baiyue/finance/FinancePlatformOptionalCustomerQueryTest.java b/finance/src/test/java/com/baiyue/finance/FinancePlatformOptionalCustomerQueryTest.java index c54a04c07..5e5efc42d 100644 --- a/finance/src/test/java/com/baiyue/finance/FinancePlatformOptionalCustomerQueryTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinancePlatformOptionalCustomerQueryTest.java @@ -16,7 +16,7 @@ class FinancePlatformOptionalCustomerQueryTest { @Test void allCustomerQueriesOmitCustomerPredicateAndArguments() { RecordingJdbc jdbc = new RecordingJdbc(); - FinanceQueryService service = new FinanceQueryService(jdbc, new ObjectMapper()); + FinanceQueryService service = new FinanceQueryService(jdbc, new ObjectMapper(), FinanceCustomerNamesTestSupport.names()); service.feePool(null, null); service.statements(null, false); @@ -33,7 +33,7 @@ class FinancePlatformOptionalCustomerQueryTest { @Test void customerFilterAddsPredicateAndRealCustomerArgument() { RecordingJdbc jdbc = new RecordingJdbc(); - FinanceQueryService service = new FinanceQueryService(jdbc, new ObjectMapper()); + FinanceQueryService service = new FinanceQueryService(jdbc, new ObjectMapper(), FinanceCustomerNamesTestSupport.names()); service.feePool(31L, null); service.statements(31L, false); @@ -49,7 +49,7 @@ class FinancePlatformOptionalCustomerQueryTest { @Test void optionalCustomerNeverTreatsZeroAsAllCustomers() { - FinanceQueryService service = new FinanceQueryService(new RecordingJdbc(), new ObjectMapper()); + FinanceQueryService service = new FinanceQueryService(new RecordingJdbc(), new ObjectMapper(), FinanceCustomerNamesTestSupport.names()); assertThatThrownBy(() -> service.statements(0L, false)).hasMessageContaining("客户范围无效"); assertThatThrownBy(() -> service.reconciliationStatements(0L)).hasMessageContaining("客户范围无效"); diff --git a/finance/src/test/java/com/baiyue/finance/FinanceSettlementBehaviorTest.java b/finance/src/test/java/com/baiyue/finance/FinanceSettlementBehaviorTest.java index e5923f6ac..c59f4328e 100644 --- a/finance/src/test/java/com/baiyue/finance/FinanceSettlementBehaviorTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinanceSettlementBehaviorTest.java @@ -100,6 +100,7 @@ class FinanceSettlementBehaviorTest { assertThat(all).singleElement().extracting(SettlementSummary::settlementId).isEqualTo(88L); assertThat(scoped).singleElement().extracting(SettlementSummary::customerId).isEqualTo(10L); + assertThat(scoped).singleElement().extracting(SettlementSummary::customerName).isEqualTo("测试客户公司"); ArgumentCaptor allSql = ArgumentCaptor.forClass(String.class); ArgumentCaptor scopedSql = ArgumentCaptor.forClass(String.class); verify(jdbc).queryForList(allSql.capture(), eq("PAYABLE")); @@ -633,7 +634,7 @@ class FinanceSettlementBehaviorTest { return jdbc.queryForList(sql, invocation.getArguments()); }); return new FinanceSettlementService(jdbc, new ObjectMapper().findAndRegisterModules(), - new FinanceVoucherValidator(fileClient, "internal-token"), supplierValidator, documentNumbers, new com.baiyue.finance.repository.FinanceSettlementDetailRepository(mapper)); + new FinanceVoucherValidator(fileClient, "internal-token"), supplierValidator, documentNumbers, new com.baiyue.finance.repository.FinanceSettlementDetailRepository(mapper), FinanceCustomerNamesTestSupport.names()); } private static FinanceSupplierValidator supplierValidator() { diff --git a/finance/src/test/java/com/baiyue/finance/ReceivableStatisticsServiceTest.java b/finance/src/test/java/com/baiyue/finance/ReceivableStatisticsServiceTest.java index b6709ad8e..f764ab344 100644 --- a/finance/src/test/java/com/baiyue/finance/ReceivableStatisticsServiceTest.java +++ b/finance/src/test/java/com/baiyue/finance/ReceivableStatisticsServiceTest.java @@ -28,7 +28,7 @@ class ReceivableStatisticsServiceTest { row(10001, "客户A", "300.00", "100.00", "200.00"), row(10002, "客户B", "80.00", "80.00", "0.00")); when(jdbc.query(anyString(), any(RowMapper.class), any(Object[].class))).thenReturn(rows); - ReceivableStatisticsService service = new ReceivableStatisticsService(jdbc); + ReceivableStatisticsService service = new ReceivableStatisticsService(jdbc, FinanceCustomerNamesTestSupport.names()); var result = service.query(YearMonth.of(2026, 7), null, 1, 1); @@ -51,12 +51,35 @@ class ReceivableStatisticsServiceTest { when(jdbc.query(anyString(), any(RowMapper.class), any(Object[].class))) .thenReturn(List.of(row(10001, "客户A", "300.00", "100.00", "199.99"))); - assertThatThrownBy(() -> new ReceivableStatisticsService(jdbc) + assertThatThrownBy(() -> new ReceivableStatisticsService(jdbc, FinanceCustomerNamesTestSupport.names()) .query(YearMonth.of(2026, 7), null, 1, 20)) .isInstanceOf(FinanceEventException.class) .hasMessageContaining("勾稽异常"); } + @Test + @SuppressWarnings("unchecked") + void queryAndExportResolveTheSameCustomerNameWithoutChangingAmounts() throws Exception { + var jdbc = mock(JdbcTemplate.class); + when(jdbc.query(anyString(), any(RowMapper.class), any(Object[].class))).thenAnswer(invocation -> { + RowMapper mapper = invocation.getArgument(1); + var rs = mock(java.sql.ResultSet.class); + when(rs.getLong("tenant_id")).thenReturn(9L); + when(rs.getString("customer_name_snapshot")).thenReturn("客户9"); + when(rs.getBigDecimal("total_receivable")).thenReturn(new BigDecimal("300.00")); + when(rs.getBigDecimal("total_received")).thenReturn(new BigDecimal("100.00")); + when(rs.getBigDecimal("total_outstanding")).thenReturn(new BigDecimal("200.00")); + return List.of(mapper.mapRow(rs, 0)); + }); + var service = new ReceivableStatisticsService(jdbc, FinanceCustomerNamesTestSupport.names()); + var query = service.query(YearMonth.of(2026, 10), 9L, 1, 20); + assertThat(query.records()).singleElement().satisfies(row -> { + assertThat(row.customerName()).isEqualTo("测试客户公司"); + assertThat(row.totalReceivable()).isEqualByComparingTo("300.00"); + }); + assertThat(service.exportRows(YearMonth.of(2026, 10), 9L)).isEqualTo(query.records()); + } + private static ReceivableStatisticsRow row(long id, String name, String total, String received, String outstanding) { return new ReceivableStatisticsRow(id, name, "CNY", new BigDecimal(total), new BigDecimal(received), new BigDecimal(outstanding)); diff --git a/finance/src/test/java/com/baiyue/finance/ReconciliationStatementBehaviorTest.java b/finance/src/test/java/com/baiyue/finance/ReconciliationStatementBehaviorTest.java index f741990ef..2620a8a44 100644 --- a/finance/src/test/java/com/baiyue/finance/ReconciliationStatementBehaviorTest.java +++ b/finance/src/test/java/com/baiyue/finance/ReconciliationStatementBehaviorTest.java @@ -83,6 +83,8 @@ class ReconciliationStatementBehaviorTest { @Test void byCutoffSelectsEligibleSourcesAndCreatesDraft() throws Exception { JdbcTemplate jdbc = createJdbc(); + when(jdbc.query(org.mockito.ArgumentMatchers.startsWith("SELECT customer_name_snapshot"), any(RowMapper.class), eq(9L))) + .thenReturn(List.of()); ReconciliationStatementService target = serviceWithNumber(jdbc); CreateReconciliationRequest request = new CreateReconciliationRequest(9L, "CNY", "BY_CUTOFF", List.of(), LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), "截止日对账"); @@ -90,7 +92,15 @@ class ReconciliationStatementBehaviorTest { var result = target.create(71L, "cutoff-key", request); assertThat(result.status()).isEqualTo("DRAFT"); + assertThat(result.customerName()).isEqualTo("测试客户公司"); assertThat(result.totalReceivable()).isEqualByComparingTo("100.00"); + var creator = ArgumentCaptor.forClass(PreparedStatementCreator.class); + verify(jdbc).update(creator.capture(), any(KeyHolder.class)); + var connection = mock(java.sql.Connection.class); + var insert = mock(java.sql.PreparedStatement.class); + when(connection.prepareStatement(anyString(), eq(java.sql.Statement.RETURN_GENERATED_KEYS))).thenReturn(insert); + creator.getValue().createPreparedStatement(connection); + verify(insert).setString(4, "测试客户公司"); verify(jdbc).query(org.mockito.ArgumentMatchers.startsWith("SELECT id,source_fee_id"), any(RowMapper.class), eq(9L), eq(LocalDate.of(2026, 8, 1)), eq(LocalDate.of(2026, 8, 31))); } @@ -589,9 +599,30 @@ class ReconciliationStatementBehaviorTest { }; } + /** 审批前后使用不同 DTO,实际序列化后的详情金额必须完整返回而非变成零。 */ + @Test + void revisionReadsBeforeBillAndAfterDetailAmountsAndRejectsMissingAmount() throws Exception { + var service = serviceWithNumber(mock(JdbcTemplate.class)); + var rs = mock(ResultSet.class); + when(rs.getString("action")).thenReturn("RECONCILIATION_ADJUST"); + when(rs.getString("before_data")).thenReturn("{\"total\":240.00}"); + when(rs.getString("after_data")).thenReturn("{\"totalReceivable\":240.03}"); + when(rs.getTimestamp("created_at")).thenReturn(Timestamp.valueOf("2026-10-27 10:00:00")); + com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationRevision revision = + ReflectionTestUtils.invokeMethod(service, "revision", rs, 1); + assertThat(revision.beforeAmount()).isEqualByComparingTo("240.00"); + assertThat(revision.afterAmount()).isEqualByComparingTo("240.03"); + when(rs.getString("after_data")).thenReturn("{\"totalReceivable\":9999999999999999.99}"); + revision = ReflectionTestUtils.invokeMethod(service, "revision", rs, 1); + assertThat(revision.afterAmount()).isEqualByComparingTo("9999999999999999.99"); + when(rs.getString("after_data")).thenReturn("{}"); + assertThatThrownBy(() -> ReflectionTestUtils.invokeMethod(service, "revision", rs, 1)) + .isInstanceOf(FinanceEventException.class).hasMessageContaining("快照损坏"); + } + private static ReconciliationStatementService serviceWithNumber(JdbcTemplate jdbc) { FinanceDocumentNumberGenerator documentNumbers = mock(FinanceDocumentNumberGenerator.class); org.mockito.Mockito.lenient().when(documentNumbers.next("REC")).thenReturn("REC202608180001"); - return new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules(), documentNumbers); + return new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules(), documentNumbers, FinanceCustomerNamesTestSupport.names()); } } diff --git a/finance/src/test/java/com/baiyue/finance/service/FinanceExportProviderServiceTest.java b/finance/src/test/java/com/baiyue/finance/service/FinanceExportProviderServiceTest.java index 663f1c3e9..12b66abcd 100644 --- a/finance/src/test/java/com/baiyue/finance/service/FinanceExportProviderServiceTest.java +++ b/finance/src/test/java/com/baiyue/finance/service/FinanceExportProviderServiceTest.java @@ -85,6 +85,26 @@ class FinanceExportProviderServiceTest { assertThat(continued.snapshotToken()).isEqualTo(first.snapshotToken()); } + @Test + void exportsPaidSourcesUsingTheSameProjectionAndFiltersAsTheList() throws Exception { + var mapper = new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules(); + var source = mapper.treeToValue(mapper.createObjectNode().put("feeSourceId", 81).put("sourceFeeId", 9001) + .put("sourceVersion", 2).put("sourceSystem", "FIRSTLEG").put("sourceType", "CONTAINER_FEE") + .put("direction", "PAYABLE").put("customerId", 10001).put("customerName", "客户A") + .put("feeItemName", "装车费").put("amount", 300).put("currency", "CNY") + .put("reviewStatus", "APPROVED").put("sourceStatus", "PAID").put("statementGenerated", false), + com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceSummary.class); + when(operationService.listFinanceFeeSources(1, 200, null, "PAYABLE", "PAID", null, null, null, null, null)) + .thenReturn(PageEnvelope.success(List.of(source), 1, 200, 1)); + var result = service.exportBatch(FinanceExportProviderService.FEE_SOURCE_LIST, + request(Map.of("direction", "PAYABLE", "status", "PAID"), null, null)); + assertThat(result.rows()).singleElement().satisfies(row -> { + assertThat(row).containsEntry("sourceStatus", "PAID").containsEntry("sourceFeeId", "9001"); + assertThat(row.get("amount")).isEqualTo(new BigDecimal("300")); + }); + org.mockito.Mockito.verifyNoInteractions(queryService); + } + @Test void rejectsUnknownParametersAndSnapshotReuseAcrossFilters() { assertThatThrownBy(() -> service.exportBatch(FinanceExportProviderService.CUSTOMER_ACCOUNT_LIST,