fix(finance): reconcile payment progress audit amounts and customer names
This commit is contained in:
@@ -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<>'VOIDED'))";
|
||||
// 应付进度取同客户的有效结算单;作废单释放来源,直接已付使用来源中的付款事实。
|
||||
String PAYABLE_STATUS = "(SELECT CASE s.payment_status WHEN 'PAID' THEN 'PAID' WHEN 'PARTIALLY_PAID' THEN 'PARTIALLY_PAID'" +
|
||||
" ELSE 'PAYABLE_BILLED' END FROM fin_settlement_line sl JOIN fin_settlement s ON s.id=sl.settlement_id AND s.tenant_id=sl.tenant_id" +
|
||||
" WHERE sl.tenant_id=f.customer_id AND sl.fee_source_id=f.id AND s.direction='PAYABLE' AND s.status<>'VOIDED' ORDER BY s.id DESC LIMIT 1)";
|
||||
String STATUS = "CASE WHEN f.status IN ('VOIDED','SUPERSEDED','SETTLED') THEN f.status" +
|
||||
" WHEN f.direction='PAYABLE' THEN COALESCE(" + PAYABLE_STATUS + ",CASE WHEN f.settlement_mode='DIRECT_PAID'" +
|
||||
" AND f.review_status='APPROVED' AND f.paid_at IS NOT NULL THEN 'PAID' ELSE f.status END)" +
|
||||
" WHEN f.status='AVAILABLE' AND " + GENERATED + " THEN 'BILLED' ELSE f.status END";
|
||||
String BUSINESS_DATE = "CAST(JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.businessDate')) AS DATE)";
|
||||
String FILTER = " FROM fin_fee_source f WHERE 1=1 " +
|
||||
"<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);
|
||||
}
|
||||
}
|
||||
+16
@@ -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<>();
|
||||
|
||||
+44
-9
@@ -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+");
|
||||
}
|
||||
}
|
||||
+14
-41
@@ -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(),
|
||||
|
||||
+4
-25
@@ -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());
|
||||
}
|
||||
|
||||
+13
-10
@@ -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);
|
||||
|
||||
+238
@@ -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() {
|
||||
|
||||
+3
-3
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
+20
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user