优化
This commit is contained in:
@@ -46,7 +46,7 @@ public class PlatformFinanceController {
|
||||
|
||||
@GetMapping("/customer-accounts/{customerId}")
|
||||
@PreAuthorize("hasAuthority('platform:finance:customer-account:list')")
|
||||
@Operation(summary = "客户人工应收账户及流水")
|
||||
@Operation(summary = "客户正式应收账户及收款流水")
|
||||
public ApiResponse<?> customerAccount(@PathVariable long customerId) {
|
||||
return FinanceResponses.success(queryService.customerAccount(customerId));
|
||||
}
|
||||
|
||||
@@ -62,17 +62,17 @@ public class FinanceCustomerAccountService {
|
||||
String placeholders = String.join(",", customerIds.stream().map(id -> "?").toList());
|
||||
Map<Long, MutableMetrics> result = new HashMap<>();
|
||||
customerIds.forEach(id -> result.put(id, new MutableMetrics()));
|
||||
jdbc.queryForList("SELECT tenant_id customer_id,SUM(outstanding_amount) amount FROM fin_bill WHERE tenant_id IN (" + placeholders + ") AND status<>'VOIDED' GROUP BY tenant_id", customerIds.toArray())
|
||||
jdbc.queryForList("SELECT tenant_id customer_id,SUM(outstanding_amount) amount FROM fin_settlement WHERE tenant_id IN (" + placeholders + ") AND direction='RECEIVABLE' AND status<>'VOIDED' GROUP BY tenant_id", customerIds.toArray())
|
||||
.forEach(row -> metric(result, row, "outstandingReceivable", "amount"));
|
||||
jdbc.queryForList("SELECT customer_id,SUM(amount) amount FROM fin_fee_source f WHERE customer_id IN (" + placeholders + ") AND direction='RECEIVABLE' AND status='AVAILABLE' AND NOT EXISTS (SELECT 1 FROM fin_bill_line bl WHERE bl.source_id=f.id AND bl.source_version=f.source_fee_version) GROUP BY customer_id", customerIds.toArray())
|
||||
jdbc.queryForList("SELECT customer_id,SUM(amount) amount FROM fin_fee_source f WHERE customer_id IN (" + placeholders + ") AND direction='RECEIVABLE' AND review_status='APPROVED' AND currency_code='CNY' AND amount>0 AND status='AVAILABLE' AND NOT 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') GROUP BY customer_id", customerIds.toArray())
|
||||
.forEach(row -> metric(result, row, "pendingBilling", "amount"));
|
||||
jdbc.queryForList("SELECT tenant_id customer_id,SUM(received_amount) amount FROM fin_receipt WHERE tenant_id IN (" + placeholders + ") AND status='CONFIRMED' GROUP BY tenant_id", customerIds.toArray())
|
||||
.forEach(row -> metric(result, row, "receivedInPeriod", "amount"));
|
||||
jdbc.queryForList("SELECT customer_id,SUM(amount) amount FROM (SELECT customer_id,amount FROM fin_fee_source WHERE customer_id IN (" + placeholders + ") AND direction='PAYABLE' AND settlement_mode='ON_ACCOUNT' AND review_status='APPROVED' AND status NOT IN ('VOIDED','SUPERSEDED') UNION ALL SELECT tenant_id customer_id,cost_amount amount FROM fin_cost WHERE tenant_id IN (" + placeholders + ") AND status='CONFIRMED') costs GROUP BY customer_id", concat(customerIds, 2))
|
||||
jdbc.queryForList("SELECT customer_id,SUM(amount) amount FROM (SELECT customer_id,amount FROM fin_fee_source WHERE customer_id IN (" + placeholders + ") AND direction='PAYABLE' AND settlement_mode='ON_ACCOUNT' AND review_status='APPROVED' AND currency_code='CNY' AND amount>0 AND status NOT IN ('VOIDED','SUPERSEDED') UNION ALL SELECT tenant_id customer_id,cost_amount amount FROM fin_cost WHERE tenant_id IN (" + placeholders + ") AND cost_origin='FIRSTLEG_DIRECT_PAID' AND status='CONFIRMED') costs GROUP BY customer_id", concat(customerIds, 2))
|
||||
.forEach(row -> metric(result, row, "totalCost", "amount"));
|
||||
jdbc.queryForList("SELECT customer_id,SUM(amount) amount FROM (SELECT tenant_id customer_id,cost_amount amount FROM fin_cost WHERE tenant_id IN (" + placeholders + ") AND status='CONFIRMED' UNION ALL SELECT tenant_id customer_id,paid_amount amount FROM fin_settlement WHERE tenant_id IN (" + placeholders + ") AND direction='PAYABLE' AND status<>'VOIDED') paid_costs GROUP BY customer_id", concat(customerIds, 2))
|
||||
jdbc.queryForList("SELECT customer_id,SUM(amount) amount FROM (SELECT tenant_id customer_id,cost_amount amount FROM fin_cost WHERE tenant_id IN (" + placeholders + ") AND cost_origin='FIRSTLEG_DIRECT_PAID' AND status='CONFIRMED' UNION ALL SELECT tenant_id customer_id,paid_amount amount FROM fin_settlement WHERE tenant_id IN (" + placeholders + ") AND direction='PAYABLE' AND status<>'VOIDED') paid_costs GROUP BY customer_id", concat(customerIds, 2))
|
||||
.forEach(row -> metric(result, row, "paidCost", "amount"));
|
||||
jdbc.queryForList("SELECT tenant_id customer_id,SUM(outstanding_amount) amount FROM fin_settlement WHERE tenant_id IN (" + placeholders + ") AND direction='PAYABLE' AND status<>'VOIDED' GROUP BY tenant_id", customerIds.toArray())
|
||||
jdbc.queryForList("SELECT customer_id,SUM(amount) amount FROM (SELECT tenant_id customer_id,outstanding_amount amount FROM fin_settlement WHERE tenant_id IN (" + placeholders + ") AND direction='PAYABLE' AND status<>'VOIDED' UNION ALL SELECT f.customer_id,f.amount FROM fin_fee_source f WHERE f.customer_id IN (" + placeholders + ") AND f.direction='PAYABLE' AND f.settlement_mode='ON_ACCOUNT' AND f.review_status='APPROVED' AND f.currency_code='CNY' AND f.amount>0 AND f.status NOT IN ('VOIDED','SUPERSEDED') AND NOT EXISTS (SELECT 1 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')) payable_obligations GROUP BY customer_id", concat(customerIds, 2))
|
||||
.forEach(row -> metric(result, row, "outstandingPayable", "amount"));
|
||||
jdbc.queryForList("SELECT customer_id,MAX(updated_at) last_changed_at FROM (SELECT tenant_id customer_id,updated_at FROM fin_bill WHERE tenant_id IN (" + placeholders + ") UNION ALL SELECT tenant_id customer_id,updated_at FROM fin_receipt WHERE tenant_id IN (" + placeholders + ") UNION ALL SELECT tenant_id customer_id,updated_at FROM fin_cost WHERE tenant_id IN (" + placeholders + ") UNION ALL SELECT customer_id,updated_at FROM fin_fee_source WHERE customer_id IN (" + placeholders + ") UNION ALL SELECT tenant_id customer_id,updated_at FROM fin_settlement WHERE tenant_id IN (" + placeholders + ")) changes GROUP BY customer_id", concat(customerIds, 5))
|
||||
.forEach(row -> metric(result, row, "lastChangedAt", "last_changed_at"));
|
||||
|
||||
@@ -72,6 +72,7 @@ public class FinanceFeeSourceEventService {
|
||||
}
|
||||
long id = ((Number) row.get("id")).longValue();
|
||||
if ("FIRSTLEG_FEE_VOIDED".equals(event.eventType())) {
|
||||
requireNotFrozen(id);
|
||||
jdbc.update("UPDATE fin_fee_source SET status='VOIDED',operation_version=operation_version+1 WHERE id=? AND status<>'VOIDED'", id);
|
||||
voidDirectPaidCost(snapshot);
|
||||
}
|
||||
@@ -80,6 +81,7 @@ public class FinanceFeeSourceEventService {
|
||||
if ("FIRSTLEG_FEE_VOIDED".equals(event.eventType())) {
|
||||
throw error("FIN-EVT-005", "作废事件对应的费用来源版本不存在");
|
||||
}
|
||||
requireSourceNotFrozen(snapshot.feeId());
|
||||
jdbc.update("UPDATE fin_fee_source SET status='SUPERSEDED' WHERE source_system='FIRSTLEG' AND source_fee_id=? AND status='AVAILABLE'",
|
||||
snapshot.feeId());
|
||||
jdbc.update("UPDATE fin_cost SET status='VOIDED',voided_at=CURRENT_TIMESTAMP(3),void_reason='来源版本已替代',version=version+1,updated_by=0 WHERE cost_origin='FIRSTLEG_DIRECT_PAID' AND source_system='FIRSTLEG' AND source_fee_id=? AND status='CONFIRMED'",
|
||||
@@ -100,6 +102,16 @@ public class FinanceFeeSourceEventService {
|
||||
return sourceId;
|
||||
}
|
||||
|
||||
private void requireSourceNotFrozen(long sourceFeeId) {
|
||||
Integer frozen = jdbc.queryForObject("SELECT COUNT(*) FROM fin_fee_source f WHERE f.source_system='FIRSTLEG' AND f.source_fee_id=? AND (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') OR EXISTS (SELECT 1 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.status<>'VOIDED'))", Integer.class, sourceFeeId);
|
||||
if (frozen != null && frozen > 0) throw error("FIN-EVT-016", "费用来源已进入有效单据,禁止调整或替代");
|
||||
}
|
||||
|
||||
private void requireNotFrozen(long feeSourceId) {
|
||||
Integer frozen = jdbc.queryForObject("SELECT COUNT(*) FROM fin_fee_source f WHERE f.id=? AND (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') OR EXISTS (SELECT 1 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.status<>'VOIDED'))", Integer.class, feeSourceId);
|
||||
if (frozen != null && frozen > 0) throw error("FIN-EVT-016", "费用来源已进入有效单据,禁止作废");
|
||||
}
|
||||
|
||||
private void materializeDirectPaidCost(FirstlegFeeSourceClient.Snapshot snapshot) {
|
||||
String costNo = "DIRECT-" + snapshot.sourceType() + "-" + snapshot.feeId() + "-V" + snapshot.feeVersion();
|
||||
jdbc.update("INSERT INTO fin_cost(tenant_id,cost_no,cost_type,cost_origin,source_system,source_type,source_fee_id,source_fee_version,source_hash,order_id,inbound_id,container_id,supplier_id,supplier_name_snapshot,fee_item_code,fee_item_name,currency_code,cost_amount,voucher_file_ids,paid_at,payment_method,payment_reference_no,status,confirmed_at,created_by,created_by_name,updated_by) VALUES(?,?,'SOURCE_DIRECT_PAID','FIRSTLEG_DIRECT_PAID','FIRSTLEG',?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,'CONFIRMED',CURRENT_TIMESTAMP(3),0,'系统',0)",
|
||||
|
||||
+10
-8
@@ -66,12 +66,12 @@ public class FinancePhase1OperationService {
|
||||
String base = " FROM (" + CUSTOMER_IDS + ") c LEFT JOIN fin_customer_settlement_profile p ON p.tenant_id=c.customer_id" + filter;
|
||||
Long total = jdbc.queryForObject("SELECT COUNT(*)" + base, Long.class, filterArgs);
|
||||
String select = "SELECT c.customer_id,COALESCE(p.customer_name_snapshot,CONCAT('客户',c.customer_id)) customer_name,COALESCE(p.status,'ACTIVE') account_status," +
|
||||
"(SELECT COALESCE(SUM(outstanding_amount),0) FROM fin_bill b WHERE b.tenant_id=c.customer_id AND b.status<>'VOIDED') outstanding_receivable," +
|
||||
"(SELECT COALESCE(SUM(amount),0) FROM fin_fee_source f WHERE f.customer_id=c.customer_id AND f.direction='RECEIVABLE' AND f.status='AVAILABLE' AND NOT EXISTS(SELECT 1 FROM fin_bill_line bl WHERE bl.source_id=f.id AND bl.source_version=f.source_fee_version)) pending_billing," +
|
||||
"(SELECT COALESCE(SUM(outstanding_amount),0) FROM fin_settlement ar WHERE ar.tenant_id=c.customer_id AND ar.direction='RECEIVABLE' AND ar.status<>'VOIDED') outstanding_receivable," +
|
||||
"(SELECT COALESCE(SUM(amount),0) FROM fin_fee_source f WHERE f.customer_id=c.customer_id AND f.direction='RECEIVABLE' AND f.review_status='APPROVED' AND f.currency_code='CNY' AND f.amount>0 AND f.status='AVAILABLE' AND NOT 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')) pending_billing," +
|
||||
"(SELECT COALESCE(SUM(received_amount),0) FROM fin_receipt r WHERE r.tenant_id=c.customer_id AND r.status='CONFIRMED') received_period," +
|
||||
"((SELECT COALESCE(SUM(amount),0) FROM fin_fee_source f WHERE f.customer_id=c.customer_id AND f.direction='PAYABLE' AND f.settlement_mode='ON_ACCOUNT' AND f.review_status='APPROVED' AND f.status NOT IN ('VOIDED','SUPERSEDED'))+(SELECT COALESCE(SUM(cost_amount),0) FROM fin_cost x WHERE x.tenant_id=c.customer_id AND x.status='CONFIRMED')) total_cost," +
|
||||
"((SELECT COALESCE(SUM(cost_amount),0) FROM fin_cost x WHERE x.tenant_id=c.customer_id AND x.status='CONFIRMED')+(SELECT COALESCE(SUM(paid_amount),0) FROM fin_settlement x WHERE x.tenant_id=c.customer_id AND x.direction='PAYABLE' AND x.status<>'VOIDED')) paid_cost," +
|
||||
"(SELECT COALESCE(SUM(outstanding_amount),0) FROM fin_settlement x WHERE x.tenant_id=c.customer_id AND x.direction='PAYABLE' AND x.status<>'VOIDED') outstanding_payable," +
|
||||
"((SELECT COALESCE(SUM(amount),0) FROM fin_fee_source f WHERE f.customer_id=c.customer_id AND f.direction='PAYABLE' AND f.settlement_mode='ON_ACCOUNT' AND f.review_status='APPROVED' AND f.currency_code='CNY' AND f.amount>0 AND f.status NOT IN ('VOIDED','SUPERSEDED'))+(SELECT COALESCE(SUM(cost_amount),0) FROM fin_cost x WHERE x.tenant_id=c.customer_id AND x.cost_origin='FIRSTLEG_DIRECT_PAID' AND x.status='CONFIRMED')) total_cost," +
|
||||
"((SELECT COALESCE(SUM(cost_amount),0) FROM fin_cost x WHERE x.tenant_id=c.customer_id AND x.cost_origin='FIRSTLEG_DIRECT_PAID' AND x.status='CONFIRMED')+(SELECT COALESCE(SUM(paid_amount),0) FROM fin_settlement x WHERE x.tenant_id=c.customer_id AND x.direction='PAYABLE' AND x.status<>'VOIDED')) paid_cost," +
|
||||
"((SELECT COALESCE(SUM(outstanding_amount),0) FROM fin_settlement x WHERE x.tenant_id=c.customer_id AND x.direction='PAYABLE' AND x.status<>'VOIDED')+(SELECT COALESCE(SUM(f.amount),0) FROM fin_fee_source f WHERE f.customer_id=c.customer_id AND f.direction='PAYABLE' AND f.settlement_mode='ON_ACCOUNT' AND f.review_status='APPROVED' AND f.currency_code='CNY' AND f.amount>0 AND f.status NOT IN ('VOIDED','SUPERSEDED') AND NOT EXISTS(SELECT 1 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'))) outstanding_payable," +
|
||||
"GREATEST(COALESCE((SELECT MAX(updated_at) FROM fin_bill b WHERE b.tenant_id=c.customer_id),'1970-01-01'),COALESCE((SELECT MAX(updated_at) FROM fin_receipt r WHERE r.tenant_id=c.customer_id),'1970-01-01'),COALESCE((SELECT MAX(updated_at) FROM fin_cost x WHERE x.tenant_id=c.customer_id),'1970-01-01'),COALESCE((SELECT MAX(updated_at) FROM fin_fee_source f WHERE f.customer_id=c.customer_id),'1970-01-01'),COALESCE((SELECT MAX(updated_at) FROM fin_settlement x WHERE x.tenant_id=c.customer_id),'1970-01-01')) last_changed_at" + base + " ORDER BY last_changed_at DESC,c.customer_id DESC LIMIT ? OFFSET ?";
|
||||
Object[] args = java.util.Arrays.copyOf(filterArgs, filterArgs.length + 2);
|
||||
args[args.length - 2] = pageSize; args[args.length - 1] = (page - 1) * pageSize;
|
||||
@@ -89,14 +89,15 @@ public class FinancePhase1OperationService {
|
||||
List<RawLedger> rows = new ArrayList<>();
|
||||
rows.addAll(jdbc.query("SELECT id,bill_no,created_at,total_amount,created_by,created_by_name,remark FROM fin_bill WHERE tenant_id=? AND status<>'VOIDED'", (rs, n) -> raw(rs, "SETTLEMENT_ISSUED", "BILL", "bill_no", "total_amount", null), customerId));
|
||||
rows.addAll(jdbc.query("SELECT id,receipt_no,received_at received_time,received_amount,created_by,created_by_name,remark FROM fin_receipt WHERE tenant_id=? AND status='CONFIRMED'", (rs, n) -> raw(rs, "RECEIPT", "RECEIPT", "receipt_no", null, "received_amount"), customerId));
|
||||
rows.addAll(jdbc.query("SELECT id,cost_no,created_at,cost_amount,created_by,created_by_name,remark FROM fin_cost WHERE tenant_id=? AND status<>'VOIDED'", (rs, n) -> raw(rs, "PAYMENT", "COST", "cost_no", null, null), customerId));
|
||||
rows.addAll(jdbc.query("SELECT id,cost_no,created_at,cost_amount,created_by,created_by_name,remark FROM fin_cost WHERE tenant_id=? AND cost_origin='FIRSTLEG_DIRECT_PAID' AND status='CONFIRMED'", (rs, n) -> raw(rs, "SUPPLIER_DIRECT_PAID", "DIRECT_PAID_COST", "cost_no", null, null), customerId));
|
||||
rows.addAll(jdbc.query("SELECT p.id,CONCAT(s.settlement_no,'-',p.id) payment_no,p.created_at,p.amount cost_amount,p.created_by,p.created_by_name,p.remark FROM fin_settlement_payment p JOIN fin_settlement s ON s.id=p.settlement_id AND s.tenant_id=p.tenant_id WHERE p.tenant_id=? AND p.transaction_type='PAYMENT' AND s.direction='PAYABLE' AND s.status<>'VOIDED'", (rs, n) -> raw(rs, "SUPPLIER_ON_ACCOUNT_PAYMENT", "AP_PAYMENT", "payment_no", null, null), customerId));
|
||||
rows.sort(Comparator.comparing(RawLedger::occurredAt).thenComparing(RawLedger::id));
|
||||
BigDecimal outstanding = BigDecimal.ZERO;
|
||||
List<LedgerEntry> projected = new ArrayList<>();
|
||||
for (RawLedger row : rows) {
|
||||
BigDecimal receivableChange = row.receivable();
|
||||
outstanding = outstanding.add(receivableChange);
|
||||
BigDecimal payable = "COST".equals(row.documentType()) ? row.amount() : BigDecimal.ZERO;
|
||||
BigDecimal payable = List.of("DIRECT_PAID_COST", "AP_PAYMENT").contains(row.documentType()) ? row.amount() : BigDecimal.ZERO;
|
||||
LedgerEntry entry = new LedgerEntry(row.id(), row.occurredAt(), row.businessType(), row.documentType(), row.id(), row.documentNo(), receivableChange, payable, outstanding, row.operatorName(), row.remark());
|
||||
if ((businessType == null || businessType.isBlank() || businessType.equals(entry.businessType())) && inRange(entry.occurredAt(), startDate, endDate)) projected.add(entry);
|
||||
}
|
||||
@@ -343,7 +344,8 @@ public class FinancePhase1OperationService {
|
||||
|
||||
private static RawLedger raw(ResultSet rs, String businessType, String documentType, String noColumn, String debitColumn, String creditColumn) throws SQLException {
|
||||
LocalDateTime occurred = time(rs, documentType.equals("RECEIPT") ? "received_time" : "created_at");
|
||||
BigDecimal amount = documentType.equals("COST") ? money(rs, "cost_amount") : debitColumn != null ? money(rs, debitColumn) : money(rs, creditColumn);
|
||||
BigDecimal amount = List.of("DIRECT_PAID_COST", "AP_PAYMENT").contains(documentType)
|
||||
? money(rs, "cost_amount") : debitColumn != null ? money(rs, debitColumn) : money(rs, creditColumn);
|
||||
BigDecimal receivable = debitColumn != null ? amount : creditColumn != null ? amount.negate() : BigDecimal.ZERO;
|
||||
return new RawLedger(rs.getLong("id"), occurred, businessType, documentType, rs.getString(noColumn), amount, receivable,
|
||||
rs.getLong("created_by"), rs.getString("created_by_name"), rs.getString("remark"));
|
||||
|
||||
@@ -19,7 +19,6 @@ import com.baiyue.finance.dto.FinanceReadModels.ReconciliationSummary;
|
||||
import com.baiyue.finance.dto.FinanceReadModels.StatementDetail;
|
||||
import com.baiyue.finance.dto.FinanceReadModels.StatementLine;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.BadSqlGrammarException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
@@ -49,12 +48,12 @@ public class FinanceQueryService {
|
||||
|
||||
public CustomerAccount customerAccount(long customerId) {
|
||||
requireCustomer(customerId);
|
||||
BigDecimal total = amount("SELECT COALESCE(SUM(total_amount),0) FROM fin_bill WHERE tenant_id=? AND status<>'VOIDED'", customerId);
|
||||
BigDecimal received = amount("SELECT COALESCE(SUM(received_amount),0) FROM fin_bill WHERE tenant_id=? AND status<>'VOIDED'", customerId);
|
||||
BigDecimal outstanding = amount("SELECT COALESCE(SUM(outstanding_amount),0) FROM fin_bill WHERE tenant_id=? AND status<>'VOIDED'", customerId);
|
||||
BigDecimal total = amount("SELECT COALESCE(SUM(total_amount),0) FROM fin_settlement WHERE tenant_id=? AND direction='RECEIVABLE' AND status<>'VOIDED'", customerId);
|
||||
BigDecimal received = amount("SELECT COALESCE(SUM(paid_amount),0) FROM fin_settlement WHERE tenant_id=? AND direction='RECEIVABLE' AND status<>'VOIDED'", customerId);
|
||||
BigDecimal outstanding = amount("SELECT COALESCE(SUM(outstanding_amount),0) FROM fin_settlement WHERE tenant_id=? AND direction='RECEIVABLE' AND status<>'VOIDED'", customerId);
|
||||
List<AccountEntry> entries = new ArrayList<>();
|
||||
entries.addAll(jdbc.query("SELECT id,bill_no,created_at,total_amount,currency_code,status FROM fin_bill WHERE tenant_id=? AND status<>'VOIDED'", (rs, rowNum) ->
|
||||
new AccountEntry("RECEIVABLE", rs.getLong("id"), rs.getString("bill_no"), time(rs, "created_at"),
|
||||
entries.addAll(jdbc.query("SELECT id,settlement_no,created_at,total_amount,currency_code,status FROM fin_settlement WHERE tenant_id=? AND direction='RECEIVABLE' AND status<>'VOIDED'", (rs, rowNum) ->
|
||||
new AccountEntry("RECEIVABLE", rs.getLong("id"), rs.getString("settlement_no"), time(rs, "created_at"),
|
||||
rs.getBigDecimal("total_amount"), BigDecimal.ZERO, rs.getString("currency_code"), rs.getString("status")), customerId));
|
||||
entries.addAll(jdbc.query("SELECT id,receipt_no,received_at,received_amount,currency_code,status FROM fin_receipt WHERE tenant_id=? AND status<>'VOIDED'", (rs, rowNum) ->
|
||||
new AccountEntry("RECEIPT", rs.getLong("id"), rs.getString("receipt_no"), time(rs, "received_at"),
|
||||
@@ -69,33 +68,28 @@ public class FinanceQueryService {
|
||||
"COALESCE(SUM(CASE WHEN s.direction='RECEIVABLE' AND s.status<>'VOIDED' THEN s.total_amount ELSE 0 END),0) total_receivable," +
|
||||
"COALESCE(SUM(CASE WHEN s.direction='RECEIVABLE' AND s.status<>'VOIDED' THEN s.paid_amount ELSE 0 END),0) received_amount," +
|
||||
"COALESCE(SUM(CASE WHEN s.direction='RECEIVABLE' AND s.status<>'VOIDED' THEN s.outstanding_amount ELSE 0 END),0) outstanding_receivable," +
|
||||
"COALESCE(SUM(CASE WHEN s.direction='PAYABLE' AND s.status<>'VOIDED' THEN s.outstanding_amount ELSE 0 END),0) outstanding_payable," +
|
||||
"(SELECT COALESCE(SUM(f.amount),0) FROM fin_fee_source f WHERE f.customer_id=? AND f.direction='RECEIVABLE' " +
|
||||
"AND f.currency_code='CNY' AND f.status='AVAILABLE' " +
|
||||
"AND f.review_status='APPROVED' AND f.amount>0 " +
|
||||
"AND NOT 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 b.status<>'VOIDED')) pending_billing," +
|
||||
"((SELECT COALESCE(SUM(f.amount),0) FROM fin_fee_source f WHERE f.customer_id=? AND f.direction='PAYABLE' AND f.settlement_mode='ON_ACCOUNT' AND f.review_status='APPROVED' AND f.status NOT IN ('VOIDED','SUPERSEDED'))+" +
|
||||
"(SELECT COALESCE(SUM(c.cost_amount),0) FROM fin_cost c WHERE c.tenant_id=? AND c.status='CONFIRMED')) total_cost," +
|
||||
"((SELECT COALESCE(SUM(c.cost_amount),0) FROM fin_cost c WHERE c.tenant_id=? AND c.status='CONFIRMED')+" +
|
||||
"COALESCE(SUM(CASE WHEN s.direction='PAYABLE' AND s.status<>'VOIDED' THEN s.paid_amount ELSE 0 END),0)) paid_cost," +
|
||||
"((SELECT COALESCE(SUM(f.amount),0) FROM fin_fee_source f WHERE f.customer_id=? AND f.direction='PAYABLE' AND f.settlement_mode='ON_ACCOUNT' AND f.review_status='APPROVED' AND f.currency_code='CNY' AND f.amount>0 AND f.status NOT IN ('VOIDED','SUPERSEDED'))+" +
|
||||
"(SELECT COALESCE(SUM(c.cost_amount),0) FROM fin_cost c WHERE c.tenant_id=? AND c.cost_origin='FIRSTLEG_DIRECT_PAID' AND c.status='CONFIRMED')) total_cost," +
|
||||
"((SELECT COALESCE(SUM(c.cost_amount),0) FROM fin_cost c WHERE c.tenant_id=? AND c.cost_origin='FIRSTLEG_DIRECT_PAID' AND c.status='CONFIRMED')+" +
|
||||
"(SELECT COALESCE(SUM(ps.paid_amount),0) FROM fin_settlement ps WHERE ps.tenant_id=? AND ps.direction='PAYABLE' AND ps.status<>'VOIDED')) paid_cost," +
|
||||
"((SELECT COALESCE(SUM(os.outstanding_amount),0) FROM fin_settlement os WHERE os.tenant_id=? AND os.direction='PAYABLE' AND os.status<>'VOIDED')+" +
|
||||
"(SELECT COALESCE(SUM(f.amount),0) FROM fin_fee_source f WHERE f.customer_id=? AND f.direction='PAYABLE' AND f.settlement_mode='ON_ACCOUNT' AND f.review_status='APPROVED' AND f.currency_code='CNY' AND f.amount>0 AND f.status NOT IN ('VOIDED','SUPERSEDED') " +
|
||||
"AND NOT EXISTS (SELECT 1 FROM fin_settlement_line sl JOIN fin_settlement aps ON aps.id=sl.settlement_id AND aps.tenant_id=sl.tenant_id WHERE sl.tenant_id=f.customer_id AND sl.fee_source_id=f.id AND aps.direction='PAYABLE' AND aps.status<>'VOIDED'))) outstanding_payable," +
|
||||
"GREATEST(COALESCE(MAX(CASE WHEN s.status<>'VOIDED' THEN s.updated_at END),'1970-01-01')," +
|
||||
"COALESCE((SELECT MAX(f.updated_at) FROM fin_fee_source f WHERE f.customer_id=?),'1970-01-01')," +
|
||||
"COALESCE((SELECT MAX(c.updated_at) FROM fin_cost c WHERE c.tenant_id=?),'1970-01-01')) last_changed_at " +
|
||||
"FROM fin_settlement s WHERE s.tenant_id=?";
|
||||
try {
|
||||
return jdbc.queryForObject(sql, (rs, rowNum) -> new CustomerAccountSummary(customerId, CNY,
|
||||
money(rs, "total_receivable"), money(rs, "received_amount"), money(rs, "outstanding_receivable"),
|
||||
money(rs, "pending_billing"), money(rs, "total_cost"), money(rs, "paid_cost"),
|
||||
money(rs, "outstanding_payable"), Instant.now(), time(rs, "last_changed_at")),
|
||||
customerId, customerId, customerId, customerId, customerId, customerId, customerId);
|
||||
} catch (BadSqlGrammarException ignored) {
|
||||
// Empty/partially migrated Finance databases may not have a settlement projection yet.
|
||||
// The read-only account summary must still be usable as a zero CNY baseline.
|
||||
return new CustomerAccountSummary(customerId, CNY, BigDecimal.ZERO, BigDecimal.ZERO,
|
||||
BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO,
|
||||
Instant.now(), null);
|
||||
}
|
||||
return jdbc.queryForObject(sql, (rs, rowNum) -> new CustomerAccountSummary(customerId, CNY,
|
||||
money(rs, "total_receivable"), money(rs, "received_amount"), money(rs, "outstanding_receivable"),
|
||||
money(rs, "pending_billing"), money(rs, "total_cost"), money(rs, "paid_cost"),
|
||||
money(rs, "outstanding_payable"), Instant.now(), time(rs, "last_changed_at")),
|
||||
customerId, customerId, customerId, customerId, customerId, customerId, customerId,
|
||||
customerId, customerId, customerId);
|
||||
}
|
||||
|
||||
public List<FeePoolItem> feePool(Long customerId, String direction) {
|
||||
|
||||
@@ -34,6 +34,7 @@ CREATE TABLE IF NOT EXISTS fin_fee_source (
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_fin_fee_source (source_system, source_fee_id, source_fee_version),
|
||||
KEY idx_fin_fee_source_customer (customer_id, status),
|
||||
KEY idx_fin_fee_source_customer_fund (customer_id, direction, settlement_mode, review_status, status, currency_code, id),
|
||||
CONSTRAINT chk_fin_fee_source_currency CHECK (currency_code = 'CNY'),
|
||||
CONSTRAINT chk_fin_fee_source_amount CHECK (amount >= 0),
|
||||
CONSTRAINT chk_fin_fee_source_settlement CHECK (
|
||||
@@ -227,7 +228,7 @@ CREATE TABLE IF NOT EXISTS fin_cost (
|
||||
fee_item_code VARCHAR(64) NOT NULL COMMENT '成本费用项目编码快照',
|
||||
fee_item_name VARCHAR(200) NOT NULL COMMENT '成本费用项目名称快照',
|
||||
currency_code CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT 'ISO 4217三位币种代码,首期仅CNY',
|
||||
cost_amount DECIMAL(20,6) NOT NULL COMMENT '成本总金额,精度6位小数',
|
||||
cost_amount DECIMAL(20,6) NOT NULL COMMENT '成本总金额;仅FIRSTLEG_DIRECT_PAID且CONFIRMED计入已线下支付供应商款',
|
||||
voucher_file_ids JSON NOT NULL COMMENT 'File服务财务凭证文件ID字符串数组JSON',
|
||||
paid_at DATETIME(3) NULL COMMENT '直接已付时间;人工补充成本可为空',
|
||||
payment_method VARCHAR(32) NULL COMMENT '直接已付方式:ONLINE、BANK_TRANSFER、CASH、OTHER',
|
||||
@@ -250,6 +251,7 @@ CREATE TABLE IF NOT EXISTS fin_cost (
|
||||
UNIQUE KEY uk_fin_cost_source_version (source_system, source_type, source_fee_id, source_fee_version),
|
||||
KEY idx_fin_cost_ticket (tenant_id, order_id, inbound_id),
|
||||
KEY idx_fin_cost_container (tenant_id, container_id),
|
||||
KEY idx_fin_cost_customer_origin_status (tenant_id, cost_origin, status),
|
||||
CONSTRAINT chk_fin_cost_tenant CHECK (tenant_id > 0),
|
||||
CONSTRAINT chk_fin_cost_currency CHECK (currency_code = 'CNY'),
|
||||
CONSTRAINT chk_fin_cost_amount CHECK (cost_amount > 0),
|
||||
@@ -356,8 +358,8 @@ CREATE TABLE IF NOT EXISTS fin_settlement (
|
||||
business_period VARCHAR(32) NULL COMMENT '结算业务期间快照',
|
||||
currency_code CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT 'ISO 4217三位币种代码,首期仅CNY',
|
||||
total_amount DECIMAL(20,6) NOT NULL COMMENT '正式结算总金额,精度6位小数',
|
||||
paid_amount DECIMAL(20,6) NOT NULL DEFAULT 0 COMMENT '累计已收或已付金额,精度6位小数',
|
||||
outstanding_amount DECIMAL(20,6) NOT NULL COMMENT '未结清金额,精度6位小数',
|
||||
paid_amount DECIMAL(20,6) NOT NULL DEFAULT 0 COMMENT '累计已收或已付金额;PAYABLE已付计入已线下支付供应商款',
|
||||
outstanding_amount DECIMAL(20,6) NOT NULL COMMENT '未结清金额;RECEIVABLE计待收,PAYABLE与未制单ON_ACCOUNT来源共同计待付',
|
||||
generated_date DATE NOT NULL COMMENT '结算单生成日期',
|
||||
due_date DATE NOT NULL COMMENT '结算到期日期',
|
||||
payment_status VARCHAR(24) NOT NULL DEFAULT 'UNPAID' COMMENT '收付款状态:UNPAID未收付、PARTIALLY_PAID部分收付、PAID已结清',
|
||||
@@ -415,6 +417,7 @@ CREATE TABLE IF NOT EXISTS fin_settlement_payment (
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_fin_settlement_payment_no (tenant_id, transaction_no),
|
||||
KEY idx_fin_settlement_payment_settlement (tenant_id, settlement_id, created_at),
|
||||
KEY idx_fin_settlement_payment_customer_type_time (tenant_id, transaction_type, created_at, settlement_id),
|
||||
CONSTRAINT chk_fin_settlement_payment_tenant CHECK (tenant_id > 0),
|
||||
CONSTRAINT chk_fin_settlement_payment_currency CHECK (currency_code = 'CNY'),
|
||||
CONSTRAINT chk_fin_settlement_payment_amount CHECK (amount > 0)
|
||||
|
||||
@@ -16,8 +16,10 @@ import java.math.BigDecimal;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.Timestamp;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.mock;
|
||||
@@ -27,11 +29,36 @@ import org.springframework.jdbc.BadSqlGrammarException;
|
||||
|
||||
class CustomerAccountSummaryBehaviorTest {
|
||||
|
||||
@Test
|
||||
void accountReadUsesFormalArSettlementsAndKeepsReceiptHistorySeparate() {
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||
when(jdbc.queryForObject(anyString(), org.mockito.ArgumentMatchers.eq(BigDecimal.class),
|
||||
org.mockito.ArgumentMatchers.eq(9L))).thenReturn(new BigDecimal("100.00"));
|
||||
when(jdbc.query(anyString(), any(RowMapper.class), org.mockito.ArgumentMatchers.eq(9L)))
|
||||
.thenReturn(List.of());
|
||||
|
||||
var result = new FinanceQueryService(jdbc, new ObjectMapper()).customerAccount(9L);
|
||||
|
||||
assertThat(result.accountMode()).isEqualTo("MANUAL_RECEIVABLE_LEDGER");
|
||||
var amountSql = org.mockito.ArgumentCaptor.forClass(String.class);
|
||||
verify(jdbc, org.mockito.Mockito.times(3)).queryForObject(amountSql.capture(),
|
||||
org.mockito.ArgumentMatchers.eq(BigDecimal.class), org.mockito.ArgumentMatchers.eq(9L));
|
||||
assertThat(amountSql.getAllValues()).allSatisfy(sql -> assertThat(sql)
|
||||
.contains("FROM fin_settlement", "direction='RECEIVABLE'", "status<>'VOIDED'"));
|
||||
var entrySql = org.mockito.ArgumentCaptor.forClass(String.class);
|
||||
verify(jdbc, org.mockito.Mockito.times(2)).query(entrySql.capture(), any(RowMapper.class),
|
||||
org.mockito.ArgumentMatchers.eq(9L));
|
||||
assertThat(entrySql.getAllValues()).anySatisfy(sql -> assertThat(sql)
|
||||
.contains("settlement_no", "FROM fin_settlement", "direction='RECEIVABLE'"));
|
||||
assertThat(entrySql.getAllValues()).anySatisfy(sql -> assertThat(sql)
|
||||
.contains("FROM fin_receipt", "status<>'VOIDED'"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aggregatesFormalSettlementsInOneCustomerScopedQuery() throws Exception {
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||
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())).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));
|
||||
FinanceQueryService service = new FinanceQueryService(jdbc, new ObjectMapper());
|
||||
|
||||
@@ -52,14 +79,15 @@ class CustomerAccountSummaryBehaviorTest {
|
||||
org.mockito.ArgumentMatchers.eq(9L), org.mockito.ArgumentMatchers.eq(9L),
|
||||
org.mockito.ArgumentMatchers.eq(9L), org.mockito.ArgumentMatchers.eq(9L),
|
||||
org.mockito.ArgumentMatchers.eq(9L), org.mockito.ArgumentMatchers.eq(9L),
|
||||
org.mockito.ArgumentMatchers.eq(9L));
|
||||
org.mockito.ArgumentMatchers.eq(9L), org.mockito.ArgumentMatchers.eq(9L),
|
||||
org.mockito.ArgumentMatchers.eq(9L), org.mockito.ArgumentMatchers.eq(9L));
|
||||
assertThat(sql.getValue())
|
||||
.contains("FROM fin_settlement s WHERE s.tenant_id=?")
|
||||
.contains("s.direction='RECEIVABLE' AND s.status<>'VOIDED'")
|
||||
.contains("s.direction='PAYABLE' AND s.status<>'VOIDED'")
|
||||
.contains("f.settlement_mode='ON_ACCOUNT'", "c.status='CONFIRMED'", "s.paid_amount")
|
||||
.contains("os.direction='PAYABLE' AND os.status<>'VOIDED'")
|
||||
.contains("f.settlement_mode='ON_ACCOUNT'", "c.cost_origin='FIRSTLEG_DIRECT_PAID'", "ps.paid_amount")
|
||||
.contains("f.customer_id=?", "f.status='AVAILABLE'", "f.review_status='APPROVED'", "f.amount>0")
|
||||
.contains("b.status<>'VOIDED'")
|
||||
.contains("b.status<>'VOIDED'", "FROM fin_settlement_line sl", "sl.fee_source_id=f.id", "aps.status<>'VOIDED'")
|
||||
.doesNotContain("JSON_EXTRACT")
|
||||
.doesNotContain("SUM(received_amount)", "FROM fin_receipt r");
|
||||
}
|
||||
@@ -68,7 +96,7 @@ class CustomerAccountSummaryBehaviorTest {
|
||||
void emptyCustomerReturnsZeroAmounts() throws Exception {
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||
ResultSet rs = summaryRow("0", "0", "0", "0", "0", "0", "0");
|
||||
when(jdbc.queryForObject(anyString(), any(RowMapper.class), 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));
|
||||
|
||||
CustomerAccountSummary result = new FinanceQueryService(jdbc, new ObjectMapper()).customerAccountSummary(77L);
|
||||
@@ -83,23 +111,13 @@ class CustomerAccountSummaryBehaviorTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingSettlementProjectionReturnsZeroCnyBaseline() {
|
||||
void missingSettlementProjectionFailsClosedInsteadOfReturningFakeZeroAmounts() {
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||
when(jdbc.queryForObject(anyString(), any(RowMapper.class), 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));
|
||||
|
||||
CustomerAccountSummary result = new FinanceQueryService(jdbc, new ObjectMapper()).customerAccountSummary(88L);
|
||||
|
||||
assertThat(result.customerId()).isEqualTo(88L);
|
||||
assertThat(result.currency()).isEqualTo("CNY");
|
||||
assertThat(result.totalReceivable()).isZero();
|
||||
assertThat(result.receivedAmount()).isZero();
|
||||
assertThat(result.outstandingReceivable()).isZero();
|
||||
assertThat(result.pendingBilling()).isZero();
|
||||
assertThat(result.totalCost()).isZero();
|
||||
assertThat(result.paidCost()).isZero();
|
||||
assertThat(result.outstandingPayable()).isZero();
|
||||
assertThat(result.lastChangedAt()).isNull();
|
||||
assertThatThrownBy(() -> new FinanceQueryService(jdbc, new ObjectMapper()).customerAccountSummary(88L))
|
||||
.isInstanceOf(BadSqlGrammarException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -16,6 +16,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.atLeastOnce;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@@ -42,6 +43,29 @@ class FinanceCustomerAccountServiceTest {
|
||||
verify(iam).page("internal-token", 1, 20, "Alice", "ACTIVE", 900000000000000001L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void operatingFundQueriesUseFormalArUnbilledApDedupAndSupplierOnlyPaidAmounts() {
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||
IamCustomerClient iam = mock(IamCustomerClient.class);
|
||||
when(jdbc.queryForList(anyString(), any(Object[].class))).thenReturn(List.of());
|
||||
when(iam.page("internal-token", 1, 20, null, null, 9L)).thenReturn(ApiResponse.success(
|
||||
new PageResult<>(List.of(new IamCustomerClient.Customer(9L, "C-9", "客户9", "ACTIVE", null, null)),
|
||||
1, 20, 1, 1)));
|
||||
|
||||
new FinanceCustomerAccountService(jdbc, iam, "internal-token").list(1, 20, null, null, 9L);
|
||||
|
||||
var sql = org.mockito.ArgumentCaptor.forClass(String.class);
|
||||
verify(jdbc, atLeastOnce()).queryForList(sql.capture(), any(Object[].class));
|
||||
String all = String.join("\n", sql.getAllValues());
|
||||
assertThat(all)
|
||||
.contains("SUM(outstanding_amount) amount FROM fin_settlement", "direction='RECEIVABLE'")
|
||||
.contains("cost_origin='FIRSTLEG_DIRECT_PAID' AND status='CONFIRMED'")
|
||||
.contains("direction='PAYABLE' AND status<>'VOIDED'")
|
||||
.contains("NOT EXISTS (SELECT 1 FROM fin_settlement_line")
|
||||
.contains("sl.fee_source_id=f.id", "s.status<>'VOIDED'")
|
||||
.contains("review_status='APPROVED'", "currency_code='CNY'", "amount>0");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsUnsupportedFinanceAccountStatus() {
|
||||
var service = new FinanceCustomerAccountService(mock(JdbcTemplate.class), mock(IamCustomerClient.class), "token");
|
||||
|
||||
@@ -49,6 +49,11 @@ class FinanceExportContractTest {
|
||||
assertThat(openApi).contains("/internal/exports/v1/{exportType}/batches:")
|
||||
.contains("customerId?, status?, settlementNo?")
|
||||
.contains("所有 DETAIL 仅 settlementId")
|
||||
.contains("仅三类运营资金:待收客户款、待付供应商款、已线下支付供应商款")
|
||||
.contains("operatingCards: [outstandingReceivable, outstandingPayable, paidCost]")
|
||||
.contains("辅助字段只允许用于列表、明细或历史说明,不得生成第四类运营资金卡片")
|
||||
.contains("AP outstanding加未制单有效ON_ACCOUNT来源并去重")
|
||||
.contains("已线下支付供应商款;不存在客户线下支付含义")
|
||||
.doesNotContain("DETAIL: settlementId, direction");
|
||||
for (String type : TYPES) {
|
||||
assertThat(openApi).contains("- " + type);
|
||||
|
||||
@@ -147,6 +147,22 @@ class FinanceFeeSourceEventServiceTest {
|
||||
verify(jdbc).update(startsWith("INSERT INTO fin_fee_source"), any(Object[].class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void adjustmentRejectsSourceFrozenByActiveBillOrAp() {
|
||||
FinanceFeeSourceEvent event = inboundEvent("adjusted-frozen", "FIRSTLEG_FEE_ADJUSTED", 2, 7);
|
||||
givenNewEvent();
|
||||
when(firstleg.inbound(anyString(), eq(21L), eq(31L), eq(2)))
|
||||
.thenReturn(ApiResponse.success(snapshot("INBOUND_FEE", 7, 2, 21L, null)));
|
||||
when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_fee_source f WHERE f.source_system='FIRSTLEG'"),
|
||||
eq(Integer.class), eq(31L))).thenReturn(1);
|
||||
|
||||
assertThatThrownBy(() -> service.consume(event))
|
||||
.isInstanceOf(FinanceEventException.class)
|
||||
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-EVT-016");
|
||||
verify(jdbc, never()).update(contains("status='SUPERSEDED'"), anyLong());
|
||||
verify(jdbc, never()).update(startsWith("INSERT INTO fin_fee_source"), any(Object[].class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void voidMarksExistingVersionWithoutDeletingHistory() {
|
||||
FinanceFeeSourceEvent event = inboundEvent("voided-2", "FIRSTLEG_FEE_VOIDED", 2, 7);
|
||||
@@ -160,6 +176,24 @@ class FinanceFeeSourceEventServiceTest {
|
||||
verify(jdbc, never()).update(startsWith("DELETE"), any(Object[].class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void voidRejectsSourceFrozenByActiveBillOrAp() {
|
||||
FinanceFeeSourceEvent event = inboundEvent("voided-frozen", "FIRSTLEG_FEE_VOIDED", 2, 7);
|
||||
when(jdbc.queryForList(startsWith("SELECT payload_hash,status"), eq("FIRSTLEG"), eq("voided-frozen")))
|
||||
.thenReturn(List.of());
|
||||
when(firstleg.inbound(anyString(), eq(21L), eq(31L), eq(2)))
|
||||
.thenReturn(ApiResponse.success(snapshot("INBOUND_FEE", 7, 2, 21L, null)));
|
||||
when(jdbc.queryForList(startsWith("SELECT id,source_hash,status"), eq(31L), eq(2)))
|
||||
.thenReturn(List.of(Map.of("id", 88L, "source_hash", "source-hash", "status", "AVAILABLE")));
|
||||
when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_fee_source f WHERE f.id=?"),
|
||||
eq(Integer.class), eq(88L))).thenReturn(1);
|
||||
|
||||
assertThatThrownBy(() -> service.consume(event))
|
||||
.isInstanceOf(FinanceEventException.class)
|
||||
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-EVT-016");
|
||||
verify(jdbc, never()).update(contains("SET status='VOIDED'"), eq(88L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sameEventAndContentReturnsFirstResourceWithoutCallingFirstlegAgain() throws Exception {
|
||||
FinanceFeeSourceEvent event = inboundEvent("same-1", "FIRSTLEG_FEE_PUBLISHED", 1, 7);
|
||||
|
||||
@@ -106,6 +106,9 @@ class FinancePhase1OperationBehaviorTest {
|
||||
assertThat(result.requestId()).isNotBlank();
|
||||
assertThat(result.traceId()).isNotBlank();
|
||||
assertThat(jdbc.accountListSql).contains("p.customer_name_snapshot");
|
||||
assertThat(jdbc.accountListSql)
|
||||
.contains("ar.direction='RECEIVABLE'", "cost_origin='FIRSTLEG_DIRECT_PAID'", "FROM fin_settlement_line sl")
|
||||
.contains("sl.fee_source_id=f.id", "s.status<>'VOIDED'");
|
||||
assertThat(jdbc.accountListSql).doesNotContain("CAST(c.customer_id AS CHAR)");
|
||||
}
|
||||
|
||||
@@ -118,7 +121,11 @@ class FinancePhase1OperationBehaviorTest {
|
||||
service.listDerivedCustomerAccountLedger(9L, 1, 20, null, null, null);
|
||||
|
||||
assertThat(result.data()).isEmpty();
|
||||
assertThat(jdbc.ledgerCustomerIds).containsExactly(9L, 9L, 9L);
|
||||
assertThat(jdbc.ledgerCustomerIds).containsExactly(9L, 9L, 9L, 9L);
|
||||
assertThat(jdbc.ledgerSql).anySatisfy(sql -> assertThat(sql)
|
||||
.contains("cost_origin='FIRSTLEG_DIRECT_PAID'", "status='CONFIRMED'"));
|
||||
assertThat(jdbc.ledgerSql).anySatisfy(sql -> assertThat(sql)
|
||||
.contains("fin_settlement_payment", "transaction_type='PAYMENT'", "s.direction='PAYABLE'"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -349,6 +356,7 @@ class FinancePhase1OperationBehaviorTest {
|
||||
|
||||
private static final class AccountJdbc extends JdbcTemplate {
|
||||
private final List<Long> ledgerCustomerIds = new ArrayList<>();
|
||||
private final List<String> ledgerSql = new ArrayList<>();
|
||||
private String accountListSql = "";
|
||||
|
||||
@Override
|
||||
@@ -369,8 +377,9 @@ class FinancePhase1OperationBehaviorTest {
|
||||
"ACTIVE", LocalDateTime.of(2026, 8, 11, 10, 0)));
|
||||
}
|
||||
if (sql.startsWith("SELECT id,bill_no") || sql.startsWith("SELECT id,receipt_no")
|
||||
|| sql.startsWith("SELECT id,cost_no")) {
|
||||
|| sql.startsWith("SELECT id,cost_no") || sql.startsWith("SELECT p.id")) {
|
||||
ledgerCustomerIds.add(((Number) Arrays.asList(args).get(0)).longValue());
|
||||
ledgerSql.add(sql);
|
||||
return List.of();
|
||||
}
|
||||
return List.of();
|
||||
|
||||
@@ -56,6 +56,8 @@ class FinanceSqlCommentContractTest {
|
||||
assertThat(sql).contains("RECEIVED已接收、PROCESSED已完成、FAILED处理失败");
|
||||
assertThat(sql).contains("Firstleg费用来源完整不可变JSON快照");
|
||||
assertThat(sql).contains("ON_ACCOUNT账期应付、DIRECT_PAID业务已付");
|
||||
assertThat(sql).contains("仅FIRSTLEG_DIRECT_PAID且CONFIRMED计入已线下支付供应商款");
|
||||
assertThat(sql).contains("RECEIVABLE计待收,PAYABLE与未制单ON_ACCOUNT来源共同计待付");
|
||||
assertThat(sql).contains("规范化完整请求的SHA-256十六进制哈希");
|
||||
}
|
||||
|
||||
@@ -72,6 +74,17 @@ class FinanceSqlCommentContractTest {
|
||||
.contains("queue_column_comment('baiyue_finance', 'fin_bill_line', 'adjustment_reason'");
|
||||
}
|
||||
|
||||
@Test
|
||||
void operatingFundAggregatesHaveDedicatedBaselineIndexes() throws Exception {
|
||||
String sql = Files.readString(Path.of("src/main/resources/db/finance.sql"), StandardCharsets.UTF_8);
|
||||
assertThat(sql)
|
||||
.contains("KEY idx_fin_fee_source_customer_fund (customer_id, direction, settlement_mode, review_status, status, currency_code, id)")
|
||||
.contains("KEY idx_fin_cost_customer_origin_status (tenant_id, cost_origin, status)")
|
||||
.contains("KEY idx_fin_settlement_customer_direction_status (tenant_id, direction, status)")
|
||||
.contains("KEY idx_fin_settlement_line_source (fee_source_id)")
|
||||
.contains("KEY idx_fin_settlement_payment_customer_type_time (tenant_id, transaction_type, created_at, settlement_id)");
|
||||
}
|
||||
|
||||
@Test
|
||||
void financeLedgerSourcesPersistOperatorNameSnapshotsInBaselineAndUnifiedSql() throws Exception {
|
||||
String baseline = Files.readString(Path.of("src/main/resources/db/finance.sql"), StandardCharsets.UTF_8);
|
||||
|
||||
Reference in New Issue
Block a user