fix(finance): reconcile payment progress audit amounts and customer names
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
package com.baiyue.finance.dto;
|
||||
|
||||
/** 接收事务锁定的来源版本及其不可变客户归属。 */
|
||||
public record BusinessFeeSourceVersion(int sourceVersion, long customerId) { }
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.baiyue.finance.mapper;
|
||||
|
||||
import com.baiyue.finance.dto.BusinessFeeSourceVersion;
|
||||
import org.apache.ibatis.annotations.Arg;
|
||||
import org.apache.ibatis.annotations.ConstructorArgs;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Options;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** 费用接收时锁定稳定来源,防止新版本改写其他客户的费用。 */
|
||||
@Mapper
|
||||
public interface BusinessFeeSourceMapper {
|
||||
/** 使用完整来源键锁定全部版本;锁定读取必须绕过缓存并参与接收事务。 */
|
||||
@Select("SELECT source_fee_version,customer_id FROM fin_fee_source "
|
||||
+ "WHERE source_system=#{system} AND source_type=#{type} AND source_fee_id=#{feeId} "
|
||||
+ "ORDER BY source_fee_version FOR UPDATE")
|
||||
@ConstructorArgs({
|
||||
@Arg(column = "source_fee_version", javaType = int.class),
|
||||
@Arg(column = "customer_id", javaType = long.class)
|
||||
})
|
||||
@Options(useCache = false, flushCache = Options.FlushCachePolicy.TRUE)
|
||||
List<BusinessFeeSourceVersion> lockVersions(@Param("system") String system,
|
||||
@Param("type") String type, @Param("feeId") long feeId);
|
||||
}
|
||||
@@ -3,14 +3,23 @@ package com.baiyue.finance.mapper;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.apache.ibatis.annotations.Options;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/** 费用池按稳定来源读取当前版本,历史快照保留在同一费用的操作记录中。 */
|
||||
@Mapper
|
||||
public interface FinanceFeeSourceReadMapper {
|
||||
String GENERATED = "(EXISTS(SELECT 1 FROM fin_bill_line bl WHERE bl.source_id=f.id AND bl.source_version=f.source_fee_version))";
|
||||
String STATUS = "CASE WHEN f.status='AVAILABLE' AND " + GENERATED + " THEN 'BILLED' ELSE f.status END";
|
||||
String GENERATED = "(EXISTS(SELECT 1 FROM fin_bill_line bl JOIN fin_bill b ON b.id=bl.bill_id AND b.tenant_id=bl.tenant_id" +
|
||||
" WHERE bl.tenant_id=f.customer_id AND bl.source_id=f.id AND bl.source_version=f.source_fee_version AND b.status<>'VOIDED'))";
|
||||
// 应付进度取同客户的有效结算单;作废单释放来源,直接已付使用来源中的付款事实。
|
||||
String PAYABLE_STATUS = "(SELECT CASE s.payment_status WHEN 'PAID' THEN 'PAID' WHEN 'PARTIALLY_PAID' THEN 'PARTIALLY_PAID'" +
|
||||
" ELSE 'PAYABLE_BILLED' END FROM fin_settlement_line sl JOIN fin_settlement s ON s.id=sl.settlement_id AND s.tenant_id=sl.tenant_id" +
|
||||
" WHERE sl.tenant_id=f.customer_id AND sl.fee_source_id=f.id AND s.direction='PAYABLE' AND s.status<>'VOIDED' ORDER BY s.id DESC LIMIT 1)";
|
||||
String STATUS = "CASE WHEN f.status IN ('VOIDED','SUPERSEDED','SETTLED') THEN f.status" +
|
||||
" WHEN f.direction='PAYABLE' THEN COALESCE(" + PAYABLE_STATUS + ",CASE WHEN f.settlement_mode='DIRECT_PAID'" +
|
||||
" AND f.review_status='APPROVED' AND f.paid_at IS NOT NULL THEN 'PAID' ELSE f.status END)" +
|
||||
" WHEN f.status='AVAILABLE' AND " + GENERATED + " THEN 'BILLED' ELSE f.status END";
|
||||
String BUSINESS_DATE = "CAST(JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.businessDate')) AS DATE)";
|
||||
String FILTER = " FROM fin_fee_source f WHERE 1=1 " +
|
||||
"<if test=\"status == null or status != 'SUPERSEDED'\">" +
|
||||
@@ -41,6 +50,13 @@ public interface FinanceFeeSourceReadMapper {
|
||||
" ORDER BY " + BUSINESS_DATE + " DESC,f.id DESC LIMIT #{limit} OFFSET #{offset}</script>")
|
||||
List<Map<String, Object>> list(Map<String, Object> filters);
|
||||
|
||||
/** 详情与列表共用状态投影,仍允许通过精确 ID 查看历史版本。 */
|
||||
@Select("<script>SELECT f.*," + GENERATED + " statement_generated," + STATUS +
|
||||
" effective_status FROM fin_fee_source f WHERE f.id=#{id}</script>")
|
||||
// 同事务的审核/调整仍含 JDBC 写入,读取必须清空 MyBatis 会话缓存才能返回变更后状态。
|
||||
@Options(useCache = false, flushCache = Options.FlushCachePolicy.TRUE)
|
||||
Map<String, Object> detail(@Param("id") long id);
|
||||
|
||||
String LINEAGE = " FROM fin_fee_source anchor JOIN fin_fee_source f ON f.source_system=anchor.source_system" +
|
||||
" AND f.source_type=anchor.source_type AND f.source_fee_id=anchor.source_fee_id" +
|
||||
" AND f.customer_id=anchor.customer_id AND f.direction=anchor.direction" +
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.baiyue.finance.repository;
|
||||
|
||||
import com.baiyue.finance.dto.BusinessFeeSourceVersion;
|
||||
import com.baiyue.finance.mapper.BusinessFeeSourceMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** 为接收服务提供同一事务中的费用来源版本与归属事实。 */
|
||||
@Repository
|
||||
@RequiredArgsConstructor
|
||||
public class BusinessFeeSourceRepository {
|
||||
private final BusinessFeeSourceMapper mapper;
|
||||
|
||||
/** 原来源的客户不能被新版本替换,查询范围不得按传入客户提前过滤。 */
|
||||
public List<BusinessFeeSourceVersion> lockVersions(String system, String type, long feeId) {
|
||||
return mapper.lockVersions(system, type, feeId);
|
||||
}
|
||||
}
|
||||
+16
@@ -1,6 +1,7 @@
|
||||
package com.baiyue.finance.repository;
|
||||
|
||||
import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceSummary;
|
||||
import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceDetail;
|
||||
import com.baiyue.finance.dto.FinancePhase1Dtos.OperationLog;
|
||||
import com.baiyue.finance.mapper.FinanceFeeSourceReadMapper;
|
||||
import com.baiyue.finance.service.FinanceEventException;
|
||||
@@ -51,6 +52,21 @@ public class FinanceFeeSourceReadRepository {
|
||||
return mapper.list(filters).stream().map(this::summary).toList();
|
||||
}
|
||||
|
||||
/** 精确版本详情使用与列表相同的金额和状态,并保留审核信息及操作记录。 */
|
||||
public FeeSourceDetail detail(long id) {
|
||||
Map<String, Object> raw = mapper.detail(id);
|
||||
if (raw == null || raw.isEmpty()) throw new FinanceEventException("FIN-FEE-404", "费用来源不存在");
|
||||
Map<String, Object> row = row(raw);
|
||||
FeeSourceSummary s = summary(row);
|
||||
return new FeeSourceDetail(s.feeSourceId(), s.sourceSystem(), s.sourceType(), s.sourceBusinessId(), s.sourceFeeId(),
|
||||
s.sourceVersion(), s.direction(), s.customerId(), s.customerName(), s.supplierId(), s.supplierName(), s.orderId(),
|
||||
s.inboundId(), s.inboundCode(), s.containerId(), s.containerNo(), s.businessDate(), s.feeItemName(),
|
||||
s.quantity(), s.quantityUnit(), s.unitPrice(), s.periodStart(), s.periodEnd(), s.amount(), s.currency(),
|
||||
s.originalCurrency(), s.originalAmount(), s.exchangeRate(), s.exchangeRateMonth(), s.reviewStatus(),
|
||||
s.sourceStatus(), s.statementGenerated(), text(row.get("review_reason")), text(row.get("source_hash")),
|
||||
((Number) row.get("operation_version")).intValue(), logs(s.customerId(), id));
|
||||
}
|
||||
|
||||
/** 由不可变版本追加同步历史,并串联同一费用所有既有审计记录。 */
|
||||
public List<OperationLog> logs(long customerId, long id) {
|
||||
List<OperationLog> result = new ArrayList<>();
|
||||
|
||||
+44
-9
@@ -6,6 +6,7 @@ import com.baiyue.common.event.DeliveryAck;
|
||||
import com.baiyue.common.event.EventPayloadCanonicalizer;
|
||||
import com.baiyue.common.event.EventPayloadHash;
|
||||
import com.baiyue.common.event.EventTypes;
|
||||
import com.baiyue.finance.repository.BusinessFeeSourceRepository;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -13,11 +14,13 @@ import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/** Consumes complete overseas business fee snapshots without a cross-region synchronous callback. */
|
||||
/** 接收海外费用不可变快照,在同一事务内完成来源版本与接收记录。 */
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class BusinessFeeSourceEventService {
|
||||
@@ -26,7 +29,9 @@ public class BusinessFeeSourceEventService {
|
||||
private final JdbcTemplate jdbc;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final FinanceSupplierValidator supplierValidator;
|
||||
private final BusinessFeeSourceRepository sources;
|
||||
|
||||
/** 先核对不可变事件,再恢复已提交结果;仅新事件检查外部主档。 */
|
||||
@Transactional
|
||||
public DeliveryAck consume(EventEnvelope envelope) {
|
||||
try {
|
||||
@@ -56,6 +61,8 @@ public class BusinessFeeSourceEventService {
|
||||
return DeliveryAck.of(envelope, DeliveryAck.Outcome.DUPLICATE);
|
||||
throw error("FIN-EVT-003", "相同事件正在处理或上次处理失败");
|
||||
}
|
||||
// 回执丢失后的原事件重放不能依赖供应商当前是否可用,否则已入账事件无法确认。
|
||||
validateSupplier(source);
|
||||
jdbc.update("INSERT INTO fin_inbox_event(event_id,source_system,event_type,payload,payload_hash,status) VALUES (?,?,?,?,?,'RECEIVED')",
|
||||
envelope.eventId(), sourceSystem, envelope.eventType(), eventJson, envelope.payloadHash());
|
||||
long result = apply(sourceSystem, source);
|
||||
@@ -64,6 +71,7 @@ public class BusinessFeeSourceEventService {
|
||||
return DeliveryAck.of(envelope, DeliveryAck.Outcome.APPLIED);
|
||||
}
|
||||
|
||||
/** 锁定同一来源的历史版本,拒绝跨客户替代,并保留已出账来源冻结规则。 */
|
||||
private long apply(String system, BusinessFeeSourceChangedV1 source) {
|
||||
long feeId = positive(source.sourceFeeId(), "sourceFeeId");
|
||||
List<Map<String, Object>> same = jdbc.queryForList(
|
||||
@@ -83,10 +91,12 @@ public class BusinessFeeSourceEventService {
|
||||
}
|
||||
if ("VOIDED".equals(source.action()))
|
||||
throw error("FIN-EVT-005", "作废事件对应的费用来源版本不存在");
|
||||
List<Map<String, Object>> versions = jdbc.queryForList(
|
||||
"SELECT source_fee_version FROM fin_fee_source WHERE source_system=? AND source_type=? AND source_fee_id=? FOR UPDATE",
|
||||
system, source.sourceType(), feeId);
|
||||
if (versions.stream().anyMatch(row -> ((Number) row.get("source_fee_version")).intValue() > source.sourceVersion()))
|
||||
var versions = sources.lockVersions(system, source.sourceType(), feeId);
|
||||
long customerId = positive(source.customerId(), "customerId");
|
||||
// 来源身份跨版本保持客户归属;检查失败时 Inbox 插入也随当前事务回滚。
|
||||
if (versions.stream().anyMatch(row -> row.customerId() != customerId))
|
||||
throw error("FIN-EVT-018", "费用来源客户归属不允许变更");
|
||||
if (versions.stream().anyMatch(row -> row.sourceVersion() > source.sourceVersion()))
|
||||
throw error("FIN-EVT-008", "费用来源版本已过期");
|
||||
requireSourceNotFrozen(system, source.sourceType(), feeId);
|
||||
jdbc.update("UPDATE fin_fee_source SET status='SUPERSEDED' WHERE source_system=? AND source_type=? AND source_fee_id=? AND status='AVAILABLE'",
|
||||
@@ -110,6 +120,7 @@ public class BusinessFeeSourceEventService {
|
||||
return id;
|
||||
}
|
||||
|
||||
/** 校验载荷自身的身份、金额和摘要,不依赖会随时间变化的外部数据。 */
|
||||
private void validate(EventEnvelope envelope, BusinessFeeSourceChangedV1 value) {
|
||||
if (value == null || !ACTIONS.contains(value.action()) || blank(value.sourceSystem())
|
||||
|| "FIRSTLEG".equalsIgnoreCase(value.sourceSystem()) || blank(value.sourceType())
|
||||
@@ -130,6 +141,7 @@ public class BusinessFeeSourceEventService {
|
||||
}
|
||||
positive(value.sourceFeeId(), "sourceFeeId");
|
||||
positive(value.customerId(), "customerId");
|
||||
validateAmounts(value);
|
||||
if (value.walletSettlement() != null) {
|
||||
if (!"RECEIVABLE".equals(value.direction())) throw error("FIN-EVT-012", "钱包消费只能关联应收费用");
|
||||
positive(value.walletSettlement().holdId(), "walletSettlement.holdId");
|
||||
@@ -149,10 +161,8 @@ public class BusinessFeeSourceEventService {
|
||||
throw error("FIN-EVT-015", "费用来源尚未发布或已失效");
|
||||
}
|
||||
if ("PAYABLE".equals(value.direction())) {
|
||||
long supplierId = positive(value.supplierId(), "supplierId");
|
||||
var supplier = supplierValidator.requireActive(supplierId, trim(value.supplierCategory()));
|
||||
if (blank(value.supplierName()) || !value.supplierName().equals(supplier.name()))
|
||||
throw error("FIN-EVT-012", "应付来源供应商快照不一致");
|
||||
positive(value.supplierId(), "supplierId");
|
||||
if (blank(value.supplierName())) throw error("FIN-EVT-012", "应付来源供应商快照不完整");
|
||||
validateSettlement(value);
|
||||
} else if (!blank(value.supplierId()) || !blank(value.supplierName()) || !blank(value.supplierCategory())
|
||||
|| value.settlementMode() != null && !Set.of("BILL", "WALLET").contains(value.settlementMode()) || value.paidAt() != null
|
||||
@@ -174,6 +184,31 @@ public class BusinessFeeSourceEventService {
|
||||
if (!expected.equals(value.sourceHash())) throw error("FIN-EVT-004", "费用来源快照哈希不一致");
|
||||
}
|
||||
|
||||
/** 使用来源冻结汇率复核人民币金额,与海外生产端的分位四舍五入口径一致。 */
|
||||
private void validateAmounts(BusinessFeeSourceChangedV1 value) {
|
||||
try {
|
||||
// 不在接收端静默修正金额或截断汇率;无效精度须由生产端重新审核。
|
||||
value.originalAmount().setScale(2, RoundingMode.UNNECESSARY);
|
||||
value.totalAmount().setScale(2, RoundingMode.UNNECESSARY);
|
||||
value.exchangeRate().setScale(6, RoundingMode.UNNECESSARY);
|
||||
} catch (ArithmeticException invalidPrecision) {
|
||||
throw error("FIN-EVT-019", "费用金额或汇率精度无效");
|
||||
}
|
||||
if ("CNY".equals(value.originalCurrency()) && value.exchangeRate().compareTo(BigDecimal.ONE) != 0)
|
||||
throw error("FIN-EVT-019", "人民币原币费用汇率必须为1");
|
||||
BigDecimal expected = value.originalAmount().multiply(value.exchangeRate()).setScale(2, RoundingMode.HALF_UP);
|
||||
if (expected.compareTo(value.totalAmount()) != 0)
|
||||
throw error("FIN-EVT-019", "费用人民币金额与原币金额、汇率不一致");
|
||||
}
|
||||
|
||||
/** 首次接收应付仍须校验启用供应商与名称快照,依赖不可用时失败关闭。 */
|
||||
private void validateSupplier(BusinessFeeSourceChangedV1 value) {
|
||||
if (!"PAYABLE".equals(value.direction())) return;
|
||||
var supplier = supplierValidator.requireActive(positive(value.supplierId(), "supplierId"), trim(value.supplierCategory()));
|
||||
if (!value.supplierName().equals(supplier.name()))
|
||||
throw error("FIN-EVT-012", "应付来源供应商快照不一致");
|
||||
}
|
||||
|
||||
private void validateSettlement(BusinessFeeSourceChangedV1 value) {
|
||||
boolean files = value.paymentVoucherFileIds() != null && !value.paymentVoucherFileIds().isEmpty();
|
||||
if ("ON_ACCOUNT".equals(value.settlementMode())) {
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.baiyue.finance.service;
|
||||
|
||||
import com.baiyue.finance.client.IamCustomerClient;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.function.BiFunction;
|
||||
|
||||
/** 单据客户名称取真实主档或有效快照,内部引用不能作为名称展示。 */
|
||||
@Service
|
||||
public class FinanceCustomerNames {
|
||||
private final IamCustomerClient customers;
|
||||
private final String token;
|
||||
|
||||
/** 复用已存在的 IAM 客户查询,不跨库读取客户资料。 */
|
||||
public FinanceCustomerNames(IamCustomerClient customers,
|
||||
@Value("${baiyue.security.internal-service-token}") String token) {
|
||||
this.customers = customers;
|
||||
this.token = token;
|
||||
}
|
||||
|
||||
/** 新建单据必须取得真实客户名称,依赖故障或客户不存在时停止建单。 */
|
||||
public String required(long customerId) {
|
||||
String name = lookup(customerId);
|
||||
if (name == null) throw new FinanceEventException("FIN-CUSTOMER-404", "客户名称未提供,无法生成财务单据");
|
||||
return name;
|
||||
}
|
||||
|
||||
/** 应收结算沿用有效的对账单名称,缺失时必须取得真实主档后才能建单。 */
|
||||
public String requiredSnapshot(long customerId, String snapshot) {
|
||||
return valid(customerId, snapshot) ? snapshot.trim() : required(customerId);
|
||||
}
|
||||
|
||||
/** 每次列表/详情请求内复用同客户查询结果,保留有效的历史名称快照。 */
|
||||
public BiFunction<Long, String, String> resolver() {
|
||||
var names = new HashMap<Long, String>();
|
||||
return (customerId, snapshot) -> {
|
||||
if (valid(customerId, snapshot)) return snapshot.trim();
|
||||
return names.computeIfAbsent(customerId, id -> {
|
||||
String name = lookup(id);
|
||||
return name == null ? "—" : name;
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/** 仅接受所请求客户的主档,不能拿其他客户记录填充当前单据。 */
|
||||
private String lookup(long customerId) {
|
||||
try {
|
||||
var response = customers.page(token, 1, 1, null, null, customerId);
|
||||
if (response == null || !response.successful() || response.data() == null || response.data().records() == null)
|
||||
throw new FinanceEventException("FIN_503", "客户主数据暂不可用");
|
||||
var rows = response.data().records();
|
||||
if (rows.isEmpty()) return null;
|
||||
if (rows.size() != 1 || rows.get(0).id() != customerId)
|
||||
throw new FinanceEventException("FIN_503", "客户主数据返回范围异常");
|
||||
String name = rows.get(0).tenantName();
|
||||
return valid(customerId, name) ? name.trim() : null;
|
||||
} catch (FinanceEventException failure) {
|
||||
throw failure;
|
||||
} catch (RuntimeException failure) {
|
||||
throw new FinanceEventException("FIN_503", "客户主数据暂不可用");
|
||||
}
|
||||
}
|
||||
|
||||
/** 空名称与内部编号占位不是有效的业务名称。 */
|
||||
private static boolean valid(long customerId, String name) {
|
||||
return name != null && !name.isBlank() && !name.trim().equals("—")
|
||||
&& !name.trim().equals(Long.toString(customerId)) && !name.trim().matches("客户\\s*(?:ID)?\\s*\\d+");
|
||||
}
|
||||
}
|
||||
+14
-41
@@ -7,13 +7,12 @@ import com.baiyue.common.core.export.ExportContracts.ExportBatchResponse;
|
||||
import com.baiyue.common.core.export.ExportContracts.ExportColumn;
|
||||
import com.baiyue.common.core.export.ExportContracts.ExportColumnType;
|
||||
import com.baiyue.finance.dto.FinancePhase1Dtos.AccountSummary;
|
||||
import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceDetail;
|
||||
import com.baiyue.finance.dto.FinancePhase1Dtos.LedgerEntry;
|
||||
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementDetail;
|
||||
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementLine;
|
||||
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementSummary;
|
||||
import com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationDetail;
|
||||
import com.baiyue.finance.dto.FinanceReadModels.FeePoolItem;
|
||||
import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceSummary;
|
||||
import com.baiyue.finance.dto.ReceivableStatisticsDtos.ReceivableStatisticsRow;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
@@ -199,41 +198,16 @@ public class FinanceExportProviderService {
|
||||
.map(value -> receivableStatisticsRow(month, value)).toList();
|
||||
}
|
||||
|
||||
/** 导出与列表使用相同当前版本、状态及筛选,已付款来源不能被可选结算池遗漏。 */
|
||||
private List<Map<String, Object>> feeRows(Map<String, Object> p) {
|
||||
Long customerId = longValue(p, "customerId", false);
|
||||
String direction = text(p, "direction");
|
||||
String status = text(p, "status");
|
||||
String reviewStatus = text(p, "reviewStatus");
|
||||
Boolean generated = bool(p, "statementGenerated");
|
||||
LocalDate from = date(p, "businessDateFrom"), to = date(p, "businessDateTo");
|
||||
String keyword = lower(text(p, "keyword"));
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
for (FeePoolItem item : queryService.feePool(customerId, direction)) {
|
||||
FeeSourceDetail detail = detail(item);
|
||||
String sourceStatus = detail == null ? item.status() : detail.sourceStatus();
|
||||
String actualReview = detail == null ? null : detail.reviewStatus();
|
||||
Boolean actualGenerated = detail == null ? null : detail.statementGenerated();
|
||||
LocalDate businessDate = detail == null || detail.businessDate() == null
|
||||
? (item.createdAt() == null ? null : item.createdAt().toLocalDate()) : detail.businessDate();
|
||||
if (status != null && !status.equalsIgnoreCase(sourceStatus)) continue;
|
||||
if (reviewStatus != null && !reviewStatus.equalsIgnoreCase(actualReview)) continue;
|
||||
if (generated != null && !generated.equals(actualGenerated)) continue;
|
||||
if (from != null && (businessDate == null || businessDate.isBefore(from))) continue;
|
||||
if (to != null && (businessDate == null || businessDate.isAfter(to))) continue;
|
||||
String searchable = lower(String.join(" ", value(item.poolItemId()), value(item.sourceId()), value(item.feeItemName()),
|
||||
detail == null ? "" : value(detail.inboundCode()), detail == null ? "" : value(detail.containerNo())));
|
||||
if (keyword != null && !searchable.contains(keyword)) continue;
|
||||
rows.add(feeRow(item, detail, businessDate));
|
||||
for (int page = 1; ; page++) {
|
||||
var result = operationService.listFinanceFeeSources(page, 200, longValue(p, "customerId", false),
|
||||
text(p, "direction"), text(p, "status"), text(p, "reviewStatus"), bool(p, "statementGenerated"),
|
||||
date(p, "businessDateFrom"), date(p, "businessDateTo"), text(p, "keyword"));
|
||||
result.data().forEach(source -> rows.add(feeRow(source)));
|
||||
if ((long) page * 200 >= result.page().total()) return rows;
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
private FeeSourceDetail detail(FeePoolItem item) {
|
||||
if ("MANUAL_COST".equals(item.sourceType())) return null;
|
||||
String[] parts = item.poolItemId().split(":", -1);
|
||||
if (parts.length != 2) return null;
|
||||
try { return operationService.getFinanceFeeSource(Long.parseLong(parts[1])); }
|
||||
catch (RuntimeException ignored) { return null; }
|
||||
}
|
||||
|
||||
private List<Map<String, Object>> statementRows(Map<String, Object> p) {
|
||||
@@ -376,13 +350,12 @@ public class FinanceExportProviderService {
|
||||
"documentNo", value.documentNo(), "receivableChange", value.receivableChange(), "payableChange", value.payableChange(),
|
||||
"outstandingReceivableAfter", value.outstandingReceivableAfter(), "operatorName", value.operatorName(), "remark", value.remark());
|
||||
}
|
||||
private Map<String, Object> feeRow(FeePoolItem item, FeeSourceDetail detail, LocalDate businessDate) {
|
||||
return row("customerName", detail == null ? null : detail.customerName(), "direction", item.direction(), "sourceType", item.sourceType(),
|
||||
"sourceFeeId", String.valueOf(item.sourceId()), "sourceVersion", item.sourceVersion(), "inboundCode", detail == null ? null : detail.inboundCode(),
|
||||
"containerNo", detail == null ? null : detail.containerNo(), "businessDate", businessDate, "feeItemName", item.feeItemName(),
|
||||
"quantity", detail == null ? null : detail.quantity(), "unitPrice", detail == null ? null : detail.unitPrice(), "amount", item.amount(),
|
||||
"reviewStatus", detail == null ? null : detail.reviewStatus(),
|
||||
"sourceStatus", detail == null ? item.status() : detail.sourceStatus(), "statementGenerated", detail == null ? null : detail.statementGenerated());
|
||||
private Map<String, Object> feeRow(FeeSourceSummary source) {
|
||||
return row("customerName", source.customerName(), "direction", source.direction(), "sourceType", source.sourceType(),
|
||||
"sourceFeeId", String.valueOf(source.sourceFeeId()), "sourceVersion", source.sourceVersion(), "inboundCode", source.inboundCode(),
|
||||
"containerNo", source.containerNo(), "businessDate", source.businessDate(), "feeItemName", source.feeItemName(),
|
||||
"quantity", source.quantity(), "unitPrice", source.unitPrice(), "amount", source.amount(),
|
||||
"reviewStatus", source.reviewStatus(), "sourceStatus", source.sourceStatus(), "statementGenerated", source.statementGenerated());
|
||||
}
|
||||
private Map<String, Object> statementRow(ReconciliationDetail value) {
|
||||
return row("statementNo", value.statementNo(), "customerName", value.customerName(), "periodStart", value.periodStart(),
|
||||
|
||||
+4
-25
@@ -178,7 +178,7 @@ public class FinancePhase1OperationService {
|
||||
String normalizedReview = normalized(reviewStatus,
|
||||
List.of("DRAFT", "PENDING", "APPROVED", "REJECTED"), "审核状态无效");
|
||||
String normalizedSourceStatus = normalized(sourceStatus,
|
||||
List.of("AVAILABLE", "LOCKED", "BILLED", "SETTLED", "VOIDED", "SUPERSEDED"), "费用来源状态无效");
|
||||
List.of("AVAILABLE", "LOCKED", "BILLED", "SETTLED", "PAYABLE_BILLED", "PARTIALLY_PAID", "PAID", "VOIDED", "SUPERSEDED"), "费用来源状态无效");
|
||||
Map<String, Object> filters = feeSources.filters(customerId, normalizedDirection, normalizedSourceStatus,
|
||||
normalizedReview, statementGenerated, businessDateFrom, businessDateTo, blankToNull(keyword), page, pageSize);
|
||||
return PageEnvelope.success(feeSources.list(filters), page, pageSize, feeSources.count(filters));
|
||||
@@ -186,9 +186,7 @@ public class FinancePhase1OperationService {
|
||||
|
||||
public FeeSourceDetail getFinanceFeeSource(long feeSourceId) {
|
||||
if (feeSourceId <= 0) throw error("FIN-REQ-400", "费用来源ID无效");
|
||||
List<FeeSourceDetail> rows = jdbc.query("SELECT * FROM fin_fee_source WHERE id=?", (rs, n) -> detail(rs), feeSourceId);
|
||||
if (rows.isEmpty()) throw error("FIN-FEE-404", "费用来源不存在");
|
||||
return rows.get(0);
|
||||
return feeSources.detail(feeSourceId);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -225,6 +223,7 @@ public class FinancePhase1OperationService {
|
||||
adjustment ? "POST" : "PUT", path, request);
|
||||
if (replay != null) return getFinanceFeeSource(replay);
|
||||
if (before.statementGenerated()) throw error("FIN-FEE-409", "已生成单据的费用来源不可编辑或调整");
|
||||
if (!"AVAILABLE".equals(before.sourceStatus())) throw error("FIN-FEE-409", "费用已被占用、结清或作废,不可编辑或调整");
|
||||
String requiredStatus = adjustment ? "APPROVED" : before.reviewStatus();
|
||||
if (!adjustment && !List.of("DRAFT", "REJECTED").contains(requiredStatus)) {
|
||||
throw error("FIN-FEE-409", "只有草稿或已驳回费用可编辑");
|
||||
@@ -302,6 +301,7 @@ public class FinancePhase1OperationService {
|
||||
"/api/platform/finance/v1/fee-sources/" + feeSourceId + (voidSource ? "/void" : "/submit"), request);
|
||||
if (replay != null) return getFinanceFeeSource(replay);
|
||||
if (before.statementGenerated()) throw error("FIN-FEE-409", "已生成单据的费用来源不可操作");
|
||||
if (!"AVAILABLE".equals(before.sourceStatus())) throw error("FIN-FEE-409", "费用已被占用、结清或作废,不可操作");
|
||||
if (!allowedReviews.contains(before.reviewStatus())) throw error("FIN-FEE-409", "费用来源当前状态不允许该操作");
|
||||
int changed = voidSource
|
||||
? jdbc.update("UPDATE fin_fee_source SET status='VOIDED',operation_version=operation_version+1 WHERE id=? AND operation_version=? AND status='AVAILABLE' AND review_status=?",
|
||||
@@ -315,27 +315,6 @@ public class FinancePhase1OperationService {
|
||||
return after;
|
||||
}
|
||||
|
||||
/** 读取指定不可变版本,并附带同客户同方向的费用调整历史。 */
|
||||
private FeeSourceDetail detail(ResultSet rs) throws SQLException {
|
||||
JsonNode snapshot = parse(rs.getString("source_snapshot"));
|
||||
long id = rs.getLong("id");
|
||||
boolean generated = Boolean.TRUE.equals(jdbc.queryForObject("SELECT COUNT(*)>0 FROM fin_bill_line WHERE source_id=? AND source_version=?", Boolean.class, id, rs.getInt("source_fee_version")));
|
||||
String status = rs.getString("status");
|
||||
if (generated && "AVAILABLE".equals(status)) status = "BILLED";
|
||||
BigDecimal quantity = decimal(snapshot, "chargeableQuantity");
|
||||
BigDecimal amount = rs.getBigDecimal("amount");
|
||||
BigDecimal originalAmount = firstDecimal(decimal(snapshot, "originalAmount"), amount);
|
||||
BigDecimal unitPrice = firstDecimal(decimal(snapshot, "unitPrice"),
|
||||
quantity != null && quantity.signum() > 0 ? originalAmount.divide(quantity, 6, java.math.RoundingMode.HALF_UP) : null);
|
||||
return new FeeSourceDetail(id, rs.getString("source_system"), rs.getString("source_type"), firstText(rs.getString("source_no"), text(snapshot, "sourceBusinessId")), rs.getLong("source_fee_id"), rs.getInt("source_fee_version"), rs.getString("direction"), rs.getLong("customer_id"), text(snapshot, "customerName"), jsonLong(snapshot, "supplierId"), text(snapshot, "supplierName"),
|
||||
nullableLong(rs, "order_id"), nullableLong(rs, "inbound_id"), text(snapshot, "inboundCode"), nullableLong(rs, "container_id"), text(snapshot, "containerNo"), date(snapshot, "businessDate"), rs.getString("fee_item_name"),
|
||||
quantity, text(snapshot, "quantityUnit"), unitPrice, nullableDate(snapshot, "periodStart"), nullableDate(snapshot, "periodEnd"),
|
||||
amount, rs.getString("currency_code"), firstText(text(snapshot, "originalCurrency"), rs.getString("currency_code")), originalAmount,
|
||||
firstDecimal(decimal(snapshot, "exchangeRate"), BigDecimal.ONE), text(snapshot, "exchangeRateMonth"),
|
||||
defaultString(rs.getString("review_status"), "DRAFT"), status, generated,
|
||||
rs.getString("review_reason"), rs.getString("source_hash"), rs.getInt("operation_version"), feeSources.logs(rs.getLong("customer_id"), id));
|
||||
}
|
||||
|
||||
private Long reserve(long customerId, long operatorId, String operation, String key, String method, String path, Object body) {
|
||||
if (!StringUtils.hasText(key) || key.length() > 128) throw error("FIN-IDEM-400", "必须提供有效的Idempotency-Key");
|
||||
String hash = hash(Map.of("method", method, "path", path, "customerId", customerId, "body", body));
|
||||
|
||||
@@ -40,10 +40,12 @@ public class FinanceQueryService {
|
||||
private static final String CNY = "CNY";
|
||||
private final JdbcTemplate jdbc;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final FinanceCustomerNames customerNames;
|
||||
|
||||
public FinanceQueryService(JdbcTemplate jdbc, ObjectMapper objectMapper) {
|
||||
public FinanceQueryService(JdbcTemplate jdbc, ObjectMapper objectMapper, FinanceCustomerNames customerNames) {
|
||||
this.jdbc = jdbc;
|
||||
this.objectMapper = objectMapper;
|
||||
this.customerNames = customerNames;
|
||||
}
|
||||
|
||||
public CustomerAccount customerAccount(long customerId) {
|
||||
@@ -131,12 +133,13 @@ public class FinanceQueryService {
|
||||
|
||||
public List<Statement> statements(Long customerId, boolean clientVisibleOnly) {
|
||||
validateOptionalCustomer(customerId);
|
||||
var names = customerNames.resolver();
|
||||
String visible = clientVisibleOnly ? " AND status IN ('ISSUED','PARTIALLY_PAID','PAID')" : "";
|
||||
String sql = "SELECT id,tenant_id,bill_no,customer_name_snapshot,bill_date,due_date,total_amount,received_amount,outstanding_amount,currency_code,status,version,customer_confirmation_status,customer_confirmed_by,customer_confirmed_by_name,customer_confirmed_at,created_by,created_by_name,created_at,updated_by,updated_by_name,updated_at FROM fin_bill WHERE 1=1";
|
||||
Object[] args = new Object[0];
|
||||
if (customerId != null) { sql += " AND tenant_id=?"; args = new Object[]{customerId}; }
|
||||
return jdbc.query(sql + visible + " ORDER BY bill_date DESC,id DESC",
|
||||
(rs, rowNum) -> new Statement(rs.getLong("id"), rs.getLong("tenant_id"), rs.getString("bill_no"), rs.getString("customer_name_snapshot"),
|
||||
(rs, rowNum) -> new Statement(rs.getLong("id"), rs.getLong("tenant_id"), rs.getString("bill_no"), names.apply(rs.getLong("tenant_id"), rs.getString("customer_name_snapshot")),
|
||||
rs.getDate("bill_date").toLocalDate(), rs.getDate("due_date").toLocalDate(), rs.getBigDecimal("total_amount"), rs.getBigDecimal("received_amount"),
|
||||
rs.getBigDecimal("outstanding_amount"), rs.getString("currency_code"), rs.getString("status"), rs.getInt("version"),
|
||||
rs.getString("customer_confirmation_status"), nullableLong(rs, "customer_confirmed_by"),
|
||||
@@ -147,6 +150,7 @@ public class FinanceQueryService {
|
||||
|
||||
public List<ReconciliationSummary> reconciliationStatements(Long customerId) {
|
||||
validateOptionalCustomer(customerId);
|
||||
var names = customerNames.resolver();
|
||||
String sql = "SELECT b.id,b.tenant_id,b.bill_no,b.customer_name_snapshot,b.currency_code,b.status,b.version,b.remark," +
|
||||
"b.total_amount,b.received_amount,b.outstanding_amount,b.issued_at,b.customer_confirmation_status,b.customer_confirmed_by,b.customer_confirmed_by_name,b.customer_confirmed_at,b.created_by,b.created_by_name,b.created_at,b.updated_by,b.updated_by_name,b.updated_at," +
|
||||
"COALESCE(p.credit_days,30) credit_days," +
|
||||
@@ -163,7 +167,7 @@ public class FinanceQueryService {
|
||||
Object[] args = new Object[0];
|
||||
if (customerId != null) { sql += " AND b.tenant_id=?"; args = new Object[]{customerId}; }
|
||||
return jdbc.query(sql + " ORDER BY b.bill_date DESC,b.id DESC", (rs, rowNum) -> new ReconciliationSummary(
|
||||
rs.getLong("id"), rs.getString("bill_no"), rs.getLong("tenant_id"), rs.getString("customer_name_snapshot"),
|
||||
rs.getLong("id"), rs.getString("bill_no"), rs.getLong("tenant_id"), names.apply(rs.getLong("tenant_id"), rs.getString("customer_name_snapshot")),
|
||||
date(rs, "period_start"), date(rs, "period_end"), rs.getInt("credit_days"), rs.getString("currency_code"),
|
||||
rs.getInt("line_count"), rs.getInt("source_count"), rs.getBigDecimal("source_amount"), rs.getBigDecimal("adjustment_amount"),
|
||||
rs.getBigDecimal("total_amount"), rs.getBigDecimal("received_amount"), rs.getBigDecimal("outstanding_amount"),
|
||||
@@ -178,9 +182,10 @@ public class FinanceQueryService {
|
||||
|
||||
public StatementDetail statement(long customerId, long billId, boolean clientVisibleOnly) {
|
||||
requireCustomer(customerId);
|
||||
var names = customerNames.resolver();
|
||||
String visible = clientVisibleOnly ? " AND status IN ('ISSUED','PARTIALLY_PAID','PAID')" : "";
|
||||
List<Statement> rows = jdbc.query("SELECT id,tenant_id,bill_no,customer_name_snapshot,bill_date,due_date,total_amount,received_amount,outstanding_amount,currency_code,status,version,customer_confirmation_status,customer_confirmed_by,customer_confirmed_by_name,customer_confirmed_at,created_by,created_by_name,created_at,updated_by,updated_by_name,updated_at FROM fin_bill WHERE tenant_id=? AND id=?" + visible,
|
||||
(rs, rowNum) -> new Statement(rs.getLong("id"), rs.getLong("tenant_id"), rs.getString("bill_no"), rs.getString("customer_name_snapshot"),
|
||||
(rs, rowNum) -> new Statement(rs.getLong("id"), rs.getLong("tenant_id"), rs.getString("bill_no"), names.apply(rs.getLong("tenant_id"), rs.getString("customer_name_snapshot")),
|
||||
rs.getDate("bill_date").toLocalDate(), rs.getDate("due_date").toLocalDate(), rs.getBigDecimal("total_amount"), rs.getBigDecimal("received_amount"),
|
||||
rs.getBigDecimal("outstanding_amount"), rs.getString("currency_code"), rs.getString("status"), rs.getInt("version"),
|
||||
rs.getString("customer_confirmation_status"), nullableLong(rs, "customer_confirmed_by"),
|
||||
|
||||
@@ -52,19 +52,21 @@ public class FinanceSettlementService {
|
||||
private final FinanceVoucherValidator voucherValidator;
|
||||
private final FinanceSupplierValidator supplierValidator;
|
||||
private final FinanceDocumentNumberGenerator documentNumbers;
|
||||
private final FinanceCustomerNames customerNames;
|
||||
|
||||
/** 注入统一编号组件,确保所有业务入口共用受事务保护的日流水。 */
|
||||
@Autowired
|
||||
public FinanceSettlementService(JdbcTemplate jdbc, ObjectMapper objectMapper, FinanceVoucherValidator voucherValidator,
|
||||
FinanceSupplierValidator supplierValidator,
|
||||
FinanceDocumentNumberGenerator documentNumbers,
|
||||
com.baiyue.finance.repository.FinanceSettlementDetailRepository details) {
|
||||
com.baiyue.finance.repository.FinanceSettlementDetailRepository details, FinanceCustomerNames customerNames) {
|
||||
this.jdbc = jdbc;
|
||||
this.details = details;
|
||||
this.objectMapper = objectMapper;
|
||||
this.voucherValidator = voucherValidator;
|
||||
this.supplierValidator = supplierValidator;
|
||||
this.documentNumbers = documentNumbers;
|
||||
this.customerNames = customerNames;
|
||||
}
|
||||
|
||||
/** 创建应收结算单,幂等请求恢复原单,首次建单分配 AR 日流水。 */
|
||||
@@ -98,7 +100,7 @@ public class FinanceSettlementService {
|
||||
|
||||
LocalDate generatedDate = LocalDate.now();
|
||||
long settlementId = insertSettlement(customerId, documentNumbers.next("AR"), "RECEIVABLE",
|
||||
text(bill, "customer_name_snapshot"), null, null, request.reconciliationStatementId(),
|
||||
customerNames.requiredSnapshot(customerId, text(bill, "customer_name_snapshot")), null, null, request.reconciliationStatementId(),
|
||||
date(bill, "bill_date").format(DateTimeFormatter.ofPattern("yyyy-MM")), CNY, total,
|
||||
generatedDate, date(bill, "due_date"), operatorId);
|
||||
int inserted = jdbc.update(
|
||||
@@ -155,8 +157,7 @@ public class FinanceSettlementService {
|
||||
if (total.signum() <= 0) throw error("FIN_400", "应付结算金额必须大于0");
|
||||
String supplierCategory = supplierCategoryConstraint(sources);
|
||||
String supplierName = supplierValidator.requireActive(request.supplierId(), supplierCategory).name();
|
||||
String customerName = sources.stream().map(row -> jsonText(parseTree(text(row, "source_snapshot")), "customerName"))
|
||||
.filter(StringUtils::hasText).findFirst().orElse("客户" + customerId);
|
||||
String customerName = customerNames.required(customerId);
|
||||
LocalDate generatedDate = LocalDate.now();
|
||||
long settlementId = insertSettlement(customerId, documentNumbers.next("AP"), "PAYABLE", customerName,
|
||||
request.supplierId(), supplierName, null, generatedDate.format(DateTimeFormatter.ofPattern("yyyy-MM")),
|
||||
@@ -176,7 +177,7 @@ public class FinanceSettlementService {
|
||||
if (settlementId <= 0) throw error("FIN_404", "结算单不存在");
|
||||
List<Map<String, Object>> records = details.summary(settlementId);
|
||||
if (records.isEmpty()) throw error("FIN_404", "结算单不存在");
|
||||
SettlementSummary summary = summary(records.get(0));
|
||||
SettlementSummary summary = summary(records.get(0), customerNames.resolver());
|
||||
List<SettlementLine> lines = details.lines(summary.customerId(), settlementId)
|
||||
.stream().map(source -> line(source, summary)).toList();
|
||||
List<SettlementTransaction> transactions = new ArrayList<>(details.payments(summary.customerId(), settlementId)
|
||||
@@ -206,7 +207,8 @@ public class FinanceSettlementService {
|
||||
args.add(customerId);
|
||||
}
|
||||
sql += " ORDER BY s.generated_date DESC,s.id DESC";
|
||||
return jdbc.queryForList(sql, args.toArray()).stream().map(this::summary).toList();
|
||||
var names = customerNames.resolver();
|
||||
return jdbc.queryForList(sql, args.toArray()).stream().map(row -> summary(row, names)).toList();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -419,9 +421,9 @@ public class FinanceSettlementService {
|
||||
MDC.get("requestId"), MDC.get("traceId"));
|
||||
}
|
||||
|
||||
private SettlementSummary summary(Map<String, Object> row) {
|
||||
private SettlementSummary summary(Map<String, Object> row, java.util.function.BiFunction<Long, String, String> names) {
|
||||
return new SettlementSummary(number(row, "id").longValue(), text(row, "settlement_no"),
|
||||
text(row, "direction"), number(row, "tenant_id").longValue(), text(row, "customer_name_snapshot"),
|
||||
text(row, "direction"), number(row, "tenant_id").longValue(), names.apply(number(row, "tenant_id").longValue(), text(row, "customer_name_snapshot")),
|
||||
nullableNumber(row, "supplier_id"), text(row, "supplier_name_snapshot"),
|
||||
nullableNumber(row, "reconciliation_statement_id"), text(row, "reconciliation_statement_no"),
|
||||
text(row, "business_period"), text(row, "fee_item_summary"),
|
||||
|
||||
@@ -15,9 +15,11 @@ import java.util.List;
|
||||
public class ReceivableStatisticsService {
|
||||
private static final String CURRENCY = "CNY";
|
||||
private final JdbcTemplate jdbc;
|
||||
private final FinanceCustomerNames customerNames;
|
||||
|
||||
public ReceivableStatisticsService(JdbcTemplate jdbc) {
|
||||
public ReceivableStatisticsService(JdbcTemplate jdbc, FinanceCustomerNames customerNames) {
|
||||
this.jdbc = jdbc;
|
||||
this.customerNames = customerNames;
|
||||
}
|
||||
|
||||
public ReceivableStatisticsResponse query(YearMonth month, Long customerId, int page, int pageSize) {
|
||||
@@ -61,8 +63,9 @@ public class ReceivableStatisticsService {
|
||||
args.add(customerId);
|
||||
}
|
||||
sql.append(" GROUP BY s.tenant_id,p.customer_name_snapshot ORDER BY customer_name_snapshot,s.tenant_id");
|
||||
var names = customerNames.resolver();
|
||||
return jdbc.query(sql.toString(), (rs, rowNum) -> checked(new ReceivableStatisticsRow(
|
||||
rs.getLong("tenant_id"), rs.getString("customer_name_snapshot"), CURRENCY,
|
||||
rs.getLong("tenant_id"), names.apply(rs.getLong("tenant_id"), rs.getString("customer_name_snapshot")), CURRENCY,
|
||||
rs.getBigDecimal("total_receivable"), rs.getBigDecimal("total_received"),
|
||||
rs.getBigDecimal("total_outstanding"))), args.toArray());
|
||||
}
|
||||
|
||||
+13
-10
@@ -46,14 +46,16 @@ public class ReconciliationStatementService {
|
||||
private final JdbcTemplate jdbc;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final FinanceDocumentNumberGenerator documentNumbers;
|
||||
private final FinanceCustomerNames customerNames;
|
||||
|
||||
/** 注入统一编号组件,确保所有业务入口共用受事务保护的日流水。 */
|
||||
@Autowired
|
||||
public ReconciliationStatementService(JdbcTemplate jdbc, ObjectMapper objectMapper,
|
||||
FinanceDocumentNumberGenerator documentNumbers) {
|
||||
FinanceDocumentNumberGenerator documentNumbers, FinanceCustomerNames customerNames) {
|
||||
this.jdbc = jdbc;
|
||||
this.objectMapper = objectMapper;
|
||||
this.documentNumbers = documentNumbers;
|
||||
this.customerNames = customerNames;
|
||||
}
|
||||
|
||||
/** 创建对账单并分配 REC 日流水,重复请求返回已创建记录。 */
|
||||
@@ -195,7 +197,7 @@ public class ReconciliationStatementService {
|
||||
List<ReconciliationRevision> revisions = jdbc.query(
|
||||
"SELECT action,reason,before_data,after_data,operator_id,operator_name_snapshot,created_at FROM fin_audit_log WHERE tenant_id=? AND resource_type='RECONCILIATION_STATEMENT' AND resource_id=? AND action<>'RECONCILIATION_CUSTOMER_CONFIRM' AND before_data IS NOT NULL ORDER BY created_at,id",
|
||||
(rs, row) -> revision(rs, row + 1), customerId, statementId);
|
||||
return new ReconciliationDetail(bill.id(), bill.number(), bill.customerId(), bill.customerName(),
|
||||
return new ReconciliationDetail(bill.id(), bill.number(), bill.customerId(), customerNames.resolver().apply(bill.customerId(), bill.customerName()),
|
||||
metadata.periodStart(), metadata.periodEnd(), bill.creditDays(), "CNY", bill.total(), bill.received(),
|
||||
bill.outstanding(), bill.outstanding().signum() == 0, reconciliationStatus(bill.status()), bill.version(),
|
||||
bill.customerConfirmationStatus(), bill.customerConfirmedBy(), bill.customerConfirmedByName(),
|
||||
@@ -278,7 +280,7 @@ public class ReconciliationStatementService {
|
||||
private CustomerProfile customer(long customerId) {
|
||||
List<CustomerProfile> rows = jdbc.query("SELECT customer_name_snapshot,credit_days FROM fin_customer_settlement_profile WHERE tenant_id=? AND status='ACTIVE'",
|
||||
(rs, row) -> new CustomerProfile(rs.getString("customer_name_snapshot"), rs.getInt("credit_days")), customerId);
|
||||
return rows.isEmpty() ? new CustomerProfile("客户" + customerId, 30) : rows.get(0);
|
||||
return new CustomerProfile(customerNames.required(customerId), rows.isEmpty() ? 30 : rows.get(0).creditDays());
|
||||
}
|
||||
|
||||
private long activeSubject() {
|
||||
@@ -488,18 +490,19 @@ public class ReconciliationStatementService {
|
||||
}
|
||||
|
||||
private ReconciliationRevision revision(ResultSet rs, int revisionNo) throws SQLException {
|
||||
return new ReconciliationRevision(revisionNo, rs.getString("action").replaceFirst("^RECONCILIATION_", ""), amountFromJson(rs.getString("before_data")),
|
||||
amountFromJson(rs.getString("after_data")), rs.getString("reason"), rs.getLong("operator_id"),
|
||||
return new ReconciliationRevision(revisionNo, rs.getString("action").replaceFirst("^RECONCILIATION_", ""), amountFromJson(rs.getString("before_data"), "total"),
|
||||
amountFromJson(rs.getString("after_data"), "totalReceivable"), rs.getString("reason"), rs.getLong("operator_id"),
|
||||
rs.getString("operator_name_snapshot"),
|
||||
rs.getTimestamp("created_at").toLocalDateTime());
|
||||
}
|
||||
|
||||
private BigDecimal amountFromJson(String value) {
|
||||
if (!StringUtils.hasText(value)) return BigDecimal.ZERO;
|
||||
/** 变更前为 Bill,变更后为 ReconciliationDetail;缺失金额属于损坏快照,不能伪装为零。 */
|
||||
private BigDecimal amountFromJson(String value, String field) {
|
||||
try {
|
||||
JsonNode node = objectMapper.readTree(value);
|
||||
JsonNode amount = node.has("totalAmount") ? node.get("totalAmount") : node.get("total");
|
||||
return amount == null || amount.isNull() ? BigDecimal.ZERO : amount.decimalValue();
|
||||
JsonNode node = objectMapper.reader().with(com.fasterxml.jackson.databind.DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS).readTree(value);
|
||||
JsonNode amount = node.get(field);
|
||||
if (amount == null || !amount.isNumber()) throw new IllegalArgumentException("缺失金额");
|
||||
return amount.decimalValue();
|
||||
} catch (Exception exception) {
|
||||
throw error("FIN_001", "对账单修订快照损坏");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user