fix(finance): revalidate source ownership before reopening settlements
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
package com.baiyue.finance.mapper;
|
||||
|
||||
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 org.apache.ibatis.annotations.Update;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/** 结算状态变更与重新占用来源的事务锁;锁定读取不得复用会话缓存。 */
|
||||
@Mapper
|
||||
public interface FinanceSettlementStateMapper {
|
||||
/** 锁定待作废或重开的结算,保留客户与来源关联。 */
|
||||
@Select("SELECT id,tenant_id,total_amount,paid_amount,outstanding_amount,payment_status,status,version,direction,currency_code,supplier_id,reconciliation_statement_id FROM fin_settlement WHERE id=#{settlementId} FOR UPDATE")
|
||||
@Options(useCache = false, flushCache = Options.FlushCachePolicy.TRUE)
|
||||
List<Map<String, Object>> lockSettlement(@Param("settlementId") long settlementId);
|
||||
|
||||
/** 直接锁定来源表,不能依赖外连接右侧记录的锁语义。 */
|
||||
@Select("SELECT f.id FROM fin_fee_source f WHERE EXISTS (SELECT 1 FROM fin_settlement_line sl WHERE sl.tenant_id=#{customerId} AND sl.settlement_id=#{settlementId} AND sl.fee_source_id=f.id) ORDER BY f.id FOR UPDATE")
|
||||
@Options(useCache = false, flushCache = Options.FlushCachePolicy.TRUE)
|
||||
List<Long> lockSourceRows(@Param("customerId") long customerId, @Param("settlementId") long settlementId);
|
||||
|
||||
/** 按来源顺序锁定原单全部明细,缺失或跨客户来源仍返回明细供服务拒绝。 */
|
||||
@Select("SELECT sl.fee_source_id,sl.amount line_amount,sl.currency_code line_currency,f.id,f.customer_id,f.direction,f.currency_code,f.amount,f.review_status,f.status,f.settlement_mode,f.source_snapshot FROM fin_settlement_line sl LEFT JOIN fin_fee_source f ON f.id=sl.fee_source_id WHERE sl.tenant_id=#{customerId} AND sl.settlement_id=#{settlementId} ORDER BY sl.fee_source_id FOR UPDATE")
|
||||
@Options(useCache = false, flushCache = Options.FlushCachePolicy.TRUE)
|
||||
List<Map<String, Object>> lockSources(@Param("customerId") long customerId, @Param("settlementId") long settlementId);
|
||||
|
||||
/** 使用当前锁定读检查其他有效结算,避免等待来源锁后读取旧快照。 */
|
||||
@Select("SELECT s.id 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=#{customerId} AND sl.fee_source_id=#{sourceId} AND s.id<>#{settlementId} AND s.status<>'VOIDED' FOR UPDATE")
|
||||
@Options(useCache = false, flushCache = Options.FlushCachePolicy.TRUE)
|
||||
List<Map<String, Object>> lockOtherSourceSettlements(@Param("customerId") long customerId, @Param("sourceId") long sourceId, @Param("settlementId") long settlementId);
|
||||
|
||||
/** 与应收新建共同锁定原对账单,防止检查后被调整或再次生成应收。 */
|
||||
@Select("SELECT id,tenant_id,status,currency_code,total_amount,received_amount FROM fin_bill WHERE tenant_id=#{customerId} AND id=#{billId} FOR UPDATE")
|
||||
@Options(useCache = false, flushCache = Options.FlushCachePolicy.TRUE)
|
||||
List<Map<String, Object>> lockBill(@Param("customerId") long customerId, @Param("billId") long billId);
|
||||
|
||||
/** 读取原账单当前明细,重开不能沿用调账前的行金额。 */
|
||||
@Select("SELECT source_id fee_source_id,amount line_amount,currency_code line_currency FROM fin_bill_line WHERE tenant_id=#{customerId} AND bill_id=#{billId} ORDER BY source_id FOR UPDATE")
|
||||
@Options(useCache = false, flushCache = Options.FlushCachePolicy.TRUE)
|
||||
List<Map<String, Object>> lockBillLines(@Param("customerId") long customerId, @Param("billId") long billId);
|
||||
|
||||
/** 锁定同账单其他有效应收,作废旧单不可绕过唯一占用。 */
|
||||
@Select("SELECT id FROM fin_settlement WHERE tenant_id=#{customerId} AND direction='RECEIVABLE' AND reconciliation_statement_id=#{billId} AND id<>#{settlementId} AND status<>'VOIDED' FOR UPDATE")
|
||||
@Options(useCache = false, flushCache = Options.FlushCachePolicy.TRUE)
|
||||
List<Map<String, Object>> lockOtherBillSettlements(@Param("customerId") long customerId, @Param("billId") long billId, @Param("settlementId") long settlementId);
|
||||
|
||||
/** 客户范围内按原版本推进状态。 */
|
||||
@Update("UPDATE fin_settlement SET status=#{status},updated_by=#{operatorId},version=version+1 WHERE id=#{settlementId} AND tenant_id=#{customerId} AND version=#{version}")
|
||||
int changeStatus(@Param("status") String status, @Param("operatorId") long operatorId, @Param("settlementId") long settlementId, @Param("customerId") long customerId, @Param("version") int version);
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package com.baiyue.finance.repository;
|
||||
|
||||
import com.baiyue.finance.mapper.FinanceSettlementStateMapper;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import org.springframework.util.LinkedCaseInsensitiveMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/** 封装结算状态及原始费用、账单的事务访问。 */
|
||||
@Repository
|
||||
public class FinanceSettlementStateRepository {
|
||||
private final FinanceSettlementStateMapper mapper;
|
||||
/** 注入结算状态持久化入口。 */
|
||||
public FinanceSettlementStateRepository(FinanceSettlementStateMapper mapper) { this.mapper = mapper; }
|
||||
/** 统一数据库返回的列名大小写。 */
|
||||
private List<Map<String, Object>> rows(List<Map<String, Object>> rows) {
|
||||
return rows.stream().map(row -> { Map<String, Object> value = new LinkedCaseInsensitiveMap<>(); value.putAll(row); return value; }).toList();
|
||||
}
|
||||
/** 锁定目标结算。 */
|
||||
public List<Map<String, Object>> lockSettlement(long id) { return rows(mapper.lockSettlement(id)); }
|
||||
/** 锁定结算所有费用来源。 */
|
||||
public List<Map<String, Object>> lockSources(long customerId, long id) {
|
||||
mapper.lockSourceRows(customerId, id);
|
||||
return rows(mapper.lockSources(customerId, id));
|
||||
}
|
||||
/** 判断费用是否被其他有效结算占用。 */
|
||||
public boolean sourceOccupied(long customerId, long sourceId, long id) { return !mapper.lockOtherSourceSettlements(customerId, sourceId, id).isEmpty(); }
|
||||
/** 锁定原始对账单。 */
|
||||
public List<Map<String, Object>> lockBill(long customerId, long billId) { return rows(mapper.lockBill(customerId, billId)); }
|
||||
/** 锁定原始对账单的当前明细。 */
|
||||
public List<Map<String, Object>> lockBillLines(long customerId, long billId) { return rows(mapper.lockBillLines(customerId, billId)); }
|
||||
/** 判断原对账单是否已生成其他有效应收。 */
|
||||
public boolean billOccupied(long customerId, long billId, long id) { return !mapper.lockOtherBillSettlements(customerId, billId, id).isEmpty(); }
|
||||
/** 按客户及版本变更结算状态。 */
|
||||
public int changeStatus(String status, long operatorId, long id, long customerId, int version) { return mapper.changeStatus(status, operatorId, id, customerId, version); }
|
||||
}
|
||||
@@ -53,13 +53,15 @@ public class FinanceSettlementService {
|
||||
private final FinanceSupplierValidator supplierValidator;
|
||||
private final FinanceDocumentNumberGenerator documentNumbers;
|
||||
private final FinanceCustomerNames customerNames;
|
||||
private final com.baiyue.finance.repository.FinanceSettlementStateRepository states;
|
||||
|
||||
/** 注入统一编号组件,确保所有业务入口共用受事务保护的日流水。 */
|
||||
@Autowired
|
||||
public FinanceSettlementService(JdbcTemplate jdbc, ObjectMapper objectMapper, FinanceVoucherValidator voucherValidator,
|
||||
FinanceSupplierValidator supplierValidator,
|
||||
FinanceDocumentNumberGenerator documentNumbers,
|
||||
com.baiyue.finance.repository.FinanceSettlementDetailRepository details, FinanceCustomerNames customerNames) {
|
||||
com.baiyue.finance.repository.FinanceSettlementDetailRepository details, FinanceCustomerNames customerNames,
|
||||
com.baiyue.finance.repository.FinanceSettlementStateRepository states) {
|
||||
this.jdbc = jdbc;
|
||||
this.details = details;
|
||||
this.objectMapper = objectMapper;
|
||||
@@ -67,6 +69,7 @@ public class FinanceSettlementService {
|
||||
this.supplierValidator = supplierValidator;
|
||||
this.documentNumbers = documentNumbers;
|
||||
this.customerNames = customerNames;
|
||||
this.states = states;
|
||||
}
|
||||
|
||||
/** 创建应收结算单,幂等请求恢复原单,首次建单分配 AR 日流水。 */
|
||||
@@ -89,11 +92,9 @@ public class FinanceSettlementService {
|
||||
Long replay = reserve(customerId, operatorId, "SETTLEMENT_AR_CREATE", key, "POST",
|
||||
"/api/platform/finance/v1/receivable-settlements", request);
|
||||
if (replay != null) return getSettlement(replay);
|
||||
Integer occupied = jdbc.queryForObject(
|
||||
"SELECT COUNT(*) FROM fin_settlement WHERE tenant_id=? AND direction='RECEIVABLE' " +
|
||||
"AND reconciliation_statement_id=? AND status<>'VOIDED'", Integer.class,
|
||||
customerId, request.reconciliationStatementId());
|
||||
if (occupied != null && occupied > 0) throw error("FIN_409", "对账单已生成有效应收结算单");
|
||||
// 新建与重开共用当前锁定读,等待来源锁后不能沿用事务开始时的占用快照。
|
||||
if (states.billOccupied(customerId, request.reconciliationStatementId(), 0))
|
||||
throw error("FIN_409", "对账单已生成有效应收结算单");
|
||||
Integer lineCount = jdbc.queryForObject("SELECT COUNT(*) FROM fin_bill_line WHERE tenant_id=? AND bill_id=?",
|
||||
Integer.class, customerId, request.reconciliationStatementId());
|
||||
if (lineCount == null || lineCount == 0) throw error("FIN_409", "对账单没有可结算费用明细");
|
||||
@@ -148,10 +149,8 @@ public class FinanceSettlementService {
|
||||
throw error("FIN_409", "费用来源未审核、金额无效或状态不可用");
|
||||
}
|
||||
if (!CNY.equals(text(source, "currency_code"))) throw error("FIN_400", "费用来源币种必须为CNY");
|
||||
Integer used = jdbc.queryForObject(
|
||||
"SELECT COUNT(*) FROM fin_settlement_line sl JOIN fin_settlement s ON s.id=sl.settlement_id " +
|
||||
"WHERE sl.fee_source_id=? AND s.status<>'VOIDED'", Integer.class, number(source, "id").longValue());
|
||||
if (used != null && used > 0) throw error("FIN_409", "费用来源已被有效结算单占用");
|
||||
if (states.sourceOccupied(customerId, number(source, "id").longValue(), 0))
|
||||
throw error("FIN_409", "费用来源已被有效结算单占用");
|
||||
}
|
||||
BigDecimal total = sources.stream().map(row -> decimal(row, "amount")).reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
if (total.signum() <= 0) throw error("FIN_400", "应付结算金额必须大于0");
|
||||
@@ -249,13 +248,14 @@ public class FinanceSettlementService {
|
||||
return getSettlement(settlementId);
|
||||
}
|
||||
|
||||
/** 作废保留来源关联;重开必须重新校验并锁定当前仍可占用的原始依据。 */
|
||||
@Transactional
|
||||
public SettlementDetail actionSettlement(long settlementId, long operatorId, String key,
|
||||
SettlementActionRequest request) {
|
||||
validateSettlementAction(request);
|
||||
Map<String, Object> settlement = one(
|
||||
"SELECT id,tenant_id,total_amount,paid_amount,outstanding_amount,payment_status,status,version " +
|
||||
"FROM fin_settlement WHERE id=? FOR UPDATE", "结算单不存在", settlementId);
|
||||
List<Map<String, Object>> locked = states.lockSettlement(settlementId);
|
||||
if (locked.isEmpty()) throw error("FIN_404", "结算单不存在");
|
||||
Map<String, Object> settlement = locked.get(0);
|
||||
long customerId = number(settlement, "tenant_id").longValue();
|
||||
Long replay = reserve(customerId, operatorId, "SETTLEMENT_STATE_ACTION", key, "POST",
|
||||
"/api/platform/finance/v1/settlements/" + settlementId + "/actions", request);
|
||||
@@ -274,11 +274,10 @@ public class FinanceSettlementService {
|
||||
if (!"VOIDED".equals(currentStatus) || decimal(settlement, "paid_amount").signum() != 0) {
|
||||
throw error("FIN_409", "只有未发生收付款的已作废结算单可以重开");
|
||||
}
|
||||
requireReopenableSources(settlement);
|
||||
targetStatus = "ISSUED";
|
||||
}
|
||||
int changed = jdbc.update("UPDATE fin_settlement SET status=?,updated_by=?,version=version+1 " +
|
||||
"WHERE id=? AND tenant_id=? AND version=?", targetStatus, operatorId, settlementId, customerId,
|
||||
request.expectedVersion());
|
||||
int changed = states.changeStatus(targetStatus, operatorId, settlementId, customerId, request.expectedVersion());
|
||||
if (changed != 1) throw error("FIN_409", "结算单版本冲突");
|
||||
audit(customerId, settlementId, request.action(), request.reason(), settlementStateSnapshot(settlement),
|
||||
settlementStateSnapshot(settlement, targetStatus), operatorId);
|
||||
@@ -286,6 +285,77 @@ public class FinanceSettlementService {
|
||||
return getSettlement(settlementId);
|
||||
}
|
||||
|
||||
/** 重开沿用原单金额,来源变化必须重新生成结算,不覆盖历史单据。 */
|
||||
private void requireReopenableSources(Map<String, Object> settlement) {
|
||||
long customerId = number(settlement, "tenant_id").longValue();
|
||||
long settlementId = number(settlement, "id").longValue();
|
||||
if (!CNY.equals(text(settlement, "currency_code")) || decimal(settlement, "total_amount").signum() <= 0)
|
||||
throw error("FIN_409", "原结算金额或币种已不满足重开条件");
|
||||
if ("RECEIVABLE".equals(text(settlement, "direction"))) {
|
||||
requireReopenableReceivable(settlement);
|
||||
return;
|
||||
}
|
||||
if (!"PAYABLE".equals(text(settlement, "direction"))) throw error("FIN_409", "结算方向不支持重开");
|
||||
Long supplierId = nullableNumber(settlement, "supplier_id");
|
||||
List<Map<String, Object>> sources = states.lockSources(customerId, settlementId);
|
||||
if (supplierId == null || sources.isEmpty()) throw error("FIN_409", "原应付来源或供应商不完整");
|
||||
BigDecimal total = BigDecimal.ZERO;
|
||||
List<Map<String, Object>> categories = new ArrayList<>();
|
||||
for (Map<String, Object> source : sources) {
|
||||
JsonNode snapshot = parseTree(text(source, "source_snapshot"));
|
||||
if (source.get("id") == null || number(source, "customer_id").longValue() != customerId
|
||||
|| !"PAYABLE".equals(text(source, "direction")) || !"AVAILABLE".equals(text(source, "status"))
|
||||
|| !"APPROVED".equals(text(source, "review_status")) || !"ON_ACCOUNT".equals(text(source, "settlement_mode"))
|
||||
|| !CNY.equals(text(source, "currency_code")) || !CNY.equals(text(source, "line_currency"))
|
||||
|| !supplierId.equals(jsonLong(snapshot, "supplierId")) || decimal(source, "amount").signum() <= 0
|
||||
|| decimal(source, "amount").compareTo(decimal(source, "line_amount")) != 0)
|
||||
throw error("FIN_409", "原应付费用已调整、作废或不再满足结算条件,请重新生成结算单");
|
||||
if (states.sourceOccupied(customerId, number(source, "fee_source_id").longValue(), settlementId))
|
||||
throw error("FIN_409", "原应付费用已被其他有效结算单占用");
|
||||
total = total.add(decimal(source, "line_amount"));
|
||||
Map<String, Object> category = new java.util.HashMap<>();
|
||||
category.put("supplier_category", jsonText(snapshot, "supplierCategory"));
|
||||
categories.add(category);
|
||||
}
|
||||
if (total.compareTo(decimal(settlement, "total_amount")) != 0)
|
||||
throw error("FIN_409", "原应付结算与费用金额不一致,请重新生成结算单");
|
||||
supplierValidator.requireActive(supplierId, supplierCategoryConstraint(categories));
|
||||
}
|
||||
|
||||
/** 应收重开还须核对原账单当前状态及逐行金额,防止调账后恢复旧应收。 */
|
||||
private void requireReopenableReceivable(Map<String, Object> settlement) {
|
||||
long customerId = number(settlement, "tenant_id").longValue();
|
||||
long settlementId = number(settlement, "id").longValue();
|
||||
Long billId = nullableNumber(settlement, "reconciliation_statement_id");
|
||||
if (billId == null) throw error("FIN_409", "原应收缺少对账单关联");
|
||||
List<Map<String, Object>> bills = states.lockBill(customerId, billId);
|
||||
if (bills.isEmpty()) throw error("FIN_409", "原对账单不存在或不属于当前客户");
|
||||
Map<String, Object> bill = bills.get(0);
|
||||
if (!"ISSUED".equals(text(bill, "status")) || !CNY.equals(text(bill, "currency_code"))
|
||||
|| decimal(bill, "received_amount").signum() != 0
|
||||
|| decimal(bill, "total_amount").compareTo(decimal(settlement, "total_amount")) != 0)
|
||||
throw error("FIN_409", "原对账单状态或金额已变化,请重新生成应收结算单");
|
||||
if (states.billOccupied(customerId, billId, settlementId))
|
||||
throw error("FIN_409", "原对账单已生成其他有效应收结算单");
|
||||
List<Map<String, Object>> lines = states.lockBillLines(customerId, billId);
|
||||
List<Map<String, Object>> sources = states.lockSources(customerId, settlementId);
|
||||
if (lines.isEmpty() || sources.size() != lines.size()) throw error("FIN_409", "原应收明细与对账单不一致");
|
||||
BigDecimal total = BigDecimal.ZERO;
|
||||
for (Map<String, Object> source : sources) {
|
||||
var matching = lines.stream().filter(line -> number(line, "fee_source_id").longValue() == number(source, "fee_source_id").longValue()).toList();
|
||||
if (source.get("id") == null || number(source, "customer_id").longValue() != customerId
|
||||
|| !"RECEIVABLE".equals(text(source, "direction")) || !"BILLED".equals(text(source, "status"))
|
||||
|| !"APPROVED".equals(text(source, "review_status")) || !CNY.equals(text(source, "currency_code"))
|
||||
|| !CNY.equals(text(source, "line_currency")) || matching.size() != 1
|
||||
|| !CNY.equals(text(matching.get(0), "line_currency"))
|
||||
|| decimal(source, "line_amount").compareTo(decimal(matching.get(0), "line_amount")) != 0)
|
||||
throw error("FIN_409", "原应收费用或明细金额已变化,请重新生成结算单");
|
||||
total = total.add(decimal(source, "line_amount"));
|
||||
}
|
||||
if (total.compareTo(decimal(settlement, "total_amount")) != 0)
|
||||
throw error("FIN_409", "原应收明细合计与结算金额不一致");
|
||||
}
|
||||
|
||||
private long insertSettlement(long customerId, String no, String direction, String customerName,
|
||||
Long supplierId, String supplierName, Long reconciliationId,
|
||||
String businessPeriod, String currency, BigDecimal total,
|
||||
|
||||
Reference in New Issue
Block a user