From e4372783d086f3fc91c1202503f328a9927042f8 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 11 Aug 2026 10:45:53 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/nacos/cn/finance-service.yml | 6 + config/nacos/local/finance-service.yml | 6 + deploy/sql/README.md | 9 +- deploy/sql/头程数据库统一增量-MySQL8.sql | 365 +++++++++++++++++- .../controller/ClientFinanceController.java | 35 ++ .../controller/PlatformFinanceController.java | 88 ++++- .../finance/service/FinanceService.java | 127 +++++- finance/src/main/resources/db/finance.sql | 54 ++- .../baiyue/finance/FinanceContractTest.java | 27 ++ .../finance/FinanceServiceValidationTest.java | 35 ++ .../gateway/config/GatewaySecurityConfig.java | 2 + gateway/src/main/resources/application.yml | 13 + .../config/FinanceRouteContractTest.java | 33 ++ 13 files changed, 758 insertions(+), 42 deletions(-) create mode 100644 config/nacos/cn/finance-service.yml create mode 100644 config/nacos/local/finance-service.yml create mode 100644 finance/src/main/java/com/baiyue/finance/controller/ClientFinanceController.java create mode 100644 finance/src/test/java/com/baiyue/finance/FinanceContractTest.java create mode 100644 finance/src/test/java/com/baiyue/finance/FinanceServiceValidationTest.java create mode 100644 gateway/src/test/java/com/baiyue/gateway/config/FinanceRouteContractTest.java diff --git a/config/nacos/cn/finance-service.yml b/config/nacos/cn/finance-service.yml new file mode 100644 index 000000000..5bf4ba68c --- /dev/null +++ b/config/nacos/cn/finance-service.yml @@ -0,0 +1,6 @@ +spring: + datasource: + url: ${FINANCE_DB_URL} + username: ${FINANCE_DB_USERNAME} + password: ${FINANCE_DB_PASSWORD} + driver-class-name: com.mysql.cj.jdbc.Driver diff --git a/config/nacos/local/finance-service.yml b/config/nacos/local/finance-service.yml new file mode 100644 index 000000000..3c78aee86 --- /dev/null +++ b/config/nacos/local/finance-service.yml @@ -0,0 +1,6 @@ +spring: + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: ${FINANCE_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_finance?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true} + username: ${FINANCE_DB_USERNAME:root} + password: ${FINANCE_DB_PASSWORD:123456} diff --git a/deploy/sql/README.md b/deploy/sql/README.md index c68bb91a8..24bb60f79 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -9,7 +9,7 @@ - 模块 `src/main/resources/db/` 下的 SQL 用于开发溯源;`deploy/sql/` 下已有日期文件全部冻结为历史记录,不再单独部署,也不得与固定入口重复执行。 - 固定入口不包含 `auth/phase1-admin-accounts.sql` 和 `warehouse/warehouse-seed.sql`,不会写入固定密码账号、演示仓库、演示快递公司或演示订单;正式平台公共仓库主数据已内联到固定入口。 - `warehouse/phase2-pallet-location-schema-migration.sql` 的有效结构已被当前 `phase2-pallet-location-schema.sql` 和后续共享溢出位迁移完整覆盖,因此不重复内联,避免每次部署无条件删除并重建同名唯一索引。 -- 脚本会创建 8 个独立数据库:`baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_event_bridge`。Gateway、Print、Integration Service 无数据库。 +- 脚本会创建 9 个独立数据库:`baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_event_bridge`、`baiyue_finance`。Gateway、Print、Integration Service 无数据库。 - 完整执行所有增量区块并通过末段真实结构校验后,脚本最末才会返回唯一的 `PASS`;任一强校验失败会通过 `SIGNAL` 终止。 后续追加增量必须满足: @@ -20,6 +20,13 @@ 4. 每个增量区块末尾增加只读断言,并同步维护统一脚本末尾的总体验证。 5. MySQL DDL 会隐式提交,执行前仍应在目标环境确认连接和授权;不能把跨库脚本视为可整体回滚事务。 +## Finance 首期人工财务基线 + +- Finance 数据库为 `baiyue_finance`,模块溯源脚本为 `finance/src/main/resources/db/finance.sql`,测试和存量环境仍只执行统一入口。 +- 首期只支持 CNY,包含平台主体、客户默认 30 天账期、账单及明细、人工收款及账单分配、单票/整柜人工成本、体积分摊结果、Firstleg 费用来源事件、幂等记录和不可变审计日志。 +- 不包含钱包、发票、供应商在线应付、银行自动对账、复杂核销、汇率和月结。 +- 统一脚本末尾会强校验 12 张 Finance 表、客户隔离/CNY/人工凭证关键字段,以及费用来源、Inbox、账单、收款、成本和写操作幂等唯一约束;任一缺失不会返回 `PASS`。 + ## 历史一期回归脚本 - 历史文件:`2026-07-31一期回归测试环境增量迁移.sql`,仅供追溯,不再作为统一入口。 diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 7e6099d7d..c0214b9fc 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -9946,12 +9946,322 @@ DROP PROCEDURE assert_phase3_warehouse_roles_ready; DROP TEMPORARY TABLE IF EXISTS tmp_phase3_warehouse_role_permission; +-- Finance 首期人工财务基线:CNY 账单、收款、成本、来源事件、幂等与审计。 +CREATE DATABASE IF NOT EXISTS baiyue_finance + DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; +USE baiyue_finance; + +CREATE TABLE IF NOT EXISTS fin_fee_source ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + source_system VARCHAR(32) NOT NULL DEFAULT 'FIRSTLEG', + source_fee_id BIGINT UNSIGNED NOT NULL, + source_fee_version INT UNSIGNED NOT NULL, + customer_id BIGINT UNSIGNED NOT NULL, + order_id BIGINT UNSIGNED NULL, + inbound_id BIGINT UNSIGNED NULL, + fee_item_name VARCHAR(200) NOT NULL, + amount DECIMAL(20,6) NOT NULL, + currency_code CHAR(3) NOT NULL DEFAULT 'CNY', + status VARCHAR(16) NOT NULL DEFAULT 'AVAILABLE', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + 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), + CONSTRAINT chk_fin_fee_source_currency CHECK (currency_code = 'CNY'), + CONSTRAINT chk_fin_fee_source_amount CHECK (amount >= 0) +) ENGINE=InnoDB COMMENT='Firstleg费用来源快照池;账单人工选择并固化明细'; + +CREATE TABLE IF NOT EXISTS fin_inbox_event ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + event_id VARCHAR(128) NOT NULL, + source_system VARCHAR(32) NOT NULL, + event_type VARCHAR(64) NOT NULL, + payload JSON NOT NULL, + status VARCHAR(16) NOT NULL DEFAULT 'RECEIVED', + received_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + processed_at DATETIME(3) NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_fin_inbox_event (source_system, event_id) +) ENGINE=InnoDB COMMENT='Firstleg费用事件幂等接收记录'; + +CREATE TABLE IF NOT EXISTS fin_subject ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + subject_code VARCHAR(64) NOT NULL, + subject_name VARCHAR(200) NOT NULL, + status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE', + version INT UNSIGNED NOT NULL DEFAULT 0, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_fin_subject_code (subject_code) +) ENGINE=InnoDB COMMENT='平台财务主体;首期仅一个有效主体'; + +CREATE TABLE IF NOT EXISTS fin_customer_settlement_profile ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键', + customer_name_snapshot VARCHAR(200) NOT NULL, + credit_days INT UNSIGNED NOT NULL DEFAULT 30 COMMENT '默认30个自然日', + status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE', + version INT UNSIGNED NOT NULL DEFAULT 0, + created_by BIGINT UNSIGNED NOT NULL, + updated_by BIGINT UNSIGNED NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_fin_customer_profile (tenant_id), + CONSTRAINT chk_fin_customer_profile_tenant CHECK (tenant_id > 0), + CONSTRAINT chk_fin_customer_profile_days CHECK (credit_days <= 3650) +) ENGINE=InnoDB COMMENT='客户结算配置;一客户一个结算对象'; + +CREATE TABLE IF NOT EXISTS fin_bill ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + tenant_id BIGINT UNSIGNED NOT NULL, + bill_no VARCHAR(64) NOT NULL, + subject_id BIGINT UNSIGNED NOT NULL, + customer_name_snapshot VARCHAR(200) NOT NULL, + currency_code CHAR(3) NOT NULL DEFAULT 'CNY', + bill_date DATE NOT NULL, + due_date DATE NOT NULL, + status VARCHAR(24) NOT NULL DEFAULT 'DRAFT' COMMENT 'DRAFT/ISSUED/PARTIALLY_PAID/PAID/VOIDED', + total_amount DECIMAL(20,6) NOT NULL DEFAULT 0, + received_amount DECIMAL(20,6) NOT NULL DEFAULT 0, + outstanding_amount DECIMAL(20,6) NOT NULL DEFAULT 0, + remark VARCHAR(500) NULL, + issued_at DATETIME(3) NULL, + issued_by BIGINT UNSIGNED NULL, + voided_at DATETIME(3) NULL, + voided_by BIGINT UNSIGNED NULL, + void_reason VARCHAR(500) NULL, + version INT UNSIGNED NOT NULL DEFAULT 0, + created_by BIGINT UNSIGNED NOT NULL, + updated_by BIGINT UNSIGNED NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_fin_bill_no (tenant_id, bill_no), + KEY idx_fin_bill_customer_status_date (tenant_id, status, bill_date), + CONSTRAINT chk_fin_bill_tenant CHECK (tenant_id > 0), + CONSTRAINT chk_fin_bill_currency CHECK (currency_code = 'CNY'), + CONSTRAINT chk_fin_bill_amounts CHECK (total_amount >= 0 AND received_amount >= 0 AND outstanding_amount >= 0) +) ENGINE=InnoDB COMMENT='客户CNY账单;首期账单即应收台账'; + +CREATE TABLE IF NOT EXISTS fin_bill_line ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + tenant_id BIGINT UNSIGNED NOT NULL, + bill_id BIGINT UNSIGNED NOT NULL, + source_type VARCHAR(32) NOT NULL DEFAULT 'FIRSTLEG_FEE', + source_id BIGINT UNSIGNED NOT NULL, + source_version INT UNSIGNED NOT NULL, + source_no VARCHAR(64) NULL, + order_id BIGINT UNSIGNED NULL, + inbound_id BIGINT UNSIGNED NULL, + fee_item_code VARCHAR(64) NOT NULL, + fee_item_name VARCHAR(200) NOT NULL, + amount DECIMAL(20,6) NOT NULL, + currency_code CHAR(3) NOT NULL DEFAULT 'CNY', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_fin_bill_line_source (source_type, source_id, source_version), + KEY idx_fin_bill_line_bill (tenant_id, bill_id), + CONSTRAINT chk_fin_bill_line_tenant CHECK (tenant_id > 0), + CONSTRAINT chk_fin_bill_line_currency CHECK (currency_code = 'CNY'), + CONSTRAINT chk_fin_bill_line_amount CHECK (amount >= 0) +) ENGINE=InnoDB COMMENT='账单费用来源快照明细'; + +CREATE TABLE IF NOT EXISTS fin_receipt ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + tenant_id BIGINT UNSIGNED NOT NULL, + receipt_no VARCHAR(64) NOT NULL, + subject_id BIGINT UNSIGNED NOT NULL, + currency_code CHAR(3) NOT NULL DEFAULT 'CNY', + received_amount DECIMAL(20,6) NOT NULL, + received_at DATETIME(3) NOT NULL, + payment_method VARCHAR(32) NULL, + voucher_no VARCHAR(128) NULL, + voucher_file_ids JSON NOT NULL COMMENT 'File服务文件ID字符串数组', + status VARCHAR(16) NOT NULL DEFAULT 'CONFIRMED' COMMENT 'CONFIRMED/VOIDED', + remark VARCHAR(500) NULL, + voided_at DATETIME(3) NULL, + voided_by BIGINT UNSIGNED NULL, + void_reason VARCHAR(500) NULL, + version INT UNSIGNED NOT NULL DEFAULT 0, + created_by BIGINT UNSIGNED NOT NULL, + updated_by BIGINT UNSIGNED NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_fin_receipt_no (tenant_id, receipt_no), + KEY idx_fin_receipt_customer_time (tenant_id, received_at), + CONSTRAINT chk_fin_receipt_tenant CHECK (tenant_id > 0), + CONSTRAINT chk_fin_receipt_currency CHECK (currency_code = 'CNY'), + CONSTRAINT chk_fin_receipt_amount CHECK (received_amount > 0) +) ENGINE=InnoDB COMMENT='平台财务人工登记收款及凭证'; + +CREATE TABLE IF NOT EXISTS fin_receipt_bill ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + tenant_id BIGINT UNSIGNED NOT NULL, + receipt_id BIGINT UNSIGNED NOT NULL, + bill_id BIGINT UNSIGNED NOT NULL, + applied_amount DECIMAL(20,6) NOT NULL, + created_by BIGINT UNSIGNED NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_fin_receipt_bill_pair (receipt_id, bill_id), + KEY idx_fin_receipt_bill_bill (tenant_id, bill_id), + CONSTRAINT chk_fin_receipt_bill_tenant CHECK (tenant_id > 0), + CONSTRAINT chk_fin_receipt_bill_amount CHECK (applied_amount > 0) +) ENGINE=InnoDB COMMENT='人工收款对应账单金额;UI不使用复杂核销术语'; + +CREATE TABLE IF NOT EXISTS fin_cost ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + tenant_id BIGINT UNSIGNED NOT NULL, + cost_no VARCHAR(64) NOT NULL, + cost_type VARCHAR(24) NOT NULL COMMENT 'SINGLE_ORDER/CONTAINER_SHARED', + order_id BIGINT UNSIGNED NULL, + inbound_id BIGINT UNSIGNED NULL, + container_id BIGINT UNSIGNED NULL, + supplier_id BIGINT UNSIGNED NULL, + supplier_name_snapshot VARCHAR(200) NULL, + fee_item_code VARCHAR(64) NOT NULL, + fee_item_name VARCHAR(200) NOT NULL, + currency_code CHAR(3) NOT NULL DEFAULT 'CNY', + cost_amount DECIMAL(20,6) NOT NULL, + voucher_file_ids JSON NOT NULL, + status VARCHAR(16) NOT NULL DEFAULT 'DRAFT' COMMENT 'DRAFT/CONFIRMED/VOIDED', + remark VARCHAR(500) NULL, + confirmed_at DATETIME(3) NULL, + confirmed_by BIGINT UNSIGNED NULL, + voided_at DATETIME(3) NULL, + voided_by BIGINT UNSIGNED NULL, + void_reason VARCHAR(500) NULL, + version INT UNSIGNED NOT NULL DEFAULT 0, + created_by BIGINT UNSIGNED NOT NULL, + updated_by BIGINT UNSIGNED NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_fin_cost_no (tenant_id, cost_no), + 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), + CONSTRAINT chk_fin_cost_currency CHECK (currency_code = 'CNY'), + CONSTRAINT chk_fin_cost_amount CHECK (cost_amount > 0), + 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) + ) +) ENGINE=InnoDB COMMENT='人工成本;单票直接归属,整柜按体积分摊'; + +CREATE TABLE IF NOT EXISTS fin_cost_allocation ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + tenant_id BIGINT UNSIGNED NOT NULL, + cost_id BIGINT UNSIGNED NOT NULL, + order_id BIGINT UNSIGNED NOT NULL, + inbound_id BIGINT UNSIGNED NULL, + volume_cbm DECIMAL(20,6) NOT NULL, + total_volume_cbm DECIMAL(20,6) NOT NULL, + allocation_rate DECIMAL(20,10) NOT NULL, + allocated_amount DECIMAL(20,6) NOT NULL, + rounding_adjustment TINYINT NOT NULL DEFAULT 0 COMMENT '1表示尾差归集行', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_fin_cost_allocation_order (cost_id, order_id), + KEY idx_fin_cost_allocation_query (tenant_id, order_id), + CONSTRAINT chk_fin_cost_allocation_volume CHECK (volume_cbm >= 0 AND total_volume_cbm > 0), + CONSTRAINT chk_fin_cost_allocation_amount CHECK (allocated_amount >= 0) +) ENGINE=InnoDB COMMENT='整柜成本按有效体积分摊的可审计结果'; + +CREATE TABLE IF NOT EXISTS fin_bill_revision ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + tenant_id BIGINT UNSIGNED NOT NULL, + bill_id BIGINT UNSIGNED NOT NULL, + version INT UNSIGNED NOT NULL, + reason VARCHAR(500) NULL, + bill_snapshot JSON NOT NULL, + line_snapshot JSON NOT NULL, + created_by BIGINT UNSIGNED NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_fin_bill_revision (bill_id, version), + KEY idx_fin_bill_revision_tenant (tenant_id, bill_id) +) ENGINE=InnoDB COMMENT='出账后调整前的不可变账单及明细版本快照'; + +CREATE TABLE IF NOT EXISTS fin_idempotency_record ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + tenant_id BIGINT UNSIGNED NOT NULL, + operator_id BIGINT UNSIGNED NOT NULL, + operation VARCHAR(64) NOT NULL, + idempotency_key VARCHAR(128) NOT NULL, + request_hash CHAR(64) NOT NULL, + status VARCHAR(16) NOT NULL COMMENT 'PROCESSING/SUCCEEDED/FAILED', + result_resource_id BIGINT UNSIGNED NULL, + error_code VARCHAR(64) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_fin_idempotency (tenant_id, operator_id, operation, idempotency_key) +) ENGINE=InnoDB COMMENT='Finance人工写操作幂等记录'; + +CREATE TABLE IF NOT EXISTS fin_audit_log ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + tenant_id BIGINT UNSIGNED NOT NULL, + resource_type VARCHAR(32) NOT NULL, + resource_id BIGINT UNSIGNED NOT NULL, + action VARCHAR(64) NOT NULL, + reason VARCHAR(500) NULL, + before_data JSON NULL, + after_data JSON NULL, + operator_id BIGINT UNSIGNED NOT NULL, + operator_name_snapshot VARCHAR(128) NOT NULL, + request_id VARCHAR(128) NULL, + trace_id VARCHAR(128) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + 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不可变业务审计明细'; + + -- 三期统一最终总断言:稳定箱号、单箱测量、墨家收件箱、客户尺寸和装柜权限。 USE baiyue_firstleg; DROP PROCEDURE IF EXISTS assert_unified_final_schema; DELIMITER $$ CREATE PROCEDURE assert_unified_final_schema() BEGIN + IF (SELECT COUNT(*) FROM information_schema.tables + WHERE table_schema = 'baiyue_finance' + AND table_name IN ('fin_fee_source', 'fin_inbox_event', 'fin_subject', + 'fin_customer_settlement_profile', 'fin_bill', 'fin_bill_line', + 'fin_receipt', 'fin_receipt_bill', 'fin_cost', 'fin_cost_allocation', + 'fin_bill_revision', 'fin_idempotency_record', 'fin_audit_log')) <> 13 THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '统一迁移失败:Finance 首期人工财务表未完整创建'; + END IF; + IF (SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = 'baiyue_finance' + AND ((table_name = 'fin_customer_settlement_profile' AND column_name IN ('tenant_id', 'credit_days')) + OR (table_name = 'fin_bill' AND column_name IN ('tenant_id', 'currency_code', 'total_amount', 'received_amount', 'outstanding_amount')) + OR (table_name = 'fin_receipt' AND column_name IN ('tenant_id', 'currency_code', 'voucher_file_ids')) + OR (table_name = 'fin_cost' AND column_name IN ('tenant_id', 'cost_type', 'currency_code', 'voucher_file_ids')))) <> 14 THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '统一迁移失败:Finance 客户隔离、CNY 金额或人工凭证字段不完整'; + END IF; + IF (SELECT COUNT(DISTINCT CONCAT(table_name, '.', index_name)) + FROM information_schema.statistics + WHERE table_schema = 'baiyue_finance' + AND non_unique = 0 + AND ((table_name = 'fin_fee_source' AND index_name = 'uk_fin_fee_source') + OR (table_name = 'fin_inbox_event' AND index_name = 'uk_fin_inbox_event') + OR (table_name = 'fin_bill' AND index_name = 'uk_fin_bill_no') + OR (table_name = 'fin_receipt' AND index_name = 'uk_fin_receipt_no') + OR (table_name = 'fin_cost' AND index_name = 'uk_fin_cost_no') + OR (table_name = 'fin_bill_revision' AND index_name = 'uk_fin_bill_revision') + OR (table_name = 'fin_idempotency_record' AND index_name = 'uk_fin_idempotency'))) <> 6 THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '统一迁移失败:Finance 来源事件或人工写操作幂等约束不完整'; + END IF; IF (SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_inbound_carton_label') <> 1 THEN @@ -10238,6 +10548,59 @@ BEGIN END IF; END$$ DELIMITER ; + +-- Finance F1 人工财务权限:平台财务可写,客户财务仅本客户只读。 +INSERT INTO baiyue_iam.iam_permission + (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, deleted) +VALUES + (5105000, 0, 'PLATFORM', 'MENU', 0, 'platform:finance', '财务管理', '/finance', NULL, 60, 'ACTIVE', 0), + (5105010, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:bill:view', '查看账单', NULL, 'GET', 10, 'ACTIVE', 0), + (5105011, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:bill:create', '创建账单', NULL, 'POST', 20, 'ACTIVE', 0), + (5105012, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:bill:update', '修改草稿账单', NULL, 'PUT', 30, 'ACTIVE', 0), + (5105013, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:bill:issue', '出账', NULL, 'POST', 40, 'ACTIVE', 0), + (5105014, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:bill:adjust', '调整账单', NULL, 'POST', 50, 'ACTIVE', 0), + (5105015, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:bill:void', '作废账单', NULL, 'POST', 60, 'ACTIVE', 0), + (5105016, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:receipt:view', '查看收款', NULL, 'GET', 70, 'ACTIVE', 0), + (5105017, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:receipt:create', '登记收款', NULL, 'POST', 80, 'ACTIVE', 0), + (5105018, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:cost:view', '查看成本', NULL, 'GET', 90, 'ACTIVE', 0), + (5105019, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:cost:create', '登记成本', NULL, 'POST', 100, 'ACTIVE', 0), + (5105020, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:customer-terms:view', '查看客户账期', NULL, 'GET', 110, 'ACTIVE', 0), + (5105021, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:customer-terms:update', '修改客户账期', NULL, 'PUT', 120, 'ACTIVE', 0), + (5205000, 10001, 'MERCHANT', 'MENU', 5200000, 'merchant:finance:statement', '财务账单', '/finance/statements', NULL, 40, 'ACTIVE', 0), + (5205010, 10001, 'MERCHANT', 'BUTTON', 5205000, 'merchant:finance:statement:list', '查看账单', NULL, 'GET', 10, 'ACTIVE', 0), + (5205011, 10001, 'MERCHANT', 'BUTTON', 5205000, 'merchant:finance:statement:view', '查看账单详情及收款', NULL, 'GET', 20, 'ACTIVE', 0) +ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name), parent_id=VALUES(parent_id), route_path=VALUES(route_path), http_method=VALUES(http_method), status='ACTIVE', deleted=0; + +INSERT INTO baiyue_iam.iam_role_permission (role_id, permission_id, tenant_id) +SELECT 4000001, id, 0 FROM baiyue_iam.iam_permission + WHERE tenant_id=0 AND app_code='PLATFORM' AND (permission_code='platform:finance' OR permission_code LIKE 'platform:finance:%') +ON DUPLICATE KEY UPDATE tenant_id=VALUES(tenant_id); +INSERT INTO baiyue_iam.iam_role_permission (role_id, permission_id, tenant_id) +SELECT 4000101, id, 10001 FROM baiyue_iam.iam_permission + WHERE tenant_id=10001 AND app_code='MERCHANT' AND (permission_code='merchant:finance:statement' OR permission_code LIKE 'merchant:finance:%') +ON DUPLICATE KEY UPDATE tenant_id=VALUES(tenant_id); + +-- Finance 权限和角色授权断言。 +DELIMITER $$ +CREATE PROCEDURE assert_finance_permissions() +BEGIN + IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission WHERE tenant_id=0 AND app_code='PLATFORM' AND permission_code LIKE 'platform:finance:%' AND status='ACTIVE' AND deleted=0) <> 12 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:平台 Finance 权限不完整'; + END IF; + IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission WHERE tenant_id=10001 AND app_code='MERCHANT' AND permission_code IN ('merchant:finance:statement:list','merchant:finance:statement:view') AND status='ACTIVE' AND deleted=0) <> 2 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:客户 Finance 只读权限不完整'; + END IF; + IF (SELECT COUNT(*) FROM baiyue_iam.iam_role_permission rp JOIN baiyue_iam.iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id WHERE rp.role_id=4000001 AND rp.tenant_id=0 AND (p.permission_code='platform:finance' OR p.permission_code LIKE 'platform:finance:%')) <> 13 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:平台管理员 Finance 授权不完整'; + END IF; + IF (SELECT COUNT(*) FROM baiyue_iam.iam_role_permission rp JOIN baiyue_iam.iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id WHERE rp.role_id=4000101 AND rp.tenant_id=10001 AND (p.permission_code='merchant:finance:statement' OR p.permission_code LIKE 'merchant:finance:%')) <> 3 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:客户管理员 Finance 只读授权不完整'; + END IF; +END$$ +DELIMITER ; +CALL assert_finance_permissions(); +DROP PROCEDURE IF EXISTS assert_finance_permissions; + CALL assert_unified_final_schema(); DROP PROCEDURE IF EXISTS assert_unified_final_schema; CALL assert_loading_pick_task_shapes(1); @@ -10245,4 +10608,4 @@ DROP PROCEDURE IF EXISTS assert_loading_pick_task_shapes; -- 二、三期对外统一使用 INBOUNDED;历史 WAIT_SPLIT 仅由查询适配为 INBOUNDED,统一迁移不改写主表或历史状态。 SELECT 'PASS' AS migration_status, - '统一增量全部执行完成;父子订单、履约分片、装柜门禁、订舱拖车、报关船期、船司查询、仓库角色、费用、公共资源及可靠审计结构校验通过' AS message; + '统一增量全部执行完成;父子订单、履约分片、装柜门禁、订舱拖车、报关船期、船司查询、仓库角色、费用、Finance人工财务、公共资源及可靠审计结构校验通过' AS message; diff --git a/finance/src/main/java/com/baiyue/finance/controller/ClientFinanceController.java b/finance/src/main/java/com/baiyue/finance/controller/ClientFinanceController.java new file mode 100644 index 000000000..22f0044e2 --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/controller/ClientFinanceController.java @@ -0,0 +1,35 @@ +package com.baiyue.finance.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.finance.service.FinanceService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +@Tag(name = "客户财务只读") +@RestController +@RequestMapping("/api/client/finance/v1") +public class ClientFinanceController { + private final FinanceService service; + public ClientFinanceController(FinanceService service) { this.service = service; } + + @GetMapping("/bills") + @PreAuthorize("hasAuthority('merchant:finance:statement:list')") + @Operation(summary = "客户只读查询账单") + public ApiResponse bills(@AuthenticationPrincipal AuthenticatedUser user) { return ApiResponse.success(service.listBills(user.tenantId())); } + + @GetMapping("/bills/{billId}") + @PreAuthorize("hasAuthority('merchant:finance:statement:view')") + @Operation(summary = "客户只读查看账单详情") + public ApiResponse> bill(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long billId) { return ApiResponse.success(service.bill(user.tenantId(), billId)); } + + @GetMapping("/receipts") + @PreAuthorize("hasAuthority('merchant:finance:statement:view')") + @Operation(summary = "客户只读查询收款记录") + public ApiResponse receipts(@AuthenticationPrincipal AuthenticatedUser user) { return ApiResponse.success(service.listReceipts(user.tenantId())); } +} diff --git a/finance/src/main/java/com/baiyue/finance/controller/PlatformFinanceController.java b/finance/src/main/java/com/baiyue/finance/controller/PlatformFinanceController.java index c9bf7a651..d04eb36e8 100644 --- a/finance/src/main/java/com/baiyue/finance/controller/PlatformFinanceController.java +++ b/finance/src/main/java/com/baiyue/finance/controller/PlatformFinanceController.java @@ -15,50 +15,100 @@ import java.util.Map; @Tag(name = "人工财务台账") @RestController -@RequestMapping("/api/platform/v1/finance") +@RequestMapping("/api/platform/finance/v1") public class PlatformFinanceController { private final FinanceService service; public PlatformFinanceController(FinanceService service) { this.service = service; } @GetMapping("/customers/{tenantId}/terms") - @PreAuthorize("hasAuthority('finance:customer-terms:view')") + @PreAuthorize("hasAuthority('platform:finance:customer-terms:view')") @Operation(summary = "查询客户账期") public ApiResponse> terms(@PathVariable long tenantId) { return ApiResponse.success(service.customerTerms(tenantId)); } + @GetMapping("/bills") + @PreAuthorize("hasAuthority('platform:finance:bill:view')") + @Operation(summary = "查询平台账单") + public ApiResponse bills(@RequestParam long customerId) { return ApiResponse.success(service.listBills(customerId)); } + @PutMapping("/customers/{tenantId}/terms") - @PreAuthorize("hasAuthority('finance:customer-terms:update')") + @PreAuthorize("hasAuthority('platform:finance:customer-terms:update')") @BusinessAudit(event = "FINANCE_CUSTOMER_TERMS_UPDATE", resourceType = "CUSTOMER") public ApiResponse> saveTerms(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long tenantId, @RequestHeader("Idempotency-Key") String key, @RequestBody TermsRequest request) { return ApiResponse.success(service.saveCustomerTerms(tenantId, user.userId(), request.customerName(), request.creditDays(), key)); } - @PostMapping("/customers/{tenantId}/bills") - @PreAuthorize("hasAuthority('finance:bill:create')") + @PostMapping("/bills") + @PreAuthorize("hasAuthority('platform:finance:bill:create')") @BusinessAudit(event = "FINANCE_BILL_CREATE", resourceType = "FINANCE_BILL") - public ApiResponse> createBill(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long tenantId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody FinanceService.BillRequest request) { - return ApiResponse.success(service.createBill(tenantId, user.userId(), request == null ? "客户" + tenantId : request.billNo(), request, key)); + public ApiResponse> createBill(@AuthenticationPrincipal AuthenticatedUser user, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody CreateBillRequest request) { + var lines = request.lines().stream().map(line -> new FinanceService.BillLine("FIRSTLEG_FEE", line.sourceFeeId(), line.sourceFeeVersion(), null, line.orderId(), line.inboundId(), "MANUAL", line.feeItemName(), line.amount())).toList(); + var internal = new FinanceService.BillRequest(null, 1L, request.billDate(), null, lines); + return ApiResponse.success(service.createBill(request.customerId(), user.userId(), "客户" + request.customerId(), internal, key)); } - @PostMapping("/customers/{tenantId}/bills/{billId}/issue") - @PreAuthorize("hasAuthority('finance:bill:issue')") + @PostMapping("/bills/{billId}/issue") + @PreAuthorize("hasAuthority('platform:finance:bill:issue')") @BusinessAudit(event = "FINANCE_BILL_ISSUE", resourceType = "FINANCE_BILL") - public ApiResponse> issueBill(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long tenantId, @PathVariable long billId, @RequestHeader("Idempotency-Key") String key) { - return ApiResponse.success(service.issueBill(tenantId, user.userId(), billId, key)); + public ApiResponse> issueBill(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long billId, @RequestHeader("Idempotency-Key") String key, @RequestBody VersionRequest request) { + return ApiResponse.success(service.issueBillById(user.userId(), billId, request.expectedVersion(), key)); } - @PostMapping("/customers/{tenantId}/receipts") - @PreAuthorize("hasAuthority('finance:receipt:create')") + @GetMapping("/bills/{billId}") + @PreAuthorize("hasAuthority('platform:finance:bill:view')") + public ApiResponse> bill(@PathVariable long billId) { return ApiResponse.success(service.billById(billId)); } + + @GetMapping("/receipts") + @PreAuthorize("hasAuthority('platform:finance:receipt:view')") + public ApiResponse receipts(@RequestParam(required = false) Long customerId) { return ApiResponse.success(service.listReceipts(customerId)); } + + @PostMapping("/receipts") + @PreAuthorize("hasAuthority('platform:finance:receipt:create')") @BusinessAudit(event = "FINANCE_RECEIPT_CREATE", resourceType = "FINANCE_RECEIPT") - public ApiResponse> receipt(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long tenantId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody FinanceService.ReceiptRequest request) { - return ApiResponse.success(service.createReceipt(tenantId, user.userId(), request, key)); + public ApiResponse> receipt(@AuthenticationPrincipal AuthenticatedUser user, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody ReceiptRequest request) { + if (request.amount() == null || request.amount().signum() <= 0) throw new IllegalArgumentException("收款金额必须大于0"); + if (request.receivedDate() == null) throw new IllegalArgumentException("到账日期不能为空"); + if (request.allocations() == null || request.allocations().isEmpty()) throw new IllegalArgumentException("收款至少需要分配一张账单"); + if (request.currency() != null && !"CNY".equals(request.currency())) throw new IllegalArgumentException("首期仅支持CNY"); + var allocated = request.allocations().stream().map(ReceiptAllocationInput::amount).reduce(java.math.BigDecimal.ZERO, java.math.BigDecimal::add); + if (request.amount().compareTo(allocated) != 0) throw new IllegalArgumentException("收款金额必须等于分配金额合计"); + var allocations = request.allocations().stream().map(a -> new FinanceService.ReceiptAllocation(a.billId(), a.amount())).toList(); + var internal = new FinanceService.ReceiptRequest(null, 1L, request.receivedDate().atStartOfDay(), request.paymentMethod(), null, request.voucherFileIds(), request.remark(), allocations); + return ApiResponse.success(service.createReceipt(request.customerId(), user.userId(), internal, key)); } - @PostMapping("/customers/{tenantId}/costs") - @PreAuthorize("hasAuthority('finance:cost:create')") + @GetMapping("/cost-entries") + @PreAuthorize("hasAuthority('platform:finance:cost:view')") + public ApiResponse costs(@RequestParam(required = false) Long customerId) { return ApiResponse.success(service.listCosts(customerId)); } + + @PostMapping("/cost-entries") + @PreAuthorize("hasAuthority('platform:finance:cost:create')") @BusinessAudit(event = "FINANCE_COST_CREATE", resourceType = "FINANCE_COST") - public ApiResponse> cost(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long tenantId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody FinanceService.CostRequest request) { - return ApiResponse.success(service.createCost(tenantId, user.userId(), request, key)); + public ApiResponse> cost(@AuthenticationPrincipal AuthenticatedUser user, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody CostRequest request) { + if (request.currency() != null && !"CNY".equals(request.currency())) throw new IllegalArgumentException("首期仅支持CNY"); + var internal = new FinanceService.CostRequest(null, request.costType(), request.orderId(), null, request.containerId(), null, null, "MANUAL", request.expenseName(), request.amount(), request.voucherFileIds(), request.remark()); + return ApiResponse.success(service.createCost(request.customerId(), user.userId(), internal, key)); } + @PutMapping("/bills/{billId}") + @PreAuthorize("hasAuthority('platform:finance:bill:update')") + public ApiResponse> update(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long billId, @RequestHeader("Idempotency-Key") String key, @RequestBody UpdateBillRequest request) { return ApiResponse.success(service.updateBill(user.userId(), billId, request.customerId(), request.billDate(), request.lines(), request.expectedVersion(), key)); } + + @PostMapping("/bills/{billId}/adjust") + @PreAuthorize("hasAuthority('platform:finance:bill:adjust')") + public ApiResponse> adjust(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long billId, @RequestHeader("Idempotency-Key") String key, @RequestBody AdjustBillRequest request) { return ApiResponse.success(service.adjustBill(user.userId(), billId, request.lines(), request.expectedVersion(), request.reason(), key)); } + + @PostMapping("/bills/{billId}/void") + @PreAuthorize("hasAuthority('platform:finance:bill:void')") + public ApiResponse> voidBill(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long billId, @RequestHeader("Idempotency-Key") String key, @RequestBody ReasonRequest request) { return ApiResponse.success(service.voidBill(user.userId(), billId, request.expectedVersion(), request.reason(), key)); } + public record TermsRequest(String customerName, Integer creditDays) {} + public record CreateBillRequest(long customerId, java.time.LocalDate billDate, java.util.List lines) {} + public record BillLineInput(long sourceFeeId, int sourceFeeVersion, Long orderId, Long inboundId, String feeItemName, java.math.BigDecimal amount) {} + public record VersionRequest(int expectedVersion) {} + public record ReasonRequest(int expectedVersion, String reason) {} + public record UpdateBillRequest(long customerId, java.time.LocalDate billDate, java.util.List lines, int expectedVersion) {} + public record AdjustBillRequest(int expectedVersion, String reason, java.util.List lines) {} + public record ReceiptRequest(long customerId, java.time.LocalDate receivedDate, java.math.BigDecimal amount, String currency, String paymentMethod, java.util.List voucherFileIds, String remark, java.util.List allocations) {} + public record ReceiptAllocationInput(long billId, java.math.BigDecimal amount) {} + public record CostRequest(long customerId, String costType, Long orderId, Long containerId, String expenseName, java.math.BigDecimal amount, String currency, java.util.List voucherFileIds, String remark) {} } diff --git a/finance/src/main/java/com/baiyue/finance/service/FinanceService.java b/finance/src/main/java/com/baiyue/finance/service/FinanceService.java index 5cdf15a3c..771253ca1 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinanceService.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceService.java @@ -38,6 +38,41 @@ public class FinanceService { return rows.isEmpty() ? Map.of("tenantId", tenantId, "creditDays", 30, "status", "ACTIVE") : rows.get(0); } + public List> listBills(long tenantId) { + requireCustomer(tenantId); + return jdbc.queryForList("SELECT id,bill_no,customer_name_snapshot,currency_code,bill_date,due_date,status,total_amount,received_amount,outstanding_amount,version FROM fin_bill WHERE tenant_id=? ORDER BY bill_date DESC,id DESC", tenantId); + } + + public Map bill(long tenantId, long billId) { + requireCustomer(tenantId); + Map result = jdbc.queryForMap("SELECT * FROM fin_bill WHERE tenant_id=? AND id=?", tenantId, billId); + result.put("lines", jdbc.queryForList("SELECT * FROM fin_bill_line WHERE tenant_id=? AND bill_id=? ORDER BY id", tenantId, billId)); + return result; + } + + public Map billById(long billId) { + Long tenantId = jdbc.queryForObject("SELECT tenant_id FROM fin_bill WHERE id=?", Long.class, billId); + if (tenantId == null) throw bad("账单不存在"); + return bill(tenantId, billId); + } + + @Transactional + public Map issueBillById(long operatorId, long billId, int expectedVersion, String idempotencyKey) { + Long tenantId = jdbc.queryForObject("SELECT tenant_id FROM fin_bill WHERE id=?", Long.class, billId); + if (tenantId == null) throw bad("账单不存在"); + return issueBill(tenantId, operatorId, billId, expectedVersion, idempotencyKey); + } + + public List> listReceipts(Long tenantId) { + String sql = "SELECT r.id,r.tenant_id,r.receipt_no,r.received_amount,r.received_at,r.payment_method,r.voucher_no,r.voucher_file_ids,r.status,rb.bill_id,rb.applied_amount FROM fin_receipt r LEFT JOIN fin_receipt_bill rb ON rb.receipt_id=r.id"; + return tenantId == null ? jdbc.queryForList(sql + " ORDER BY r.received_at DESC,r.id DESC") : jdbc.queryForList(sql + " WHERE r.tenant_id=? ORDER BY r.received_at DESC,r.id DESC", tenantId); + } + + public List> listCosts(Long tenantId) { + String sql = "SELECT * FROM fin_cost"; + return tenantId == null ? jdbc.queryForList(sql + " ORDER BY created_at DESC,id DESC") : jdbc.queryForList(sql + " WHERE tenant_id=? ORDER BY created_at DESC,id DESC", tenantId); + } + @Transactional public Map saveCustomerTerms(long tenantId, long operatorId, String customerName, Integer creditDays, String idempotencyKey) { @@ -48,6 +83,7 @@ public class FinanceService { jdbc.update("INSERT INTO fin_customer_settlement_profile(tenant_id,customer_name_snapshot,credit_days,created_by,updated_by) VALUES(?,?,?,?,?) " + "ON DUPLICATE KEY UPDATE customer_name_snapshot=VALUES(customer_name_snapshot), credit_days=VALUES(credit_days), updated_by=VALUES(updated_by), version=version+1", tenantId, StringUtils.hasText(customerName) ? customerName : "客户" + tenantId, days, operatorId, operatorId); + complete(tenantId, operatorId, "CUSTOMER_TERMS_SAVE", idempotencyKey, tenantId); return customerTerms(tenantId); } @@ -56,31 +92,37 @@ public class FinanceService { String idempotencyKey) { requireCustomer(tenantId); reserve(tenantId, operatorId, "BILL_CREATE", idempotencyKey); if (request.lines() == null || request.lines().isEmpty()) throw bad("账单至少需要一条费用明细"); + request.lines().forEach(line -> { if (line.sourceId() <= 0 || line.sourceVersion() <= 0) throw bad("费用来源版本无效"); if (line.amount() == null || line.amount().signum() < 0) throw bad("费用金额不能为负数"); }); BigDecimal total = request.lines().stream().map(BillLine::amount).reduce(BigDecimal.ZERO, BigDecimal::add); if (total.signum() < 0) throw bad("账单金额不能为负数"); - int days = ((Number) customerTerms(tenantId).get("credit_days")).intValue(); + Object credit = customerTerms(tenantId).get("credit_days"); + if (credit == null) credit = customerTerms(tenantId).get("creditDays"); + int days = credit == null ? 30 : ((Number) credit).intValue(); LocalDate billDate = request.billDate() == null ? LocalDate.now() : request.billDate(); KeyHolder holder = new GeneratedKeyHolder(); jdbc.update(connection -> { PreparedStatement ps = connection.prepareStatement("INSERT INTO fin_bill(tenant_id,bill_no,subject_id,customer_name_snapshot,currency_code,bill_date,due_date,total_amount,outstanding_amount,remark,created_by,updated_by) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)", Statement.RETURN_GENERATED_KEYS); - ps.setLong(1, tenantId); ps.setString(2, request.billNo()); ps.setLong(3, request.subjectId()); ps.setString(4, customerName); + ps.setLong(1, tenantId); ps.setString(2, temporaryNo("BILL")); ps.setLong(3, request.subjectId()); ps.setString(4, customerName); ps.setString(5, "CNY"); ps.setObject(6, billDate); ps.setObject(7, billDate.plusDays(days)); ps.setBigDecimal(8, total); ps.setBigDecimal(9, total); ps.setString(10, request.remark()); ps.setLong(11, operatorId); ps.setLong(12, operatorId); return ps; }, holder); long billId = holder.getKey().longValue(); + jdbc.update("UPDATE fin_bill SET bill_no=? WHERE id=?", stableNo("BILL", billId), billId); for (BillLine line : request.lines()) { jdbc.update("INSERT INTO fin_bill_line(tenant_id,bill_id,source_type,source_id,source_version,source_no,order_id,inbound_id,fee_item_code,fee_item_name,amount) VALUES(?,?,?,?,?,?,?,?,?,?,?)", tenantId, billId, line.sourceType(), line.sourceId(), line.sourceVersion(), line.sourceNo(), line.orderId(), line.inboundId(), line.feeItemCode(), line.feeItemName(), line.amount()); } + complete(tenantId, operatorId, "BILL_CREATE", idempotencyKey, billId); return jdbc.queryForMap("SELECT * FROM fin_bill WHERE id=?", billId); } @Transactional - public Map issueBill(long tenantId, long operatorId, long billId, String idempotencyKey) { + public Map issueBill(long tenantId, long operatorId, long billId, int expectedVersion, String idempotencyKey) { requireCustomer(tenantId); reserve(tenantId, operatorId, "BILL_ISSUE", idempotencyKey); - int updated = jdbc.update("UPDATE fin_bill SET status='ISSUED', issued_at=?, issued_by=?, updated_by=?, version=version+1 WHERE id=? AND tenant_id=? AND status='DRAFT'", - LocalDateTime.now(), operatorId, operatorId, billId, tenantId); + int updated = jdbc.update("UPDATE fin_bill SET status='ISSUED', issued_at=?, issued_by=?, updated_by=?, version=version+1 WHERE id=? AND tenant_id=? AND status='DRAFT' AND version=?", + LocalDateTime.now(), operatorId, operatorId, billId, tenantId, expectedVersion); if (updated != 1) throw bad("账单不存在或已出账"); + complete(tenantId, operatorId, "BILL_ISSUE", idempotencyKey, billId); return jdbc.queryForMap("SELECT * FROM fin_bill WHERE id=? AND tenant_id=?", billId, tenantId); } @@ -88,47 +130,96 @@ public class FinanceService { public Map createReceipt(long tenantId, long operatorId, ReceiptRequest request, String idempotencyKey) { requireCustomer(tenantId); reserve(tenantId, operatorId, "RECEIPT_CREATE", idempotencyKey); if (request.allocations() == null || request.allocations().isEmpty()) throw bad("收款至少需要分配一张账单"); - BigDecimal total = request.allocations().stream().map(ReceiptAllocation::amount).reduce(BigDecimal.ZERO, BigDecimal::add); + if (request.allocations().stream().anyMatch(a -> a == null || a.amount() == null || a.amount().signum() <= 0)) { + throw bad("分配金额必须大于0"); + } + Map byBill = request.allocations().stream().collect( + java.util.stream.Collectors.toMap(ReceiptAllocation::billId, ReceiptAllocation::amount, BigDecimal::add)); + BigDecimal total = byBill.values().stream().reduce(BigDecimal.ZERO, BigDecimal::add); if (total.signum() <= 0) throw bad("收款金额必须大于0"); - for (ReceiptAllocation allocation : request.allocations()) { - Map bill = jdbc.queryForMap("SELECT outstanding_amount,status FROM fin_bill WHERE id=? AND tenant_id=? FOR UPDATE", allocation.billId(), tenantId); + for (var entry : byBill.entrySet()) { + Map bill = jdbc.queryForMap("SELECT outstanding_amount,status FROM fin_bill WHERE id=? AND tenant_id=? FOR UPDATE", entry.getKey(), tenantId); BigDecimal outstanding = new BigDecimal(bill.get("outstanding_amount").toString()); - if ("VOIDED".equals(bill.get("status")) || allocation.amount().compareTo(outstanding) > 0) throw bad("收款金额超过账单未收余额"); + if ("DRAFT".equals(bill.get("status")) || "VOIDED".equals(bill.get("status")) + || entry.getValue().compareTo(outstanding) > 0) { + throw bad("只能分配已出账且未作废账单,且金额不得超过未收余额"); + } } KeyHolder holder = new GeneratedKeyHolder(); jdbc.update(connection -> { PreparedStatement ps = connection.prepareStatement("INSERT INTO fin_receipt(tenant_id,receipt_no,subject_id,received_amount,received_at,payment_method,voucher_no,voucher_file_ids,remark,created_by,updated_by) VALUES(?,?,?,?,?,?,?,?,?,?,?)", Statement.RETURN_GENERATED_KEYS); - ps.setLong(1, tenantId); ps.setString(2, request.receiptNo()); ps.setLong(3, request.subjectId()); ps.setBigDecimal(4, total); ps.setObject(5, request.receivedAt() == null ? LocalDateTime.now() : request.receivedAt()); ps.setString(6, request.paymentMethod()); ps.setString(7, request.voucherNo()); ps.setString(8, json(request.voucherFileIds())); ps.setString(9, request.remark()); ps.setLong(10, operatorId); ps.setLong(11, operatorId); return ps; + ps.setLong(1, tenantId); ps.setString(2, temporaryNo("RCPT")); ps.setLong(3, request.subjectId()); ps.setBigDecimal(4, total); ps.setObject(5, request.receivedAt() == null ? LocalDateTime.now() : request.receivedAt()); ps.setString(6, request.paymentMethod()); ps.setString(7, request.voucherNo()); ps.setString(8, json(request.voucherFileIds())); ps.setString(9, request.remark()); ps.setLong(10, operatorId); ps.setLong(11, operatorId); return ps; }, holder); long receiptId = holder.getKey().longValue(); - for (ReceiptAllocation allocation : request.allocations()) { - jdbc.update("INSERT INTO fin_receipt_bill(tenant_id,receipt_id,bill_id,applied_amount,created_by) VALUES(?,?,?,?,?)", tenantId, receiptId, allocation.billId(), allocation.amount(), operatorId); - jdbc.update("UPDATE fin_bill SET received_amount=received_amount+?, outstanding_amount=outstanding_amount-?, status=CASE WHEN outstanding_amount-?=0 THEN 'PAID' ELSE 'PARTIALLY_PAID' END, updated_by=?, version=version+1 WHERE id=? AND tenant_id=?", allocation.amount(), allocation.amount(), allocation.amount(), operatorId, allocation.billId(), tenantId); + jdbc.update("UPDATE fin_receipt SET receipt_no=? WHERE id=?", stableNo("RCPT", receiptId), receiptId); + for (var entry : byBill.entrySet()) { + BigDecimal applied = entry.getValue(); + jdbc.update("INSERT INTO fin_receipt_bill(tenant_id,receipt_id,bill_id,applied_amount,created_by) VALUES(?,?,?,?,?)", tenantId, receiptId, entry.getKey(), applied, operatorId); + jdbc.update("UPDATE fin_bill SET received_amount=received_amount+?, outstanding_amount=outstanding_amount-?, status=CASE WHEN outstanding_amount-?=0 THEN 'PAID' ELSE 'PARTIALLY_PAID' END, updated_by=?, version=version+1 WHERE id=? AND tenant_id=?", applied, applied, applied, operatorId, entry.getKey(), tenantId); } + complete(tenantId, operatorId, "RECEIPT_CREATE", idempotencyKey, receiptId); return jdbc.queryForMap("SELECT * FROM fin_receipt WHERE id=?", receiptId); } @Transactional public Map createCost(long tenantId, long operatorId, CostRequest request, String idempotencyKey) { requireCustomer(tenantId); reserve(tenantId, operatorId, "COST_CREATE", idempotencyKey); - if (!"TICKET".equals(request.costType()) && !"CONTAINER".equals(request.costType())) throw bad("成本类型只能是TICKET或CONTAINER"); + if (!"SINGLE_ORDER".equals(request.costType()) && !"CONTAINER_SHARED".equals(request.costType())) throw bad("成本类型只能是SINGLE_ORDER或CONTAINER_SHARED"); if (request.amount() == null || request.amount().signum() <= 0) throw bad("成本金额必须大于0"); - jdbc.update("INSERT INTO fin_cost(tenant_id,cost_no,cost_type,order_id,inbound_id,container_id,supplier_id,supplier_name_snapshot,fee_item_code,fee_item_name,cost_amount,voucher_file_ids,remark,created_by,updated_by) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", - tenantId, request.costNo(), request.costType(), request.orderId(), request.inboundId(), request.containerId(), request.supplierId(), request.supplierName(), request.feeItemCode(), request.feeItemName(), request.amount(), json(request.voucherFileIds()), request.remark(), operatorId, operatorId); - return jdbc.queryForMap("SELECT * FROM fin_cost WHERE tenant_id=? AND cost_no=?", tenantId, request.costNo()); + KeyHolder holder = new GeneratedKeyHolder(); + jdbc.update(connection -> { PreparedStatement ps = connection.prepareStatement("INSERT INTO fin_cost(tenant_id,cost_no,cost_type,order_id,inbound_id,container_id,supplier_id,supplier_name_snapshot,fee_item_code,fee_item_name,cost_amount,voucher_file_ids,remark,created_by,updated_by) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", Statement.RETURN_GENERATED_KEYS); + ps.setLong(1, tenantId); ps.setString(2, temporaryNo("COST")); ps.setString(3, request.costType()); ps.setObject(4, request.orderId()); ps.setObject(5, request.inboundId()); ps.setObject(6, request.containerId()); ps.setObject(7, request.supplierId()); ps.setString(8, request.supplierName()); ps.setString(9, request.feeItemCode()); ps.setString(10, request.feeItemName()); ps.setBigDecimal(11, request.amount()); ps.setString(12, json(request.voucherFileIds())); ps.setString(13, request.remark()); ps.setLong(14, operatorId); ps.setLong(15, operatorId); return ps; }, holder); + long costId = holder.getKey().longValue(); + jdbc.update("UPDATE fin_cost SET cost_no=? WHERE id=?", stableNo("COST", costId), costId); + complete(tenantId, operatorId, "COST_CREATE", idempotencyKey, costId); + return jdbc.queryForMap("SELECT * FROM fin_cost WHERE id=?", costId); } private void reserve(long tenantId, long operatorId, String operation, String key) { if (!StringUtils.hasText(key) || key.length() > 128) throw bad("必须提供有效的 Idempotency-Key"); Integer count = jdbc.queryForObject("SELECT COUNT(*) FROM fin_idempotency_record WHERE tenant_id=? AND operator_id=? AND operation=? AND idempotency_key=?", Integer.class, tenantId, operatorId, operation, key); if (count != null && count > 0) throw new BizException(ResultCode.CONFLICT, "相同请求已提交"); - jdbc.update("INSERT INTO fin_idempotency_record(tenant_id,operator_id,operation,idempotency_key,request_hash,status) VALUES(?,?,?,?,SHA2(?,256),'SUCCEEDED')", tenantId, operatorId, operation, key, key); + jdbc.update("INSERT INTO fin_idempotency_record(tenant_id,operator_id,operation,idempotency_key,request_hash,status) VALUES(?,?,?,?,SHA2(?,256),'PROCESSING')", tenantId, operatorId, operation, key, key); } + private void complete(long tenantId, long operatorId, String operation, String key, long resourceId) { + jdbc.update("UPDATE fin_idempotency_record SET status='SUCCEEDED',result_resource_id=? WHERE tenant_id=? AND operator_id=? AND operation=? AND idempotency_key=? AND status='PROCESSING'", resourceId, tenantId, operatorId, operation, key); + } + private static String temporaryNo(String prefix) { return prefix + "-PENDING-" + java.util.UUID.randomUUID(); } + private static String stableNo(String prefix, long id) { return prefix + "-" + String.format(java.util.Locale.ROOT, "%020d", id); } + private static void requireCustomer(long tenantId) { if (tenantId <= 0) throw bad("客户范围无效"); } private static BizException bad(String message) { return new BizException(ResultCode.BAD_REQUEST, message); } private String json(Object value) { try { return objectMapper.writeValueAsString(value == null ? List.of() : value); } catch (JsonProcessingException e) { throw new IllegalArgumentException("凭证字段格式错误", e); } } + @Transactional + public Map updateBill(long operatorId, long billId, long requestedCustomerId, LocalDate billDate, List lines, int expectedVersion, String key) { + Long tenantId = billTenant(billId); if (tenantId != requestedCustomerId) throw bad("账单不属于指定客户"); reserve(tenantId, operatorId, "BILL_UPDATE", key); + replaceDraftLines(tenantId, billId, lines); + BigDecimal total = lineTotal(lines); + LocalDate effectiveDate = billDate == null ? LocalDate.now() : billDate; + Object credit = customerTerms(tenantId).get("credit_days"); + if (credit == null) credit = customerTerms(tenantId).get("creditDays"); + LocalDate dueDate = effectiveDate.plusDays(credit == null ? 30 : ((Number) credit).intValue()); + int changed = jdbc.update("UPDATE fin_bill SET bill_date=?,due_date=?,version=version+1,updated_by=? WHERE id=? AND tenant_id=? AND status='DRAFT' AND version=?", effectiveDate,dueDate,operatorId,billId,tenantId,expectedVersion); + if (changed != 1) throw bad("仅草稿账单可修改且版本必须匹配"); + jdbc.update("UPDATE fin_bill SET total_amount=?, outstanding_amount=? WHERE id=? AND tenant_id=?", total, total, billId, tenantId); + complete(tenantId, operatorId, "BILL_UPDATE", key, billId); + return bill(tenantId,billId); + } + @Transactional + public Map adjustBill(long operatorId,long billId,List lines,int expectedVersion,String reason,String key){ Long tenantId=billTenant(billId); reserve(tenantId,operatorId,"BILL_ADJUST",key); if (lines == null || lines.isEmpty()) throw bad("调整后至少需要一条费用明细"); Map current = bill(tenantId,billId); snapshot(tenantId,billId,expectedVersion,reason,current,operatorId); BigDecimal total = lineTotal(lines); BigDecimal received = new BigDecimal(current.get("received_amount").toString()); if (total.compareTo(received) < 0) throw bad("调整金额不能低于已收金额"); BigDecimal outstanding=total.subtract(received); String status=outstanding.signum()==0 ? "PAID" : (received.signum()==0 ? "ISSUED" : "PARTIALLY_PAID"); int c=jdbc.update("UPDATE fin_bill SET remark=?,total_amount=?,outstanding_amount=?,status=?,version=version+1,updated_by=? WHERE id=? AND tenant_id=? AND status IN ('ISSUED','PARTIALLY_PAID','PAID') AND version=?",reason,total,outstanding,status,operatorId,billId,tenantId,expectedVersion); if(c!=1) throw bad("账单状态或版本不允许调整"); jdbc.update("DELETE FROM fin_bill_line WHERE tenant_id=? AND bill_id=?",tenantId,billId); insertLines(tenantId,billId,lines); complete(tenantId,operatorId,"BILL_ADJUST",key,billId); return bill(tenantId,billId); } + @Transactional + public Map voidBill(long operatorId,long billId,int expectedVersion,String reason,String key){ Long tenantId=billTenant(billId); reserve(tenantId,operatorId,"BILL_VOID",key); if (!StringUtils.hasText(reason)) throw bad("作废原因不能为空"); int c=jdbc.update("UPDATE fin_bill SET status='VOIDED',voided_at=?,voided_by=?,void_reason=?,updated_by=?,version=version+1 WHERE id=? AND tenant_id=? AND status IN ('DRAFT','ISSUED') AND received_amount=0 AND version=?",LocalDateTime.now(),operatorId,reason.trim(),operatorId,billId,tenantId,expectedVersion); if(c!=1) throw bad("已有收款、账单状态或版本不允许作废"); complete(tenantId,operatorId,"BILL_VOID",key,billId); return bill(tenantId,billId); } + private long billTenant(long billId){ Long t=jdbc.queryForObject("SELECT tenant_id FROM fin_bill WHERE id=?",Long.class,billId); if(t==null) throw bad("账单不存在"); return t; } + private void snapshot(long tenantId,long billId,int version,String reason,Map bill,long operatorId){ + var lines=jdbc.queryForList("SELECT * FROM fin_bill_line WHERE tenant_id=? AND bill_id=? ORDER BY id",tenantId,billId); + jdbc.update("INSERT INTO fin_bill_revision(tenant_id,bill_id,version,reason,bill_snapshot,line_snapshot,created_by) VALUES(?,?,?,?,?,?,?)",tenantId,billId,version,reason,json(bill),json(lines),operatorId); + } + private BigDecimal lineTotal(List lines) { if (lines == null || lines.isEmpty()) throw bad("账单至少需要一条费用明细"); return lines.stream().map(l -> { if (l.amount() == null || l.amount().signum() < 0) throw bad("费用金额不能为负数"); return l.amount(); }).reduce(BigDecimal.ZERO, BigDecimal::add); } + private void replaceDraftLines(long tenantId, long billId, List lines) { lineTotal(lines); Map current=bill(tenantId,billId); if (!"DRAFT".equals(current.get("status"))) throw bad("仅草稿账单可修改"); jdbc.update("DELETE FROM fin_bill_line WHERE tenant_id=? AND bill_id=?",tenantId,billId); insertLines(tenantId,billId,lines); } + private void insertLines(long tenantId,long billId,List lines) { for (var line: lines) jdbc.update("INSERT INTO fin_bill_line(tenant_id,bill_id,source_type,source_id,source_version,order_id,inbound_id,fee_item_code,fee_item_name,amount) VALUES(?,?,?,?,?,?,?,?,?,?)",tenantId,billId,"FIRSTLEG_FEE",line.sourceFeeId(),line.sourceFeeVersion(),line.orderId(),line.inboundId(),"MANUAL",line.feeItemName(),line.amount()); } + public record BillRequest(String billNo, long subjectId, LocalDate billDate, String remark, List lines) {} public record BillLine(String sourceType, long sourceId, int sourceVersion, String sourceNo, Long orderId, Long inboundId, String feeItemCode, String feeItemName, BigDecimal amount) {} public record ReceiptRequest(String receiptNo, long subjectId, LocalDateTime receivedAt, String paymentMethod, String voucherNo, List voucherFileIds, String remark, List allocations) {} diff --git a/finance/src/main/resources/db/finance.sql b/finance/src/main/resources/db/finance.sql index 934373730..a02b17a73 100644 --- a/finance/src/main/resources/db/finance.sql +++ b/finance/src/main/resources/db/finance.sql @@ -2,6 +2,39 @@ CREATE DATABASE IF NOT EXISTS baiyue_finance DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; USE baiyue_finance; +CREATE TABLE IF NOT EXISTS fin_fee_source ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + source_system VARCHAR(32) NOT NULL DEFAULT 'FIRSTLEG', + source_fee_id BIGINT UNSIGNED NOT NULL, + source_fee_version INT UNSIGNED NOT NULL, + customer_id BIGINT UNSIGNED NOT NULL, + order_id BIGINT UNSIGNED NULL, + inbound_id BIGINT UNSIGNED NULL, + fee_item_name VARCHAR(200) NOT NULL, + amount DECIMAL(20,6) NOT NULL, + currency_code CHAR(3) NOT NULL DEFAULT 'CNY', + status VARCHAR(16) NOT NULL DEFAULT 'AVAILABLE', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + 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), + CONSTRAINT chk_fin_fee_source_currency CHECK (currency_code = 'CNY'), + CONSTRAINT chk_fin_fee_source_amount CHECK (amount >= 0) +) ENGINE=InnoDB COMMENT='Firstleg费用来源快照池;账单人工选择并固化明细'; + +CREATE TABLE IF NOT EXISTS fin_inbox_event ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + event_id VARCHAR(128) NOT NULL, + source_system VARCHAR(32) NOT NULL, + event_type VARCHAR(64) NOT NULL, + payload JSON NOT NULL, + status VARCHAR(16) NOT NULL DEFAULT 'RECEIVED', + received_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + processed_at DATETIME(3) NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_fin_inbox_event (source_system, event_id) +) ENGINE=InnoDB COMMENT='Firstleg费用事件幂等接收记录'; + CREATE TABLE IF NOT EXISTS fin_subject ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, subject_code VARCHAR(64) NOT NULL, @@ -136,7 +169,7 @@ CREATE TABLE IF NOT EXISTS fin_cost ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, tenant_id BIGINT UNSIGNED NOT NULL, cost_no VARCHAR(64) NOT NULL, - cost_type VARCHAR(24) NOT NULL COMMENT 'TICKET/CONTAINER', + cost_type VARCHAR(24) NOT NULL COMMENT 'SINGLE_ORDER/CONTAINER_SHARED', order_id BIGINT UNSIGNED NULL, inbound_id BIGINT UNSIGNED NULL, container_id BIGINT UNSIGNED NULL, @@ -167,8 +200,8 @@ CREATE TABLE IF NOT EXISTS fin_cost ( CONSTRAINT chk_fin_cost_currency CHECK (currency_code = 'CNY'), CONSTRAINT chk_fin_cost_amount CHECK (cost_amount > 0), CONSTRAINT chk_fin_cost_target CHECK ( - (cost_type = 'TICKET' AND order_id IS NOT NULL AND container_id IS NULL) - OR (cost_type = 'CONTAINER' AND container_id IS NOT NULL) + (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) ) ) ENGINE=InnoDB COMMENT='人工成本;单票直接归属,整柜按体积分摊'; @@ -191,6 +224,21 @@ CREATE TABLE IF NOT EXISTS fin_cost_allocation ( CONSTRAINT chk_fin_cost_allocation_amount CHECK (allocated_amount >= 0) ) ENGINE=InnoDB COMMENT='整柜成本按有效体积分摊的可审计结果'; +CREATE TABLE IF NOT EXISTS fin_bill_revision ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + tenant_id BIGINT UNSIGNED NOT NULL, + bill_id BIGINT UNSIGNED NOT NULL, + version INT UNSIGNED NOT NULL, + reason VARCHAR(500) NULL, + bill_snapshot JSON NOT NULL, + line_snapshot JSON NOT NULL, + created_by BIGINT UNSIGNED NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_fin_bill_revision (bill_id, version), + KEY idx_fin_bill_revision_tenant (tenant_id, bill_id) +) ENGINE=InnoDB COMMENT='出账后调整前的不可变账单及明细版本快照'; + CREATE TABLE IF NOT EXISTS fin_idempotency_record ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, tenant_id BIGINT UNSIGNED NOT NULL, diff --git a/finance/src/test/java/com/baiyue/finance/FinanceContractTest.java b/finance/src/test/java/com/baiyue/finance/FinanceContractTest.java new file mode 100644 index 000000000..ca49c0b2d --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/FinanceContractTest.java @@ -0,0 +1,27 @@ +package com.baiyue.finance; + +import com.baiyue.finance.controller.ClientFinanceController; +import com.baiyue.finance.controller.PlatformFinanceController; +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.*; + +class FinanceContractTest { + @Test + void controllersUseFrozenFinancePaths() throws Exception { + assertEquals("/api/platform/finance/v1", PlatformFinanceController.class.getAnnotation(org.springframework.web.bind.annotation.RequestMapping.class).value()[0]); + assertEquals("/api/client/finance/v1", ClientFinanceController.class.getAnnotation(org.springframework.web.bind.annotation.RequestMapping.class).value()[0]); + String sql = Files.readString(Path.of("src/main/resources/db/finance.sql")); + assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS fin_bill")); + assertTrue(sql.contains("currency_code CHECK (currency_code = 'CNY')") || sql.contains("currency_code = 'CNY'")); + assertTrue(sql.contains("fin_fee_source") && sql.contains("fin_inbox_event")); + String unified = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql")); + assertTrue(unified.contains("CREATE DATABASE IF NOT EXISTS baiyue_finance")); + assertTrue(unified.contains("assert_finance_permissions")); + assertTrue(unified.contains("platform:finance:bill:issue")); + assertTrue(unified.contains("merchant:finance:statement:view")); + } +} diff --git a/finance/src/test/java/com/baiyue/finance/FinanceServiceValidationTest.java b/finance/src/test/java/com/baiyue/finance/FinanceServiceValidationTest.java new file mode 100644 index 000000000..ebd8d7edc --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/FinanceServiceValidationTest.java @@ -0,0 +1,35 @@ +package com.baiyue.finance; + +import com.baiyue.finance.service.FinanceService; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.math.BigDecimal; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +class FinanceServiceValidationTest { + @Test + void rejectsUnsupportedCostTypeBeforeWrite() { + JdbcTemplate jdbc = mock(JdbcTemplate.class); + FinanceService service = new FinanceService(jdbc, new ObjectMapper()); + var request = new FinanceService.CostRequest("C-1", "TICKET", 1L, null, null, null, null, "MANUAL", "拖车", BigDecimal.ONE, List.of("f"), null); + assertThrows(RuntimeException.class, () -> service.createCost(1L, 2L, request, "k1")); + verify(jdbc, never()).update(startsWith("INSERT INTO fin_cost"), (Object[]) any()); + } + + @Test + void rejectsNonPositiveAllocation() { + JdbcTemplate jdbc = mock(JdbcTemplate.class); + FinanceService service = new FinanceService(jdbc, new ObjectMapper()); + var request = new FinanceService.ReceiptRequest("R-1", 1L, null, "BANK_TRANSFER", null, List.of("f"), null, + List.of(new FinanceService.ReceiptAllocation(10L, BigDecimal.ZERO))); + assertThrows(RuntimeException.class, () -> service.createReceipt(1L, 2L, request, "k2")); + } +} diff --git a/gateway/src/main/java/com/baiyue/gateway/config/GatewaySecurityConfig.java b/gateway/src/main/java/com/baiyue/gateway/config/GatewaySecurityConfig.java index 8d61b7730..0b0626c50 100644 --- a/gateway/src/main/java/com/baiyue/gateway/config/GatewaySecurityConfig.java +++ b/gateway/src/main/java/com/baiyue/gateway/config/GatewaySecurityConfig.java @@ -40,6 +40,8 @@ public class GatewaySecurityConfig { properties.permitPaths().forEach(path -> exchanges.pathMatchers(path).permitAll()); exchanges.pathMatchers("/api/merchant/**") .access(new AppScopeAuthorizationManager("MERCHANT")); + exchanges.pathMatchers("/api/client/**") + .access(new AppScopeAuthorizationManager("MERCHANT")); exchanges.pathMatchers("/api/platform/**") .access(new AppScopeAuthorizationManager("PLATFORM")); exchanges.anyExchange().authenticated(); diff --git a/gateway/src/main/resources/application.yml b/gateway/src/main/resources/application.yml index af97a5f14..7944607e4 100644 --- a/gateway/src/main/resources/application.yml +++ b/gateway/src/main/resources/application.yml @@ -78,6 +78,10 @@ spring: uri: lb://firstleg-service predicates: - Path=/api/merchant/v1/first-leg/**,/api/merchant/v1/customs/**,/api/platform/v1/first-leg/**,/api/platform/v1/customs/**,/api/platform/v1/port-schedules/**,/api/platform/v1/carrier-schedule-queries/**,/api/platform/v1/containers/**,/api/platform/v1/ocean-bookings/**,/api/platform/v1/truck-appointments/**,/api/platform/v1/fba-pricing/**,/api/platform/v1/inbounds/**,/api/platform/v1/fee-items/**,/api/platform/inbounds/**,/openapi/v1/inbound-measurements/** + - id: finance-service + uri: lb://finance-service + predicates: + - Path=/api/platform/finance/v1/**,/api/client/finance/v1/** # Swagger 文档聚合路由。 - id: auth-openapi uri: lb://auth-service @@ -139,6 +143,12 @@ spring: - Path=/event-bridge-service/v3/api-docs/** filters: - StripPrefix=1 + - id: finance-openapi + uri: lb://finance-service + predicates: + - Path=/finance-service/v3/api-docs/** + filters: + - StripPrefix=1 baiyue: cors: allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:5173,http://localhost:5174,http://127.0.0.1:5173,http://127.0.0.1:5174} @@ -170,6 +180,7 @@ baiyue: - /audit-service/v3/api-docs/** - /integration-service/v3/api-docs/** - /event-bridge-service/v3/api-docs/** + - /finance-service/v3/api-docs/** management: endpoints: @@ -216,6 +227,8 @@ springdoc: url: /integration-service/v3/api-docs - name: event-bridge url: /event-bridge-service/v3/api-docs + - name: finance + url: /finance-service/v3/api-docs logging: file: diff --git a/gateway/src/test/java/com/baiyue/gateway/config/FinanceRouteContractTest.java b/gateway/src/test/java/com/baiyue/gateway/config/FinanceRouteContractTest.java new file mode 100644 index 000000000..b7fcd10a4 --- /dev/null +++ b/gateway/src/test/java/com/baiyue/gateway/config/FinanceRouteContractTest.java @@ -0,0 +1,33 @@ +package com.baiyue.gateway.config; + +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; + +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; + +class FinanceRouteContractTest { + @Test + void routesOnlyTheConfirmedFinanceApiPrefixes() throws Exception { + String yaml = new ClassPathResource("application.yml") + .getContentAsString(StandardCharsets.UTF_8); + + assertThat(yaml) + .contains("uri: lb://finance-service") + .contains("/api/platform/finance/v1/**") + .contains("/api/client/finance/v1/**") + .doesNotContain("/api/platform/v1/finance") + .doesNotContain("/api/merchant/v1/finance"); + } + + @Test + void exposesFinanceSwaggerAggregationRoute() throws Exception { + String yaml = new ClassPathResource("application.yml") + .getContentAsString(StandardCharsets.UTF_8); + + assertThat(yaml) + .contains("/finance-service/v3/api-docs/**") + .contains("url: /finance-service/v3/api-docs"); + } +}