优化
This commit is contained in:
@@ -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
|
||||
@@ -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}
|
||||
@@ -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`,仅供追溯,不再作为统一入口。
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Map<String, Object>> 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())); }
|
||||
}
|
||||
+69
-19
@@ -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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String,Object>> 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<Map<String,Object>> 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<Map<String,Object>> 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<BillLineInput> 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<BillLineInput> lines, int expectedVersion) {}
|
||||
public record AdjustBillRequest(int expectedVersion, String reason, java.util.List<BillLineInput> lines) {}
|
||||
public record ReceiptRequest(long customerId, java.time.LocalDate receivedDate, java.math.BigDecimal amount, String currency, String paymentMethod, java.util.List<String> voucherFileIds, String remark, java.util.List<ReceiptAllocationInput> 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<String> voucherFileIds, String remark) {}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,41 @@ public class FinanceService {
|
||||
return rows.isEmpty() ? Map.of("tenantId", tenantId, "creditDays", 30, "status", "ACTIVE") : rows.get(0);
|
||||
}
|
||||
|
||||
public List<Map<String, Object>> 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<String, Object> bill(long tenantId, long billId) {
|
||||
requireCustomer(tenantId);
|
||||
Map<String, Object> 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<String, Object> 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<String, Object> 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<Map<String, Object>> 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<Map<String, Object>> 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<String, Object> 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<String, Object> issueBill(long tenantId, long operatorId, long billId, String idempotencyKey) {
|
||||
public Map<String, Object> 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<String, Object> 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<Long, BigDecimal> 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<String, Object> 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<String, Object> 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<String, Object> 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<String,Object> updateBill(long operatorId, long billId, long requestedCustomerId, LocalDate billDate, List<com.baiyue.finance.controller.PlatformFinanceController.BillLineInput> 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<String,Object> adjustBill(long operatorId,long billId,List<com.baiyue.finance.controller.PlatformFinanceController.BillLineInput> 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<String,Object> 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<String,Object> 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<String,Object> 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<com.baiyue.finance.controller.PlatformFinanceController.BillLineInput> 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<com.baiyue.finance.controller.PlatformFinanceController.BillLineInput> lines) { lineTotal(lines); Map<String,Object> 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<com.baiyue.finance.controller.PlatformFinanceController.BillLineInput> 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<BillLine> 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<String> voucherFileIds, String remark, List<ReceiptAllocation> allocations) {}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user