diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 5c7adf4f8..bd9433200 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -11184,6 +11184,11 @@ CREATE TABLE IF NOT EXISTS fl_container_fee_detail ( supplier_id BIGINT UNSIGNED NULL COMMENT '应付Warehouse供应商ID快照;应收为空', supplier_name VARCHAR(200) NULL COMMENT '应付Warehouse供应商名称快照;应收为空', supplier_category VARCHAR(16) NULL COMMENT '费用项目供应商类别约束快照;为空表示任意启用供应商', + settlement_mode VARCHAR(16) NULL COMMENT '仅PAYABLE:ON_ACCOUNT账期应付、DIRECT_PAID业务已付;RECEIVABLE为空', + paid_at DATETIME(3) NULL COMMENT 'DIRECT_PAID业务已付时间', + payment_method VARCHAR(32) NULL COMMENT 'DIRECT_PAID付款方式', + payment_reference_no VARCHAR(128) NULL COMMENT 'DIRECT_PAID付款流水号', + payment_voucher_file_ids JSON NULL COMMENT 'DIRECT_PAID付款凭证File ID数组', business_date DATE NOT NULL COMMENT '费用业务日期', amount DECIMAL(18,2) NOT NULL COMMENT 'CNY费用金额', currency CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT '币种,首期固定CNY', @@ -11206,6 +11211,15 @@ CREATE TABLE IF NOT EXISTS fl_container_fee_detail ( CONSTRAINT chk_container_fee_currency CHECK (currency = 'CNY'), CONSTRAINT chk_container_fee_amount CHECK (amount >= 0), CONSTRAINT chk_container_fee_review CHECK (review_status IN ('PENDING', 'APPROVED', 'REJECTED')), + CONSTRAINT chk_container_fee_settlement CHECK ( + (direction='RECEIVABLE' AND settlement_mode IS NULL AND paid_at IS NULL AND payment_method IS NULL + AND payment_reference_no IS NULL AND payment_voucher_file_ids IS NULL) + OR (direction='PAYABLE' AND settlement_mode='ON_ACCOUNT' AND paid_at IS NULL AND payment_method IS NULL + AND payment_reference_no IS NULL AND payment_voucher_file_ids IS NULL) + OR (direction='PAYABLE' AND settlement_mode='DIRECT_PAID' AND paid_at IS NOT NULL + AND payment_method IN ('ONLINE','BANK_TRANSFER','CASH','OTHER') + AND (payment_reference_no IS NOT NULL OR JSON_LENGTH(payment_voucher_file_ids)>0)) + ), CONSTRAINT chk_container_fee_source CHECK (source_status IN ('ACTIVE', 'VOIDED')) ) ENGINE=InnoDB COMMENT='柜次级人工应收应付费用来源;审核通过后自动同步Finance'; @@ -11224,6 +11238,11 @@ CREATE TABLE IF NOT EXISTS fl_container_fee_source_snapshot ( supplier_id BIGINT UNSIGNED NULL COMMENT 'Warehouse供应商ID快照', supplier_name VARCHAR(200) NULL COMMENT 'Warehouse供应商名称快照', supplier_category VARCHAR(16) NULL COMMENT '费用项目供应商类别约束快照;为空表示任意启用供应商', + settlement_mode VARCHAR(16) NULL COMMENT '仅PAYABLE:ON_ACCOUNT账期应付、DIRECT_PAID业务已付;RECEIVABLE为空', + paid_at DATETIME(3) NULL COMMENT 'DIRECT_PAID业务已付时间', + payment_method VARCHAR(32) NULL COMMENT 'DIRECT_PAID付款方式', + payment_reference_no VARCHAR(128) NULL COMMENT 'DIRECT_PAID付款流水号', + payment_voucher_file_ids JSON NULL COMMENT 'DIRECT_PAID付款凭证File ID数组', business_date DATE NOT NULL COMMENT '费用业务日期', amount DECIMAL(18,2) NOT NULL COMMENT 'CNY费用金额', currency CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT '币种,首期固定CNY', @@ -11267,12 +11286,42 @@ CALL firstleg_finance_add_column('fl_container_fee_detail', 'supplier_name', "VARCHAR(200) NULL COMMENT '应付Warehouse供应商名称快照;应收为空' AFTER supplier_id"); CALL firstleg_finance_add_column('fl_container_fee_detail', 'supplier_category', "VARCHAR(16) NULL COMMENT '费用项目供应商类别约束快照;为空表示任意启用供应商' AFTER supplier_name"); +CALL firstleg_finance_add_column('fl_container_fee_detail', 'settlement_mode', + "VARCHAR(16) NULL COMMENT '仅PAYABLE:ON_ACCOUNT或DIRECT_PAID' AFTER supplier_category"); +CALL firstleg_finance_add_column('fl_container_fee_detail', 'paid_at', + "DATETIME(3) NULL COMMENT 'DIRECT_PAID业务已付时间' AFTER settlement_mode"); +CALL firstleg_finance_add_column('fl_container_fee_detail', 'payment_method', + "VARCHAR(32) NULL COMMENT 'DIRECT_PAID付款方式' AFTER paid_at"); +CALL firstleg_finance_add_column('fl_container_fee_detail', 'payment_reference_no', + "VARCHAR(128) NULL COMMENT 'DIRECT_PAID付款流水号' AFTER payment_method"); +CALL firstleg_finance_add_column('fl_container_fee_detail', 'payment_voucher_file_ids', + "JSON NULL COMMENT 'DIRECT_PAID付款凭证File ID数组' AFTER payment_reference_no"); +CALL firstleg_finance_add_column('fl_container_fee_source_snapshot', 'settlement_mode', + "VARCHAR(16) NULL COMMENT '仅PAYABLE:ON_ACCOUNT或DIRECT_PAID' AFTER supplier_category"); +CALL firstleg_finance_add_column('fl_container_fee_source_snapshot', 'paid_at', + "DATETIME(3) NULL COMMENT 'DIRECT_PAID业务已付时间' AFTER settlement_mode"); +CALL firstleg_finance_add_column('fl_container_fee_source_snapshot', 'payment_method', + "VARCHAR(32) NULL COMMENT 'DIRECT_PAID付款方式' AFTER paid_at"); +CALL firstleg_finance_add_column('fl_container_fee_source_snapshot', 'payment_reference_no', + "VARCHAR(128) NULL COMMENT 'DIRECT_PAID付款流水号' AFTER payment_method"); +CALL firstleg_finance_add_column('fl_container_fee_source_snapshot', 'payment_voucher_file_ids', + "JSON NULL COMMENT 'DIRECT_PAID付款凭证File ID数组' AFTER payment_reference_no"); CALL firstleg_finance_add_column('fl_inbound_fee_detail', 'supplier_id', "BIGINT UNSIGNED NULL COMMENT '应付Warehouse供应商ID快照;应收为空' AFTER direction"); CALL firstleg_finance_add_column('fl_inbound_fee_detail', 'supplier_name', "VARCHAR(200) NULL COMMENT '应付Warehouse供应商名称快照;应收为空' AFTER supplier_id"); CALL firstleg_finance_add_column('fl_inbound_fee_detail', 'supplier_category', "VARCHAR(16) NULL COMMENT '费用项目供应商类别约束快照;为空表示任意启用供应商' AFTER supplier_name"); +CALL firstleg_finance_add_column('fl_inbound_fee_detail', 'settlement_mode', + "VARCHAR(16) NULL COMMENT '仅PAYABLE:ON_ACCOUNT或DIRECT_PAID' AFTER supplier_category"); +CALL firstleg_finance_add_column('fl_inbound_fee_detail', 'paid_at', + "DATETIME(3) NULL COMMENT 'DIRECT_PAID业务已付时间' AFTER settlement_mode"); +CALL firstleg_finance_add_column('fl_inbound_fee_detail', 'payment_method', + "VARCHAR(32) NULL COMMENT 'DIRECT_PAID付款方式' AFTER paid_at"); +CALL firstleg_finance_add_column('fl_inbound_fee_detail', 'payment_reference_no', + "VARCHAR(128) NULL COMMENT 'DIRECT_PAID付款流水号' AFTER payment_method"); +CALL firstleg_finance_add_column('fl_inbound_fee_detail', 'payment_voucher_file_ids', + "JSON NULL COMMENT 'DIRECT_PAID付款凭证File ID数组' AFTER payment_reference_no"); DROP PROCEDURE IF EXISTS firstleg_finance_add_column; DROP PROCEDURE IF EXISTS firstleg_finance_assert_schema; @@ -11295,12 +11344,22 @@ BEGIN END IF; IF (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'fl_container_fee_detail' - AND column_name IN ('supplier_id', 'supplier_name', 'supplier_category')) <> 3 THEN + AND column_name IN ('supplier_id', 'supplier_name', 'supplier_category', 'settlement_mode', + 'paid_at', 'payment_method', 'payment_reference_no', + 'payment_voucher_file_ids')) <> 8 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Firstleg Finance迁移失败:柜次费用供应商快照字段不完整'; END IF; + IF (SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'fl_container_fee_source_snapshot' + AND column_name IN ('settlement_mode', 'paid_at', 'payment_method', + 'payment_reference_no', 'payment_voucher_file_ids')) <> 5 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Firstleg Finance迁移失败:柜次费用已付版本快照字段不完整'; + END IF; IF (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_fee_detail' - AND column_name IN ('supplier_id', 'supplier_name', 'supplier_category')) <> 3 THEN + AND column_name IN ('supplier_id', 'supplier_name', 'supplier_category', 'settlement_mode', + 'paid_at', 'payment_method', 'payment_reference_no', + 'payment_voucher_file_ids')) <> 8 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Firstleg Finance迁移失败:入库费用供应商快照字段不完整'; END IF; END$$ @@ -11342,6 +11401,16 @@ CALL finance_fee_source_add_column('fin_fee_source', 'fee_item_code', "VARCHAR(64) NULL COMMENT '来源费用项目编码快照' AFTER container_id"); CALL finance_fee_source_add_column('fin_fee_source', 'source_hash', "CHAR(64) NULL COMMENT 'Firstleg费用来源完整快照SHA-256哈希' AFTER currency_code"); +CALL finance_fee_source_add_column('fin_fee_source', 'settlement_mode', + "VARCHAR(16) NULL COMMENT '仅PAYABLE:ON_ACCOUNT账期应付、DIRECT_PAID业务已付;RECEIVABLE为空' AFTER currency_code"); +CALL finance_fee_source_add_column('fin_fee_source', 'paid_at', + "DATETIME(3) NULL COMMENT 'DIRECT_PAID业务已付时间' AFTER settlement_mode"); +CALL finance_fee_source_add_column('fin_fee_source', 'payment_method', + "VARCHAR(32) NULL COMMENT 'DIRECT_PAID付款方式' AFTER paid_at"); +CALL finance_fee_source_add_column('fin_fee_source', 'payment_reference_no', + "VARCHAR(128) NULL COMMENT 'DIRECT_PAID付款流水号' AFTER payment_method"); +CALL finance_fee_source_add_column('fin_fee_source', 'payment_voucher_file_ids', + "JSON NULL COMMENT 'DIRECT_PAID付款凭证File ID数组' AFTER payment_reference_no"); CALL finance_fee_source_add_column('fin_fee_source', 'source_snapshot', "JSON NULL COMMENT 'Firstleg费用来源完整不可变JSON快照' AFTER source_hash"); CALL finance_fee_source_add_column('fin_fee_source', 'review_status', @@ -11401,8 +11470,9 @@ BEGIN IF (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'fin_fee_source' AND column_name IN ('source_type','source_no','direction','container_id','fee_item_code', - 'source_hash','source_snapshot','review_status','review_reason', - 'operation_version','updated_at')) <> 11 THEN + 'settlement_mode','paid_at','payment_method','payment_reference_no', + 'payment_voucher_file_ids','source_hash','source_snapshot','review_status','review_reason', + 'operation_version','updated_at')) <> 16 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Finance费用来源迁移失败:来源快照字段不完整'; END IF; IF (SELECT COUNT(*) FROM information_schema.columns diff --git a/finance/src/main/java/com/baiyue/finance/dto/FinancePhase1Dtos.java b/finance/src/main/java/com/baiyue/finance/dto/FinancePhase1Dtos.java index a25c3fb6c..0d0342ec8 100644 --- a/finance/src/main/java/com/baiyue/finance/dto/FinancePhase1Dtos.java +++ b/finance/src/main/java/com/baiyue/finance/dto/FinancePhase1Dtos.java @@ -28,7 +28,8 @@ public final class FinancePhase1Dtos { public record AccountSummary(long customerId, String customerCode, String customerName, String contactName, String contactPhone, BigDecimal outstandingReceivable, BigDecimal pendingBilling, - BigDecimal receivedInPeriod, BigDecimal outstandingPayable, + BigDecimal receivedInPeriod, BigDecimal totalCost, + BigDecimal paidCost, BigDecimal outstandingPayable, String status, LocalDateTime lastChangedAt) {} public record LedgerEntry(long ledgerId, LocalDateTime occurredAt, String businessType, diff --git a/finance/src/main/java/com/baiyue/finance/dto/FinanceReadModels.java b/finance/src/main/java/com/baiyue/finance/dto/FinanceReadModels.java index 5296d7d25..1f3b3eaab 100644 --- a/finance/src/main/java/com/baiyue/finance/dto/FinanceReadModels.java +++ b/finance/src/main/java/com/baiyue/finance/dto/FinanceReadModels.java @@ -26,6 +26,8 @@ public final class FinanceReadModels { BigDecimal receivedAmount, BigDecimal outstandingReceivable, BigDecimal pendingBilling, + BigDecimal totalCost, + BigDecimal paidCost, BigDecimal outstandingPayable, Instant asOf, LocalDateTime lastChangedAt) {} diff --git a/finance/src/main/java/com/baiyue/finance/service/FinanceCustomerAccountService.java b/finance/src/main/java/com/baiyue/finance/service/FinanceCustomerAccountService.java index 146c4157b..62721a6ed 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinanceCustomerAccountService.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceCustomerAccountService.java @@ -50,7 +50,8 @@ public class FinanceCustomerAccountService { Metrics value = metrics.getOrDefault(customer.id(), Metrics.ZERO); return new AccountSummary(customer.id(), customer.tenantCode(), customer.tenantName(), customer.contactName(), customer.contactPhone(), value.outstandingReceivable(), - value.pendingBilling(), value.receivedInPeriod(), value.outstandingPayable(), + value.pendingBilling(), value.receivedInPeriod(), value.totalCost(), value.paidCost(), + value.outstandingPayable(), customer.status(), value.lastChangedAt()); }).toList(); return PageEnvelope.success(rows, (int) customers.page(), (int) customers.size(), customers.total()); @@ -67,11 +68,13 @@ public class FinanceCustomerAccountService { .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 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)) + .forEach(row -> metric(result, row, "totalCost", "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='DIRECT_PAID' 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' 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, 3)) + .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()) .forEach(row -> metric(result, row, "outstandingPayable", "amount")); - Object[] changeArgs = new Object[customerIds.size() * 3]; - for (int i = 0; i < 3; i++) System.arraycopy(customerIds.toArray(), 0, changeArgs, i * customerIds.size(), customerIds.size()); - 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 + ")) changes GROUP BY customer_id", changeArgs) + 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")); Map frozen = new HashMap<>(); result.forEach((id, value) -> frozen.put(id, value.freeze())); @@ -98,10 +101,19 @@ public class FinanceCustomerAccountService { private static String blankToNull(String value) { return value == null || value.isBlank() ? null : value.trim(); } + private static Object[] concat(List values, int copies) { + Object[] result = new Object[values.size() * copies]; + Object[] source = values.toArray(); + for (int i = 0; i < copies; i++) System.arraycopy(source, 0, result, i * source.length, source.length); + return result; + } + private static final class MutableMetrics { private BigDecimal outstandingReceivable = BigDecimal.ZERO; private BigDecimal pendingBilling = BigDecimal.ZERO; private BigDecimal receivedInPeriod = BigDecimal.ZERO; + private BigDecimal totalCost = BigDecimal.ZERO; + private BigDecimal paidCost = BigDecimal.ZERO; private BigDecimal outstandingPayable = BigDecimal.ZERO; private LocalDateTime lastChangedAt; void set(String field, BigDecimal value) { @@ -109,16 +121,20 @@ public class FinanceCustomerAccountService { case "outstandingReceivable" -> outstandingReceivable = value; case "pendingBilling" -> pendingBilling = value; case "receivedInPeriod" -> receivedInPeriod = value; + case "totalCost" -> totalCost = value; + case "paidCost" -> paidCost = value; case "outstandingPayable" -> outstandingPayable = value; default -> { } } } - Metrics freeze() { return new Metrics(outstandingReceivable, pendingBilling, receivedInPeriod, outstandingPayable, lastChangedAt); } + Metrics freeze() { return new Metrics(outstandingReceivable, pendingBilling, receivedInPeriod, totalCost, paidCost, outstandingPayable, lastChangedAt); } } private record Metrics(BigDecimal outstandingReceivable, BigDecimal pendingBilling, - BigDecimal receivedInPeriod, BigDecimal outstandingPayable, + BigDecimal receivedInPeriod, BigDecimal totalCost, BigDecimal paidCost, + BigDecimal outstandingPayable, LocalDateTime lastChangedAt) { - private static final Metrics ZERO = new Metrics(BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, null); + private static final Metrics ZERO = new Metrics(BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, + BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, null); } } diff --git a/finance/src/main/java/com/baiyue/finance/service/FinanceExportProviderService.java b/finance/src/main/java/com/baiyue/finance/service/FinanceExportProviderService.java index 9b51034cb..49895f9d6 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinanceExportProviderService.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceExportProviderService.java @@ -65,7 +65,8 @@ public class FinanceExportProviderService { column("customerCode", "客户编码", ExportColumnType.STRING, 18), column("customerName", "客户", ExportColumnType.STRING, 28), column("contactName", "联系人", ExportColumnType.STRING, 16), column("contactPhone", "联系电话", ExportColumnType.STRING, 18), column("outstandingReceivable", "待收(CNY)", ExportColumnType.DECIMAL, 16), column("pendingBilling", "待开账(CNY)", ExportColumnType.DECIMAL, 16), - column("receivedInPeriod", "本期已收(CNY)", ExportColumnType.DECIMAL, 16), column("outstandingPayable", "待付(CNY)", ExportColumnType.DECIMAL, 16), + column("receivedInPeriod", "本期已收(CNY)", ExportColumnType.DECIMAL, 16), column("totalCost", "总成本(CNY)", ExportColumnType.DECIMAL, 16), + column("paidCost", "已付成本(CNY)", ExportColumnType.DECIMAL, 16), column("outstandingPayable", "待付(CNY)", ExportColumnType.DECIMAL, 16), column("status", "客户状态", ExportColumnType.STRING, 14), column("lastChangedAt", "最近变动", ExportColumnType.DATETIME, 21)); private static final List LEDGER_COLUMNS = List.of( column("ledgerId", "流水号", ExportColumnType.STRING, 22), column("occurredAt", "发生时间", ExportColumnType.DATETIME, 21), @@ -366,7 +367,8 @@ public class FinanceExportProviderService { private Map accountRow(AccountSummary value) { return row("customerCode", value.customerCode(), "customerName", value.customerName(), "contactName", value.contactName(), "contactPhone", value.contactPhone(), "outstandingReceivable", value.outstandingReceivable(), "pendingBilling", value.pendingBilling(), - "receivedInPeriod", value.receivedInPeriod(), "outstandingPayable", value.outstandingPayable(), "status", value.status(), "lastChangedAt", value.lastChangedAt()); + "receivedInPeriod", value.receivedInPeriod(), "totalCost", value.totalCost(), "paidCost", value.paidCost(), + "outstandingPayable", value.outstandingPayable(), "status", value.status(), "lastChangedAt", value.lastChangedAt()); } private Map ledgerRow(LedgerEntry value) { return row("ledgerId", String.valueOf(value.ledgerId()), "occurredAt", value.occurredAt(), "businessType", value.businessType(), diff --git a/finance/src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java b/finance/src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java index bf845be45..ac158ce18 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java @@ -69,12 +69,16 @@ public class FinancePhase1OperationService { "(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(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.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(amount),0) FROM fin_fee_source f WHERE f.customer_id=c.customer_id AND f.direction='PAYABLE' AND f.settlement_mode='DIRECT_PAID' 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')+(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," + - "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')) last_changed_at" + base + " ORDER BY last_changed_at DESC,c.customer_id DESC LIMIT ? OFFSET ?"; + "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; List rows = jdbc.query(select, (rs, row) -> new AccountSummary(rs.getLong("customer_id"), String.valueOf(rs.getLong("customer_id")), rs.getString("customer_name"), null, null, - money(rs, "outstanding_receivable"), money(rs, "pending_billing"), money(rs, "received_period"), money(rs, "outstanding_payable"), rs.getString("account_status"), time(rs, "last_changed_at")), args); + money(rs, "outstanding_receivable"), money(rs, "pending_billing"), money(rs, "received_period"), + money(rs, "total_cost"), money(rs, "paid_cost"), money(rs, "outstanding_payable"), + rs.getString("account_status"), time(rs, "last_changed_at")), args); return PageEnvelope.success(rows, page, pageSize, total == null ? 0 : total); } diff --git a/finance/src/main/java/com/baiyue/finance/service/FinanceQueryService.java b/finance/src/main/java/com/baiyue/finance/service/FinanceQueryService.java index 54e12cfd8..2c74acf70 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinanceQueryService.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceQueryService.java @@ -75,19 +75,27 @@ public class FinanceQueryService { "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.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(f.amount),0) FROM fin_fee_source f WHERE f.customer_id=? AND f.direction='PAYABLE' AND f.settlement_mode='DIRECT_PAID' 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')+" + + "COALESCE(SUM(CASE WHEN s.direction='PAYABLE' AND s.status<>'VOIDED' THEN s.paid_amount ELSE 0 END),0)) paid_cost," + "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 " + + "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, "outstanding_payable"), Instant.now(), time(rs, "last_changed_at")), - customerId, customerId, customerId); + 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); } 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); + BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, + Instant.now(), null); } } diff --git a/finance/src/test/java/com/baiyue/finance/CustomerAccountSummaryBehaviorTest.java b/finance/src/test/java/com/baiyue/finance/CustomerAccountSummaryBehaviorTest.java index 573be27cd..5423d5f10 100644 --- a/finance/src/test/java/com/baiyue/finance/CustomerAccountSummaryBehaviorTest.java +++ b/finance/src/test/java/com/baiyue/finance/CustomerAccountSummaryBehaviorTest.java @@ -30,8 +30,8 @@ class CustomerAccountSummaryBehaviorTest { @Test void aggregatesFormalSettlementsInOneCustomerScopedQuery() throws Exception { JdbcTemplate jdbc = mock(JdbcTemplate.class); - ResultSet rs = summaryRow("300.00", "140.00", "160.00", "25.00", "80.00"); - when(jdbc.queryForObject(anyString(), any(RowMapper.class), any(), any(), any())).thenAnswer(invocation -> + ResultSet rs = summaryRow("300.00", "140.00", "160.00", "25.00", "260.00", "180.00", "80.00"); + when(jdbc.queryForObject(anyString(), any(RowMapper.class), any(), any(), any(), any(), any(), any(), any(), any())).thenAnswer(invocation -> ((RowMapper) invocation.getArgument(1)).mapRow(rs, 0)); FinanceQueryService service = new FinanceQueryService(jdbc, new ObjectMapper()); @@ -43,15 +43,21 @@ class CustomerAccountSummaryBehaviorTest { assertThat(result.receivedAmount()).isEqualByComparingTo("140.00"); assertThat(result.outstandingReceivable()).isEqualByComparingTo("160.00"); assertThat(result.pendingBilling()).isEqualByComparingTo("25.00"); + assertThat(result.totalCost()).isEqualByComparingTo("260.00"); + assertThat(result.paidCost()).isEqualByComparingTo("180.00"); assertThat(result.outstandingPayable()).isEqualByComparingTo("80.00"); var sql = org.mockito.ArgumentCaptor.forClass(String.class); verify(jdbc).queryForObject(sql.capture(), any(RowMapper.class), - 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='DIRECT_PAID'", "c.status='CONFIRMED'", "s.paid_amount") .contains("f.customer_id=?", "f.status='AVAILABLE'", "f.review_status='APPROVED'", "f.amount>0") .contains("b.status<>'VOIDED'") .doesNotContain("JSON_EXTRACT") @@ -61,8 +67,8 @@ class CustomerAccountSummaryBehaviorTest { @Test void emptyCustomerReturnsZeroAmounts() throws Exception { JdbcTemplate jdbc = mock(JdbcTemplate.class); - ResultSet rs = summaryRow("0", "0", "0", "0", "0"); - when(jdbc.queryForObject(anyString(), any(RowMapper.class), any(), any(), any())).thenAnswer(invocation -> + ResultSet rs = summaryRow("0", "0", "0", "0", "0", "0", "0"); + when(jdbc.queryForObject(anyString(), any(RowMapper.class), 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); @@ -71,13 +77,15 @@ class CustomerAccountSummaryBehaviorTest { assertThat(result.receivedAmount()).isZero(); assertThat(result.outstandingReceivable()).isZero(); assertThat(result.pendingBilling()).isZero(); + assertThat(result.totalCost()).isZero(); + assertThat(result.paidCost()).isZero(); assertThat(result.outstandingPayable()).isZero(); } @Test void missingSettlementProjectionReturnsZeroCnyBaseline() { JdbcTemplate jdbc = mock(JdbcTemplate.class); - when(jdbc.queryForObject(anyString(), any(RowMapper.class), any(), any(), any())) + when(jdbc.queryForObject(anyString(), any(RowMapper.class), any(), any(), any(), any(), any(), any(), any(), any())) .thenThrow(new BadSqlGrammarException("customer summary", "SELECT ...", null)); CustomerAccountSummary result = new FinanceQueryService(jdbc, new ObjectMapper()).customerAccountSummary(88L); @@ -88,6 +96,8 @@ class CustomerAccountSummaryBehaviorTest { 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(); } @@ -107,12 +117,15 @@ class CustomerAccountSummaryBehaviorTest { } private static ResultSet summaryRow(String total, String paid, String outstanding, - String pending, String payable) throws Exception { + String pending, String totalCost, String paidCost, + String payable) throws Exception { ResultSet rs = mock(ResultSet.class); when(rs.getBigDecimal("total_receivable")).thenReturn(new BigDecimal(total)); when(rs.getBigDecimal("received_amount")).thenReturn(new BigDecimal(paid)); when(rs.getBigDecimal("outstanding_receivable")).thenReturn(new BigDecimal(outstanding)); when(rs.getBigDecimal("pending_billing")).thenReturn(new BigDecimal(pending)); + when(rs.getBigDecimal("total_cost")).thenReturn(new BigDecimal(totalCost)); + when(rs.getBigDecimal("paid_cost")).thenReturn(new BigDecimal(paidCost)); when(rs.getBigDecimal("outstanding_payable")).thenReturn(new BigDecimal(payable)); when(rs.getTimestamp("last_changed_at")).thenReturn(Timestamp.valueOf(LocalDateTime.of(2026, 8, 13, 12, 0))); return rs; diff --git a/finance/src/test/java/com/baiyue/finance/FinancePhase1OperationBehaviorTest.java b/finance/src/test/java/com/baiyue/finance/FinancePhase1OperationBehaviorTest.java index a1eeca5df..55d71920b 100644 --- a/finance/src/test/java/com/baiyue/finance/FinancePhase1OperationBehaviorTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinancePhase1OperationBehaviorTest.java @@ -365,6 +365,7 @@ class FinancePhase1OperationBehaviorTest { return (List) List.of(new FinancePhase1Dtos.AccountSummary( 9L, "9", "客户9", null, null, new BigDecimal("100.00"), new BigDecimal("20.00"), new BigDecimal("30.00"), BigDecimal.ZERO, + BigDecimal.ZERO, BigDecimal.ZERO, "ACTIVE", LocalDateTime.of(2026, 8, 11, 10, 0))); } if (sql.startsWith("SELECT id,bill_no") || sql.startsWith("SELECT id,receipt_no") diff --git a/finance/src/test/java/com/baiyue/finance/FinancePlatformOptionalCustomerQueryTest.java b/finance/src/test/java/com/baiyue/finance/FinancePlatformOptionalCustomerQueryTest.java index 568c5e3dd..c54a04c07 100644 --- a/finance/src/test/java/com/baiyue/finance/FinancePlatformOptionalCustomerQueryTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinancePlatformOptionalCustomerQueryTest.java @@ -24,7 +24,7 @@ class FinancePlatformOptionalCustomerQueryTest { service.receivableSettlements(null, false); service.payableSettlements(null); - assertThat(jdbc.calls).hasSize(6).allSatisfy(call -> { + assertThat(jdbc.calls).hasSize(5).allSatisfy(call -> { assertThat(call.sql()).doesNotContain("customer_id=?", "tenant_id=?"); assertThat(call.args()).isEmpty(); }); @@ -41,7 +41,7 @@ class FinancePlatformOptionalCustomerQueryTest { service.receivableSettlements(31L, false); service.payableSettlements(31L); - assertThat(jdbc.calls).hasSize(6).allSatisfy(call -> { + assertThat(jdbc.calls).hasSize(5).allSatisfy(call -> { assertThat(call.sql()).containsAnyOf("customer_id=?", "tenant_id=?"); assertThat(call.args()).containsExactly(31L); });