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.Mapper;
|
||||||
import org.apache.ibatis.annotations.Param;
|
import org.apache.ibatis.annotations.Param;
|
||||||
import org.apache.ibatis.annotations.Select;
|
import org.apache.ibatis.annotations.Select;
|
||||||
|
import org.apache.ibatis.annotations.Options;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/** 费用池按稳定来源读取当前版本,历史快照保留在同一费用的操作记录中。 */
|
/** 费用池按稳定来源读取当前版本,历史快照保留在同一费用的操作记录中。 */
|
||||||
@Mapper
|
@Mapper
|
||||||
public interface FinanceFeeSourceReadMapper {
|
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 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" +
|
||||||
String STATUS = "CASE WHEN f.status='AVAILABLE' AND " + GENERATED + " THEN 'BILLED' ELSE f.status END";
|
" 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 BUSINESS_DATE = "CAST(JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.businessDate')) AS DATE)";
|
||||||
String FILTER = " FROM fin_fee_source f WHERE 1=1 " +
|
String FILTER = " FROM fin_fee_source f WHERE 1=1 " +
|
||||||
"<if test=\"status == null or status != 'SUPERSEDED'\">" +
|
"<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>")
|
" ORDER BY " + BUSINESS_DATE + " DESC,f.id DESC LIMIT #{limit} OFFSET #{offset}</script>")
|
||||||
List<Map<String, Object>> list(Map<String, Object> filters);
|
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" +
|
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.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" +
|
" 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;
|
package com.baiyue.finance.repository;
|
||||||
|
|
||||||
import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceSummary;
|
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.dto.FinancePhase1Dtos.OperationLog;
|
||||||
import com.baiyue.finance.mapper.FinanceFeeSourceReadMapper;
|
import com.baiyue.finance.mapper.FinanceFeeSourceReadMapper;
|
||||||
import com.baiyue.finance.service.FinanceEventException;
|
import com.baiyue.finance.service.FinanceEventException;
|
||||||
@@ -51,6 +52,21 @@ public class FinanceFeeSourceReadRepository {
|
|||||||
return mapper.list(filters).stream().map(this::summary).toList();
|
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) {
|
public List<OperationLog> logs(long customerId, long id) {
|
||||||
List<OperationLog> result = new ArrayList<>();
|
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.EventPayloadCanonicalizer;
|
||||||
import com.baiyue.common.event.EventPayloadHash;
|
import com.baiyue.common.event.EventPayloadHash;
|
||||||
import com.baiyue.common.event.EventTypes;
|
import com.baiyue.common.event.EventTypes;
|
||||||
|
import com.baiyue.finance.repository.BusinessFeeSourceRepository;
|
||||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
@@ -13,11 +14,13 @@ import org.springframework.jdbc.core.JdbcTemplate;
|
|||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.math.RoundingMode;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
/** Consumes complete overseas business fee snapshots without a cross-region synchronous callback. */
|
/** 接收海外费用不可变快照,在同一事务内完成来源版本与接收记录。 */
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class BusinessFeeSourceEventService {
|
public class BusinessFeeSourceEventService {
|
||||||
@@ -26,7 +29,9 @@ public class BusinessFeeSourceEventService {
|
|||||||
private final JdbcTemplate jdbc;
|
private final JdbcTemplate jdbc;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
private final FinanceSupplierValidator supplierValidator;
|
private final FinanceSupplierValidator supplierValidator;
|
||||||
|
private final BusinessFeeSourceRepository sources;
|
||||||
|
|
||||||
|
/** 先核对不可变事件,再恢复已提交结果;仅新事件检查外部主档。 */
|
||||||
@Transactional
|
@Transactional
|
||||||
public DeliveryAck consume(EventEnvelope envelope) {
|
public DeliveryAck consume(EventEnvelope envelope) {
|
||||||
try {
|
try {
|
||||||
@@ -56,6 +61,8 @@ public class BusinessFeeSourceEventService {
|
|||||||
return DeliveryAck.of(envelope, DeliveryAck.Outcome.DUPLICATE);
|
return DeliveryAck.of(envelope, DeliveryAck.Outcome.DUPLICATE);
|
||||||
throw error("FIN-EVT-003", "相同事件正在处理或上次处理失败");
|
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')",
|
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());
|
envelope.eventId(), sourceSystem, envelope.eventType(), eventJson, envelope.payloadHash());
|
||||||
long result = apply(sourceSystem, source);
|
long result = apply(sourceSystem, source);
|
||||||
@@ -64,6 +71,7 @@ public class BusinessFeeSourceEventService {
|
|||||||
return DeliveryAck.of(envelope, DeliveryAck.Outcome.APPLIED);
|
return DeliveryAck.of(envelope, DeliveryAck.Outcome.APPLIED);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 锁定同一来源的历史版本,拒绝跨客户替代,并保留已出账来源冻结规则。 */
|
||||||
private long apply(String system, BusinessFeeSourceChangedV1 source) {
|
private long apply(String system, BusinessFeeSourceChangedV1 source) {
|
||||||
long feeId = positive(source.sourceFeeId(), "sourceFeeId");
|
long feeId = positive(source.sourceFeeId(), "sourceFeeId");
|
||||||
List<Map<String, Object>> same = jdbc.queryForList(
|
List<Map<String, Object>> same = jdbc.queryForList(
|
||||||
@@ -83,10 +91,12 @@ public class BusinessFeeSourceEventService {
|
|||||||
}
|
}
|
||||||
if ("VOIDED".equals(source.action()))
|
if ("VOIDED".equals(source.action()))
|
||||||
throw error("FIN-EVT-005", "作废事件对应的费用来源版本不存在");
|
throw error("FIN-EVT-005", "作废事件对应的费用来源版本不存在");
|
||||||
List<Map<String, Object>> versions = jdbc.queryForList(
|
var versions = sources.lockVersions(system, source.sourceType(), feeId);
|
||||||
"SELECT source_fee_version FROM fin_fee_source WHERE source_system=? AND source_type=? AND source_fee_id=? FOR UPDATE",
|
long customerId = positive(source.customerId(), "customerId");
|
||||||
system, source.sourceType(), feeId);
|
// 来源身份跨版本保持客户归属;检查失败时 Inbox 插入也随当前事务回滚。
|
||||||
if (versions.stream().anyMatch(row -> ((Number) row.get("source_fee_version")).intValue() > source.sourceVersion()))
|
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", "费用来源版本已过期");
|
throw error("FIN-EVT-008", "费用来源版本已过期");
|
||||||
requireSourceNotFrozen(system, source.sourceType(), feeId);
|
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'",
|
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;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 校验载荷自身的身份、金额和摘要,不依赖会随时间变化的外部数据。 */
|
||||||
private void validate(EventEnvelope envelope, BusinessFeeSourceChangedV1 value) {
|
private void validate(EventEnvelope envelope, BusinessFeeSourceChangedV1 value) {
|
||||||
if (value == null || !ACTIONS.contains(value.action()) || blank(value.sourceSystem())
|
if (value == null || !ACTIONS.contains(value.action()) || blank(value.sourceSystem())
|
||||||
|| "FIRSTLEG".equalsIgnoreCase(value.sourceSystem()) || blank(value.sourceType())
|
|| "FIRSTLEG".equalsIgnoreCase(value.sourceSystem()) || blank(value.sourceType())
|
||||||
@@ -130,6 +141,7 @@ public class BusinessFeeSourceEventService {
|
|||||||
}
|
}
|
||||||
positive(value.sourceFeeId(), "sourceFeeId");
|
positive(value.sourceFeeId(), "sourceFeeId");
|
||||||
positive(value.customerId(), "customerId");
|
positive(value.customerId(), "customerId");
|
||||||
|
validateAmounts(value);
|
||||||
if (value.walletSettlement() != null) {
|
if (value.walletSettlement() != null) {
|
||||||
if (!"RECEIVABLE".equals(value.direction())) throw error("FIN-EVT-012", "钱包消费只能关联应收费用");
|
if (!"RECEIVABLE".equals(value.direction())) throw error("FIN-EVT-012", "钱包消费只能关联应收费用");
|
||||||
positive(value.walletSettlement().holdId(), "walletSettlement.holdId");
|
positive(value.walletSettlement().holdId(), "walletSettlement.holdId");
|
||||||
@@ -149,10 +161,8 @@ public class BusinessFeeSourceEventService {
|
|||||||
throw error("FIN-EVT-015", "费用来源尚未发布或已失效");
|
throw error("FIN-EVT-015", "费用来源尚未发布或已失效");
|
||||||
}
|
}
|
||||||
if ("PAYABLE".equals(value.direction())) {
|
if ("PAYABLE".equals(value.direction())) {
|
||||||
long supplierId = positive(value.supplierId(), "supplierId");
|
positive(value.supplierId(), "supplierId");
|
||||||
var supplier = supplierValidator.requireActive(supplierId, trim(value.supplierCategory()));
|
if (blank(value.supplierName())) throw error("FIN-EVT-012", "应付来源供应商快照不完整");
|
||||||
if (blank(value.supplierName()) || !value.supplierName().equals(supplier.name()))
|
|
||||||
throw error("FIN-EVT-012", "应付来源供应商快照不一致");
|
|
||||||
validateSettlement(value);
|
validateSettlement(value);
|
||||||
} else if (!blank(value.supplierId()) || !blank(value.supplierName()) || !blank(value.supplierCategory())
|
} else if (!blank(value.supplierId()) || !blank(value.supplierName()) || !blank(value.supplierCategory())
|
||||||
|| value.settlementMode() != null && !Set.of("BILL", "WALLET").contains(value.settlementMode()) || value.paidAt() != null
|
|| 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", "费用来源快照哈希不一致");
|
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) {
|
private void validateSettlement(BusinessFeeSourceChangedV1 value) {
|
||||||
boolean files = value.paymentVoucherFileIds() != null && !value.paymentVoucherFileIds().isEmpty();
|
boolean files = value.paymentVoucherFileIds() != null && !value.paymentVoucherFileIds().isEmpty();
|
||||||
if ("ON_ACCOUNT".equals(value.settlementMode())) {
|
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.ExportColumn;
|
||||||
import com.baiyue.common.core.export.ExportContracts.ExportColumnType;
|
import com.baiyue.common.core.export.ExportContracts.ExportColumnType;
|
||||||
import com.baiyue.finance.dto.FinancePhase1Dtos.AccountSummary;
|
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.FinancePhase1Dtos.LedgerEntry;
|
||||||
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementDetail;
|
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementDetail;
|
||||||
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementLine;
|
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementLine;
|
||||||
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementSummary;
|
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementSummary;
|
||||||
import com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationDetail;
|
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.baiyue.finance.dto.ReceivableStatisticsDtos.ReceivableStatisticsRow;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import jakarta.annotation.PostConstruct;
|
import jakarta.annotation.PostConstruct;
|
||||||
@@ -199,41 +198,16 @@ public class FinanceExportProviderService {
|
|||||||
.map(value -> receivableStatisticsRow(month, value)).toList();
|
.map(value -> receivableStatisticsRow(month, value)).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 导出与列表使用相同当前版本、状态及筛选,已付款来源不能被可选结算池遗漏。 */
|
||||||
private List<Map<String, Object>> feeRows(Map<String, Object> p) {
|
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<>();
|
List<Map<String, Object>> rows = new ArrayList<>();
|
||||||
for (FeePoolItem item : queryService.feePool(customerId, direction)) {
|
for (int page = 1; ; page++) {
|
||||||
FeeSourceDetail detail = detail(item);
|
var result = operationService.listFinanceFeeSources(page, 200, longValue(p, "customerId", false),
|
||||||
String sourceStatus = detail == null ? item.status() : detail.sourceStatus();
|
text(p, "direction"), text(p, "status"), text(p, "reviewStatus"), bool(p, "statementGenerated"),
|
||||||
String actualReview = detail == null ? null : detail.reviewStatus();
|
date(p, "businessDateFrom"), date(p, "businessDateTo"), text(p, "keyword"));
|
||||||
Boolean actualGenerated = detail == null ? null : detail.statementGenerated();
|
result.data().forEach(source -> rows.add(feeRow(source)));
|
||||||
LocalDate businessDate = detail == null || detail.businessDate() == null
|
if ((long) page * 200 >= result.page().total()) return rows;
|
||||||
? (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));
|
|
||||||
}
|
}
|
||||||
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) {
|
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(),
|
"documentNo", value.documentNo(), "receivableChange", value.receivableChange(), "payableChange", value.payableChange(),
|
||||||
"outstandingReceivableAfter", value.outstandingReceivableAfter(), "operatorName", value.operatorName(), "remark", value.remark());
|
"outstandingReceivableAfter", value.outstandingReceivableAfter(), "operatorName", value.operatorName(), "remark", value.remark());
|
||||||
}
|
}
|
||||||
private Map<String, Object> feeRow(FeePoolItem item, FeeSourceDetail detail, LocalDate businessDate) {
|
private Map<String, Object> feeRow(FeeSourceSummary source) {
|
||||||
return row("customerName", detail == null ? null : detail.customerName(), "direction", item.direction(), "sourceType", item.sourceType(),
|
return row("customerName", source.customerName(), "direction", source.direction(), "sourceType", source.sourceType(),
|
||||||
"sourceFeeId", String.valueOf(item.sourceId()), "sourceVersion", item.sourceVersion(), "inboundCode", detail == null ? null : detail.inboundCode(),
|
"sourceFeeId", String.valueOf(source.sourceFeeId()), "sourceVersion", source.sourceVersion(), "inboundCode", source.inboundCode(),
|
||||||
"containerNo", detail == null ? null : detail.containerNo(), "businessDate", businessDate, "feeItemName", item.feeItemName(),
|
"containerNo", source.containerNo(), "businessDate", source.businessDate(), "feeItemName", source.feeItemName(),
|
||||||
"quantity", detail == null ? null : detail.quantity(), "unitPrice", detail == null ? null : detail.unitPrice(), "amount", item.amount(),
|
"quantity", source.quantity(), "unitPrice", source.unitPrice(), "amount", source.amount(),
|
||||||
"reviewStatus", detail == null ? null : detail.reviewStatus(),
|
"reviewStatus", source.reviewStatus(), "sourceStatus", source.sourceStatus(), "statementGenerated", source.statementGenerated());
|
||||||
"sourceStatus", detail == null ? item.status() : detail.sourceStatus(), "statementGenerated", detail == null ? null : detail.statementGenerated());
|
|
||||||
}
|
}
|
||||||
private Map<String, Object> statementRow(ReconciliationDetail value) {
|
private Map<String, Object> statementRow(ReconciliationDetail value) {
|
||||||
return row("statementNo", value.statementNo(), "customerName", value.customerName(), "periodStart", value.periodStart(),
|
return row("statementNo", value.statementNo(), "customerName", value.customerName(), "periodStart", value.periodStart(),
|
||||||
|
|||||||
+4
-25
@@ -178,7 +178,7 @@ public class FinancePhase1OperationService {
|
|||||||
String normalizedReview = normalized(reviewStatus,
|
String normalizedReview = normalized(reviewStatus,
|
||||||
List.of("DRAFT", "PENDING", "APPROVED", "REJECTED"), "审核状态无效");
|
List.of("DRAFT", "PENDING", "APPROVED", "REJECTED"), "审核状态无效");
|
||||||
String normalizedSourceStatus = normalized(sourceStatus,
|
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,
|
Map<String, Object> filters = feeSources.filters(customerId, normalizedDirection, normalizedSourceStatus,
|
||||||
normalizedReview, statementGenerated, businessDateFrom, businessDateTo, blankToNull(keyword), page, pageSize);
|
normalizedReview, statementGenerated, businessDateFrom, businessDateTo, blankToNull(keyword), page, pageSize);
|
||||||
return PageEnvelope.success(feeSources.list(filters), page, pageSize, feeSources.count(filters));
|
return PageEnvelope.success(feeSources.list(filters), page, pageSize, feeSources.count(filters));
|
||||||
@@ -186,9 +186,7 @@ public class FinancePhase1OperationService {
|
|||||||
|
|
||||||
public FeeSourceDetail getFinanceFeeSource(long feeSourceId) {
|
public FeeSourceDetail getFinanceFeeSource(long feeSourceId) {
|
||||||
if (feeSourceId <= 0) throw error("FIN-REQ-400", "费用来源ID无效");
|
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);
|
return feeSources.detail(feeSourceId);
|
||||||
if (rows.isEmpty()) throw error("FIN-FEE-404", "费用来源不存在");
|
|
||||||
return rows.get(0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -225,6 +223,7 @@ public class FinancePhase1OperationService {
|
|||||||
adjustment ? "POST" : "PUT", path, request);
|
adjustment ? "POST" : "PUT", path, request);
|
||||||
if (replay != null) return getFinanceFeeSource(replay);
|
if (replay != null) return getFinanceFeeSource(replay);
|
||||||
if (before.statementGenerated()) throw error("FIN-FEE-409", "已生成单据的费用来源不可编辑或调整");
|
if (before.statementGenerated()) throw error("FIN-FEE-409", "已生成单据的费用来源不可编辑或调整");
|
||||||
|
if (!"AVAILABLE".equals(before.sourceStatus())) throw error("FIN-FEE-409", "费用已被占用、结清或作废,不可编辑或调整");
|
||||||
String requiredStatus = adjustment ? "APPROVED" : before.reviewStatus();
|
String requiredStatus = adjustment ? "APPROVED" : before.reviewStatus();
|
||||||
if (!adjustment && !List.of("DRAFT", "REJECTED").contains(requiredStatus)) {
|
if (!adjustment && !List.of("DRAFT", "REJECTED").contains(requiredStatus)) {
|
||||||
throw error("FIN-FEE-409", "只有草稿或已驳回费用可编辑");
|
throw error("FIN-FEE-409", "只有草稿或已驳回费用可编辑");
|
||||||
@@ -302,6 +301,7 @@ public class FinancePhase1OperationService {
|
|||||||
"/api/platform/finance/v1/fee-sources/" + feeSourceId + (voidSource ? "/void" : "/submit"), request);
|
"/api/platform/finance/v1/fee-sources/" + feeSourceId + (voidSource ? "/void" : "/submit"), request);
|
||||||
if (replay != null) return getFinanceFeeSource(replay);
|
if (replay != null) return getFinanceFeeSource(replay);
|
||||||
if (before.statementGenerated()) throw error("FIN-FEE-409", "已生成单据的费用来源不可操作");
|
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", "费用来源当前状态不允许该操作");
|
if (!allowedReviews.contains(before.reviewStatus())) throw error("FIN-FEE-409", "费用来源当前状态不允许该操作");
|
||||||
int changed = voidSource
|
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=?",
|
? 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;
|
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) {
|
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");
|
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));
|
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 static final String CNY = "CNY";
|
||||||
private final JdbcTemplate jdbc;
|
private final JdbcTemplate jdbc;
|
||||||
private final ObjectMapper objectMapper;
|
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.jdbc = jdbc;
|
||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
|
this.customerNames = customerNames;
|
||||||
}
|
}
|
||||||
|
|
||||||
public CustomerAccount customerAccount(long customerId) {
|
public CustomerAccount customerAccount(long customerId) {
|
||||||
@@ -131,12 +133,13 @@ public class FinanceQueryService {
|
|||||||
|
|
||||||
public List<Statement> statements(Long customerId, boolean clientVisibleOnly) {
|
public List<Statement> statements(Long customerId, boolean clientVisibleOnly) {
|
||||||
validateOptionalCustomer(customerId);
|
validateOptionalCustomer(customerId);
|
||||||
|
var names = customerNames.resolver();
|
||||||
String visible = clientVisibleOnly ? " AND status IN ('ISSUED','PARTIALLY_PAID','PAID')" : "";
|
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";
|
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];
|
Object[] args = new Object[0];
|
||||||
if (customerId != null) { sql += " AND tenant_id=?"; args = new Object[]{customerId}; }
|
if (customerId != null) { sql += " AND tenant_id=?"; args = new Object[]{customerId}; }
|
||||||
return jdbc.query(sql + visible + " ORDER BY bill_date DESC,id DESC",
|
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.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.getBigDecimal("outstanding_amount"), rs.getString("currency_code"), rs.getString("status"), rs.getInt("version"),
|
||||||
rs.getString("customer_confirmation_status"), nullableLong(rs, "customer_confirmed_by"),
|
rs.getString("customer_confirmation_status"), nullableLong(rs, "customer_confirmed_by"),
|
||||||
@@ -147,6 +150,7 @@ public class FinanceQueryService {
|
|||||||
|
|
||||||
public List<ReconciliationSummary> reconciliationStatements(Long customerId) {
|
public List<ReconciliationSummary> reconciliationStatements(Long customerId) {
|
||||||
validateOptionalCustomer(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," +
|
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," +
|
"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," +
|
"COALESCE(p.credit_days,30) credit_days," +
|
||||||
@@ -163,7 +167,7 @@ public class FinanceQueryService {
|
|||||||
Object[] args = new Object[0];
|
Object[] args = new Object[0];
|
||||||
if (customerId != null) { sql += " AND b.tenant_id=?"; args = new Object[]{customerId}; }
|
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(
|
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"),
|
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.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"),
|
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) {
|
public StatementDetail statement(long customerId, long billId, boolean clientVisibleOnly) {
|
||||||
requireCustomer(customerId);
|
requireCustomer(customerId);
|
||||||
|
var names = customerNames.resolver();
|
||||||
String visible = clientVisibleOnly ? " AND status IN ('ISSUED','PARTIALLY_PAID','PAID')" : "";
|
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,
|
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.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.getBigDecimal("outstanding_amount"), rs.getString("currency_code"), rs.getString("status"), rs.getInt("version"),
|
||||||
rs.getString("customer_confirmation_status"), nullableLong(rs, "customer_confirmed_by"),
|
rs.getString("customer_confirmation_status"), nullableLong(rs, "customer_confirmed_by"),
|
||||||
|
|||||||
@@ -52,19 +52,21 @@ public class FinanceSettlementService {
|
|||||||
private final FinanceVoucherValidator voucherValidator;
|
private final FinanceVoucherValidator voucherValidator;
|
||||||
private final FinanceSupplierValidator supplierValidator;
|
private final FinanceSupplierValidator supplierValidator;
|
||||||
private final FinanceDocumentNumberGenerator documentNumbers;
|
private final FinanceDocumentNumberGenerator documentNumbers;
|
||||||
|
private final FinanceCustomerNames customerNames;
|
||||||
|
|
||||||
/** 注入统一编号组件,确保所有业务入口共用受事务保护的日流水。 */
|
/** 注入统一编号组件,确保所有业务入口共用受事务保护的日流水。 */
|
||||||
@Autowired
|
@Autowired
|
||||||
public FinanceSettlementService(JdbcTemplate jdbc, ObjectMapper objectMapper, FinanceVoucherValidator voucherValidator,
|
public FinanceSettlementService(JdbcTemplate jdbc, ObjectMapper objectMapper, FinanceVoucherValidator voucherValidator,
|
||||||
FinanceSupplierValidator supplierValidator,
|
FinanceSupplierValidator supplierValidator,
|
||||||
FinanceDocumentNumberGenerator documentNumbers,
|
FinanceDocumentNumberGenerator documentNumbers,
|
||||||
com.baiyue.finance.repository.FinanceSettlementDetailRepository details) {
|
com.baiyue.finance.repository.FinanceSettlementDetailRepository details, FinanceCustomerNames customerNames) {
|
||||||
this.jdbc = jdbc;
|
this.jdbc = jdbc;
|
||||||
this.details = details;
|
this.details = details;
|
||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
this.voucherValidator = voucherValidator;
|
this.voucherValidator = voucherValidator;
|
||||||
this.supplierValidator = supplierValidator;
|
this.supplierValidator = supplierValidator;
|
||||||
this.documentNumbers = documentNumbers;
|
this.documentNumbers = documentNumbers;
|
||||||
|
this.customerNames = customerNames;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 创建应收结算单,幂等请求恢复原单,首次建单分配 AR 日流水。 */
|
/** 创建应收结算单,幂等请求恢复原单,首次建单分配 AR 日流水。 */
|
||||||
@@ -98,7 +100,7 @@ public class FinanceSettlementService {
|
|||||||
|
|
||||||
LocalDate generatedDate = LocalDate.now();
|
LocalDate generatedDate = LocalDate.now();
|
||||||
long settlementId = insertSettlement(customerId, documentNumbers.next("AR"), "RECEIVABLE",
|
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,
|
date(bill, "bill_date").format(DateTimeFormatter.ofPattern("yyyy-MM")), CNY, total,
|
||||||
generatedDate, date(bill, "due_date"), operatorId);
|
generatedDate, date(bill, "due_date"), operatorId);
|
||||||
int inserted = jdbc.update(
|
int inserted = jdbc.update(
|
||||||
@@ -155,8 +157,7 @@ public class FinanceSettlementService {
|
|||||||
if (total.signum() <= 0) throw error("FIN_400", "应付结算金额必须大于0");
|
if (total.signum() <= 0) throw error("FIN_400", "应付结算金额必须大于0");
|
||||||
String supplierCategory = supplierCategoryConstraint(sources);
|
String supplierCategory = supplierCategoryConstraint(sources);
|
||||||
String supplierName = supplierValidator.requireActive(request.supplierId(), supplierCategory).name();
|
String supplierName = supplierValidator.requireActive(request.supplierId(), supplierCategory).name();
|
||||||
String customerName = sources.stream().map(row -> jsonText(parseTree(text(row, "source_snapshot")), "customerName"))
|
String customerName = customerNames.required(customerId);
|
||||||
.filter(StringUtils::hasText).findFirst().orElse("客户" + customerId);
|
|
||||||
LocalDate generatedDate = LocalDate.now();
|
LocalDate generatedDate = LocalDate.now();
|
||||||
long settlementId = insertSettlement(customerId, documentNumbers.next("AP"), "PAYABLE", customerName,
|
long settlementId = insertSettlement(customerId, documentNumbers.next("AP"), "PAYABLE", customerName,
|
||||||
request.supplierId(), supplierName, null, generatedDate.format(DateTimeFormatter.ofPattern("yyyy-MM")),
|
request.supplierId(), supplierName, null, generatedDate.format(DateTimeFormatter.ofPattern("yyyy-MM")),
|
||||||
@@ -176,7 +177,7 @@ public class FinanceSettlementService {
|
|||||||
if (settlementId <= 0) throw error("FIN_404", "结算单不存在");
|
if (settlementId <= 0) throw error("FIN_404", "结算单不存在");
|
||||||
List<Map<String, Object>> records = details.summary(settlementId);
|
List<Map<String, Object>> records = details.summary(settlementId);
|
||||||
if (records.isEmpty()) throw error("FIN_404", "结算单不存在");
|
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)
|
List<SettlementLine> lines = details.lines(summary.customerId(), settlementId)
|
||||||
.stream().map(source -> line(source, summary)).toList();
|
.stream().map(source -> line(source, summary)).toList();
|
||||||
List<SettlementTransaction> transactions = new ArrayList<>(details.payments(summary.customerId(), settlementId)
|
List<SettlementTransaction> transactions = new ArrayList<>(details.payments(summary.customerId(), settlementId)
|
||||||
@@ -206,7 +207,8 @@ public class FinanceSettlementService {
|
|||||||
args.add(customerId);
|
args.add(customerId);
|
||||||
}
|
}
|
||||||
sql += " ORDER BY s.generated_date DESC,s.id DESC";
|
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
|
@Transactional
|
||||||
@@ -419,9 +421,9 @@ public class FinanceSettlementService {
|
|||||||
MDC.get("requestId"), MDC.get("traceId"));
|
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"),
|
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, "supplier_id"), text(row, "supplier_name_snapshot"),
|
||||||
nullableNumber(row, "reconciliation_statement_id"), text(row, "reconciliation_statement_no"),
|
nullableNumber(row, "reconciliation_statement_id"), text(row, "reconciliation_statement_no"),
|
||||||
text(row, "business_period"), text(row, "fee_item_summary"),
|
text(row, "business_period"), text(row, "fee_item_summary"),
|
||||||
|
|||||||
@@ -15,9 +15,11 @@ import java.util.List;
|
|||||||
public class ReceivableStatisticsService {
|
public class ReceivableStatisticsService {
|
||||||
private static final String CURRENCY = "CNY";
|
private static final String CURRENCY = "CNY";
|
||||||
private final JdbcTemplate jdbc;
|
private final JdbcTemplate jdbc;
|
||||||
|
private final FinanceCustomerNames customerNames;
|
||||||
|
|
||||||
public ReceivableStatisticsService(JdbcTemplate jdbc) {
|
public ReceivableStatisticsService(JdbcTemplate jdbc, FinanceCustomerNames customerNames) {
|
||||||
this.jdbc = jdbc;
|
this.jdbc = jdbc;
|
||||||
|
this.customerNames = customerNames;
|
||||||
}
|
}
|
||||||
|
|
||||||
public ReceivableStatisticsResponse query(YearMonth month, Long customerId, int page, int pageSize) {
|
public ReceivableStatisticsResponse query(YearMonth month, Long customerId, int page, int pageSize) {
|
||||||
@@ -61,8 +63,9 @@ public class ReceivableStatisticsService {
|
|||||||
args.add(customerId);
|
args.add(customerId);
|
||||||
}
|
}
|
||||||
sql.append(" GROUP BY s.tenant_id,p.customer_name_snapshot ORDER BY customer_name_snapshot,s.tenant_id");
|
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(
|
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_receivable"), rs.getBigDecimal("total_received"),
|
||||||
rs.getBigDecimal("total_outstanding"))), args.toArray());
|
rs.getBigDecimal("total_outstanding"))), args.toArray());
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-10
@@ -46,14 +46,16 @@ public class ReconciliationStatementService {
|
|||||||
private final JdbcTemplate jdbc;
|
private final JdbcTemplate jdbc;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
private final FinanceDocumentNumberGenerator documentNumbers;
|
private final FinanceDocumentNumberGenerator documentNumbers;
|
||||||
|
private final FinanceCustomerNames customerNames;
|
||||||
|
|
||||||
/** 注入统一编号组件,确保所有业务入口共用受事务保护的日流水。 */
|
/** 注入统一编号组件,确保所有业务入口共用受事务保护的日流水。 */
|
||||||
@Autowired
|
@Autowired
|
||||||
public ReconciliationStatementService(JdbcTemplate jdbc, ObjectMapper objectMapper,
|
public ReconciliationStatementService(JdbcTemplate jdbc, ObjectMapper objectMapper,
|
||||||
FinanceDocumentNumberGenerator documentNumbers) {
|
FinanceDocumentNumberGenerator documentNumbers, FinanceCustomerNames customerNames) {
|
||||||
this.jdbc = jdbc;
|
this.jdbc = jdbc;
|
||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
this.documentNumbers = documentNumbers;
|
this.documentNumbers = documentNumbers;
|
||||||
|
this.customerNames = customerNames;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 创建对账单并分配 REC 日流水,重复请求返回已创建记录。 */
|
/** 创建对账单并分配 REC 日流水,重复请求返回已创建记录。 */
|
||||||
@@ -195,7 +197,7 @@ public class ReconciliationStatementService {
|
|||||||
List<ReconciliationRevision> revisions = jdbc.query(
|
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",
|
"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);
|
(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(),
|
metadata.periodStart(), metadata.periodEnd(), bill.creditDays(), "CNY", bill.total(), bill.received(),
|
||||||
bill.outstanding(), bill.outstanding().signum() == 0, reconciliationStatus(bill.status()), bill.version(),
|
bill.outstanding(), bill.outstanding().signum() == 0, reconciliationStatus(bill.status()), bill.version(),
|
||||||
bill.customerConfirmationStatus(), bill.customerConfirmedBy(), bill.customerConfirmedByName(),
|
bill.customerConfirmationStatus(), bill.customerConfirmedBy(), bill.customerConfirmedByName(),
|
||||||
@@ -278,7 +280,7 @@ public class ReconciliationStatementService {
|
|||||||
private CustomerProfile customer(long customerId) {
|
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'",
|
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);
|
(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() {
|
private long activeSubject() {
|
||||||
@@ -488,18 +490,19 @@ public class ReconciliationStatementService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private ReconciliationRevision revision(ResultSet rs, int revisionNo) throws SQLException {
|
private ReconciliationRevision revision(ResultSet rs, int revisionNo) throws SQLException {
|
||||||
return new ReconciliationRevision(revisionNo, rs.getString("action").replaceFirst("^RECONCILIATION_", ""), amountFromJson(rs.getString("before_data")),
|
return new ReconciliationRevision(revisionNo, rs.getString("action").replaceFirst("^RECONCILIATION_", ""), amountFromJson(rs.getString("before_data"), "total"),
|
||||||
amountFromJson(rs.getString("after_data")), rs.getString("reason"), rs.getLong("operator_id"),
|
amountFromJson(rs.getString("after_data"), "totalReceivable"), rs.getString("reason"), rs.getLong("operator_id"),
|
||||||
rs.getString("operator_name_snapshot"),
|
rs.getString("operator_name_snapshot"),
|
||||||
rs.getTimestamp("created_at").toLocalDateTime());
|
rs.getTimestamp("created_at").toLocalDateTime());
|
||||||
}
|
}
|
||||||
|
|
||||||
private BigDecimal amountFromJson(String value) {
|
/** 变更前为 Bill,变更后为 ReconciliationDetail;缺失金额属于损坏快照,不能伪装为零。 */
|
||||||
if (!StringUtils.hasText(value)) return BigDecimal.ZERO;
|
private BigDecimal amountFromJson(String value, String field) {
|
||||||
try {
|
try {
|
||||||
JsonNode node = objectMapper.readTree(value);
|
JsonNode node = objectMapper.reader().with(com.fasterxml.jackson.databind.DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS).readTree(value);
|
||||||
JsonNode amount = node.has("totalAmount") ? node.get("totalAmount") : node.get("total");
|
JsonNode amount = node.get(field);
|
||||||
return amount == null || amount.isNull() ? BigDecimal.ZERO : amount.decimalValue();
|
if (amount == null || !amount.isNumber()) throw new IllegalArgumentException("缺失金额");
|
||||||
|
return amount.decimalValue();
|
||||||
} catch (Exception exception) {
|
} catch (Exception exception) {
|
||||||
throw error("FIN_001", "对账单修订快照损坏");
|
throw error("FIN_001", "对账单修订快照损坏");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,16 @@ import com.baiyue.common.event.EventPayloadHash;
|
|||||||
import com.baiyue.common.event.EventTypes;
|
import com.baiyue.common.event.EventTypes;
|
||||||
import com.baiyue.finance.service.BusinessFeeSourceEventService;
|
import com.baiyue.finance.service.BusinessFeeSourceEventService;
|
||||||
import com.baiyue.finance.service.FinanceSupplierValidator;
|
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.JsonNode;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import org.junit.jupiter.api.Test;
|
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.mockito.ArgumentCaptor;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
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.assertj.core.api.Assertions.assertThat;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.ArgumentMatchers.anyInt;
|
import static org.mockito.ArgumentMatchers.anyInt;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyLong;
|
||||||
import static org.mockito.ArgumentMatchers.anyString;
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
import static org.mockito.ArgumentMatchers.argThat;
|
import static org.mockito.ArgumentMatchers.argThat;
|
||||||
import static org.mockito.ArgumentMatchers.eq;
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
@@ -37,7 +45,8 @@ class BusinessFeeSourceEventServiceTest {
|
|||||||
private final JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
private final JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||||
private final ObjectMapper json = new ObjectMapper().findAndRegisterModules();
|
private final ObjectMapper json = new ObjectMapper().findAndRegisterModules();
|
||||||
private final FinanceSupplierValidator suppliers = mock(FinanceSupplierValidator.class);
|
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
|
@Test
|
||||||
void importsOverseasFeeWithoutInventingInboundReference() {
|
void importsOverseasFeeWithoutInventingInboundReference() {
|
||||||
@@ -116,19 +125,93 @@ class BusinessFeeSourceEventServiceTest {
|
|||||||
verify(jdbc, never()).update(anyString(), any(Object[].class));
|
verify(jdbc, never()).update(anyString(), any(Object[].class));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 旧版本不能覆盖已有较新版本,客户相同时仍保留原乱序门禁。 */
|
||||||
@Test
|
@Test
|
||||||
void lateOlderVersionCannotSupersedeNewerSourceOrCost() {
|
void lateOlderVersionCannotSupersedeNewerSourceOrCost() {
|
||||||
when(suppliers.requireActive(71L, null)).thenReturn(
|
when(suppliers.requireActive(71L, null)).thenReturn(
|
||||||
new com.baiyue.finance.client.WarehouseSupplierClient.SupplierSnapshot(71L, "供应商", null, "ACTIVE"));
|
new com.baiyue.finance.client.WarehouseSupplierClient.SupplierSnapshot(71L, "供应商", null, "ACTIVE"));
|
||||||
when(jdbc.queryForList(startsWith("SELECT source_fee_version"), eq("LASTLEG"),
|
when(sources.lockVersions("LASTLEG", "OVERSEAS_CONTAINER_FEE", 701L))
|
||||||
eq("OVERSEAS_CONTAINER_FEE"), eq(701L)))
|
.thenReturn(List.of(new BusinessFeeSourceVersion(2, 10001L)));
|
||||||
.thenReturn(List.of(Map.of("source_fee_version", 2)));
|
|
||||||
assertThatThrownBy(() -> service.consume(containerEvent("CNY", "61", "PUBLISHED", 1)))
|
assertThatThrownBy(() -> service.consume(containerEvent("CNY", "61", "PUBLISHED", 1)))
|
||||||
.hasMessageContaining("版本已过期");
|
.hasMessageContaining("版本已过期");
|
||||||
verify(jdbc, never()).update(startsWith("UPDATE fin_fee_source"), any(Object[].class));
|
verify(jdbc, never()).update(startsWith("UPDATE fin_fee_source"), any(Object[].class));
|
||||||
verify(jdbc, never()).update(startsWith("UPDATE fin_cost"), 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
|
@Test
|
||||||
void replayReturnsOriginalSourceWithoutDuplicatingCost() {
|
void replayReturnsOriginalSourceWithoutDuplicatingCost() {
|
||||||
EventEnvelope event = containerEvent("CNY", "61", "PUBLISHED", 1);
|
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)))
|
when(jdbc.query(anyString(), any(RowMapper.class), org.mockito.ArgumentMatchers.eq(9L)))
|
||||||
.thenReturn(List.of());
|
.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");
|
assertThat(result.accountMode()).isEqualTo("MANUAL_RECEIVABLE_LEDGER");
|
||||||
var amountSql = org.mockito.ArgumentCaptor.forClass(String.class);
|
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");
|
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 ->
|
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));
|
((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);
|
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 ->
|
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));
|
((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.totalReceivable()).isZero();
|
||||||
assertThat(result.receivedAmount()).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()))
|
when(jdbc.queryForObject(anyString(), any(RowMapper.class), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
|
||||||
.thenThrow(new BadSqlGrammarException("customer summary", "SELECT ...", null));
|
.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);
|
.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,"
|
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),"
|
+ "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)");
|
+ "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
|
@Test
|
||||||
void receivablePoolNeverContainsPayableSource() {
|
void receivablePoolNeverContainsPayableSource() {
|
||||||
DirectionJdbc jdbc = new DirectionJdbc();
|
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");
|
List<FeePoolItem> result = service.feePool(7L, "RECEIVABLE");
|
||||||
|
|
||||||
@@ -30,7 +30,7 @@ class FinanceFeePoolDirectionBehaviorTest {
|
|||||||
@Test
|
@Test
|
||||||
void payablePoolNeverContainsReceivableSource() {
|
void payablePoolNeverContainsReceivableSource() {
|
||||||
DirectionJdbc jdbc = new DirectionJdbc();
|
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");
|
List<FeePoolItem> result = service.feePool(7L, "PAYABLE");
|
||||||
|
|
||||||
@@ -42,7 +42,7 @@ class FinanceFeePoolDirectionBehaviorTest {
|
|||||||
@Test
|
@Test
|
||||||
void payablePoolOnlyQueriesApprovedUnsettledSourcesForTheSelectedSupplier() {
|
void payablePoolOnlyQueriesApprovedUnsettledSourcesForTheSelectedSupplier() {
|
||||||
DirectionJdbc jdbc = new DirectionJdbc();
|
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);
|
service.feePool(7L, "PAYABLE", 81L);
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import org.springframework.jdbc.datasource.DriverManagerDataSource;
|
|||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
@@ -30,8 +31,11 @@ public class FinanceFeeSourceReadMapperTest {
|
|||||||
try (var connection = data.getConnection()) {
|
try (var connection = data.getConnection()) {
|
||||||
jdbc.execute("CREATE ALIAS JSON_EXTRACT FOR 'com.baiyue.finance.FinanceFeeSourceReadMapperTest.jsonExtract'");
|
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 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_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)");
|
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)");
|
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, 1, "FIRSTLEG", "INBOUND_FEE", 700, 1, 9, direction, "SUPERSEDED", "100.00");
|
||||||
insert(jdbc, 2, "FIRSTLEG", "INBOUND_FEE", 700, 2, 9, direction, "AVAILABLE", "120.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(
|
assertThat(repository.logs(9, 1)).extracting(log -> log.action()).containsExactly(
|
||||||
"FEE_SOURCE_PUBLISHED", "FEE_SOURCE_REVIEW_APPROVE");
|
"FEE_SOURCE_PUBLISHED", "FEE_SOURCE_REVIEW_APPROVE");
|
||||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fin_fee_source", Long.class)).isEqualTo(8);
|
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fin_fee_source", Long.class)).isEqualTo(8);
|
||||||
jdbc.update("INSERT INTO fin_bill_line VALUES(2,2)");
|
if ("RECEIVABLE".equals(direction)) {
|
||||||
filters.put("status", "BILLED"); filters.put("statementGenerated", true);
|
jdbc.update("INSERT INTO fin_bill VALUES(91,9,'ISSUED')");
|
||||||
assertThat(repository.count(filters)).isEqualTo(1);
|
jdbc.update("INSERT INTO fin_bill_line VALUES(2,2,9,91)");
|
||||||
assertThat(repository.list(filters)).extracting(row -> row.feeSourceId()).containsExactly(2L);
|
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");
|
insert(jdbc, 9, "FINANCE", "MANUAL", 700, 1, 9, direction, "AVAILABLE", "120.00");
|
||||||
|
|||||||
@@ -189,8 +189,8 @@ class FinancePhase1OperationBehaviorTest {
|
|||||||
/** 保留费用审核状态读取。 */
|
/** 保留费用审核状态读取。 */
|
||||||
@Test
|
@Test
|
||||||
void approvedFeeCannotBeEditedAsDraft() throws Exception {
|
void approvedFeeCannotBeEditedAsDraft() throws Exception {
|
||||||
JdbcTemplate jdbc = feeSourceJdbc("REJECTED", false, 2);
|
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||||
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper(), null, "", mock(FinanceFeeSourceReadRepository.class));
|
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper(), null, "", feeSources("REJECTED", false, 2));
|
||||||
|
|
||||||
assertThat(service.getFinanceFeeSource(88L).reviewStatus()).isEqualTo("REJECTED");
|
assertThat(service.getFinanceFeeSource(88L).reviewStatus()).isEqualTo("REJECTED");
|
||||||
}
|
}
|
||||||
@@ -199,7 +199,7 @@ class FinancePhase1OperationBehaviorTest {
|
|||||||
@Test
|
@Test
|
||||||
void feeSourceDetailUsesFixedDtoAndRealCustomerScope() throws Exception {
|
void feeSourceDetailUsesFixedDtoAndRealCustomerScope() throws Exception {
|
||||||
FinancePhase1OperationService service = new FinancePhase1OperationService(
|
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);
|
FeeSourceDetail detail = service.getFinanceFeeSource(88L);
|
||||||
|
|
||||||
@@ -212,8 +212,8 @@ class FinancePhase1OperationBehaviorTest {
|
|||||||
/** 业务来源费用必须回到所属系统调整。 */
|
/** 业务来源费用必须回到所属系统调整。 */
|
||||||
@Test
|
@Test
|
||||||
void firstlegFeeEditAndAdjustAreBlockedInsteadOfMutatingFinanceCopy() throws Exception {
|
void firstlegFeeEditAndAdjustAreBlockedInsteadOfMutatingFinanceCopy() throws Exception {
|
||||||
JdbcTemplate jdbc = feeSourceJdbc("REJECTED", false, 1);
|
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||||
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper(), null, "", mock(FinanceFeeSourceReadRepository.class));
|
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper(), null, "", feeSources("REJECTED", false, 1));
|
||||||
|
|
||||||
var request = new FinancePhase1Dtos.FeeSourceEditRequest(
|
var request = new FinancePhase1Dtos.FeeSourceEditRequest(
|
||||||
new BigDecimal("120.00"), "更正金额", 1);
|
new BigDecimal("120.00"), "更正金额", 1);
|
||||||
@@ -228,7 +228,7 @@ class FinancePhase1OperationBehaviorTest {
|
|||||||
/** 头程作废等待权威来源事件,不能直接改财务副本。 */
|
/** 头程作废等待权威来源事件,不能直接改财务副本。 */
|
||||||
@Test
|
@Test
|
||||||
void voidingNyexx2608180003DelegatesToFirstlegAndWaitsForEvent() throws Exception {
|
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),
|
when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(9L), eq(71L),
|
||||||
eq("FEE_SOURCE_VOID"), eq("void-key"))).thenReturn(List.of());
|
eq("FEE_SOURCE_VOID"), eq("void-key"))).thenReturn(List.of());
|
||||||
FirstlegFeeSourceClient firstleg = mock(FirstlegFeeSourceClient.class);
|
FirstlegFeeSourceClient firstleg = mock(FirstlegFeeSourceClient.class);
|
||||||
@@ -236,7 +236,7 @@ class FinancePhase1OperationBehaviorTest {
|
|||||||
.thenReturn(ApiResponse.success(new FirstlegFeeSourceClient.CommandResult(
|
.thenReturn(ApiResponse.success(new FirstlegFeeSourceClient.CommandResult(
|
||||||
"INBOUND_FEE", 700L, 3, "VOIDED", false)));
|
"INBOUND_FEE", 700L, 3, "VOIDED", false)));
|
||||||
FinancePhase1OperationService service = new FinancePhase1OperationService(
|
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",
|
FeeSourceDetail result = service.voidFinanceFeeSource(88L, 71L, "finance",
|
||||||
"void-key", new FinancePhase1Dtos.FeeSourceActionRequest("测试作废", 2));
|
"void-key", new FinancePhase1Dtos.FeeSourceActionRequest("测试作废", 2));
|
||||||
@@ -262,13 +262,9 @@ class FinancePhase1OperationBehaviorTest {
|
|||||||
void reviewUsesExpectedVersionAndReturnsUpdatedDetail() throws Exception {
|
void reviewUsesExpectedVersionAndReturnsUpdatedDetail() throws Exception {
|
||||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||||
AtomicInteger reads = new AtomicInteger();
|
AtomicInteger reads = new AtomicInteger();
|
||||||
when(jdbc.query(startsWith("SELECT * FROM fin_fee_source"), any(RowMapper.class), (Object[]) any())).thenAnswer(invocation -> {
|
var sources = mock(FinanceFeeSourceReadRepository.class);
|
||||||
@SuppressWarnings("unchecked") RowMapper<FeeSourceDetail> mapper = invocation.getArgument(1);
|
when(sources.detail(88L)).thenAnswer(invocation -> reads.getAndIncrement() == 0
|
||||||
String review = reads.getAndIncrement() == 0 ? "PENDING" : "APPROVED";
|
? feeDetail("PENDING", false, 0) : feeDetail("APPROVED", false, 1));
|
||||||
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);
|
|
||||||
when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(9L), eq(71L),
|
when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(9L), eq(71L),
|
||||||
eq("FEE_SOURCE_REVIEW"), eq("review-key"))).thenReturn(List.of());
|
eq("FEE_SOURCE_REVIEW"), eq("review-key"))).thenReturn(List.of());
|
||||||
when(jdbc.update(startsWith("INSERT INTO fin_idempotency_record"), eq(9L), eq(71L),
|
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);
|
eq(71L), eq("操作人 71"), any(), any())).thenReturn(1);
|
||||||
when(jdbc.update(startsWith("UPDATE fin_idempotency_record SET status='SUCCEEDED'"),
|
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);
|
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",
|
FeeSourceDetail result = service.reviewFinanceFeeSource(88L, 71L, "review-key",
|
||||||
new FinancePhase1Dtos.ReviewRequest("APPROVE", "核对无误", 0));
|
new FinancePhase1Dtos.ReviewRequest("APPROVE", "核对无误", 0));
|
||||||
@@ -332,45 +328,30 @@ class FinancePhase1OperationBehaviorTest {
|
|||||||
.when(service).getFinanceFeeSource(88L);
|
.when(service).getFinanceFeeSource(88L);
|
||||||
assertThatThrownBy(() -> service.editFinanceFeeSource(88, 71, "frozen", request, true))
|
assertThatThrownBy(() -> service.editFinanceFeeSource(88, 71, "frozen", request, true))
|
||||||
.isInstanceOf(FinanceEventException.class).hasMessageContaining("已生成单据");
|
.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 的审计查询。 */
|
/** 服务行为测试由 Repository 返回明确快照;正式 SQL 与 DTO 映射由 Mapper 数据库测试覆盖。 */
|
||||||
private static JdbcTemplate feeSourceJdbc(String reviewStatus, boolean included, int version) throws Exception {
|
private static FinanceFeeSourceReadRepository feeSources(String review, boolean generated, int version) throws Exception {
|
||||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
var repository = mock(FinanceFeeSourceReadRepository.class);
|
||||||
when(jdbc.query(startsWith("SELECT * FROM fin_fee_source"), any(RowMapper.class), (Object[]) any())).thenAnswer(invocation -> {
|
when(repository.detail(88L)).thenReturn(feeDetail(review, generated, version));
|
||||||
@SuppressWarnings("unchecked") RowMapper<FeeSourceDetail> mapper = invocation.getArgument(1);
|
return repository;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ResultSet resultSet(String reviewStatus, boolean included, int version) throws Exception {
|
private static FeeSourceDetail feeDetail(String review, boolean generated, int version) throws Exception {
|
||||||
ResultSet rs = mock(ResultSet.class);
|
var node = objectMapper().createObjectNode().put("feeSourceId",88).put("sourceSystem","FIRSTLEG")
|
||||||
when(rs.getLong("id")).thenReturn(88L);
|
.put("sourceType","INBOUND_FEE").put("sourceFeeId",700).put("sourceVersion",3).put("customerId",9)
|
||||||
when(rs.getLong("source_fee_id")).thenReturn(700L);
|
.put("direction","RECEIVABLE").put("orderId",90).put("inboundId",100).put("containerId",200)
|
||||||
when(rs.getLong("customer_id")).thenReturn(9L);
|
.put("inboundCode","NYEXX2608180003").put("sourceBusinessId","IN-100").put("feeItemName","头程运费")
|
||||||
when(rs.getLong("order_id")).thenReturn(90L);
|
.put("amount",100).put("currency","CNY").put("operationVersion",version).put("reviewStatus",review)
|
||||||
when(rs.getLong("inbound_id")).thenReturn(100L);
|
.put("sourceStatus",generated ? "BILLED" : "AVAILABLE").put("statementGenerated",generated)
|
||||||
when(rs.getLong("container_id")).thenReturn(200L);
|
.put("sourceHash","a".repeat(64)).put("businessDate","2026-08-11");
|
||||||
when(rs.wasNull()).thenReturn(false, false, true, false, false, true);
|
return objectMapper().treeToValue(node,FeeSourceDetail.class);
|
||||||
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 ObjectMapper objectMapper() {
|
private static ObjectMapper objectMapper() {
|
||||||
|
|||||||
+3
-3
@@ -16,7 +16,7 @@ class FinancePlatformOptionalCustomerQueryTest {
|
|||||||
@Test
|
@Test
|
||||||
void allCustomerQueriesOmitCustomerPredicateAndArguments() {
|
void allCustomerQueriesOmitCustomerPredicateAndArguments() {
|
||||||
RecordingJdbc jdbc = new RecordingJdbc();
|
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.feePool(null, null);
|
||||||
service.statements(null, false);
|
service.statements(null, false);
|
||||||
@@ -33,7 +33,7 @@ class FinancePlatformOptionalCustomerQueryTest {
|
|||||||
@Test
|
@Test
|
||||||
void customerFilterAddsPredicateAndRealCustomerArgument() {
|
void customerFilterAddsPredicateAndRealCustomerArgument() {
|
||||||
RecordingJdbc jdbc = new RecordingJdbc();
|
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.feePool(31L, null);
|
||||||
service.statements(31L, false);
|
service.statements(31L, false);
|
||||||
@@ -49,7 +49,7 @@ class FinancePlatformOptionalCustomerQueryTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void optionalCustomerNeverTreatsZeroAsAllCustomers() {
|
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.statements(0L, false)).hasMessageContaining("客户范围无效");
|
||||||
assertThatThrownBy(() -> service.reconciliationStatements(0L)).hasMessageContaining("客户范围无效");
|
assertThatThrownBy(() -> service.reconciliationStatements(0L)).hasMessageContaining("客户范围无效");
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ class FinanceSettlementBehaviorTest {
|
|||||||
|
|
||||||
assertThat(all).singleElement().extracting(SettlementSummary::settlementId).isEqualTo(88L);
|
assertThat(all).singleElement().extracting(SettlementSummary::settlementId).isEqualTo(88L);
|
||||||
assertThat(scoped).singleElement().extracting(SettlementSummary::customerId).isEqualTo(10L);
|
assertThat(scoped).singleElement().extracting(SettlementSummary::customerId).isEqualTo(10L);
|
||||||
|
assertThat(scoped).singleElement().extracting(SettlementSummary::customerName).isEqualTo("测试客户公司");
|
||||||
ArgumentCaptor<String> allSql = ArgumentCaptor.forClass(String.class);
|
ArgumentCaptor<String> allSql = ArgumentCaptor.forClass(String.class);
|
||||||
ArgumentCaptor<String> scopedSql = ArgumentCaptor.forClass(String.class);
|
ArgumentCaptor<String> scopedSql = ArgumentCaptor.forClass(String.class);
|
||||||
verify(jdbc).queryForList(allSql.capture(), eq("PAYABLE"));
|
verify(jdbc).queryForList(allSql.capture(), eq("PAYABLE"));
|
||||||
@@ -633,7 +634,7 @@ class FinanceSettlementBehaviorTest {
|
|||||||
return jdbc.queryForList(sql, invocation.getArguments());
|
return jdbc.queryForList(sql, invocation.getArguments());
|
||||||
});
|
});
|
||||||
return new FinanceSettlementService(jdbc, new ObjectMapper().findAndRegisterModules(),
|
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() {
|
private static FinanceSupplierValidator supplierValidator() {
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ class ReceivableStatisticsServiceTest {
|
|||||||
row(10001, "客户A", "300.00", "100.00", "200.00"),
|
row(10001, "客户A", "300.00", "100.00", "200.00"),
|
||||||
row(10002, "客户B", "80.00", "80.00", "0.00"));
|
row(10002, "客户B", "80.00", "80.00", "0.00"));
|
||||||
when(jdbc.query(anyString(), any(RowMapper.class), any(Object[].class))).thenReturn(rows);
|
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);
|
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)))
|
when(jdbc.query(anyString(), any(RowMapper.class), any(Object[].class)))
|
||||||
.thenReturn(List.of(row(10001, "客户A", "300.00", "100.00", "199.99")));
|
.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))
|
.query(YearMonth.of(2026, 7), null, 1, 20))
|
||||||
.isInstanceOf(FinanceEventException.class)
|
.isInstanceOf(FinanceEventException.class)
|
||||||
.hasMessageContaining("勾稽异常");
|
.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) {
|
private static ReceivableStatisticsRow row(long id, String name, String total, String received, String outstanding) {
|
||||||
return new ReceivableStatisticsRow(id, name, "CNY", new BigDecimal(total),
|
return new ReceivableStatisticsRow(id, name, "CNY", new BigDecimal(total),
|
||||||
new BigDecimal(received), new BigDecimal(outstanding));
|
new BigDecimal(received), new BigDecimal(outstanding));
|
||||||
|
|||||||
@@ -83,6 +83,8 @@ class ReconciliationStatementBehaviorTest {
|
|||||||
@Test
|
@Test
|
||||||
void byCutoffSelectsEligibleSourcesAndCreatesDraft() throws Exception {
|
void byCutoffSelectsEligibleSourcesAndCreatesDraft() throws Exception {
|
||||||
JdbcTemplate jdbc = createJdbc();
|
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);
|
ReconciliationStatementService target = serviceWithNumber(jdbc);
|
||||||
CreateReconciliationRequest request = new CreateReconciliationRequest(9L, "CNY", "BY_CUTOFF", List.of(),
|
CreateReconciliationRequest request = new CreateReconciliationRequest(9L, "CNY", "BY_CUTOFF", List.of(),
|
||||||
LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), "截止日对账");
|
LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), "截止日对账");
|
||||||
@@ -90,7 +92,15 @@ class ReconciliationStatementBehaviorTest {
|
|||||||
var result = target.create(71L, "cutoff-key", request);
|
var result = target.create(71L, "cutoff-key", request);
|
||||||
|
|
||||||
assertThat(result.status()).isEqualTo("DRAFT");
|
assertThat(result.status()).isEqualTo("DRAFT");
|
||||||
|
assertThat(result.customerName()).isEqualTo("测试客户公司");
|
||||||
assertThat(result.totalReceivable()).isEqualByComparingTo("100.00");
|
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),
|
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)));
|
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) {
|
private static ReconciliationStatementService serviceWithNumber(JdbcTemplate jdbc) {
|
||||||
FinanceDocumentNumberGenerator documentNumbers = mock(FinanceDocumentNumberGenerator.class);
|
FinanceDocumentNumberGenerator documentNumbers = mock(FinanceDocumentNumberGenerator.class);
|
||||||
org.mockito.Mockito.lenient().when(documentNumbers.next("REC")).thenReturn("REC202608180001");
|
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());
|
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
|
@Test
|
||||||
void rejectsUnknownParametersAndSnapshotReuseAcrossFilters() {
|
void rejectsUnknownParametersAndSnapshotReuseAcrossFilters() {
|
||||||
assertThatThrownBy(() -> service.exportBatch(FinanceExportProviderService.CUSTOMER_ACCOUNT_LIST,
|
assertThatThrownBy(() -> service.exportBatch(FinanceExportProviderService.CUSTOMER_ACCOUNT_LIST,
|
||||||
|
|||||||
Reference in New Issue
Block a user