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 62721a6ed..94c2cf4b2 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinanceCustomerAccountService.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceCustomerAccountService.java @@ -68,9 +68,9 @@ 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)) + 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)) .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)) + 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)) .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")); diff --git a/finance/src/main/java/com/baiyue/finance/service/FinanceFeeSourceEventService.java b/finance/src/main/java/com/baiyue/finance/service/FinanceFeeSourceEventService.java index 3ddecc7b5..ab12c6bc4 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinanceFeeSourceEventService.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceFeeSourceEventService.java @@ -73,6 +73,7 @@ public class FinanceFeeSourceEventService { long id = ((Number) row.get("id")).longValue(); if ("FIRSTLEG_FEE_VOIDED".equals(event.eventType())) { jdbc.update("UPDATE fin_fee_source SET status='VOIDED',operation_version=operation_version+1 WHERE id=? AND status<>'VOIDED'", id); + voidDirectPaidCost(snapshot); } return id; } @@ -81,6 +82,8 @@ public class FinanceFeeSourceEventService { } 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'", + snapshot.feeId()); jdbc.update("INSERT INTO fin_fee_source(source_system,source_fee_id,source_fee_version,customer_id,source_type,source_no,direction,order_id,inbound_id,container_id,fee_item_code,fee_item_name,amount,currency_code,settlement_mode,paid_at,payment_method,payment_reference_no,payment_voucher_file_ids,source_hash,source_snapshot,status) VALUES ('FIRSTLEG',?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,'AVAILABLE')", snapshot.feeId(), snapshot.feeVersion(), snapshot.customerId(), snapshot.sourceType(), snapshot.sourceType().equals("INBOUND_FEE") ? snapshot.inboundCode() : snapshot.containerNo(), @@ -89,8 +92,27 @@ public class FinanceFeeSourceEventService { snapshot.settlementMode(), snapshot.paidAt(), snapshot.paymentMethod(), snapshot.paymentReferenceNo(), voucherJson(snapshot.paymentVoucherFileIds()), snapshot.sourceHash(), json(snapshot)); - return jdbc.queryForObject("SELECT id FROM fin_fee_source WHERE source_system='FIRSTLEG' AND source_fee_id=? AND source_fee_version=?", + long sourceId = jdbc.queryForObject("SELECT id FROM fin_fee_source WHERE source_system='FIRSTLEG' AND source_fee_id=? AND source_fee_version=?", Long.class, snapshot.feeId(), snapshot.feeVersion()); + if ("PAYABLE".equals(snapshot.direction()) && "DIRECT_PAID".equals(snapshot.settlementMode())) { + materializeDirectPaidCost(snapshot); + } + return sourceId; + } + + 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)", + snapshot.customerId(), costNo, snapshot.sourceType(), snapshot.feeId(), snapshot.feeVersion(), + snapshot.sourceHash(), snapshot.orderId(), snapshot.inboundId(), snapshot.containerId(), + snapshot.supplierId(), snapshot.supplierName(), snapshot.feeItemCode(), snapshot.feeItemName(), + snapshot.currency(), snapshot.totalAmount(), voucherJsonOrEmpty(snapshot.paymentVoucherFileIds()), + snapshot.paidAt(), snapshot.paymentMethod(), snapshot.paymentReferenceNo()); + } + + private void voidDirectPaidCost(FirstlegFeeSourceClient.Snapshot snapshot) { + 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_type=? AND source_fee_id=? AND source_fee_version=? AND status='CONFIRMED'", + snapshot.sourceType(), snapshot.feeId(), snapshot.feeVersion()); } private FirstlegFeeSourceClient.Snapshot loadSnapshot(FinanceFeeSourceEvent event) { @@ -178,6 +200,10 @@ public class FinanceFeeSourceEventService { return values == null || values.isEmpty() ? null : json(values); } + private String voucherJsonOrEmpty(java.util.List values) { + return values == null || values.isEmpty() ? "[]" : json(values); + } + private String json(Object value) { try { return objectMapper.writeValueAsString(value); } catch (JsonProcessingException e) { throw error("FIN-EVT-001", "事件序列化失败"); } 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 ac158ce18..08b345067 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java @@ -69,8 +69,8 @@ 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(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," + "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); 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 2c74acf70..db848ed88 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinanceQueryService.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceQueryService.java @@ -75,10 +75,9 @@ 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(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(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')+" + + "((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')," + @@ -89,7 +88,7 @@ public class FinanceQueryService { 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, 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. diff --git a/finance/src/main/resources/db/fee-source-event-migration.sql b/finance/src/main/resources/db/fee-source-event-migration.sql index 5f0edadb0..b74ba03bd 100644 --- a/finance/src/main/resources/db/fee-source-event-migration.sql +++ b/finance/src/main/resources/db/fee-source-event-migration.sql @@ -65,9 +65,44 @@ CALL finance_fee_source_add_column('fin_inbox_event', 'result_resource_id', "BIGINT UNSIGNED NULL COMMENT '成功处理生成或定位的费用来源快照ID' AFTER payload_hash"); CALL finance_fee_source_add_column('fin_inbox_event', 'error_code', "VARCHAR(64) NULL COMMENT '处理失败时的稳定FIN错误码' AFTER result_resource_id"); +CALL finance_fee_source_add_column('fin_cost', 'cost_origin', + "VARCHAR(24) NOT NULL DEFAULT 'MANUAL' COMMENT '成本来源:MANUAL人工补充、FIRSTLEG_DIRECT_PAID头程直接已付' AFTER cost_type"); +CALL finance_fee_source_add_column('fin_cost', 'source_system', + "VARCHAR(32) NULL COMMENT '来源系统,头程直接已付固定FIRSTLEG' AFTER cost_origin"); +CALL finance_fee_source_add_column('fin_cost', 'source_type', + "VARCHAR(32) NULL COMMENT '来源费用类型快照' AFTER source_system"); +CALL finance_fee_source_add_column('fin_cost', 'source_fee_id', + "BIGINT UNSIGNED NULL COMMENT '来源费用ID' AFTER source_type"); +CALL finance_fee_source_add_column('fin_cost', 'source_fee_version', + "INT UNSIGNED NULL COMMENT '来源费用版本' AFTER source_fee_id"); +CALL finance_fee_source_add_column('fin_cost', 'source_hash', + "CHAR(64) NULL COMMENT '来源不可变快照SHA-256' AFTER source_fee_version"); +CALL finance_fee_source_add_column('fin_cost', 'paid_at', + "DATETIME(3) NULL COMMENT '直接已付时间' AFTER voucher_file_ids"); +CALL finance_fee_source_add_column('fin_cost', 'payment_method', + "VARCHAR(32) NULL COMMENT '直接已付方式' AFTER paid_at"); +CALL finance_fee_source_add_column('fin_cost', 'payment_reference_no', + "VARCHAR(128) NULL COMMENT '直接已付流水号' AFTER payment_method"); DROP PROCEDURE IF EXISTS finance_fee_source_add_column; +DROP PROCEDURE IF EXISTS finance_fee_source_add_index; +DELIMITER $$ +CREATE PROCEDURE finance_fee_source_add_index() +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.statistics + WHERE table_schema=DATABASE() AND table_name='fin_cost' + AND index_name='uk_fin_cost_source_version' + ) THEN + ALTER TABLE fin_cost ADD UNIQUE KEY uk_fin_cost_source_version + (source_system, source_type, source_fee_id, source_fee_version); + END IF; +END$$ +DELIMITER ; +CALL finance_fee_source_add_index(); +DROP PROCEDURE IF EXISTS finance_fee_source_add_index; + -- 产品确认无历史财务数据;以下兼容值仅用于空环境重复迁移时保证结构收敛。 UPDATE fin_fee_source SET source_type = COALESCE(source_type, 'INBOUND_FEE'), @@ -116,6 +151,18 @@ BEGIN 'customer_confirmed_by_name','customer_confirmed_at')) <> 4 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Finance客户对账单确认字段不完整'; END IF; + IF (SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'fin_cost' + AND column_name IN ('cost_origin','source_system','source_type','source_fee_id', + 'source_fee_version','source_hash','paid_at','payment_method', + 'payment_reference_no')) <> 9 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Finance直接已付成本字段不完整'; + END IF; + IF (SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema=DATABASE() AND table_name='fin_cost' + AND index_name='uk_fin_cost_source_version') <> 4 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Finance直接已付成本唯一索引不完整'; + END IF; END$$ DELIMITER ; CALL finance_fee_source_assert_schema(); diff --git a/finance/src/main/resources/db/finance.sql b/finance/src/main/resources/db/finance.sql index 0f54bdd23..dd583fe13 100644 --- a/finance/src/main/resources/db/finance.sql +++ b/finance/src/main/resources/db/finance.sql @@ -212,7 +212,13 @@ CREATE TABLE IF NOT EXISTS fin_cost ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键', cost_no VARCHAR(64) NOT NULL COMMENT '客户范围内唯一成本编号', - cost_type VARCHAR(24) NOT NULL COMMENT '成本归属类型:SINGLE_ORDER单票成本、CONTAINER_SHARED整柜公共成本', + cost_type VARCHAR(24) NOT NULL COMMENT '成本归属类型:SINGLE_ORDER单票、CONTAINER_SHARED整柜公共、SOURCE_DIRECT_PAID来源直接已付', + cost_origin VARCHAR(24) NOT NULL DEFAULT 'MANUAL' COMMENT '成本来源:MANUAL人工补充、FIRSTLEG_DIRECT_PAID头程直接已付', + source_system VARCHAR(32) NULL COMMENT '来源系统,头程直接已付固定FIRSTLEG', + source_type VARCHAR(32) NULL COMMENT '来源费用类型快照', + source_fee_id BIGINT UNSIGNED NULL COMMENT '来源费用ID', + source_fee_version INT UNSIGNED NULL COMMENT '来源费用版本', + source_hash CHAR(64) NULL COMMENT '来源不可变快照SHA-256', order_id BIGINT UNSIGNED NULL COMMENT '成本归属的头程订单ID,单票成本必填', inbound_id BIGINT UNSIGNED NULL COMMENT '成本关联的入库单ID,可为空', container_id BIGINT UNSIGNED NULL COMMENT 'Firstleg装柜计划ID,整柜公共成本必填', @@ -223,6 +229,9 @@ CREATE TABLE IF NOT EXISTS fin_cost ( currency_code CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT 'ISO 4217三位币种代码,首期仅CNY', cost_amount DECIMAL(20,6) NOT NULL COMMENT '成本总金额,精度6位小数', 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', + payment_reference_no VARCHAR(128) NULL COMMENT '直接已付流水号', status VARCHAR(16) NOT NULL DEFAULT 'DRAFT' COMMENT '成本状态:DRAFT草稿、CONFIRMED已确认、VOIDED已作废', remark VARCHAR(500) NULL COMMENT '平台成本内部备注', confirmed_at DATETIME(3) NULL COMMENT '成本确认时间', @@ -238,6 +247,7 @@ CREATE TABLE IF NOT EXISTS fin_cost ( updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间,数据修改时自动刷新', PRIMARY KEY (id), UNIQUE KEY uk_fin_cost_no (tenant_id, cost_no), + 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), CONSTRAINT chk_fin_cost_tenant CHECK (tenant_id > 0), @@ -246,8 +256,16 @@ CREATE TABLE IF NOT EXISTS fin_cost ( CONSTRAINT chk_fin_cost_target CHECK ( (cost_type = 'SINGLE_ORDER' AND order_id IS NOT NULL AND container_id IS NULL) OR (cost_type = 'CONTAINER_SHARED' AND container_id IS NOT NULL) + OR (cost_type = 'SOURCE_DIRECT_PAID' AND (order_id IS NOT NULL OR inbound_id IS NOT NULL OR container_id IS NOT NULL)) + ), + CONSTRAINT chk_fin_cost_origin CHECK ( + (cost_origin='MANUAL' AND source_system IS NULL AND source_type IS NULL AND source_fee_id IS NULL AND source_fee_version IS NULL AND source_hash IS NULL) + OR (cost_origin='FIRSTLEG_DIRECT_PAID' AND source_system='FIRSTLEG' AND source_type IS NOT NULL + AND source_fee_id IS NOT NULL AND source_fee_version IS NOT NULL AND source_hash IS NOT NULL + AND status IN ('CONFIRMED','VOIDED') AND paid_at IS NOT NULL + AND payment_method IN ('ONLINE','BANK_TRANSFER','CASH','OTHER')) ) -) ENGINE=InnoDB COMMENT='人工成本;单票直接归属,整柜按体积分摊'; +) ENGINE=InnoDB COMMENT='成本事实;人工补充成本与头程直接已付来源成本统一留痕'; CREATE TABLE IF NOT EXISTS fin_cost_allocation ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID', diff --git a/finance/src/test/java/com/baiyue/finance/CustomerAccountSummaryBehaviorTest.java b/finance/src/test/java/com/baiyue/finance/CustomerAccountSummaryBehaviorTest.java index 5423d5f10..f99d622ce 100644 --- a/finance/src/test/java/com/baiyue/finance/CustomerAccountSummaryBehaviorTest.java +++ b/finance/src/test/java/com/baiyue/finance/CustomerAccountSummaryBehaviorTest.java @@ -31,7 +31,7 @@ class CustomerAccountSummaryBehaviorTest { 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(), any())).thenAnswer(invocation -> + when(jdbc.queryForObject(anyString(), any(RowMapper.class), any(), any(), any(), any(), any(), any(), any())).thenAnswer(invocation -> ((RowMapper) invocation.getArgument(1)).mapRow(rs, 0)); FinanceQueryService service = new FinanceQueryService(jdbc, new ObjectMapper()); @@ -52,12 +52,12 @@ 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)); 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.settlement_mode='ON_ACCOUNT'", "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") @@ -68,7 +68,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(), any())).thenAnswer(invocation -> + when(jdbc.queryForObject(anyString(), any(RowMapper.class), 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); @@ -85,7 +85,7 @@ class CustomerAccountSummaryBehaviorTest { @Test void missingSettlementProjectionReturnsZeroCnyBaseline() { JdbcTemplate jdbc = mock(JdbcTemplate.class); - when(jdbc.queryForObject(anyString(), any(RowMapper.class), any(), any(), any(), any(), any(), any(), any(), any())) + when(jdbc.queryForObject(anyString(), any(RowMapper.class), any(), any(), any(), any(), any(), any(), any())) .thenThrow(new BadSqlGrammarException("customer summary", "SELECT ...", null)); CustomerAccountSummary result = new FinanceQueryService(jdbc, new ObjectMapper()).customerAccountSummary(88L); diff --git a/finance/src/test/java/com/baiyue/finance/FinanceFeeSourceEventServiceTest.java b/finance/src/test/java/com/baiyue/finance/FinanceFeeSourceEventServiceTest.java index 89d21a4e4..b57a6b50e 100644 --- a/finance/src/test/java/com/baiyue/finance/FinanceFeeSourceEventServiceTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinanceFeeSourceEventServiceTest.java @@ -80,6 +80,7 @@ class FinanceFeeSourceEventServiceTest { assertThat(service.consume(event)).isEqualTo(101L); verify(supplierValidator).requireActive(71L, "TRUCKING"); + verify(jdbc, never()).update(startsWith("INSERT INTO fin_cost"), any(Object[].class)); ArgumentCaptor values = ArgumentCaptor.forClass(Object[].class); verify(jdbc).update(startsWith("INSERT INTO fin_fee_source"), values.capture()); assertThat(values.getValue()[19].toString()) @@ -123,6 +124,16 @@ class FinanceFeeSourceEventServiceTest { assertThat(values.getValue()[13]).isEqualTo("DIRECT_PAID"); assertThat(values.getValue()[15]).isEqualTo("BANK_TRANSFER"); assertThat(values.getValue()[17]).isEqualTo("[901]"); + ArgumentCaptor costValues = ArgumentCaptor.forClass(Object[].class); + verify(jdbc).update(startsWith("INSERT INTO fin_cost"), costValues.capture()); + assertThat(costValues.getValue()[0]).isEqualTo(7L); + assertThat(costValues.getValue()[1]).isEqualTo("DIRECT-CONTAINER_FEE-31-V1"); + assertThat(costValues.getValue()[2]).isEqualTo("CONTAINER_FEE"); + assertThat(costValues.getValue()[3]).isEqualTo(31L); + assertThat(costValues.getValue()[4]).isEqualTo(1); + assertThat(costValues.getValue()[15]).isEqualTo("[901]"); + assertThat(costValues.getValue()[17]).isEqualTo("BANK_TRANSFER"); + assertThat(costValues.getValue()[18]).isEqualTo("BANK-001"); } @Test