fix(finance): reconcile payment progress audit amounts and customer names

This commit is contained in:
2026-10-27 12:08:35 +08:00
parent 00616d5930
commit 83f2fda4cd
27 changed files with 820 additions and 176 deletions
@@ -0,0 +1,4 @@
package com.baiyue.finance.dto;
/** 接收事务锁定的来源版本及其不可变客户归属。 */
public record BusinessFeeSourceVersion(int sourceVersion, long customerId) { }
@@ -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<BusinessFeeSourceVersion> lockVersions(@Param("system") String system,
@Param("type") String type, @Param("feeId") long feeId);
}
@@ -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&lt;&gt;'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&lt;&gt;'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 " +
"<if test=\"status == null or status != 'SUPERSEDED'\">" +
@@ -41,6 +50,13 @@ public interface FinanceFeeSourceReadMapper {
" ORDER BY " + BUSINESS_DATE + " DESC,f.id DESC LIMIT #{limit} OFFSET #{offset}</script>")
List<Map<String, Object>> list(Map<String, Object> filters);
/** 详情与列表共用状态投影,仍允许通过精确 ID 查看历史版本。 */
@Select("<script>SELECT f.*," + GENERATED + " statement_generated," + STATUS +
" effective_status FROM fin_fee_source f WHERE f.id=#{id}</script>")
// 同事务的审核/调整仍含 JDBC 写入,读取必须清空 MyBatis 会话缓存才能返回变更后状态。
@Options(useCache = false, flushCache = Options.FlushCachePolicy.TRUE)
Map<String, Object> 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" +
@@ -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<BusinessFeeSourceVersion> lockVersions(String system, String type, long feeId) {
return mapper.lockVersions(system, type, feeId);
}
}
@@ -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<String, Object> raw = mapper.detail(id);
if (raw == null || raw.isEmpty()) throw new FinanceEventException("FIN-FEE-404", "费用来源不存在");
Map<String, Object> 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<OperationLog> logs(long customerId, long id) {
List<OperationLog> result = new ArrayList<>();
@@ -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<Map<String, Object>> same = jdbc.queryForList(
@@ -83,10 +91,12 @@ public class BusinessFeeSourceEventService {
}
if ("VOIDED".equals(source.action()))
throw error("FIN-EVT-005", "作废事件对应的费用来源版本不存在");
List<Map<String, Object>> 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())) {
@@ -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<Long, String, String> resolver() {
var names = new HashMap<Long, String>();
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+");
}
}
@@ -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<Map<String, Object>> feeRows(Map<String, Object> 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<Map<String, Object>> 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<Map<String, Object>> statementRows(Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> statementRow(ReconciliationDetail value) {
return row("statementNo", value.statementNo(), "customerName", value.customerName(), "periodStart", value.periodStart(),
@@ -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<String, Object> 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<FeeSourceDetail> 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));
@@ -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<Statement> 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<ReconciliationSummary> 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<Statement> 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"),
@@ -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<Map<String, Object>> 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<SettlementLine> lines = details.lines(summary.customerId(), settlementId)
.stream().map(source -> line(source, summary)).toList();
List<SettlementTransaction> 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<String, Object> row) {
private SettlementSummary summary(Map<String, Object> row, java.util.function.BiFunction<Long, String, String> 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"),
@@ -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());
}
@@ -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<ReconciliationRevision> 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<CustomerProfile> 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", "对账单修订快照损坏");
}
@@ -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);
@@ -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)));
}
}
@@ -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);
}
@@ -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)));
}
}
@@ -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");
}
}
@@ -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());
}
/** 头程与海外直付均已付款,列表和详情不能再次显示未付余额。 */
@@ -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<FeePoolItem> 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<FeePoolItem> 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);
@@ -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");
@@ -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<FeeSourceDetail> 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<FeeSourceDetail> 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() {
@@ -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("客户范围无效");
@@ -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<String> allSql = ArgumentCaptor.forClass(String.class);
ArgumentCaptor<String> 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() {
@@ -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<ReceivableStatisticsRow> 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));
@@ -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());
}
}
@@ -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,