This commit is contained in:
2026-08-13 16:14:54 +08:00
parent 8bcd23f307
commit 226ef6a839
20 changed files with 486 additions and 58 deletions
@@ -18,6 +18,7 @@ import com.baiyue.finance.dto.FinanceReadModels.Statement;
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;
@@ -69,17 +70,23 @@ public class FinanceQueryService {
"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 JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.reviewStatus'))='APPROVED' " +
"AND JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.settlementIncluded'))='true' " +
"AND f.review_status='APPROVED' AND f.settlement_included=1 " +
"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," +
"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')) last_changed_at " +
"FROM fin_settlement s WHERE s.tenant_id=?";
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, "outstanding_payable"), Instant.now(), time(rs, "last_changed_at")),
customerId, customerId, customerId);
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, "outstanding_payable"), Instant.now(), time(rs, "last_changed_at")),
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, Instant.now(), null);
}
}
public List<FeePoolItem> feePool(Long customerId, String direction) {
+74
View File
@@ -290,3 +290,77 @@ CREATE TABLE IF NOT EXISTS fin_audit_log (
KEY idx_fin_audit_resource (tenant_id, resource_type, resource_id, created_at),
KEY idx_fin_audit_operator (operator_id, created_at)
) ENGINE=InnoDB COMMENT='Finance不可变业务审计明细';
CREATE TABLE IF NOT EXISTS fin_settlement (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '正式应收或应付结算单ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
settlement_no VARCHAR(64) NOT NULL COMMENT '客户范围内唯一结算单号',
direction VARCHAR(16) NOT NULL COMMENT '结算方向:RECEIVABLE应收、PAYABLE应付',
customer_name_snapshot VARCHAR(200) NOT NULL COMMENT '结算生成时客户名称快照',
supplier_id BIGINT UNSIGNED NULL COMMENT '应付结算供应商ID;应收结算为空',
supplier_name_snapshot VARCHAR(200) NULL COMMENT '应付结算供应商名称快照',
reconciliation_statement_id BIGINT UNSIGNED NULL COMMENT '应收结算关联的已出账客户对账单ID',
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位小数',
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已结清',
status VARCHAR(24) NOT NULL DEFAULT 'ISSUED' COMMENT '结算状态:ISSUED已生效、PARTIALLY_PAID部分收付、PAID已结清、VOIDED已作废',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建操作人ID',
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改操作人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间',
PRIMARY KEY (id),
UNIQUE KEY uk_fin_settlement_no (tenant_id, settlement_no),
UNIQUE KEY uk_fin_settlement_receivable_statement (tenant_id, reconciliation_statement_id, direction),
KEY idx_fin_settlement_customer_direction_status (tenant_id, direction, status),
KEY idx_fin_settlement_supplier_status (supplier_id, direction, status),
CONSTRAINT chk_fin_settlement_tenant CHECK (tenant_id > 0),
CONSTRAINT chk_fin_settlement_currency CHECK (currency_code = 'CNY'),
CONSTRAINT chk_fin_settlement_direction CHECK (direction IN ('RECEIVABLE', 'PAYABLE')),
CONSTRAINT chk_fin_settlement_amounts CHECK (total_amount >= 0 AND paid_amount >= 0 AND outstanding_amount >= 0 AND total_amount = paid_amount + outstanding_amount)
) ENGINE=InnoDB COMMENT='正式应收与应付结算单;客户账户金额由本表派生';
CREATE TABLE IF NOT EXISTS fin_settlement_line (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '正式结算单明细ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
settlement_id BIGINT UNSIGNED NOT NULL COMMENT '所属正式结算单ID',
fee_source_id BIGINT UNSIGNED NOT NULL COMMENT '结算固化的Finance费用来源ID',
amount DECIMAL(20,6) NOT NULL COMMENT '结算明细金额,精度6位小数',
currency_code CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT 'ISO 4217三位币种代码,首期仅CNY',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '明细创建时间',
PRIMARY KEY (id),
UNIQUE KEY uk_fin_settlement_line_source (settlement_id, fee_source_id),
KEY idx_fin_settlement_line_customer (tenant_id, settlement_id),
KEY idx_fin_settlement_line_source (fee_source_id),
CONSTRAINT chk_fin_settlement_line_tenant CHECK (tenant_id > 0),
CONSTRAINT chk_fin_settlement_line_currency CHECK (currency_code = 'CNY'),
CONSTRAINT chk_fin_settlement_line_amount CHECK (amount > 0)
) ENGINE=InnoDB COMMENT='正式结算单费用来源金额快照明细';
CREATE TABLE IF NOT EXISTS fin_settlement_payment (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '正式结算单收付款记录ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
settlement_id BIGINT UNSIGNED NOT NULL COMMENT '所属正式结算单ID',
transaction_type VARCHAR(16) NOT NULL COMMENT '交易类型:RECEIPT收款、PAYMENT付款',
transaction_no VARCHAR(64) NOT NULL COMMENT '客户范围内唯一收付款业务号',
transaction_date DATE NOT NULL COMMENT '实际收款或付款日期',
amount DECIMAL(20,6) NOT NULL COMMENT '本次收款或付款金额,精度6位小数',
currency_code CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT 'ISO 4217三位币种代码,首期仅CNY',
method VARCHAR(32) NOT NULL COMMENT '人工登记的收付款方式',
reference_no VARCHAR(128) NULL COMMENT '银行流水号或外部参考号',
voucher_file_ids JSON NOT NULL COMMENT 'File服务财务凭证文件ID字符串数组',
remark VARCHAR(500) NULL COMMENT '收付款内部备注',
created_by BIGINT UNSIGNED NOT NULL COMMENT '登记操作人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '登记时间',
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),
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)
) ENGINE=InnoDB COMMENT='正式结算单人工收付款与凭证记录';
@@ -23,6 +23,7 @@ import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import org.springframework.jdbc.BadSqlGrammarException;
class CustomerAccountSummaryBehaviorTest {
@@ -51,8 +52,9 @@ class CustomerAccountSummaryBehaviorTest {
.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.customer_id=?", "f.status='AVAILABLE'", "'$.reviewStatus'", "'$.settlementIncluded'")
.contains("f.customer_id=?", "f.status='AVAILABLE'", "f.review_status='APPROVED'", "f.settlement_included=1")
.contains("b.status<>'VOIDED'")
.doesNotContain("JSON_EXTRACT")
.doesNotContain("SUM(received_amount)", "FROM fin_receipt r");
}
@@ -72,6 +74,24 @@ class CustomerAccountSummaryBehaviorTest {
assertThat(result.outstandingPayable()).isZero();
}
@Test
void missingSettlementProjectionReturnsZeroCnyBaseline() {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
when(jdbc.queryForObject(anyString(), any(RowMapper.class), 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.outstandingPayable()).isZero();
assertThat(result.lastChangedAt()).isNull();
}
@Test
void controllerUsesListPermissionAndDelegatesSummary() throws Exception {
FinanceQueryService query = mock(FinanceQueryService.class);
@@ -0,0 +1,37 @@
package com.baiyue.finance;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class FinanceSettlementSchemaContractTest {
@Test
void moduleSchemaCreatesFormalSettlementTablesAndSummaryIndex() throws Exception {
String sql = Files.readString(Path.of("src/main/resources/db/finance.sql"), StandardCharsets.UTF_8);
String unified = Files.readString(resolveUnifiedSql(), StandardCharsets.UTF_8);
assertThat(sql)
.contains("CREATE TABLE IF NOT EXISTS fin_settlement (")
.contains("CREATE TABLE IF NOT EXISTS fin_settlement_line (")
.contains("CREATE TABLE IF NOT EXISTS fin_settlement_payment (")
.contains("idx_fin_settlement_customer_direction_status (tenant_id, direction, status)")
.contains("total_amount DECIMAL(20,6)", "paid_amount DECIMAL(20,6)", "outstanding_amount DECIMAL(20,6)")
.contains("updated_at DATETIME(3)");
assertThat(unified)
.contains("CREATE TABLE IF NOT EXISTS fin_settlement (")
.contains("CREATE TABLE IF NOT EXISTS fin_settlement_line (")
.contains("CREATE TABLE IF NOT EXISTS fin_settlement_payment (")
.contains("idx_fin_settlement_customer_direction_status (tenant_id, direction, status)")
.contains("'fin_settlement', 'fin_settlement_line', 'fin_settlement_payment')) <> 16");
}
private static Path resolveUnifiedSql() {
Path direct = Path.of("deploy", "sql", "头程数据库统一增量-MySQL8.sql");
return Files.exists(direct) ? direct : Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql");
}
}
@@ -43,8 +43,8 @@ class FinanceSqlCommentContractTest {
missing.add(currentTable + "." + column.group(1));
}
}
assertThat(tableCount).isEqualTo(13);
assertThat(columnCount).isEqualTo(190);
assertThat(tableCount).isEqualTo(16);
assertThat(columnCount).isEqualTo(233);
assertThat(missing).as("缺少中文业务注释的Finance字段").isEmpty();
}