This commit is contained in:
2026-08-12 09:25:53 +08:00
parent b2af5739c8
commit 1cb4fba276
29 changed files with 1311 additions and 109 deletions
@@ -10145,6 +10145,7 @@ CREATE TABLE IF NOT EXISTS fin_bill_line (
fee_item_code VARCHAR(64) NOT NULL COMMENT '出账时费用项目编码快照', fee_item_code VARCHAR(64) NOT NULL COMMENT '出账时费用项目编码快照',
fee_item_name VARCHAR(200) NOT NULL COMMENT '出账时费用项目名称快照', fee_item_name VARCHAR(200) NOT NULL COMMENT '出账时费用项目名称快照',
amount DECIMAL(20,6) NOT NULL COMMENT '账单明细金额,精度6位小数', amount DECIMAL(20,6) NOT NULL COMMENT '账单明细金额,精度6位小数',
adjustment_reason VARCHAR(500) NULL COMMENT '最近一次账单调整行原因;完整历史保存在修订与审计记录',
currency_code CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT 'ISO 4217三位币种代码,首期仅CNY', currency_code CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT 'ISO 4217三位币种代码,首期仅CNY',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '账单明细创建时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '账单明细创建时间',
PRIMARY KEY (id), PRIMARY KEY (id),
@@ -10155,6 +10156,16 @@ CREATE TABLE IF NOT EXISTS fin_bill_line (
CONSTRAINT chk_fin_bill_line_amount CHECK (amount >= 0) CONSTRAINT chk_fin_bill_line_amount CHECK (amount >= 0)
) ENGINE=InnoDB COMMENT='账单费用来源快照明细'; ) ENGINE=InnoDB COMMENT='账单费用来源快照明细';
SET @fin_bill_line_adjustment_reason_sql = IF(
EXISTS(SELECT 1 FROM information_schema.columns
WHERE table_schema='baiyue_finance' AND table_name='fin_bill_line' AND column_name='adjustment_reason'),
'SELECT 1',
'ALTER TABLE baiyue_finance.fin_bill_line ADD COLUMN adjustment_reason VARCHAR(500) NULL COMMENT ''最近一次账单调整行原因;完整历史保存在修订与审计记录'' AFTER amount'
);
PREPARE fin_bill_line_adjustment_reason_stmt FROM @fin_bill_line_adjustment_reason_sql;
EXECUTE fin_bill_line_adjustment_reason_stmt;
DEALLOCATE PREPARE fin_bill_line_adjustment_reason_stmt;
CREATE TABLE IF NOT EXISTS fin_receipt ( CREATE TABLE IF NOT EXISTS fin_receipt (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID', id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
@@ -11191,6 +11202,7 @@ CALL queue_column_comment('baiyue_finance', 'fin_bill', 'void_reason', '账单
CALL queue_column_comment('baiyue_finance', 'fin_bill', 'voided_at', '作废时间,未作废时为空'); CALL queue_column_comment('baiyue_finance', 'fin_bill', 'voided_at', '作废时间,未作废时为空');
CALL queue_column_comment('baiyue_finance', 'fin_bill', 'voided_by', '作废操作人ID,未作废时为空'); CALL queue_column_comment('baiyue_finance', 'fin_bill', 'voided_by', '作废操作人ID,未作废时为空');
CALL queue_column_comment('baiyue_finance', 'fin_bill_line', 'amount', '账单明细金额,精度6位小数'); CALL queue_column_comment('baiyue_finance', 'fin_bill_line', 'amount', '账单明细金额,精度6位小数');
CALL queue_column_comment('baiyue_finance', 'fin_bill_line', 'adjustment_reason', '最近一次账单调整行原因;完整历史保存在修订与审计记录');
CALL queue_column_comment('baiyue_finance', 'fin_bill_line', 'bill_id', '所属账单ID'); CALL queue_column_comment('baiyue_finance', 'fin_bill_line', 'bill_id', '所属账单ID');
CALL queue_column_comment('baiyue_finance', 'fin_bill_line', 'created_at', '账单明细创建时间'); CALL queue_column_comment('baiyue_finance', 'fin_bill_line', 'created_at', '账单明细创建时间');
CALL queue_column_comment('baiyue_finance', 'fin_bill_line', 'currency_code', 'ISO 4217三位币种代码,首期仅CNY'); CALL queue_column_comment('baiyue_finance', 'fin_bill_line', 'currency_code', 'ISO 4217三位币种代码,首期仅CNY');
@@ -13760,10 +13772,10 @@ VALUES
(5205011, 10001, 'MERCHANT', 'BUTTON', 5205000, 'merchant:finance:statement:view', '查看账单详情及收款', NULL, 'GET', 20, '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; 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;
-- Finance G1/G2 新权限只进入可分配权限目录,不默认授予平台管理员。 -- Finance G1-G4 新权限只进入可分配权限目录,不默认授予平台管理员。
DROP PROCEDURE IF EXISTS migrate_finance_g1_g2_permissions; DROP PROCEDURE IF EXISTS migrate_finance_g1_g4_permissions;
DELIMITER $$ DELIMITER $$
CREATE PROCEDURE migrate_finance_g1_g2_permissions() CREATE PROCEDURE migrate_finance_g1_g4_permissions()
BEGIN BEGIN
IF EXISTS ( IF EXISTS (
SELECT 1 SELECT 1
@@ -13772,15 +13784,35 @@ BEGIN
OR (id = 5105023 AND permission_code <> 'platform:finance:fee-source:view') OR (id = 5105023 AND permission_code <> 'platform:finance:fee-source:view')
OR (id = 5105024 AND permission_code <> 'platform:finance:fee-source:review') OR (id = 5105024 AND permission_code <> 'platform:finance:fee-source:review')
OR (id = 5105025 AND permission_code <> 'platform:finance:fee-source:settlement') OR (id = 5105025 AND permission_code <> 'platform:finance:fee-source:settlement')
OR (id = 5105026 AND permission_code <> 'platform:finance:reconciliation:create')
OR (id = 5105027 AND permission_code <> 'platform:finance:reconciliation:review')
OR (id = 5105028 AND permission_code <> 'platform:finance:reconciliation:issue')
OR (id = 5105029 AND permission_code <> 'platform:finance:reconciliation:adjust')
OR (id = 5105030 AND permission_code <> 'platform:finance:reconciliation:void')
OR (id = 5105031 AND permission_code <> 'platform:finance:settlement-ar:create')
OR (id = 5105032 AND permission_code <> 'platform:finance:settlement-ap:create')
OR (id = 5105033 AND permission_code <> 'platform:finance:settlement-ar:view')
OR (id = 5105034 AND permission_code <> 'platform:finance:settlement-ap:view')
OR (id = 5105035 AND permission_code <> 'platform:finance:payment:create')
OR (permission_code = 'platform:finance:account:view' AND id <> 5105022) OR (permission_code = 'platform:finance:account:view' AND id <> 5105022)
OR (permission_code = 'platform:finance:fee-source:view' AND id <> 5105023) OR (permission_code = 'platform:finance:fee-source:view' AND id <> 5105023)
OR (permission_code = 'platform:finance:fee-source:review' AND id <> 5105024) OR (permission_code = 'platform:finance:fee-source:review' AND id <> 5105024)
OR (permission_code = 'platform:finance:fee-source:settlement' AND id <> 5105025) OR (permission_code = 'platform:finance:fee-source:settlement' AND id <> 5105025)
OR (id IN (5105022, 5105023, 5105024, 5105025) OR (permission_code = 'platform:finance:reconciliation:create' AND id <> 5105026)
OR (permission_code = 'platform:finance:reconciliation:review' AND id <> 5105027)
OR (permission_code = 'platform:finance:reconciliation:issue' AND id <> 5105028)
OR (permission_code = 'platform:finance:reconciliation:adjust' AND id <> 5105029)
OR (permission_code = 'platform:finance:reconciliation:void' AND id <> 5105030)
OR (permission_code = 'platform:finance:settlement-ar:create' AND id <> 5105031)
OR (permission_code = 'platform:finance:settlement-ap:create' AND id <> 5105032)
OR (permission_code = 'platform:finance:settlement-ar:view' AND id <> 5105033)
OR (permission_code = 'platform:finance:settlement-ap:view' AND id <> 5105034)
OR (permission_code = 'platform:finance:payment:create' AND id <> 5105035)
OR (id BETWEEN 5105022 AND 5105035
AND (tenant_id <> 0 OR app_code <> 'PLATFORM')) AND (tenant_id <> 0 OR app_code <> 'PLATFORM'))
) THEN ) THEN
SIGNAL SQLSTATE '45000' SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = 'finance G1/G2 permission id/code conflict'; SET MESSAGE_TEXT = 'finance G1-G4 permission id/code conflict';
END IF; END IF;
INSERT INTO baiyue_iam.iam_permission INSERT INTO baiyue_iam.iam_permission
@@ -13794,7 +13826,17 @@ BEGIN
(5105024, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:fee-source:review', (5105024, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:fee-source:review',
'审核财务费用来源', NULL, 'POST', 150, 'ACTIVE', 1, 0), '审核财务费用来源', NULL, 'POST', 150, 'ACTIVE', 1, 0),
(5105025, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:fee-source:settlement', (5105025, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:fee-source:settlement',
'设置费用来源参与结算', NULL, 'PUT', 160, 'ACTIVE', 1, 0) '设置费用来源参与结算', NULL, 'PUT', 160, 'ACTIVE', 1, 0),
(5105026, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:reconciliation:create', '生成和提交客户对账单', NULL, 'POST', 170, 'ACTIVE', 1, 0),
(5105027, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:reconciliation:review', '审核客户对账单', NULL, 'POST', 180, 'ACTIVE', 1, 0),
(5105028, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:reconciliation:issue', '出账客户对账单', NULL, 'POST', 190, 'ACTIVE', 1, 0),
(5105029, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:reconciliation:adjust', '调整客户对账单', NULL, 'POST', 200, 'ACTIVE', 1, 0),
(5105030, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:reconciliation:void', '作废或重开客户对账单', NULL, 'POST', 210, 'ACTIVE', 1, 0),
(5105031, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:settlement-ar:create', '生成应收结算单', NULL, 'POST', 220, 'ACTIVE', 1, 0),
(5105032, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:settlement-ap:create', '生成应付结算单', NULL, 'POST', 230, 'ACTIVE', 1, 0),
(5105033, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:settlement-ar:view', '查看应收结算单', NULL, 'GET', 240, 'ACTIVE', 1, 0),
(5105034, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:settlement-ap:view', '查看应付结算单', NULL, 'GET', 250, 'ACTIVE', 1, 0),
(5105035, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:payment:create', '登记应付付款', NULL, 'POST', 260, 'ACTIVE', 1, 0)
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
permission_type = VALUES(permission_type), parent_id = VALUES(parent_id), permission_type = VALUES(permission_type), parent_id = VALUES(parent_id),
permission_name = VALUES(permission_name), route_path = VALUES(route_path), permission_name = VALUES(permission_name), route_path = VALUES(route_path),
@@ -13814,14 +13856,24 @@ BEGIN
'platform:finance:account:view', 'platform:finance:account:view',
'platform:finance:fee-source:view', 'platform:finance:fee-source:view',
'platform:finance:fee-source:review', 'platform:finance:fee-source:review',
'platform:finance:fee-source:settlement')) <> 4 THEN 'platform:finance:fee-source:settlement',
'platform:finance:reconciliation:create',
'platform:finance:reconciliation:review',
'platform:finance:reconciliation:issue',
'platform:finance:reconciliation:adjust',
'platform:finance:reconciliation:void',
'platform:finance:settlement-ar:create',
'platform:finance:settlement-ap:create',
'platform:finance:settlement-ar:view',
'platform:finance:settlement-ap:view',
'platform:finance:payment:create')) <> 14 THEN
SIGNAL SQLSTATE '45000' SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = 'finance G1/G2 permission catalog is incomplete'; SET MESSAGE_TEXT = 'finance G1-G4 permission catalog is incomplete';
END IF; END IF;
END$$ END$$
DELIMITER ; DELIMITER ;
CALL migrate_finance_g1_g2_permissions(); CALL migrate_finance_g1_g4_permissions();
DROP PROCEDURE IF EXISTS migrate_finance_g1_g2_permissions; DROP PROCEDURE IF EXISTS migrate_finance_g1_g4_permissions;
INSERT INTO baiyue_iam.iam_role_permission (role_id, permission_id, tenant_id) INSERT INTO baiyue_iam.iam_role_permission (role_id, permission_id, tenant_id)
SELECT 4000001, id, 0 FROM baiyue_iam.iam_permission SELECT 4000001, id, 0 FROM baiyue_iam.iam_permission
@@ -13844,7 +13896,17 @@ DELETE rp
'platform:finance:account:view', 'platform:finance:account:view',
'platform:finance:fee-source:view', 'platform:finance:fee-source:view',
'platform:finance:fee-source:review', 'platform:finance:fee-source:review',
'platform:finance:fee-source:settlement'); 'platform:finance:fee-source:settlement',
'platform:finance:reconciliation:create',
'platform:finance:reconciliation:review',
'platform:finance:reconciliation:issue',
'platform:finance:reconciliation:adjust',
'platform:finance:reconciliation:void',
'platform:finance:settlement-ar:create',
'platform:finance:settlement-ap:create',
'platform:finance:settlement-ar:view',
'platform:finance:settlement-ap:view',
'platform:finance:payment:create');
INSERT INTO baiyue_iam.iam_role_permission (role_id, permission_id, tenant_id) INSERT INTO baiyue_iam.iam_role_permission (role_id, permission_id, tenant_id)
SELECT 4000101, id, 10001 FROM baiyue_iam.iam_permission 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:%') WHERE tenant_id=10001 AND app_code='MERCHANT' AND (permission_code='merchant:finance:statement' OR permission_code LIKE 'merchant:finance:%')
@@ -13854,7 +13916,7 @@ ON DUPLICATE KEY UPDATE tenant_id=VALUES(tenant_id);
DELIMITER $$ DELIMITER $$
CREATE PROCEDURE assert_finance_permissions() CREATE PROCEDURE assert_finance_permissions()
BEGIN 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) <> 16 THEN 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) <> 26 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:平台 Finance 权限不完整'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:平台 Finance 权限不完整';
END IF; 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 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
@@ -13863,8 +13925,8 @@ BEGIN
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 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 授权不完整'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:平台管理员 Finance 授权不完整';
END IF; 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 IN ('platform:finance:account:view','platform:finance:fee-source:view','platform:finance:fee-source:review','platform:finance:fee-source:settlement')) <> 0 THEN 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.id BETWEEN 5105022 AND 5105035) <> 0 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:Finance G1/G2 新权限不得默认授予平台管理员'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:Finance G1-G4 新权限不得默认授予平台管理员';
END IF; 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 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 只读授权不完整'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:客户管理员 Finance 只读授权不完整';
+1
View File
@@ -20,6 +20,7 @@
| 异常与响应包装 | `controller/FinanceExceptionHandler.java`、`controller/FinanceResponses.java` | | 异常与响应包装 | `controller/FinanceExceptionHandler.java`、`controller/FinanceResponses.java` |
| 数据库 | `src/main/resources/db/finance.sql`;存量/测试环境统一入口为 `../deploy/sql/头程数据库统一增量-MySQL8.sql` | | 数据库 | `src/main/resources/db/finance.sql`;存量/测试环境统一入口为 `../deploy/sql/头程数据库统一增量-MySQL8.sql` |
| 配置与网关 | `src/main/resources/application.yml`、`../config/nacos/{local,cn}/finance-service.yml`、`../gateway/src/main/resources/application.yml` | | 配置与网关 | `src/main/resources/application.yml`、`../config/nacos/{local,cn}/finance-service.yml`、`../gateway/src/main/resources/application.yml` |
| 独立服务部署链 | `../Jenkinsfile`、`../Jenkinsfile.service`、`../deploy/service-map.yml`、`../deploy/docker-compose.yml`、`../deploy/scripts/deploy-services.sh`;契约检查见 `../scripts/finance-deployment-contract.test.mjs`,部署授权边界见 `../../docs/测试环境Jenkins部署指南.md` |
| 测试 | `src/test/java/com/baiyue/finance/`;执行 `mvn -pl finance -am test` | | 测试 | `src/test/java/com/baiyue/finance/`;执行 `mvn -pl finance -am test` |
## 契约与边界 ## 契约与边界
@@ -9,6 +9,7 @@ import com.baiyue.finance.service.ReconciliationStatementService;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
@@ -34,6 +35,12 @@ public class ReconciliationStatementController {
return FinanceResponses.success(service.create(user.userId(), idempotencyKey, request)); return FinanceResponses.success(service.create(user.userId(), idempotencyKey, request));
} }
@GetMapping("/reconciliation-statements/{statementId}")
@PreAuthorize("hasAuthority('platform:finance:bill:view')")
public ApiResponse<ReconciliationDetail> getReconciliationStatement(@PathVariable long statementId) {
return FinanceResponses.success(service.detailForPlatform(statementId));
}
@PostMapping("/reconciliation-statements/{statementId}/actions") @PostMapping("/reconciliation-statements/{statementId}/actions")
@PreAuthorize("(#request.action != null && #request.action.equalsIgnoreCase('SUBMIT') && hasAuthority('platform:finance:reconciliation:create'))" @PreAuthorize("(#request.action != null && #request.action.equalsIgnoreCase('SUBMIT') && hasAuthority('platform:finance:reconciliation:create'))"
+ " || (#request.action != null && (#request.action.equalsIgnoreCase('APPROVE') || #request.action.equalsIgnoreCase('REJECT')) && hasAuthority('platform:finance:reconciliation:review'))" + " || (#request.action != null && (#request.action.equalsIgnoreCase('APPROVE') || #request.action.equalsIgnoreCase('REJECT')) && hasAuthority('platform:finance:reconciliation:review'))"
@@ -188,6 +188,15 @@ public class FinanceService {
BigDecimal applied = entry.getValue(); 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("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); 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);
Integer settlementCount = jdbc.queryForObject(
"SELECT COUNT(*) FROM fin_settlement WHERE tenant_id=? AND direction='RECEIVABLE' AND reconciliation_statement_id=? AND status<>'VOIDED'",
Integer.class, tenantId, entry.getKey());
if (settlementCount != null && settlementCount > 0) {
int settlementUpdated = jdbc.update(
"UPDATE fin_settlement SET paid_amount=paid_amount+?,outstanding_amount=outstanding_amount-?,payment_status=CASE WHEN outstanding_amount-?=0 THEN 'PAID' ELSE 'PARTIALLY_PAID' END,status=CASE WHEN outstanding_amount-?=0 THEN 'PAID' ELSE 'PARTIALLY_PAID' END,updated_by=?,version=version+1 WHERE tenant_id=? AND direction='RECEIVABLE' AND reconciliation_statement_id=? AND status IN ('ISSUED','PARTIALLY_PAID') AND outstanding_amount>=?",
applied, applied, applied, applied, operatorId, tenantId, entry.getKey(), applied);
if (settlementUpdated != 1) throw new BizException(ResultCode.CONFLICT, "应收结算单状态或余额已变化");
}
} }
complete(tenantId, operatorId, "RECEIPT_CREATE", idempotencyKey, receiptId); complete(tenantId, operatorId, "RECEIPT_CREATE", idempotencyKey, receiptId);
return jdbc.queryForMap("SELECT * FROM fin_receipt WHERE id=?", receiptId); return jdbc.queryForMap("SELECT * FROM fin_receipt WHERE id=?", receiptId);
@@ -171,10 +171,17 @@ public class FinanceSettlementService {
"FROM fin_settlement_line sl JOIN fin_fee_source f ON f.id=sl.fee_source_id " + "FROM fin_settlement_line sl JOIN fin_fee_source f ON f.id=sl.fee_source_id " +
"WHERE sl.tenant_id=? AND sl.settlement_id=? ORDER BY sl.id", summary.customerId(), settlementId) "WHERE sl.tenant_id=? AND sl.settlement_id=? ORDER BY sl.id", summary.customerId(), settlementId)
.stream().map(source -> line(source, summary)).toList(); .stream().map(source -> line(source, summary)).toList();
List<SettlementTransaction> transactions = jdbc.queryForList( List<SettlementTransaction> transactions = new ArrayList<>(jdbc.queryForList(
"SELECT id,transaction_type,transaction_date,amount,method,reference_no,voucher_file_ids,created_by,created_at,remark " + "SELECT id,transaction_type,transaction_date,amount,method,reference_no,voucher_file_ids,created_by,created_at,remark " +
"FROM fin_settlement_payment WHERE tenant_id=? AND settlement_id=? ORDER BY created_at,id", "FROM fin_settlement_payment WHERE tenant_id=? AND settlement_id=? ORDER BY created_at,id",
summary.customerId(), settlementId).stream().map(this::transaction).toList(); summary.customerId(), settlementId).stream().map(this::transaction).toList());
if ("RECEIVABLE".equals(summary.direction()) && summary.reconciliationStatementId() != null) {
transactions.addAll(jdbc.queryForList(
"SELECT r.id,'RECEIPT' transaction_type,DATE(r.received_at) transaction_date,rb.applied_amount amount,r.payment_method method,r.voucher_no reference_no,r.voucher_file_ids,r.created_by,r.created_at,r.remark " +
"FROM fin_receipt_bill rb JOIN fin_receipt r ON r.id=rb.receipt_id AND r.tenant_id=rb.tenant_id " +
"WHERE rb.tenant_id=? AND rb.bill_id=? AND r.status='CONFIRMED' ORDER BY r.created_at,r.id",
summary.customerId(), summary.reconciliationStatementId()).stream().map(this::transaction).toList());
}
List<Map<String, Object>> audits = jdbc.queryForList( List<Map<String, Object>> audits = jdbc.queryForList(
"SELECT action,reason,before_data,after_data,operator_id,created_at FROM fin_audit_log " + "SELECT action,reason,before_data,after_data,operator_id,created_at FROM fin_audit_log " +
"WHERE tenant_id=? AND resource_type='FIN_SETTLEMENT' AND resource_id=? ORDER BY created_at,id", "WHERE tenant_id=? AND resource_type='FIN_SETTLEMENT' AND resource_id=? ORDER BY created_at,id",
@@ -306,7 +313,7 @@ public class FinanceSettlementService {
if (!StringUtils.hasText(key) || key.length() > 128) throw error("FIN_400", "必须提供有效的Idempotency-Key"); if (!StringUtils.hasText(key) || key.length() > 128) throw error("FIN_400", "必须提供有效的Idempotency-Key");
String requestHash = hash(Map.of("method", method, "path", path, "customerId", customerId, "body", body)); String requestHash = hash(Map.of("method", method, "path", path, "customerId", customerId, "body", body));
List<Map<String, Object>> rows = jdbc.queryForList( List<Map<String, Object>> rows = jdbc.queryForList(
"SELECT request_hash,status,result_resource_id FROM fin_idempotency_record " + "SELECT request_hash,status,result_resource_id,updated_at FROM fin_idempotency_record " +
"WHERE tenant_id=? AND operator_id=? AND operation=? AND idempotency_key=?", "WHERE tenant_id=? AND operator_id=? AND operation=? AND idempotency_key=?",
customerId, operatorId, operation, key); customerId, operatorId, operation, key);
if (!rows.isEmpty()) { if (!rows.isEmpty()) {
@@ -315,6 +322,18 @@ public class FinanceSettlementService {
if ("SUCCEEDED".equals(text(row, "status")) && row.get("result_resource_id") != null) { if ("SUCCEEDED".equals(text(row, "status")) && row.get("result_resource_id") != null) {
return number(row, "result_resource_id").longValue(); return number(row, "result_resource_id").longValue();
} }
if ("FAILED".equals(text(row, "status"))) {
int reset = jdbc.update("UPDATE fin_idempotency_record SET status='PROCESSING',error_code=NULL,updated_at=CURRENT_TIMESTAMP(3) WHERE tenant_id=? AND operator_id=? AND operation=? AND idempotency_key=? AND status='FAILED'",
customerId, operatorId, operation, key);
if (reset == 1) return null;
}
LocalDateTime updatedAt = localTime(row.get("updated_at"));
if ("PROCESSING".equals(text(row, "status")) && updatedAt != null
&& updatedAt.isBefore(LocalDateTime.now().minusMinutes(5))) {
int reset = jdbc.update("UPDATE fin_idempotency_record SET updated_at=CURRENT_TIMESTAMP(3) WHERE tenant_id=? AND operator_id=? AND operation=? AND idempotency_key=? AND status='PROCESSING' AND updated_at=?",
customerId, operatorId, operation, key, updatedAt);
if (reset == 1) return null;
}
throw error("FIN_409", "相同请求正在处理中或等待恢复"); throw error("FIN_409", "相同请求正在处理中或等待恢复");
} }
jdbc.update("INSERT INTO fin_idempotency_record(tenant_id,operator_id,operation,idempotency_key,request_hash,status) " + jdbc.update("INSERT INTO fin_idempotency_record(tenant_id,operator_id,operation,idempotency_key,request_hash,status) " +
@@ -323,9 +342,10 @@ public class FinanceSettlementService {
} }
private void complete(long customerId, long operatorId, String operation, String key, long resourceId) { private void complete(long customerId, long operatorId, String operation, String key, long resourceId) {
jdbc.update("UPDATE fin_idempotency_record SET status='SUCCEEDED',result_resource_id=? WHERE tenant_id=? " + int changed = jdbc.update("UPDATE fin_idempotency_record SET status='SUCCEEDED',result_resource_id=?,updated_at=CURRENT_TIMESTAMP(3) WHERE tenant_id=? " +
"AND operator_id=? AND operation=? AND idempotency_key=? AND status='PROCESSING'", "AND operator_id=? AND operation=? AND idempotency_key=? AND status='PROCESSING'",
resourceId, customerId, operatorId, operation, key); resourceId, customerId, operatorId, operation, key);
if (changed != 1) throw error("FIN_409", "幂等请求完成状态冲突");
} }
private void audit(long customerId, long resourceId, String action, String reason, private void audit(long customerId, long resourceId, String action, String reason,
@@ -454,6 +474,11 @@ public class FinanceSettlementService {
if (value instanceof LocalDateTime time) return time; if (value instanceof LocalDateTime time) return time;
return LocalDateTime.parse(value.toString()); return LocalDateTime.parse(value.toString());
} }
private static LocalDateTime localTime(Object value) {
if (value instanceof Timestamp timestamp) return timestamp.toLocalDateTime();
if (value instanceof LocalDateTime time) return time;
return value == null ? null : LocalDateTime.parse(value.toString());
}
private static String blank(String value) { return StringUtils.hasText(value) ? value.trim() : null; } private static String blank(String value) { return StringUtils.hasText(value) ? value.trim() : null; }
private static boolean truth(Object value) { return value != null && Boolean.parseBoolean(value.toString()); } private static boolean truth(Object value) { return value != null && Boolean.parseBoolean(value.toString()); }
private static String jsonText(JsonNode node, String key) { private static String jsonText(JsonNode node, String key) {
@@ -1,10 +1,11 @@
package com.baiyue.finance.service; package com.baiyue.finance.service;
import com.baiyue.finance.dto.ReconciliationStatementDtos.CreateReconciliationRequest; import com.baiyue.finance.dto.ReconciliationStatementDtos.CreateReconciliationRequest;
import com.baiyue.finance.dto.ReconciliationStatementDtos.DocumentActionRequest; import com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationActionRequest;
import com.baiyue.finance.dto.ReconciliationStatementDtos.FeeSourceLine;
import com.baiyue.finance.dto.ReconciliationStatementDtos.PaymentHistory; import com.baiyue.finance.dto.ReconciliationStatementDtos.PaymentHistory;
import com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationDetail; import com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationDetail;
import com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationLine;
import com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationRevision;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.MDC; import org.slf4j.MDC;
@@ -37,7 +38,8 @@ import java.util.UUID;
@Service @Service
public class ReconciliationStatementService { public class ReconciliationStatementService {
private static final Set<String> MODES = Set.of("BY_CUTOFF", "SELECTED"); private static final Set<String> MODES = Set.of("BY_CUTOFF", "SELECTED");
private static final Set<String> ACTIONS = Set.of("SUBMIT", "APPROVE", "REJECT", "VOID"); private static final Set<String> ACTIONS = Set.of("SUBMIT", "APPROVE", "REJECT", "ISSUE", "ADJUST", "VOID", "REOPEN");
private static final Set<String> REASON_REQUIRED_ACTIONS = Set.of("REJECT", "ADJUST", "VOID", "REOPEN");
private final JdbcTemplate jdbc; private final JdbcTemplate jdbc;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
@@ -56,7 +58,7 @@ public class ReconciliationStatementService {
CustomerProfile customer = customer(request.customerId()); CustomerProfile customer = customer(request.customerId());
long subjectId = activeSubject(); long subjectId = activeSubject();
List<Source> sources = eligibleSources(request); List<Source> sources = eligibleSources(request);
if (sources.isEmpty()) throw error("FIN-REC-409", "没有符合结算条件的CNY应收费用来源"); if (sources.isEmpty()) throw error("FIN_409", "没有符合结算条件的CNY应收费用来源");
BigDecimal total = sources.stream().map(Source::amount).reduce(BigDecimal.ZERO, BigDecimal::add); BigDecimal total = sources.stream().map(Source::amount).reduce(BigDecimal.ZERO, BigDecimal::add);
LocalDate dueDate = request.periodEnd().plusDays(customer.creditDays()); LocalDate dueDate = request.periodEnd().plusDays(customer.creditDays());
@@ -79,7 +81,7 @@ public class ReconciliationStatementService {
statement.setLong(12, operatorId); statement.setLong(12, operatorId);
return statement; return statement;
}, holder); }, holder);
if (holder.getKey() == null) throw error("FIN-DATA-500", "对账单主键生成失败"); if (holder.getKey() == null) throw error("FIN_001", "对账单主键生成失败");
long statementId = holder.getKey().longValue(); long statementId = holder.getKey().longValue();
jdbc.update("UPDATE fin_bill SET bill_no=? WHERE id=?", stableNo(statementId), statementId); jdbc.update("UPDATE fin_bill SET bill_no=? WHERE id=?", stableNo(statementId), statementId);
for (Source source : sources) { for (Source source : sources) {
@@ -88,7 +90,7 @@ public class ReconciliationStatementService {
source.sourceNo(), source.orderId(), source.inboundId(), source.feeItemCode(), source.feeItemName(), source.amount()); source.sourceNo(), source.orderId(), source.inboundId(), source.feeItemCode(), source.feeItemName(), source.amount());
int locked = jdbc.update("UPDATE fin_fee_source SET status='LOCKED' WHERE id=? AND customer_id=? AND status='AVAILABLE'", int locked = jdbc.update("UPDATE fin_fee_source SET status='LOCKED' WHERE id=? AND customer_id=? AND status='AVAILABLE'",
source.id(), request.customerId()); source.id(), request.customerId());
if (locked != 1) throw error("FIN-REC-409", "费用来源已被其他对账单占用"); if (locked != 1) throw error("FIN_409", "费用来源已被其他对账单占用");
} }
CreationMetadata metadata = new CreationMetadata(request.generationMode().toUpperCase(Locale.ROOT), CreationMetadata metadata = new CreationMetadata(request.generationMode().toUpperCase(Locale.ROOT),
request.periodStart(), request.periodEnd()); request.periodStart(), request.periodEnd());
@@ -98,7 +100,7 @@ public class ReconciliationStatementService {
} }
@Transactional @Transactional
public ReconciliationDetail action(long operatorId, long statementId, String key, DocumentActionRequest request) { public ReconciliationDetail action(long operatorId, long statementId, String key, ReconciliationActionRequest request) {
validateAction(statementId, request); validateAction(statementId, request);
Bill before = bill(statementId); Bill before = bill(statementId);
Long replay = reserve(before.customerId(), operatorId, "RECONCILIATION_ACTION", key, Long replay = reserve(before.customerId(), operatorId, "RECONCILIATION_ACTION", key,
@@ -106,16 +108,23 @@ public class ReconciliationStatementService {
if (replay != null) return detail(before.customerId(), replay); if (replay != null) return detail(before.customerId(), replay);
String action = request.action().toUpperCase(Locale.ROOT); String action = request.action().toUpperCase(Locale.ROOT);
if ("ADJUST".equals(action)) {
return adjust(operatorId, statementId, key, request, before);
}
Transition transition = transition(action, before.status()); Transition transition = transition(action, before.status());
if ("VOID".equals(action)) requireNoReceipt(before.customerId(), statementId); if ("VOID".equals(action)) requireVoidable(before.customerId(), statementId);
if ("REOPEN".equals(action) && "VOIDED".equals(before.status())) reacquireSources(before.customerId(), statementId);
snapshot(before, request.reason(), operatorId); snapshot(before, request.reason(), operatorId);
int changed = jdbc.update("UPDATE fin_bill SET status=?,issued_at=CASE WHEN ?='ISSUED' THEN CURRENT_TIMESTAMP(3) ELSE issued_at END,issued_by=CASE WHEN ?='ISSUED' THEN ? ELSE issued_by END,voided_at=CASE WHEN ?='VOIDED' THEN CURRENT_TIMESTAMP(3) ELSE voided_at END,voided_by=CASE WHEN ?='VOIDED' THEN ? ELSE voided_by END,void_reason=CASE WHEN ?='VOIDED' THEN ? ELSE void_reason END,updated_by=?,version=version+1 WHERE id=? AND tenant_id=? AND status=? AND version=?", int changed = jdbc.update("UPDATE fin_bill SET status=?,issued_at=CASE WHEN ?='ISSUED' THEN CURRENT_TIMESTAMP(3) ELSE issued_at END,issued_by=CASE WHEN ?='ISSUED' THEN ? ELSE issued_by END,voided_at=CASE WHEN ?='VOIDED' THEN CURRENT_TIMESTAMP(3) ELSE voided_at END,voided_by=CASE WHEN ?='VOIDED' THEN ? ELSE voided_by END,void_reason=CASE WHEN ?='VOIDED' THEN ? ELSE void_reason END,updated_by=?,version=version+1 WHERE id=? AND tenant_id=? AND status=? AND version=?",
transition.to(), transition.to(), transition.to(), operatorId, transition.to(), transition.to(), operatorId, transition.to(), transition.to(), transition.to(), operatorId, transition.to(), transition.to(), operatorId,
transition.to(), request.reason(), operatorId, statementId, before.customerId(), transition.from(), request.expectedVersion()); transition.to(), request.reason(), operatorId, statementId, before.customerId(), transition.from(), request.expectedVersion());
if (changed != 1) throw error("FIN-REC-409", "对账单状态或版本已变化"); if (changed != 1) throw error("FIN_409", "对账单状态或版本已变化");
if ("APPROVE".equals(action)) { if ("ISSUE".equals(action)) {
jdbc.update("UPDATE fin_fee_source f JOIN fin_bill_line l ON l.source_id=f.source_fee_id AND l.source_version=f.source_fee_version SET f.status='BILLED' WHERE l.tenant_id=? AND l.bill_id=? AND f.customer_id=? AND f.status='LOCKED'", jdbc.update("UPDATE fin_fee_source f JOIN fin_bill_line l ON l.source_id=f.source_fee_id AND l.source_version=f.source_fee_version SET f.status='BILLED' WHERE l.tenant_id=? AND l.bill_id=? AND f.customer_id=? AND f.status='LOCKED'",
before.customerId(), statementId, before.customerId()); before.customerId(), statementId, before.customerId());
} else if ("VOID".equals(action)) {
jdbc.update("UPDATE fin_fee_source f JOIN fin_bill_line l ON l.source_id=f.source_fee_id AND l.source_version=f.source_fee_version SET f.status='AVAILABLE' WHERE l.tenant_id=? AND l.bill_id=? AND f.customer_id=? AND f.status IN ('LOCKED','BILLED')",
before.customerId(), statementId, before.customerId());
} }
ReconciliationDetail after = detail(before.customerId(), statementId); ReconciliationDetail after = detail(before.customerId(), statementId);
audit(before.customerId(), statementId, "RECONCILIATION_" + action, request.reason(), before, after, operatorId); audit(before.customerId(), statementId, "RECONCILIATION_" + action, request.reason(), before, after, operatorId);
@@ -123,19 +132,49 @@ public class ReconciliationStatementService {
return after; return after;
} }
private ReconciliationDetail adjust(long operatorId, long statementId, String key,
ReconciliationActionRequest request, Bill before) {
if (!"ISSUED".equals(before.status())) throw error("FIN_409", "仅已出账对账单允许调整");
snapshot(before, request.reason(), operatorId);
for (var line : request.adjustmentLines()) {
int changed = jdbc.update("UPDATE fin_bill_line SET amount=?,adjustment_reason=? WHERE id=? AND bill_id=? AND tenant_id=?",
line.adjustedAmount(), line.reason(), line.statementLineId(), statementId, before.customerId());
if (changed != 1) throw error("FIN_404", "调整行不存在或不属于该对账单");
}
BigDecimal total = jdbc.queryForObject(
"SELECT COALESCE(SUM(amount),0) FROM fin_bill_line WHERE tenant_id=? AND bill_id=?",
BigDecimal.class, before.customerId(), statementId);
if (total == null) total = BigDecimal.ZERO;
if (total.compareTo(before.received()) < 0) throw error("FIN_409", "调整后金额不得低于已收金额");
int changed = jdbc.update("UPDATE fin_bill SET total_amount=?,outstanding_amount=?-received_amount,updated_by=?,version=version+1 WHERE id=? AND tenant_id=? AND status='ISSUED' AND version=?",
total, total, operatorId, statementId, before.customerId(), request.expectedVersion());
if (changed != 1) throw error("FIN_409", "对账单状态或版本已变化");
ReconciliationDetail after = detail(before.customerId(), statementId);
audit(before.customerId(), statementId, "RECONCILIATION_ADJUST", request.reason(), before, after, operatorId);
complete(before.customerId(), operatorId, "RECONCILIATION_ACTION", key, statementId);
return after;
}
public ReconciliationDetail detail(long customerId, long statementId) { public ReconciliationDetail detail(long customerId, long statementId) {
Bill bill = bill(customerId, statementId); Bill bill = bill(customerId, statementId);
CreationMetadata metadata = creationMetadata(customerId, statementId); CreationMetadata metadata = creationMetadata(customerId, statementId);
List<FeeSourceLine> lines = jdbc.query( List<ReconciliationLine> lines = jdbc.query(
"SELECT f.id,f.source_type,f.source_fee_id,f.source_fee_version,f.direction,f.customer_id,f.order_id,f.inbound_id,f.container_id,f.fee_item_code,f.fee_item_name,f.amount,f.currency_code,f.status FROM fin_bill_line l JOIN fin_fee_source f ON f.source_fee_id=l.source_id AND f.source_fee_version=l.source_version AND f.customer_id=l.tenant_id WHERE l.tenant_id=? AND l.bill_id=? ORDER BY l.id", "SELECT f.id,l.id statement_line_id,f.source_type,f.source_fee_id,f.source_fee_version,f.source_hash,f.direction,f.customer_id,f.order_id,f.inbound_id,f.container_id,f.fee_item_code,f.fee_item_name,f.amount source_amount,l.amount statement_amount,l.adjustment_reason,f.currency_code,f.status FROM fin_bill_line l JOIN fin_fee_source f ON f.source_fee_id=l.source_id AND f.source_fee_version=l.source_version AND f.customer_id=l.tenant_id WHERE l.tenant_id=? AND l.bill_id=? ORDER BY l.id",
(rs, row) -> line(rs), customerId, statementId); (rs, row) -> line(rs), customerId, statementId);
List<PaymentHistory> payments = jdbc.query( List<PaymentHistory> payments = jdbc.query(
"SELECT r.id,r.received_at,r.received_amount,r.payment_method,r.voucher_no,r.created_by,r.created_at,r.remark FROM fin_receipt_bill rb JOIN fin_receipt r ON r.id=rb.receipt_id AND r.tenant_id=rb.tenant_id WHERE rb.tenant_id=? AND rb.bill_id=? AND r.status='CONFIRMED' ORDER BY r.received_at,r.id", "SELECT r.id,r.received_at,rb.applied_amount,r.payment_method,r.voucher_no,r.created_by,r.created_at,r.remark FROM fin_receipt_bill rb JOIN fin_receipt r ON r.id=rb.receipt_id AND r.tenant_id=rb.tenant_id WHERE rb.tenant_id=? AND rb.bill_id=? AND r.status='CONFIRMED' ORDER BY r.received_at,r.id",
(rs, row) -> payment(rs), customerId, statementId); (rs, row) -> payment(rs), customerId, statementId);
List<ReconciliationRevision> revisions = jdbc.query(
"SELECT action,reason,before_data,after_data,operator_id,created_at FROM fin_audit_log WHERE tenant_id=? AND resource_type='RECONCILIATION_STATEMENT' AND resource_id=? AND before_data IS NOT NULL ORDER BY created_at,id",
(rs, row) -> revision(rs, row + 1), customerId, statementId);
return new ReconciliationDetail(bill.id(), bill.number(), bill.customerId(), bill.customerName(), return new ReconciliationDetail(bill.id(), bill.number(), bill.customerId(), bill.customerName(),
metadata.periodStart(), metadata.periodEnd(), bill.creditDays(), "CNY", bill.total(), bill.received(), metadata.periodStart(), metadata.periodEnd(), bill.creditDays(), "CNY", bill.total(), bill.received(),
bill.outstanding(), bill.outstanding().signum() == 0, reconciliationStatus(bill.status()), bill.version(), bill.createdAt(), bill.outstanding(), bill.outstanding().signum() == 0, reconciliationStatus(bill.status()), bill.version(), bill.createdAt(),
bill.updatedAt(), bill.remark(), lines, payments); bill.updatedAt(), bill.remark(), lines, payments, revisions);
}
public ReconciliationDetail detailForPlatform(long statementId) {
return detail(bill(statementId).customerId(), statementId);
} }
private List<Source> eligibleSources(CreateReconciliationRequest request) { private List<Source> eligibleSources(CreateReconciliationRequest request) {
@@ -143,6 +182,14 @@ public class ReconciliationStatementService {
StringBuilder sql = new StringBuilder("SELECT id,source_fee_id,source_fee_version,source_type,source_no,order_id,inbound_id,container_id,fee_item_code,fee_item_name,amount FROM fin_fee_source WHERE customer_id=? AND direction='RECEIVABLE' AND status='AVAILABLE' AND currency_code='CNY' AND JSON_UNQUOTE(JSON_EXTRACT(source_snapshot,'$.reviewStatus'))='APPROVED' AND JSON_UNQUOTE(JSON_EXTRACT(source_snapshot,'$.settlementIncluded'))='true' AND STR_TO_DATE(JSON_UNQUOTE(JSON_EXTRACT(source_snapshot,'$.businessDate')),'%Y-%m-%d') BETWEEN ? AND ?"); StringBuilder sql = new StringBuilder("SELECT id,source_fee_id,source_fee_version,source_type,source_no,order_id,inbound_id,container_id,fee_item_code,fee_item_name,amount FROM fin_fee_source WHERE customer_id=? AND direction='RECEIVABLE' AND status='AVAILABLE' AND currency_code='CNY' AND JSON_UNQUOTE(JSON_EXTRACT(source_snapshot,'$.reviewStatus'))='APPROVED' AND JSON_UNQUOTE(JSON_EXTRACT(source_snapshot,'$.settlementIncluded'))='true' AND STR_TO_DATE(JSON_UNQUOTE(JSON_EXTRACT(source_snapshot,'$.businessDate')),'%Y-%m-%d') BETWEEN ? AND ?");
args.add(request.customerId()); args.add(request.periodStart()); args.add(request.periodEnd()); args.add(request.customerId()); args.add(request.periodStart()); args.add(request.periodEnd());
if ("SELECTED".equalsIgnoreCase(request.generationMode())) { if ("SELECTED".equalsIgnoreCase(request.generationMode())) {
List<Map<String, Object>> selected = jdbc.queryForList(
"SELECT id,customer_id FROM fin_fee_source WHERE id IN (" +
String.join(",", java.util.Collections.nCopies(request.feeSourceIds().size(), "?")) + ") FOR UPDATE",
request.feeSourceIds().toArray());
if (selected.size() != request.feeSourceIds().size()
|| selected.stream().anyMatch(row -> number(row, "customer_id").longValue() != request.customerId())) {
throw error("FIN_404", "费用来源不存在或不属于客户");
}
sql.append(" AND id IN ("); sql.append(" AND id IN (");
for (int index = 0; index < request.feeSourceIds().size(); index++) { for (int index = 0; index < request.feeSourceIds().size(); index++) {
if (index > 0) sql.append(','); if (index > 0) sql.append(',');
@@ -155,7 +202,7 @@ public class ReconciliationStatementService {
if ("SELECTED".equalsIgnoreCase(request.generationMode())) { if ("SELECTED".equalsIgnoreCase(request.generationMode())) {
Set<Long> actual = new HashSet<>(rows.stream().map(Source::id).toList()); Set<Long> actual = new HashSet<>(rows.stream().map(Source::id).toList());
if (!actual.equals(new HashSet<>(request.feeSourceIds()))) { if (!actual.equals(new HashSet<>(request.feeSourceIds()))) {
throw error("FIN-REC-409", "所选费用来源不存在、跨客户或不符合结算条件"); throw error("FIN_409", "所选费用来源不存在、跨客户或不符合结算条件");
} }
} }
return rows; return rows;
@@ -169,33 +216,33 @@ public class ReconciliationStatementService {
private long activeSubject() { private long activeSubject() {
List<Long> rows = jdbc.query("SELECT id FROM fin_subject WHERE status='ACTIVE' ORDER BY id LIMIT 1", (rs, row) -> rs.getLong(1)); List<Long> rows = jdbc.query("SELECT id FROM fin_subject WHERE status='ACTIVE' ORDER BY id LIMIT 1", (rs, row) -> rs.getLong(1));
if (rows.isEmpty()) throw error("FIN-REC-409", "平台财务主体未配置"); if (rows.isEmpty()) throw error("FIN_409", "平台财务主体未配置");
return rows.get(0); return rows.get(0);
} }
private Bill bill(long statementId) { private Bill bill(long statementId) {
List<Bill> rows = jdbc.query("SELECT b.*,COALESCE(p.credit_days,30) credit_days FROM fin_bill b LEFT JOIN fin_customer_settlement_profile p ON p.tenant_id=b.tenant_id WHERE b.id=?", List<Bill> rows = jdbc.query("SELECT b.*,COALESCE(p.credit_days,30) credit_days FROM fin_bill b LEFT JOIN fin_customer_settlement_profile p ON p.tenant_id=b.tenant_id WHERE b.id=?",
(rs, row) -> bill(rs), statementId); (rs, row) -> bill(rs), statementId);
if (rows.isEmpty()) throw error("FIN-REC-404", "对账单不存在"); if (rows.isEmpty()) throw error("FIN_404", "对账单不存在");
return rows.get(0); return rows.get(0);
} }
private Bill bill(long customerId, long statementId) { private Bill bill(long customerId, long statementId) {
List<Bill> rows = jdbc.query("SELECT b.*,COALESCE(p.credit_days,30) credit_days FROM fin_bill b LEFT JOIN fin_customer_settlement_profile p ON p.tenant_id=b.tenant_id WHERE b.id=? AND b.tenant_id=?", List<Bill> rows = jdbc.query("SELECT b.*,COALESCE(p.credit_days,30) credit_days FROM fin_bill b LEFT JOIN fin_customer_settlement_profile p ON p.tenant_id=b.tenant_id WHERE b.id=? AND b.tenant_id=?",
(rs, row) -> bill(rs), statementId, customerId); (rs, row) -> bill(rs), statementId, customerId);
if (rows.isEmpty()) throw error("FIN-REC-404", "对账单不存在或不属于客户"); if (rows.isEmpty()) throw error("FIN_404", "对账单不存在或不属于客户");
return rows.get(0); return rows.get(0);
} }
private CreationMetadata creationMetadata(long customerId, long statementId) { private CreationMetadata creationMetadata(long customerId, long statementId) {
List<String> rows = jdbc.query("SELECT after_data FROM fin_audit_log WHERE tenant_id=? AND resource_type='RECONCILIATION_STATEMENT' AND resource_id=? AND action='RECONCILIATION_CREATED' ORDER BY id LIMIT 1", List<String> rows = jdbc.query("SELECT after_data FROM fin_audit_log WHERE tenant_id=? AND resource_type='RECONCILIATION_STATEMENT' AND resource_id=? AND action='RECONCILIATION_CREATED' ORDER BY id LIMIT 1",
(rs, row) -> rs.getString(1), customerId, statementId); (rs, row) -> rs.getString(1), customerId, statementId);
if (rows.isEmpty()) throw error("FIN-DATA-500", "对账单期间审计快照缺失"); if (rows.isEmpty()) throw error("FIN_001", "对账单期间审计快照缺失");
try { try {
JsonNode node = objectMapper.readTree(rows.get(0)); JsonNode node = objectMapper.readTree(rows.get(0));
return new CreationMetadata(node.path("generationMode").asText(), LocalDate.parse(node.path("periodStart").asText()), LocalDate.parse(node.path("periodEnd").asText())); return new CreationMetadata(node.path("generationMode").asText(), LocalDate.parse(node.path("periodStart").asText()), LocalDate.parse(node.path("periodEnd").asText()));
} catch (Exception exception) { } catch (Exception exception) {
throw error("FIN-DATA-500", "对账单期间审计快照损坏"); throw error("FIN_001", "对账单期间审计快照损坏");
} }
} }
@@ -205,20 +252,31 @@ public class ReconciliationStatementService {
bill.customerId(), bill.id(), bill.version(), reason, json(bill), json(lines), operatorId); bill.customerId(), bill.id(), bill.version(), reason, json(bill), json(lines), operatorId);
} }
private void requireNoReceipt(long customerId, long statementId) { private void requireVoidable(long customerId, long statementId) {
Long count = jdbc.queryForObject("SELECT COUNT(*) FROM fin_receipt_bill WHERE tenant_id=? AND bill_id=?", Long.class, customerId, statementId); Long count = jdbc.queryForObject("SELECT COUNT(*) FROM fin_receipt_bill WHERE tenant_id=? AND bill_id=?", Long.class, customerId, statementId);
if (count != null && count > 0) throw error("FIN-REC-409", "已有收款记录的对账单不可作废"); if (count != null && count > 0) throw error("FIN_409", "已有收款记录的对账单不可作废");
count = jdbc.queryForObject("SELECT COUNT(*) FROM fin_settlement WHERE tenant_id=? AND reconciliation_statement_id=? AND status<>'VOIDED'",
Long.class, customerId, statementId);
if (count != null && count > 0) throw error("FIN_409", "已有有效结算单的对账单不可作废");
}
private void reacquireSources(long customerId, long statementId) {
Integer expected = jdbc.queryForObject("SELECT COUNT(*) FROM fin_bill_line WHERE tenant_id=? AND bill_id=?",
Integer.class, customerId, statementId);
int changed = jdbc.update("UPDATE fin_fee_source f JOIN fin_bill_line l ON l.source_id=f.source_fee_id AND l.source_version=f.source_fee_version SET f.status='LOCKED' WHERE l.tenant_id=? AND l.bill_id=? AND f.customer_id=? AND f.status='AVAILABLE'",
customerId, statementId, customerId);
if (expected == null || expected == 0 || changed != expected) throw error("FIN_409", "原费用来源已被占用,无法重开对账单");
} }
private Long reserve(long customerId, long operatorId, String operation, String key, private Long reserve(long customerId, long operatorId, String operation, String key,
String method, String path, Object body) { String method, String path, Object body) {
if (!StringUtils.hasText(key) || key.length() > 128) throw error("FIN-IDEM-400", "必须提供有效的Idempotency-Key"); if (!StringUtils.hasText(key) || key.length() > 128) throw error("FIN_400", "必须提供有效的Idempotency-Key");
String requestHash = hash(Map.of("method", method, "path", path, "customerId", customerId, "body", body)); String requestHash = hash(Map.of("method", method, "path", path, "customerId", customerId, "body", body));
List<Map<String, Object>> rows = jdbc.queryForList("SELECT request_hash,status,result_resource_id,updated_at FROM fin_idempotency_record WHERE tenant_id=? AND operator_id=? AND operation=? AND idempotency_key=?", List<Map<String, Object>> rows = jdbc.queryForList("SELECT request_hash,status,result_resource_id,updated_at FROM fin_idempotency_record WHERE tenant_id=? AND operator_id=? AND operation=? AND idempotency_key=?",
customerId, operatorId, operation, key); customerId, operatorId, operation, key);
if (!rows.isEmpty()) { if (!rows.isEmpty()) {
Map<String, Object> row = rows.get(0); Map<String, Object> row = rows.get(0);
if (!requestHash.equals(row.get("request_hash"))) throw error("FIN-IDEM-409", "Idempotency-Key已用于其他请求"); if (!requestHash.equals(row.get("request_hash"))) throw error("FIN_409", "Idempotency-Key已用于其他请求");
if ("SUCCEEDED".equals(row.get("status")) && row.get("result_resource_id") != null) return ((Number) row.get("result_resource_id")).longValue(); if ("SUCCEEDED".equals(row.get("status")) && row.get("result_resource_id") != null) return ((Number) row.get("result_resource_id")).longValue();
if ("FAILED".equals(row.get("status")) && jdbc.update("UPDATE fin_idempotency_record SET status='PROCESSING',error_code=NULL WHERE tenant_id=? AND operator_id=? AND operation=? AND idempotency_key=? AND status='FAILED'", if ("FAILED".equals(row.get("status")) && jdbc.update("UPDATE fin_idempotency_record SET status='PROCESSING',error_code=NULL WHERE tenant_id=? AND operator_id=? AND operation=? AND idempotency_key=? AND status='FAILED'",
customerId, operatorId, operation, key) == 1) return null; customerId, operatorId, operation, key) == 1) return null;
@@ -226,7 +284,7 @@ public class ReconciliationStatementService {
if ("PROCESSING".equals(row.get("status")) && updatedAt != null && updatedAt.isBefore(LocalDateTime.now().minusMinutes(5)) if ("PROCESSING".equals(row.get("status")) && updatedAt != null && updatedAt.isBefore(LocalDateTime.now().minusMinutes(5))
&& jdbc.update("UPDATE fin_idempotency_record SET updated_at=CURRENT_TIMESTAMP(3) WHERE tenant_id=? AND operator_id=? AND operation=? AND idempotency_key=? AND status='PROCESSING' AND updated_at=?", && jdbc.update("UPDATE fin_idempotency_record SET updated_at=CURRENT_TIMESTAMP(3) WHERE tenant_id=? AND operator_id=? AND operation=? AND idempotency_key=? AND status='PROCESSING' AND updated_at=?",
customerId, operatorId, operation, key, updatedAt) == 1) return null; customerId, operatorId, operation, key, updatedAt) == 1) return null;
throw error("FIN-IDEM-409", "相同请求正在处理中"); throw error("FIN_409", "相同请求正在处理中");
} }
jdbc.update("INSERT INTO fin_idempotency_record(tenant_id,operator_id,operation,idempotency_key,request_hash,status) VALUES(?,?,?,?,?,'PROCESSING')", jdbc.update("INSERT INTO fin_idempotency_record(tenant_id,operator_id,operation,idempotency_key,request_hash,status) VALUES(?,?,?,?,?,'PROCESSING')",
customerId, operatorId, operation, key, requestHash); customerId, operatorId, operation, key, requestHash);
@@ -236,7 +294,7 @@ public class ReconciliationStatementService {
private void complete(long customerId, long operatorId, String operation, String key, long resourceId) { private void complete(long customerId, long operatorId, String operation, String key, long resourceId) {
int changed = 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'", int changed = 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, customerId, operatorId, operation, key); resourceId, customerId, operatorId, operation, key);
if (changed != 1) throw error("FIN-IDEM-409", "幂等请求完成状态冲突"); if (changed != 1) throw error("FIN_409", "幂等请求完成状态冲突");
} }
private void audit(long customerId, long statementId, String action, String reason, private void audit(long customerId, long statementId, String action, String reason,
@@ -248,47 +306,76 @@ public class ReconciliationStatementService {
private static void validateCreate(CreateReconciliationRequest request) { private static void validateCreate(CreateReconciliationRequest request) {
if (request == null || request.customerId() <= 0 || request.periodStart() == null || request.periodEnd() == null if (request == null || request.customerId() <= 0 || request.periodStart() == null || request.periodEnd() == null
|| request.periodStart().isAfter(request.periodEnd())) throw error("FIN-REQ-400", "对账单客户和期间无效"); || request.periodStart().isAfter(request.periodEnd())) throw error("FIN_400", "对账单客户和期间无效");
if (!"CNY".equals(request.currency())) throw error("FIN_400", "一期仅支持CNY");
String mode = request.generationMode() == null ? "" : request.generationMode().toUpperCase(Locale.ROOT); String mode = request.generationMode() == null ? "" : request.generationMode().toUpperCase(Locale.ROOT);
if (!MODES.contains(mode)) throw error("FIN-REQ-400", "对账单生成方式无效"); if (!MODES.contains(mode)) throw error("FIN_400", "对账单生成方式无效");
if (request.remark() != null && request.remark().length() > 500) throw error("FIN-REQ-400", "备注不能超过500个字符"); if (request.remark() != null && request.remark().length() > 500) throw error("FIN_400", "备注不能超过500个字符");
if ("SELECTED".equals(mode)) { if ("SELECTED".equals(mode)) {
if (request.feeSourceIds() == null || request.feeSourceIds().isEmpty()) throw error("FIN-REQ-400", "勾选生成必须选择费用来源"); if (request.feeSourceIds() == null || request.feeSourceIds().isEmpty()) throw error("FIN_400", "勾选生成必须选择费用来源");
if (request.feeSourceIds().stream().anyMatch(id -> id == null || id <= 0) if (request.feeSourceIds().stream().anyMatch(id -> id == null || id <= 0)
|| new HashSet<>(request.feeSourceIds()).size() != request.feeSourceIds().size()) { || new HashSet<>(request.feeSourceIds()).size() != request.feeSourceIds().size()) {
throw error("FIN-REQ-400", "费用来源ID无效或重复"); throw error("FIN_400", "费用来源ID无效或重复");
} }
} else if (request.feeSourceIds() != null && !request.feeSourceIds().isEmpty()) { } else if (request.feeSourceIds() != null && !request.feeSourceIds().isEmpty()) {
throw error("FIN-REQ-400", "按截止期生成不能同时指定费用来源"); throw error("FIN_400", "按截止期生成不能同时指定费用来源");
} }
} }
private static void validateAction(long statementId, DocumentActionRequest request) { private static void validateAction(long statementId, ReconciliationActionRequest request) {
if (statementId <= 0 || request == null || request.expectedVersion() < 0 || !StringUtils.hasText(request.reason()) if (statementId <= 0 || request == null || request.expectedVersion() < 0) throw error("FIN_400", "对账单动作请求无效");
|| request.reason().length() > 500) throw error("FIN-REQ-400", "对账单动作请求无效"); if (request.action() == null || !ACTIONS.contains(request.action().toUpperCase(Locale.ROOT))) throw error("FIN_400", "对账单动作无效");
if (request.action() == null || !ACTIONS.contains(request.action().toUpperCase(Locale.ROOT))) throw error("FIN-REQ-400", "对账单动作无效"); String action = request.action().toUpperCase(Locale.ROOT);
if (request.reason() != null && request.reason().length() > 500) throw error("FIN_400", "原因不能超过500个字符");
if (REASON_REQUIRED_ACTIONS.contains(action) && !StringUtils.hasText(request.reason())) throw error("FIN_400", "该动作必须填写原因");
List<com.baiyue.finance.dto.ReconciliationStatementDtos.StatementAdjustmentLineRequest> lines = request.adjustmentLines();
if ("ADJUST".equals(action)) {
if (lines == null || lines.isEmpty()) throw error("FIN_400", "调整必须包含至少一条明细");
Set<Long> ids = new HashSet<>();
for (var line : lines) {
if (line == null || line.statementLineId() <= 0 || line.adjustedAmount() == null
|| line.adjustedAmount().signum() <= 0 || !StringUtils.hasText(line.reason())
|| line.reason().length() > 500 || !ids.add(line.statementLineId())) {
throw error("FIN_400", "调整明细无效或重复");
}
}
} else if (lines != null && !lines.isEmpty()) {
throw error("FIN_400", "非调整动作不能提交调整明细");
}
} }
private static Transition transition(String action, String status) { private static Transition transition(String action, String status) {
return switch (action) { return switch (action) {
case "SUBMIT" -> requireTransition(status, "DRAFT", "PENDING_REVIEW"); case "SUBMIT" -> requireTransition(status, "DRAFT", "PENDING_REVIEW");
case "APPROVE" -> requireTransition(status, "PENDING_REVIEW", "ISSUED"); case "APPROVE" -> requireTransition(status, "PENDING_REVIEW", "APPROVED");
case "REJECT" -> requireTransition(status, "PENDING_REVIEW", "REJECTED"); case "REJECT" -> requireTransition(status, "PENDING_REVIEW", "REJECTED");
case "ISSUE" -> requireTransition(status, "APPROVED", "ISSUED");
case "REOPEN" -> {
if (!Set.of("REJECTED", "VOIDED").contains(status)) throw error("FIN_409", "当前状态不允许重开");
yield new Transition(status, "DRAFT");
}
case "VOID" -> { case "VOID" -> {
if (!Set.of("DRAFT", "PENDING_REVIEW", "REJECTED", "ISSUED").contains(status)) throw error("FIN-REC-409", "当前状态不允许作废"); if (!Set.of("DRAFT", "PENDING_REVIEW", "APPROVED", "ISSUED").contains(status)) throw error("FIN_409", "当前状态不允许作废");
yield new Transition(status, "VOIDED"); yield new Transition(status, "VOIDED");
} }
default -> throw error("FIN-REQ-400", "对账单动作无效"); default -> throw error("FIN_400", "对账单动作无效");
}; };
} }
private static Transition requireTransition(String actual, String expected, String target) { private static Transition requireTransition(String actual, String expected, String target) {
if (!expected.equals(actual)) throw error("FIN-REC-409", "当前状态不允许执行该动作"); if (!expected.equals(actual)) throw error("FIN_409", "当前状态不允许执行该动作");
return new Transition(expected, target); return new Transition(expected, target);
} }
private static String reconciliationStatus(String billStatus) { private static String reconciliationStatus(String billStatus) {
return "ISSUED".equals(billStatus) ? "APPROVED" : billStatus; return billStatus;
}
private static Number number(Map<String, Object> row, String key) {
Object value = row.get(key);
if (value instanceof Number number) return number;
if (value == null) throw error("FIN_001", "财务字段缺失:" + key);
return new BigDecimal(value.toString());
} }
private static Source source(ResultSet rs) throws SQLException { private static Source source(ResultSet rs) throws SQLException {
@@ -298,21 +385,37 @@ public class ReconciliationStatementService {
rs.getString("fee_item_name"), rs.getBigDecimal("amount")); rs.getString("fee_item_name"), rs.getBigDecimal("amount"));
} }
private static FeeSourceLine line(ResultSet rs) throws SQLException { private static ReconciliationLine line(ResultSet rs) throws SQLException {
return new FeeSourceLine(rs.getLong("id"), rs.getString("source_type"), rs.getLong("source_fee_id"), return new ReconciliationLine(rs.getLong("statement_line_id"), rs.getLong("id"),
rs.getInt("source_fee_version"), rs.getString("direction"), rs.getLong("customer_id"), rs.getInt("source_fee_version"), rs.getString("source_hash"), nullableLong(rs, "inbound_id"),
nullableLong(rs, "order_id"), nullableLong(rs, "inbound_id"), nullableLong(rs, "container_id"), nullableLong(rs, "container_id"), rs.getString("fee_item_name"), rs.getBigDecimal("source_amount"),
rs.getString("fee_item_code"), rs.getString("fee_item_name"), rs.getBigDecimal("amount"), rs.getBigDecimal("statement_amount"), rs.getString("currency_code"), rs.getString("adjustment_reason"));
rs.getString("currency_code"), rs.getString("status"));
} }
private static PaymentHistory payment(ResultSet rs) throws SQLException { private static PaymentHistory payment(ResultSet rs) throws SQLException {
Timestamp received = rs.getTimestamp("received_at"); Timestamp received = rs.getTimestamp("received_at");
return new PaymentHistory(rs.getLong("id"), "RECEIPT", received.toLocalDateTime().toLocalDate(), return new PaymentHistory(rs.getLong("id"), "RECEIPT", received.toLocalDateTime().toLocalDate(),
rs.getBigDecimal("received_amount"), rs.getString("payment_method"), rs.getString("voucher_no"), rs.getBigDecimal("applied_amount"), rs.getString("payment_method"), rs.getString("voucher_no"),
rs.getLong("created_by"), rs.getTimestamp("created_at").toLocalDateTime(), rs.getString("remark")); rs.getLong("created_by"), rs.getTimestamp("created_at").toLocalDateTime(), rs.getString("remark"));
} }
private ReconciliationRevision revision(ResultSet rs, int revisionNo) throws SQLException {
return new ReconciliationRevision(revisionNo, rs.getString("action"), amountFromJson(rs.getString("before_data")),
amountFromJson(rs.getString("after_data")), rs.getString("reason"), rs.getLong("operator_id"),
rs.getTimestamp("created_at").toLocalDateTime());
}
private BigDecimal amountFromJson(String value) {
if (!StringUtils.hasText(value)) return BigDecimal.ZERO;
try {
JsonNode node = objectMapper.readTree(value);
JsonNode amount = node.has("totalAmount") ? node.get("totalAmount") : node.get("total");
return amount == null || amount.isNull() ? BigDecimal.ZERO : amount.decimalValue();
} catch (Exception exception) {
throw error("FIN_001", "对账单修订快照损坏");
}
}
private static Bill bill(ResultSet rs) throws SQLException { private static Bill bill(ResultSet rs) throws SQLException {
return new Bill(rs.getLong("id"), rs.getLong("tenant_id"), rs.getString("bill_no"), return new Bill(rs.getLong("id"), rs.getLong("tenant_id"), rs.getString("bill_no"),
rs.getString("customer_name_snapshot"), rs.getString("status"), rs.getBigDecimal("total_amount"), rs.getString("customer_name_snapshot"), rs.getString("status"), rs.getBigDecimal("total_amount"),
@@ -323,12 +426,12 @@ public class ReconciliationStatementService {
private String json(Object value) { private String json(Object value) {
try { return objectMapper.writeValueAsString(value); } try { return objectMapper.writeValueAsString(value); }
catch (Exception exception) { throw error("FIN-DATA-500", "财务数据序列化失败"); } catch (Exception exception) { throw error("FIN_001", "财务数据序列化失败"); }
} }
private String hash(Object value) { private String hash(Object value) {
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json(value).getBytes(StandardCharsets.UTF_8))); } try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json(value).getBytes(StandardCharsets.UTF_8))); }
catch (Exception exception) { throw error("FIN-DATA-500", "幂等请求哈希失败"); } catch (Exception exception) { throw error("FIN_001", "幂等请求哈希失败"); }
} }
private static LocalDateTime localTime(Object value) { private static LocalDateTime localTime(Object value) {
@@ -92,13 +92,84 @@ class FinanceServiceValidationTest {
FinanceFileClient files = mock(FinanceFileClient.class); FinanceFileClient files = mock(FinanceFileClient.class);
when(files.metadata(anyString(), eq(9L), eq(9L), eq(77L))).thenReturn(ApiResponse.success( when(files.metadata(anyString(), eq(9L), eq(9L), eq(77L))).thenReturn(ApiResponse.success(
new FinanceFileClient.Metadata(77L, 10L, "FINANCE_VOUCHER", "9", "v.jpg", "sha", "image/jpeg", 10L))); new FinanceFileClient.Metadata(77L, 10L, "FINANCE_VOUCHER", "9", "v.jpg", "sha", "image/jpeg", 10L)));
FinanceService service = new FinanceService(jdbc, new ObjectMapper()); FinanceService service = new FinanceService(jdbc, new ObjectMapper().findAndRegisterModules());
ReflectionTestUtils.setField(service, "fileClient", files); ReflectionTestUtils.setField(service, "fileClient", files);
var request = new FinanceService.ReceiptRequest("R-3", 1L, null, "BANK_TRANSFER", null, List.of("77"), null, var request = new FinanceService.ReceiptRequest("R-3", 1L, null, "BANK_TRANSFER", null, List.of("77"), null,
List.of(new FinanceService.ReceiptAllocation(10L, BigDecimal.ONE))); List.of(new FinanceService.ReceiptAllocation(10L, BigDecimal.ONE)));
assertThrows(RuntimeException.class, () -> service.createReceipt(9L, 2L, request, "k4")); assertThrows(RuntimeException.class, () -> service.createReceipt(9L, 2L, request, "k4"));
} }
@Test
void receiptAllocationUpdatesLinkedReceivableSettlement() {
JdbcTemplate jdbc = mock(JdbcTemplate.class, invocation ->
"update".equals(invocation.getMethod().getName()) && invocation.getMethod().getReturnType() == int.class
? 1 : RETURNS_DEFAULTS.answer(invocation));
FinanceFileClient files = mock(FinanceFileClient.class);
when(files.metadata("internal-token", 9L, 9L, 77L)).thenReturn(ApiResponse.success(
new FinanceFileClient.Metadata(77L, 9L, "FINANCE_VOUCHER", "9", "receipt.jpg", "sha", "image/jpeg", 9L)));
when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(9L), eq(2L), eq("RECEIPT_CREATE"), eq("receipt-key"))).thenReturn(List.of());
when(jdbc.queryForMap(startsWith("SELECT outstanding_amount"), eq(50L), eq(9L))).thenReturn(
java.util.Map.of("outstanding_amount", new BigDecimal("100.00"), "status", "ISSUED"));
when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_settlement"), eq(Integer.class), eq(9L), eq(50L))).thenReturn(1);
when(jdbc.queryForMap(startsWith("SELECT * FROM fin_receipt WHERE id="), eq(701L))).thenReturn(java.util.Map.of("id", 701L));
doAnswer(invocation -> {
org.springframework.jdbc.support.KeyHolder holder = invocation.getArgument(1);
holder.getKeyList().add(java.util.Map.of("GENERATED_KEY", 701L));
return 1;
}).when(jdbc).update(any(org.springframework.jdbc.core.PreparedStatementCreator.class), any(org.springframework.jdbc.support.KeyHolder.class));
FinanceService service = new FinanceService(jdbc, new ObjectMapper().findAndRegisterModules());
ReflectionTestUtils.setField(service, "fileClient", files);
ReflectionTestUtils.setField(service, "fileToken", "internal-token");
var request = new FinanceService.ReceiptRequest("R-4", 1L, java.time.LocalDateTime.of(2026, 8, 11, 10, 0),
"BANK_TRANSFER", null, List.of("77"), "首笔收款",
List.of(new FinanceService.ReceiptAllocation(50L, new BigDecimal("40.00"))));
service.createReceipt(9L, 2L, request, "receipt-key");
verify(jdbc).update(startsWith("UPDATE fin_settlement SET paid_amount="),
eq(new BigDecimal("40.00")), eq(new BigDecimal("40.00")), eq(new BigDecimal("40.00")),
eq(new BigDecimal("40.00")), eq(2L), eq(9L), eq(50L), eq(new BigDecimal("40.00")));
}
@Test
void receiptAllocatesThreeHundredAcrossThreeIssuedBills() {
JdbcTemplate jdbc = mock(JdbcTemplate.class, invocation ->
"update".equals(invocation.getMethod().getName()) && invocation.getMethod().getReturnType() == int.class
? 1 : RETURNS_DEFAULTS.answer(invocation));
FinanceFileClient files = mock(FinanceFileClient.class);
when(files.metadata("internal-token", 9L, 9L, 77L)).thenReturn(ApiResponse.success(
new FinanceFileClient.Metadata(77L, 9L, "FINANCE_VOUCHER", "9", "receipt.jpg", "sha", "image/jpeg", 9L)));
when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(9L), eq(2L), eq("RECEIPT_CREATE"), eq("receipt-three-key"))).thenReturn(List.of());
for (long billId : List.of(50L, 51L, 52L)) {
when(jdbc.queryForMap(startsWith("SELECT outstanding_amount"), eq(billId), eq(9L))).thenReturn(
java.util.Map.of("outstanding_amount", new BigDecimal("100.00"), "status", "ISSUED"));
when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_settlement"), eq(Integer.class), eq(9L), eq(billId))).thenReturn(0);
}
when(jdbc.queryForMap(startsWith("SELECT * FROM fin_receipt WHERE id="), eq(701L))).thenReturn(
java.util.Map.of("id", 701L, "received_amount", new BigDecimal("300.00")));
doAnswer(invocation -> {
org.springframework.jdbc.support.KeyHolder holder = invocation.getArgument(1);
holder.getKeyList().add(java.util.Map.of("GENERATED_KEY", 701L));
return 1;
}).when(jdbc).update(any(org.springframework.jdbc.core.PreparedStatementCreator.class), any(org.springframework.jdbc.support.KeyHolder.class));
FinanceService service = new FinanceService(jdbc, new ObjectMapper().findAndRegisterModules());
ReflectionTestUtils.setField(service, "fileClient", files);
ReflectionTestUtils.setField(service, "fileToken", "internal-token");
var request = new FinanceService.ReceiptRequest("R-300", 1L, java.time.LocalDateTime.of(2026, 8, 11, 10, 0),
"BANK_TRANSFER", "BANK-300", List.of("77"), "三单收款", List.of(
new FinanceService.ReceiptAllocation(50L, new BigDecimal("100.00")),
new FinanceService.ReceiptAllocation(51L, new BigDecimal("100.00")),
new FinanceService.ReceiptAllocation(52L, new BigDecimal("100.00"))));
var result = service.createReceipt(9L, 2L, request, "receipt-three-key");
assertEquals(new BigDecimal("300.00"), result.get("received_amount"));
for (long billId : List.of(50L, 51L, 52L)) {
verify(jdbc).update(startsWith("INSERT INTO fin_receipt_bill"), eq(9L), eq(701L), eq(billId),
eq(new BigDecimal("100.00")), eq(2L));
}
}
@Test @Test
void rejectsSingleOrderWhenOrderNotOwnedByCustomer() { void rejectsSingleOrderWhenOrderNotOwnedByCustomer() {
JdbcTemplate jdbc = mock(JdbcTemplate.class); JdbcTemplate jdbc = mock(JdbcTemplate.class);
@@ -17,6 +17,8 @@ import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.PreparedStatementCreator;
import org.springframework.jdbc.support.KeyHolder;
import org.springframework.security.access.AccessDeniedException; import org.springframework.security.access.AccessDeniedException;
import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.util.ReflectionTestUtils;
@@ -35,9 +37,11 @@ import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.startsWith; import static org.mockito.ArgumentMatchers.startsWith;
import org.mockito.ArgumentCaptor;
import static org.mockito.Mockito.never; import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
import static org.mockito.Mockito.RETURNS_DEFAULTS;
@ExtendWith(MockitoExtension.class) @ExtendWith(MockitoExtension.class)
class FinanceSettlementBehaviorTest { class FinanceSettlementBehaviorTest {
@@ -99,6 +103,25 @@ class FinanceSettlementBehaviorTest {
verify(jdbc, never()).update(startsWith("UPDATE fin_settlement SET paid_amount"), (Object[]) any()); verify(jdbc, never()).update(startsWith("UPDATE fin_settlement SET paid_amount"), (Object[]) any());
} }
@Test
void paymentRejectsInvalidCurrencyMissingVoucherStaleVersionAndOverage() {
JdbcTemplate noDatabase = org.mockito.Mockito.mock(JdbcTemplate.class);
FinanceSettlementService validationService = service(noDatabase, org.mockito.Mockito.mock(FinanceFileClient.class));
assertCode(() -> validationService.createSettlementPayment(88L, 7L, "usd-key",
new CreateSettlementPaymentRequest(LocalDate.now(), BigDecimal.ONE, "USD", "BANK_TRANSFER", "BANK-1", List.of("91"), 0, null)), "FIN_400");
assertCode(() -> validationService.createSettlementPayment(88L, 7L, "voucher-key",
new CreateSettlementPaymentRequest(LocalDate.now(), BigDecimal.ONE, "CNY", "BANK_TRANSFER", "BANK-1", List.of(), 0, null)), "FIN_400");
verify(noDatabase, never()).queryForList(anyString(), (Object[]) any());
JdbcTemplate jdbc = org.mockito.Mockito.mock(JdbcTemplate.class);
when(jdbc.queryForList(startsWith("SELECT id,tenant_id,direction"), eq(88L)))
.thenReturn(List.of(settlementRow("PAYABLE", "ISSUED", BigDecimal.ZERO, new BigDecimal("100.00"), 2)));
FinanceSettlementService service = service(jdbc, org.mockito.Mockito.mock(FinanceFileClient.class));
assertCode(() -> service.createSettlementPayment(88L, 7L, "stale-key", payment(new BigDecimal("40.00"), 1)), "FIN_409");
assertCode(() -> service.createSettlementPayment(88L, 7L, "over-key", payment(new BigDecimal("100.01"), 2)), "FIN_409");
verify(jdbc, never()).update(startsWith("INSERT INTO fin_settlement_payment"), (Object[]) any());
}
@Test @Test
void partialPaymentUpdatesOutstandingAndReturnsTypedDetail() { void partialPaymentUpdatesOutstandingAndReturnsTypedDetail() {
JdbcTemplate jdbc = org.mockito.Mockito.mock(JdbcTemplate.class, invocation -> JdbcTemplate jdbc = org.mockito.Mockito.mock(JdbcTemplate.class, invocation ->
@@ -136,6 +159,232 @@ class FinanceSettlementBehaviorTest {
any(), any(), any(), any(), any(), any(), any(), any()); any(), any(), any(), any(), any(), any(), any(), any());
} }
@Test
void issuedReconciliationCreatesUniqueReceivableSettlement() {
Map<String, Object> settlement = settlementRow("RECEIVABLE", "ISSUED", BigDecimal.ZERO,
new BigDecimal("300.00"), 0);
settlement.put("reconciliation_statement_id", 501L);
JdbcTemplate jdbc = creationJdbc(settlement);
when(jdbc.queryForList(startsWith("SELECT id,tenant_id,bill_no"), eq(501L))).thenReturn(List.of(Map.ofEntries(
Map.entry("id", 501L), Map.entry("tenant_id", 10L), Map.entry("bill_no", "REC-501"),
Map.entry("customer_name_snapshot", "客户10"), Map.entry("currency_code", "CNY"),
Map.entry("bill_date", Date.valueOf("2026-08-31")), Map.entry("due_date", Date.valueOf("2026-09-30")),
Map.entry("status", "ISSUED"), Map.entry("total_amount", new BigDecimal("300.00")))));
when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(10L), eq(7L), eq("SETTLEMENT_AR_CREATE"), eq("ar-key"))).thenReturn(List.of());
when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_settlement WHERE"), eq(Integer.class), eq(10L), eq(501L))).thenReturn(0);
when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_bill_line"), eq(Integer.class), eq(10L), eq(501L))).thenReturn(1);
FinanceSettlementService service = service(jdbc, org.mockito.Mockito.mock(FinanceFileClient.class));
SettlementDetail result = service.createReceivableSettlement(7L, "ar-key",
new CreateReceivableSettlementRequest(501L, "CNY"));
assertThat(result.summary().direction()).isEqualTo("RECEIVABLE");
assertThat(result.summary().totalAmount()).isEqualByComparingTo("300.00");
assertThat(result.summary().reconciliationStatementId()).isEqualTo(501L);
verify(jdbc).update(startsWith("INSERT INTO fin_settlement_line"), eq(88L), eq(10L), eq(501L));
}
@Test
void receivableCreationRejectsAlreadySettledStatement() {
JdbcTemplate jdbc = org.mockito.Mockito.mock(JdbcTemplate.class);
when(jdbc.queryForList(startsWith("SELECT id,tenant_id,bill_no"), eq(501L))).thenReturn(List.of(Map.ofEntries(
Map.entry("id", 501L), Map.entry("tenant_id", 10L), Map.entry("bill_no", "REC-501"),
Map.entry("customer_name_snapshot", "客户10"), Map.entry("currency_code", "CNY"),
Map.entry("bill_date", Date.valueOf("2026-08-31")), Map.entry("due_date", Date.valueOf("2026-09-30")),
Map.entry("status", "ISSUED"), Map.entry("total_amount", new BigDecimal("300.00")))));
when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(10L), eq(7L), eq("SETTLEMENT_AR_CREATE"), eq("ar-duplicate"))).thenReturn(List.of());
when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_settlement WHERE"), eq(Integer.class), eq(10L), eq(501L))).thenReturn(1);
assertCode(() -> service(jdbc, org.mockito.Mockito.mock(FinanceFileClient.class)).createReceivableSettlement(7L, "ar-duplicate",
new CreateReceivableSettlementRequest(501L, "CNY")), "FIN_409");
verify(jdbc, never()).update(any(PreparedStatementCreator.class), any(KeyHolder.class));
}
@Test
void eligibleSameSupplierSourcesCreatePayableSettlement() {
JdbcTemplate jdbc = creationJdbc(settlementRow("PAYABLE", "ISSUED", BigDecimal.ZERO,
new BigDecimal("100.00"), 0));
when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(10L), eq(7L), eq("SETTLEMENT_AP_CREATE"), eq("ap-key"))).thenReturn(List.of());
when(jdbc.queryForList(startsWith("SELECT id,customer_id"), eq(31L))).thenReturn(List.of(payableSource(10L, 20L)));
when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_settlement_line"), eq(Integer.class), eq(31L))).thenReturn(0);
FinanceSettlementService service = service(jdbc, org.mockito.Mockito.mock(FinanceFileClient.class));
SettlementDetail result = service.createPayableSettlement(7L, "ap-key",
new CreatePayableSettlementRequest(10L, 20L, List.of(31L), LocalDate.of(2026, 9, 30), "CNY"));
assertThat(result.summary().direction()).isEqualTo("PAYABLE");
assertThat(result.summary().supplierId()).isEqualTo(20L);
assertThat(result.summary().totalAmount()).isEqualByComparingTo("100.00");
verify(jdbc).update(startsWith("INSERT INTO fin_settlement_line"), eq(10L), eq(88L), eq(31L),
eq(new BigDecimal("100.00")), eq("CNY"));
}
@Test
void payableCreationHidesCrossCustomerSource() {
JdbcTemplate jdbc = org.mockito.Mockito.mock(JdbcTemplate.class);
when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(10L), eq(7L), eq("SETTLEMENT_AP_CREATE"), eq("ap-key"))).thenReturn(List.of());
when(jdbc.queryForList(startsWith("SELECT id,customer_id"), eq(31L))).thenReturn(List.of(payableSource(11L, 20L)));
FinanceSettlementService service = service(jdbc, org.mockito.Mockito.mock(FinanceFileClient.class));
assertCode(() -> service.createPayableSettlement(7L, "ap-key",
new CreatePayableSettlementRequest(10L, 20L, List.of(31L), LocalDate.of(2026, 9, 30), "CNY")), "FIN_404");
verify(jdbc, never()).update(any(PreparedStatementCreator.class), any(KeyHolder.class));
}
@Test
void payableCreationRejectsMixedSupplierIneligibleAndOccupiedSources() {
for (String condition : List.of("supplier", "ineligible", "occupied")) {
JdbcTemplate jdbc = org.mockito.Mockito.mock(JdbcTemplate.class);
Map<String, Object> source = payableSource(10L, "supplier".equals(condition) ? 21L : 20L);
if ("ineligible".equals(condition)) source.put("review_status", "PENDING");
when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(10L), eq(7L), eq("SETTLEMENT_AP_CREATE"), eq("ap-" + condition))).thenReturn(List.of());
when(jdbc.queryForList(startsWith("SELECT id,customer_id"), eq(31L))).thenReturn(List.of(source));
org.mockito.Mockito.lenient().when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_settlement_line"), eq(Integer.class), eq(31L)))
.thenReturn("occupied".equals(condition) ? 1 : 0);
assertCode(() -> service(jdbc, org.mockito.Mockito.mock(FinanceFileClient.class)).createPayableSettlement(7L, "ap-" + condition,
new CreatePayableSettlementRequest(10L, 20L, List.of(31L), LocalDate.of(2026, 9, 30), "CNY")), "FIN_409");
verify(jdbc, never()).update(any(PreparedStatementCreator.class), any(KeyHolder.class));
}
}
@Test
void settlementDetailRejectsMissingResource() {
JdbcTemplate jdbc = org.mockito.Mockito.mock(JdbcTemplate.class);
when(jdbc.queryForList(startsWith("SELECT id,tenant_id,settlement_no"), eq(999L))).thenReturn(List.of());
assertCode(() -> service(jdbc, org.mockito.Mockito.mock(FinanceFileClient.class)).getSettlement(999L), "FIN_404");
}
@Test
void finalPaymentMarksSettlementPaid() {
JdbcTemplate jdbc = org.mockito.Mockito.mock(JdbcTemplate.class, invocation ->
"update".equals(invocation.getMethod().getName()) ? 1 : RETURNS_DEFAULTS.answer(invocation));
FinanceFileClient fileClient = org.mockito.Mockito.mock(FinanceFileClient.class);
Map<String, Object> before = settlementRow("PAYABLE", "PARTIALLY_PAID", new BigDecimal("40.00"), new BigDecimal("60.00"), 1);
Map<String, Object> after = settlementRow("PAYABLE", "PAID", new BigDecimal("100.00"), BigDecimal.ZERO, 2);
when(jdbc.queryForList(startsWith("SELECT id,tenant_id,direction"), eq(88L))).thenReturn(List.of(before));
when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(10L), eq(7L), eq("SETTLEMENT_PAYMENT_CREATE"), eq("final-key"))).thenReturn(List.of());
stubSettlementDetail(jdbc, after);
when(fileClient.metadata("internal-token", 10L, 10L, 91L)).thenReturn(ApiResponse.success(
new FinanceFileClient.Metadata(91L, 10L, "FINANCE_VOUCHER", "10", "pay.png", "abc", "image/png", 10L)));
FinanceSettlementService service = service(jdbc, fileClient);
SettlementDetail result = service.createSettlementPayment(88L, 7L, "final-key", payment(new BigDecimal("60.00"), 1));
assertThat(result.summary().status()).isEqualTo("PAID");
assertThat(result.summary().outstandingAmount()).isZero();
}
@Test
void receivableSettlementDetailIncludesAppliedReceiptHistory() {
JdbcTemplate jdbc = org.mockito.Mockito.mock(JdbcTemplate.class);
Map<String, Object> settlement = settlementRow("RECEIVABLE", "PARTIALLY_PAID", new BigDecimal("40.00"),
new BigDecimal("60.00"), 1);
settlement.put("reconciliation_statement_id", 501L);
stubSettlementDetail(jdbc, settlement);
when(jdbc.queryForList(startsWith("SELECT r.id,'RECEIPT'"), eq(10L), eq(501L))).thenReturn(List.of(Map.ofEntries(
Map.entry("id", 701L), Map.entry("transaction_type", "RECEIPT"),
Map.entry("transaction_date", Date.valueOf("2026-08-11")), Map.entry("amount", new BigDecimal("40.00")),
Map.entry("method", "BANK_TRANSFER"), Map.entry("reference_no", "RCPT-701"),
Map.entry("voucher_file_ids", "[\"91\"]"), Map.entry("created_by", 7L),
Map.entry("created_at", Timestamp.valueOf("2026-08-11 10:00:00")), Map.entry("remark", "首笔收款"))));
FinanceSettlementService service = service(jdbc, org.mockito.Mockito.mock(FinanceFileClient.class));
SettlementDetail result = service.getSettlement(88L);
assertThat(result.transactions()).singleElement().extracting(transaction -> transaction.transactionType())
.isEqualTo("RECEIPT");
assertThat(result.transactions().get(0).amount()).isEqualByComparingTo("40.00");
}
@Test
void settlementIdempotencyReplaysRejectsMismatchAndRecoversInterruptedRecords() {
Map<String, Object> body = Map.of("customerId", 10L, "amount", "100.00");
JdbcTemplate firstJdbc = org.mockito.Mockito.mock(JdbcTemplate.class, invocation ->
"update".equals(invocation.getMethod().getName()) ? 1 : RETURNS_DEFAULTS.answer(invocation));
when(firstJdbc.queryForList(startsWith("SELECT request_hash"), eq(10L), eq(7L), eq("SETTLEMENT_AP_CREATE"), eq("idem-key"))).thenReturn(List.of());
FinanceSettlementService firstService = service(firstJdbc, org.mockito.Mockito.mock(FinanceFileClient.class));
Long first = ReflectionTestUtils.invokeMethod(firstService, "reserve", 10L, 7L, "SETTLEMENT_AP_CREATE", "idem-key", "POST", "/settlements", body);
ArgumentCaptor<String> hash = ArgumentCaptor.forClass(String.class);
verify(firstJdbc).update(startsWith("INSERT INTO fin_idempotency_record"), eq(10L), eq(7L),
eq("SETTLEMENT_AP_CREATE"), eq("idem-key"), hash.capture());
assertThat(first).isNull();
JdbcTemplate replayJdbc = org.mockito.Mockito.mock(JdbcTemplate.class);
when(replayJdbc.queryForList(startsWith("SELECT request_hash"), eq(10L), eq(7L), eq("SETTLEMENT_AP_CREATE"), eq("idem-key")))
.thenReturn(List.of(Map.of("request_hash", hash.getValue(), "status", "SUCCEEDED", "result_resource_id", 88L,
"updated_at", LocalDateTime.now())));
FinanceSettlementService replayService = service(replayJdbc, org.mockito.Mockito.mock(FinanceFileClient.class));
assertThat((Long) ReflectionTestUtils.invokeMethod(replayService, "reserve", 10L, 7L, "SETTLEMENT_AP_CREATE", "idem-key", "POST", "/settlements", body)).isEqualTo(88L);
assertCode(() -> ReflectionTestUtils.invokeMethod(replayService, "reserve", 10L, 7L, "SETTLEMENT_AP_CREATE", "idem-key", "POST", "/settlements", Map.of("customerId", 10L, "amount", "101.00")), "FIN_409");
JdbcTemplate processingJdbc = org.mockito.Mockito.mock(JdbcTemplate.class);
when(processingJdbc.queryForList(startsWith("SELECT request_hash"), eq(10L), eq(7L), eq("SETTLEMENT_AP_CREATE"), eq("processing-key")))
.thenReturn(List.of(Map.of("request_hash", hash.getValue(), "status", "PROCESSING", "updated_at", LocalDateTime.now())));
assertCode(() -> ReflectionTestUtils.invokeMethod(service(processingJdbc, org.mockito.Mockito.mock(FinanceFileClient.class)),
"reserve", 10L, 7L, "SETTLEMENT_AP_CREATE", "processing-key", "POST", "/settlements", body), "FIN_409");
JdbcTemplate recoveryJdbc = org.mockito.Mockito.mock(JdbcTemplate.class, invocation ->
"update".equals(invocation.getMethod().getName()) ? 1 : RETURNS_DEFAULTS.answer(invocation));
when(recoveryJdbc.queryForList(startsWith("SELECT request_hash"), eq(10L), eq(7L), eq("SETTLEMENT_AP_CREATE"), eq("failed-key")))
.thenReturn(List.of(Map.of("request_hash", hash.getValue(), "status", "FAILED", "updated_at", LocalDateTime.now())));
when(recoveryJdbc.queryForList(startsWith("SELECT request_hash"), eq(10L), eq(7L), eq("SETTLEMENT_AP_CREATE"), eq("stale-key")))
.thenReturn(List.of(Map.of("request_hash", hash.getValue(), "status", "PROCESSING", "updated_at", LocalDateTime.now().minusMinutes(10))));
FinanceSettlementService recoveryService = service(recoveryJdbc, org.mockito.Mockito.mock(FinanceFileClient.class));
assertThat((Long) ReflectionTestUtils.invokeMethod(recoveryService, "reserve", 10L, 7L, "SETTLEMENT_AP_CREATE", "failed-key", "POST", "/settlements", body)).isNull();
assertThat((Long) ReflectionTestUtils.invokeMethod(recoveryService, "reserve", 10L, 7L, "SETTLEMENT_AP_CREATE", "stale-key", "POST", "/settlements", body)).isNull();
JdbcTemplate conflictJdbc = org.mockito.Mockito.mock(JdbcTemplate.class);
FinanceSettlementService conflictService = service(conflictJdbc, org.mockito.Mockito.mock(FinanceFileClient.class));
assertCode(() -> ReflectionTestUtils.invokeMethod(conflictService, "complete", 10L, 7L, "SETTLEMENT_AP_CREATE", "idem-key", 88L), "FIN_409");
}
@Test
void receivableCreationRejectsAnExistingActiveSettlement() {
JdbcTemplate jdbc = org.mockito.Mockito.mock(JdbcTemplate.class);
when(jdbc.queryForList(startsWith("SELECT id,tenant_id,bill_no"), eq(501L))).thenReturn(List.of(Map.ofEntries(
Map.entry("id", 501L), Map.entry("tenant_id", 10L), Map.entry("bill_no", "REC-501"),
Map.entry("customer_name_snapshot", "客户10"), Map.entry("currency_code", "CNY"),
Map.entry("bill_date", Date.valueOf("2026-08-31")), Map.entry("due_date", Date.valueOf("2026-09-30")),
Map.entry("status", "ISSUED"), Map.entry("total_amount", new BigDecimal("300.00")))));
when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(10L), eq(7L), eq("SETTLEMENT_AR_CREATE"), eq("ar-key"))).thenReturn(List.of());
when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_settlement WHERE"), eq(Integer.class), eq(10L), eq(501L))).thenReturn(1);
FinanceSettlementService service = service(jdbc, org.mockito.Mockito.mock(FinanceFileClient.class));
assertCode(() -> service.createReceivableSettlement(7L, "ar-key", new CreateReceivableSettlementRequest(501L, "CNY")), "FIN_409");
verify(jdbc, never()).update(any(PreparedStatementCreator.class), any(KeyHolder.class));
}
@Test
void payableCreationRejectsMixedSupplierAndOccupiedSources() {
JdbcTemplate mixedJdbc = org.mockito.Mockito.mock(JdbcTemplate.class);
when(mixedJdbc.queryForList(startsWith("SELECT request_hash"), eq(10L), eq(7L), eq("SETTLEMENT_AP_CREATE"), eq("mixed-key"))).thenReturn(List.of());
when(mixedJdbc.queryForList(startsWith("SELECT id,customer_id"), eq(31L))).thenReturn(List.of(payableSource(10L, 21L)));
FinanceSettlementService mixedService = service(mixedJdbc, org.mockito.Mockito.mock(FinanceFileClient.class));
CreatePayableSettlementRequest request = new CreatePayableSettlementRequest(10L, 20L, List.of(31L), LocalDate.of(2026, 9, 30), "CNY");
assertCode(() -> mixedService.createPayableSettlement(7L, "mixed-key", request), "FIN_409");
JdbcTemplate occupiedJdbc = org.mockito.Mockito.mock(JdbcTemplate.class);
when(occupiedJdbc.queryForList(startsWith("SELECT request_hash"), eq(10L), eq(7L), eq("SETTLEMENT_AP_CREATE"), eq("occupied-key"))).thenReturn(List.of());
when(occupiedJdbc.queryForList(startsWith("SELECT id,customer_id"), eq(31L))).thenReturn(List.of(payableSource(10L, 20L)));
when(occupiedJdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_settlement_line"), eq(Integer.class), eq(31L))).thenReturn(1);
FinanceSettlementService occupiedService = service(occupiedJdbc, org.mockito.Mockito.mock(FinanceFileClient.class));
assertCode(() -> occupiedService.createPayableSettlement(7L, "occupied-key", request), "FIN_409");
}
@Test
void paymentRejectsAmountAboveOutstandingBeforeVoucherWrite() {
JdbcTemplate jdbc = org.mockito.Mockito.mock(JdbcTemplate.class);
when(jdbc.queryForList(startsWith("SELECT id,tenant_id,direction"), eq(88L)))
.thenReturn(List.of(settlementRow("PAYABLE", "ISSUED", BigDecimal.ZERO, new BigDecimal("100.00"), 0)));
FinanceSettlementService service = service(jdbc, org.mockito.Mockito.mock(FinanceFileClient.class));
assertCode(() -> service.createSettlementPayment(88L, 7L, "pay-key", payment(new BigDecimal("100.01"), 0)), "FIN_409");
verify(jdbc, never()).update(startsWith("INSERT INTO fin_settlement_payment"), (Object[]) any());
}
private static FinanceSettlementService service(JdbcTemplate jdbc, FinanceFileClient fileClient) { private static FinanceSettlementService service(JdbcTemplate jdbc, FinanceFileClient fileClient) {
FinanceSettlementService service = new FinanceSettlementService( FinanceSettlementService service = new FinanceSettlementService(
jdbc, new ObjectMapper().findAndRegisterModules(), fileClient); jdbc, new ObjectMapper().findAndRegisterModules(), fileClient);
@@ -148,6 +397,37 @@ class FinanceSettlementBehaviorTest {
"BANK-1", List.of("91"), version, "首笔付款"); "BANK-1", List.of("91"), version, "首笔付款");
} }
private static JdbcTemplate creationJdbc(Map<String, Object> settlement) {
JdbcTemplate jdbc = org.mockito.Mockito.mock(JdbcTemplate.class,
org.mockito.Mockito.withSettings().lenient().defaultAnswer(invocation ->
"update".equals(invocation.getMethod().getName()) ? 1 : RETURNS_DEFAULTS.answer(invocation)));
org.mockito.Mockito.lenient().doAnswer(invocation -> {
KeyHolder holder = invocation.getArgument(1);
holder.getKeyList().add(Map.of("GENERATED_KEY", 88L));
return 1;
}).when(jdbc).update(any(PreparedStatementCreator.class), any(KeyHolder.class));
stubSettlementDetail(jdbc, settlement);
return jdbc;
}
private static void stubSettlementDetail(JdbcTemplate jdbc, Map<String, Object> settlement) {
when(jdbc.queryForList(startsWith("SELECT id,tenant_id,settlement_no"), eq(88L))).thenReturn(List.of(settlement));
long customerId = ((Number) settlement.get("tenant_id")).longValue();
when(jdbc.queryForList(startsWith("SELECT f.id fee_source_id"), eq(customerId), eq(88L))).thenReturn(List.of());
when(jdbc.queryForList(startsWith("SELECT id,transaction_type"), eq(customerId), eq(88L))).thenReturn(List.of());
when(jdbc.queryForList(startsWith("SELECT action,reason"), eq(customerId), eq(88L))).thenReturn(List.of());
}
private static Map<String, Object> payableSource(long customerId, long supplierId) {
return new java.util.HashMap<>(Map.ofEntries(
Map.entry("id", 31L), Map.entry("customer_id", customerId), Map.entry("direction", "PAYABLE"),
Map.entry("currency_code", "CNY"), Map.entry("status", "AVAILABLE"),
Map.entry("amount", new BigDecimal("100.00")), Map.entry("review_status", "APPROVED"),
Map.entry("settlement_included", "true"), Map.entry("supplier_id", supplierId),
Map.entry("supplier_name", "供应商20"), Map.entry("source_snapshot",
"{\"customerName\":\"客户10\",\"supplierId\":20,\"supplierName\":\"供应商20\"}")));
}
private static Map<String, Object> settlementRow(String direction, String status, BigDecimal paid, private static Map<String, Object> settlementRow(String direction, String status, BigDecimal paid,
BigDecimal outstanding, int version) { BigDecimal outstanding, int version) {
return new java.util.HashMap<>(Map.ofEntries( return new java.util.HashMap<>(Map.ofEntries(
@@ -155,7 +435,7 @@ class FinanceSettlementBehaviorTest {
Map.entry("direction", direction), Map.entry("customer_name_snapshot", "客户10"), Map.entry("direction", direction), Map.entry("customer_name_snapshot", "客户10"),
Map.entry("supplier_id", 20L), Map.entry("supplier_name_snapshot", "供应商20"), Map.entry("supplier_id", 20L), Map.entry("supplier_name_snapshot", "供应商20"),
Map.entry("business_period", "2026-08"), Map.entry("currency_code", "CNY"), Map.entry("business_period", "2026-08"), Map.entry("currency_code", "CNY"),
Map.entry("total_amount", new BigDecimal("100.00")), Map.entry("paid_amount", paid), Map.entry("total_amount", paid.add(outstanding)), Map.entry("paid_amount", paid),
Map.entry("outstanding_amount", outstanding), Map.entry("generated_date", Date.valueOf("2026-08-11")), Map.entry("outstanding_amount", outstanding), Map.entry("generated_date", Date.valueOf("2026-08-11")),
Map.entry("due_date", Date.valueOf("2026-09-10")), Map.entry("due_date", Date.valueOf("2026-09-10")),
Map.entry("payment_status", paid.signum() == 0 ? "UNPAID" : status), Map.entry("status", status), Map.entry("payment_status", paid.signum() == 0 ? "UNPAID" : status), Map.entry("status", status),
@@ -44,7 +44,7 @@ class FinanceSqlCommentContractTest {
} }
} }
assertThat(tableCount).isEqualTo(13); assertThat(tableCount).isEqualTo(13);
assertThat(columnCount).isEqualTo(184); assertThat(columnCount).isEqualTo(190);
assertThat(missing).as("缺少中文业务注释的Finance字段").isEmpty(); assertThat(missing).as("缺少中文业务注释的Finance字段").isEmpty();
} }
@@ -57,4 +57,17 @@ class FinanceSqlCommentContractTest {
assertThat(sql).contains("Firstleg费用来源完整不可变JSON快照"); assertThat(sql).contains("Firstleg费用来源完整不可变JSON快照");
assertThat(sql).contains("规范化完整请求的SHA-256十六进制哈希"); assertThat(sql).contains("规范化完整请求的SHA-256十六进制哈希");
} }
@Test
void unifiedMigrationAddsBillLineAdjustmentReasonForNewAndExistingSchemas() throws Exception {
Path direct = Path.of("deploy", "sql", "头程数据库统一增量-MySQL8.sql");
Path unified = Files.exists(direct) ? direct : Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql");
String sql = Files.readString(unified, StandardCharsets.UTF_8);
assertThat(sql)
.contains("adjustment_reason VARCHAR(500) NULL COMMENT '最近一次账单调整行原因;完整历史保存在修订与审计记录'")
.contains("table_schema='baiyue_finance' AND table_name='fin_bill_line' AND column_name='adjustment_reason'")
.contains("ALTER TABLE baiyue_finance.fin_bill_line ADD COLUMN adjustment_reason")
.contains("queue_column_comment('baiyue_finance', 'fin_bill_line', 'adjustment_reason'");
}
} }
@@ -3,18 +3,21 @@ package com.baiyue.finance;
import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.finance.controller.ReconciliationStatementController; import com.baiyue.finance.controller.ReconciliationStatementController;
import com.baiyue.finance.dto.ReconciliationStatementDtos.CreateReconciliationRequest; import com.baiyue.finance.dto.ReconciliationStatementDtos.CreateReconciliationRequest;
import com.baiyue.finance.dto.ReconciliationStatementDtos.DocumentActionRequest; import com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationActionRequest;
import com.baiyue.finance.dto.ReconciliationStatementDtos.StatementAdjustmentLineRequest;
import com.baiyue.finance.service.FinanceEventException; import com.baiyue.finance.service.FinanceEventException;
import com.baiyue.finance.service.ReconciliationStatementService; import com.baiyue.finance.service.ReconciliationStatementService;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.ArgumentCaptor;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.PreparedStatementCreator; import org.springframework.jdbc.core.PreparedStatementCreator;
import org.springframework.jdbc.core.RowMapper; import org.springframework.jdbc.core.RowMapper;
import org.springframework.jdbc.support.KeyHolder; import org.springframework.jdbc.support.KeyHolder;
import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.sql.ResultSet; import java.sql.ResultSet;
@@ -45,9 +48,9 @@ class ReconciliationStatementBehaviorTest {
void controllerUsesAuthenticatedOperatorAndStableTraceResponse() { void controllerUsesAuthenticatedOperatorAndStableTraceResponse() {
ReconciliationStatementController controller = new ReconciliationStatementController(service); ReconciliationStatementController controller = new ReconciliationStatementController(service);
AuthenticatedUser user = new AuthenticatedUser(71L, null, null, null, "finance", "ADMIN", "WEB", Set.of()); AuthenticatedUser user = new AuthenticatedUser(71L, null, null, null, "finance", "ADMIN", "WEB", Set.of());
CreateReconciliationRequest create = new CreateReconciliationRequest(9L, "SELECTED", List.of(88L), CreateReconciliationRequest create = new CreateReconciliationRequest(9L, "CNY", "SELECTED", List.of(88L),
LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), "八月对账"); LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), "八月对账");
DocumentActionRequest action = new DocumentActionRequest("SUBMIT", "提交审核", 0); ReconciliationActionRequest action = new ReconciliationActionRequest("SUBMIT", "提交审核", 0, List.of());
var created = controller.createReconciliationStatement(user, "create-key", create); var created = controller.createReconciliationStatement(user, "create-key", create);
var acted = controller.actionReconciliationStatement(user, 501L, "action-key", action); var acted = controller.actionReconciliationStatement(user, 501L, "action-key", action);
@@ -64,20 +67,35 @@ class ReconciliationStatementBehaviorTest {
void selectedModeRejectsDuplicateFeeSourcesBeforeWriting() { void selectedModeRejectsDuplicateFeeSourcesBeforeWriting() {
JdbcTemplate jdbc = mock(JdbcTemplate.class); JdbcTemplate jdbc = mock(JdbcTemplate.class);
ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules()); ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules());
CreateReconciliationRequest request = new CreateReconciliationRequest(9L, "SELECTED", List.of(88L, 88L), CreateReconciliationRequest request = new CreateReconciliationRequest(9L, "CNY", "SELECTED", List.of(88L, 88L),
LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), null); LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), null);
assertThatThrownBy(() -> target.create(71L, "create-key", request)) assertThatThrownBy(() -> target.create(71L, "create-key", request))
.isInstanceOf(FinanceEventException.class) .isInstanceOf(FinanceEventException.class)
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-REQ-400"); .extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_400");
verifyNoInteractions(jdbc); verifyNoInteractions(jdbc);
} }
@Test
void byCutoffSelectsEligibleSourcesAndCreatesDraft() throws Exception {
JdbcTemplate jdbc = createJdbc();
ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules());
CreateReconciliationRequest request = new CreateReconciliationRequest(9L, "CNY", "BY_CUTOFF", List.of(),
LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), "截止日对账");
var result = target.create(71L, "cutoff-key", request);
assertThat(result.status()).isEqualTo("DRAFT");
assertThat(result.totalReceivable()).isEqualByComparingTo("100.00");
verify(jdbc).query(org.mockito.ArgumentMatchers.startsWith("SELECT id,source_fee_id"), any(RowMapper.class),
eq(9L), eq(LocalDate.of(2026, 8, 1)), eq(LocalDate.of(2026, 8, 31)));
}
@Test @Test
void selectedEligibleCnySourceCreatesStableDraftAndLocksSource() throws Exception { void selectedEligibleCnySourceCreatesStableDraftAndLocksSource() throws Exception {
JdbcTemplate jdbc = createJdbc(); JdbcTemplate jdbc = createJdbc();
ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules()); ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules());
CreateReconciliationRequest request = new CreateReconciliationRequest(9L, "SELECTED", List.of(88L), CreateReconciliationRequest request = new CreateReconciliationRequest(9L, "CNY", "SELECTED", List.of(88L),
LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), "八月对账"); LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), "八月对账");
var result = target.create(71L, "create-key", request); var result = target.create(71L, "create-key", request);
@@ -90,13 +108,40 @@ class ReconciliationStatementBehaviorTest {
verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_fee_source SET status='LOCKED'"), eq(88L), eq(9L)); verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_fee_source SET status='LOCKED'"), eq(88L), eq(9L));
} }
@Test
void selectedCrossCustomerSourceIsHiddenAsNotFoundBeforeBillInsert() throws Exception {
JdbcTemplate jdbc = mock(JdbcTemplate.class, invocation -> {
if ("update".equals(invocation.getMethod().getName()) && invocation.getMethod().getReturnType() == int.class) return 1;
return RETURNS_DEFAULTS.answer(invocation);
});
when(jdbc.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT request_hash"),
eq(9L), eq(71L), eq("RECONCILIATION_CREATE"), eq("create-key"))).thenReturn(List.of());
when(jdbc.query(org.mockito.ArgumentMatchers.startsWith("SELECT customer_name_snapshot"),
any(RowMapper.class), eq(9L))).thenReturn(List.of());
when(jdbc.query(org.mockito.ArgumentMatchers.startsWith("SELECT id FROM fin_subject"), any(RowMapper.class))).thenAnswer(invocation -> {
@SuppressWarnings("unchecked") RowMapper<Object> mapper = invocation.getArgument(1);
ResultSet rs = mock(ResultSet.class);
when(rs.getLong(1)).thenReturn(1L);
return List.of(mapper.mapRow(rs, 0));
});
when(jdbc.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT id,customer_id"), eq(88L)))
.thenReturn(List.of(Map.of("id", 88L, "customer_id", 10L)));
ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules());
assertThatThrownBy(() -> target.create(71L, "create-key", new CreateReconciliationRequest(9L, "CNY",
"SELECTED", List.of(88L), LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), null)))
.isInstanceOf(FinanceEventException.class)
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_404");
verify(jdbc, never()).update(any(PreparedStatementCreator.class), any(KeyHolder.class));
}
@Test @Test
void submitUsesExpectedVersionAndPersistsRevisionBeforeStateChange() throws Exception { void submitUsesExpectedVersionAndPersistsRevisionBeforeStateChange() throws Exception {
JdbcTemplate jdbc = actionJdbc("DRAFT", "PENDING_REVIEW", "submit-key"); JdbcTemplate jdbc = actionJdbc("DRAFT", "PENDING_REVIEW", "submit-key");
ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules()); ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules());
var result = target.action(71L, 501L, "submit-key", var result = target.action(71L, 501L, "submit-key",
new DocumentActionRequest("SUBMIT", "提交审核", 0)); new ReconciliationActionRequest("SUBMIT", "提交审核", 0, List.of()));
assertThat(result.customerId()).isEqualTo(9L); assertThat(result.customerId()).isEqualTo(9L);
assertThat(result.status()).isEqualTo("PENDING_REVIEW"); assertThat(result.status()).isEqualTo("PENDING_REVIEW");
@@ -112,14 +157,27 @@ class ReconciliationStatementBehaviorTest {
@Test @Test
void approveFreezesInternalBillAndReturnsContractStatus() throws Exception { void approveFreezesInternalBillAndReturnsContractStatus() throws Exception {
JdbcTemplate jdbc = actionJdbc("PENDING_REVIEW", "ISSUED", "approve-key"); JdbcTemplate jdbc = actionJdbc("PENDING_REVIEW", "APPROVED", "approve-key");
ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules()); ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules());
var result = target.action(71L, 501L, "approve-key", var result = target.action(71L, 501L, "approve-key",
new DocumentActionRequest("APPROVE", "审核通过", 1)); new ReconciliationActionRequest("APPROVE", "审核通过", 1, List.of()));
assertThat(result.status()).isEqualTo("APPROVED"); assertThat(result.status()).isEqualTo("APPROVED");
assertThat(result.version()).isEqualTo(2); assertThat(result.version()).isEqualTo(2);
verify(jdbc, never()).update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_fee_source f JOIN"), (Object[]) any());
}
@Test
void issueFreezesSourcesOnlyAfterApproval() throws Exception {
JdbcTemplate jdbc = actionJdbc("APPROVED", "ISSUED", "issue-key");
ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules());
var result = target.action(71L, 501L, "issue-key",
new ReconciliationActionRequest("ISSUE", null, 2, List.of()));
assertThat(result.status()).isEqualTo("ISSUED");
assertThat(result.version()).isEqualTo(3);
verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_fee_source f JOIN"), eq(9L), eq(501L), eq(9L)); verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_fee_source f JOIN"), eq(9L), eq(501L), eq(9L));
} }
@@ -137,30 +195,178 @@ class ReconciliationStatementBehaviorTest {
ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules()); ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules());
assertThatThrownBy(() -> target.action(71L, 501L, "used-key", assertThatThrownBy(() -> target.action(71L, 501L, "used-key",
new DocumentActionRequest("SUBMIT", "提交审核", 0))) new ReconciliationActionRequest("SUBMIT", "提交审核", 0, List.of())))
.isInstanceOf(FinanceEventException.class) .isInstanceOf(FinanceEventException.class)
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-IDEM-409"); .extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_409");
verify(jdbc, never()).update(org.mockito.ArgumentMatchers.startsWith("INSERT INTO fin_bill_revision"), (Object[]) any()); verify(jdbc, never()).update(org.mockito.ArgumentMatchers.startsWith("INSERT INTO fin_bill_revision"), (Object[]) any());
} }
private static JdbcTemplate actionJdbc(String beforeStatus, String afterStatus, String key) throws Exception { @Test
JdbcTemplate jdbc = mock(JdbcTemplate.class, invocation -> { void invalidActionAndVersionConflictAreRejectedBeforeMutation() throws Exception {
if ("update".equals(invocation.getMethod().getName()) && invocation.getMethod().getReturnType() == int.class) return 1; JdbcTemplate jdbc = actionJdbc("DRAFT", "DRAFT", "invalid-key");
return RETURNS_DEFAULTS.answer(invocation); ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules());
assertThatThrownBy(() -> target.action(71L, 501L, "invalid-key",
new ReconciliationActionRequest("NOT_AN_ACTION", null, 0, List.of())))
.isInstanceOf(FinanceEventException.class)
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_400");
assertThatThrownBy(() -> target.action(71L, 501L, "stale-version-key",
new ReconciliationActionRequest("SUBMIT", "提交审核", 99, List.of())))
.isInstanceOf(FinanceEventException.class)
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_409");
verify(jdbc, never()).update(org.mockito.ArgumentMatchers.startsWith("INSERT INTO fin_bill_revision"), (Object[]) any());
}
@Test
void adjustIssuedStatementUpdatesSelectedLineAndKeepsRevisionHistory() throws Exception {
JdbcTemplate jdbc = actionJdbc("ISSUED", "ISSUED", "adjust-key");
when(jdbc.queryForObject(org.mockito.ArgumentMatchers.startsWith("SELECT COALESCE(SUM(amount)"), eq(BigDecimal.class), eq(9L), eq(501L)))
.thenReturn(new BigDecimal("280.00"));
ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules());
target.action(71L, 501L, "adjust-key", new ReconciliationActionRequest("ADJUST", "运费更正", 3,
List.of(new StatementAdjustmentLineRequest(601L, new BigDecimal("280.00"), "运费更正"))));
verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_bill_line SET amount="),
eq(new BigDecimal("280.00")), eq("运费更正"), eq(601L), eq(501L), eq(9L));
verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_bill SET total_amount="),
eq(new BigDecimal("280.00")), eq(new BigDecimal("280.00")), eq(71L), eq(501L), eq(9L), eq(3));
}
@Test
void adjustmentBelowReceivedAmountIsRejected() throws Exception {
JdbcTemplate jdbc = actionJdbc("ISSUED", "ISSUED", "low-adjust-key");
when(jdbc.queryForObject(org.mockito.ArgumentMatchers.startsWith("SELECT COALESCE(SUM(amount)"), eq(BigDecimal.class), eq(9L), eq(501L)))
.thenReturn(new BigDecimal("80.00"));
when(jdbc.query(anyString(), any(RowMapper.class), (Object[]) any()))
.thenAnswer(invocation -> {
@SuppressWarnings("unchecked") RowMapper<Object> mapper = invocation.getArgument(1);
ResultSet rs = billResultSet("ISSUED", 3);
when(rs.getBigDecimal("received_amount")).thenReturn(new BigDecimal("100.00"));
return List.of(mapper.mapRow(rs, 0));
}); });
assertThatThrownBy(() -> new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules()).action(71L, 501L,
"low-adjust-key", new ReconciliationActionRequest("ADJUST", "金额错误", 3,
List.of(new StatementAdjustmentLineRequest(601L, new BigDecimal("80.00"), "金额错误")))))
.isInstanceOf(FinanceEventException.class)
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_409");
verify(jdbc, never()).update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_bill SET total_amount="), (Object[]) any());
}
@Test
void voidIssuedStatementReleasesSourcesWhenNoReceiptOrSettlementExists() throws Exception {
JdbcTemplate jdbc = actionJdbc("ISSUED", "VOIDED", "void-key");
when(jdbc.queryForObject(org.mockito.ArgumentMatchers.startsWith("SELECT COUNT(*) FROM fin_receipt_bill"), eq(Long.class), eq(9L), eq(501L))).thenReturn(0L);
when(jdbc.queryForObject(org.mockito.ArgumentMatchers.startsWith("SELECT COUNT(*) FROM fin_settlement"), eq(Long.class), eq(9L), eq(501L))).thenReturn(0L);
ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules());
var result = target.action(71L, 501L, "void-key",
new ReconciliationActionRequest("VOID", "客户重开账期", 3, List.of()));
assertThat(result.status()).isEqualTo("VOIDED");
verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_fee_source f JOIN"), eq(9L), eq(501L), eq(9L));
}
@Test
void voidRejectsStatementWithReceiptOrValidSettlement() throws Exception {
for (String query : List.of("receipt", "settlement")) {
JdbcTemplate jdbc = actionJdbc("ISSUED", "VOIDED", "void-" + query);
when(jdbc.queryForObject(org.mockito.ArgumentMatchers.startsWith("SELECT COUNT(*) FROM fin_receipt_bill"), eq(Long.class), eq(9L), eq(501L)))
.thenReturn("receipt".equals(query) ? 1L : 0L);
when(jdbc.queryForObject(org.mockito.ArgumentMatchers.startsWith("SELECT COUNT(*) FROM fin_settlement"), eq(Long.class), eq(9L), eq(501L)))
.thenReturn("settlement".equals(query) ? 1L : 0L);
assertThatThrownBy(() -> new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules()).action(71L, 501L,
"void-" + query, new ReconciliationActionRequest("VOID", "作废原因", 3, List.of())))
.isInstanceOf(FinanceEventException.class)
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_409");
verify(jdbc, never()).update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_bill SET status="), (Object[]) any());
}
}
@Test
void reopenVoidedStatementReacquiresEveryReleasedSource() throws Exception {
JdbcTemplate jdbc = actionJdbc("VOIDED", "DRAFT", "reopen-key");
when(jdbc.queryForObject(org.mockito.ArgumentMatchers.startsWith("SELECT COUNT(*) FROM fin_bill_line"), eq(Integer.class), eq(9L), eq(501L))).thenReturn(1);
ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules());
var result = target.action(71L, 501L, "reopen-key",
new ReconciliationActionRequest("REOPEN", "重新核对", 4, List.of()));
assertThat(result.status()).isEqualTo("DRAFT");
verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_fee_source f JOIN"), eq(9L), eq(501L), eq(9L));
}
@Test
void reconciliationIdempotencyRecoversFailedAndStaleProcessingRecords() {
ReconciliationActionRequest body = new ReconciliationActionRequest("SUBMIT", null, 0, List.of());
JdbcTemplate firstJdbc = mock(JdbcTemplate.class, invocation ->
"update".equals(invocation.getMethod().getName()) ? 1 : RETURNS_DEFAULTS.answer(invocation));
when(firstJdbc.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT request_hash"), eq(9L), eq(71L), eq("RECONCILIATION_ACTION"), eq("first-key"))).thenReturn(List.of());
ReconciliationStatementService firstService = new ReconciliationStatementService(firstJdbc, new ObjectMapper().findAndRegisterModules());
Long first = ReflectionTestUtils.invokeMethod(firstService, "reserve", 9L, 71L, "RECONCILIATION_ACTION", "first-key", "POST", "/statements/501/actions", body);
ArgumentCaptor<String> hash = ArgumentCaptor.forClass(String.class);
verify(firstJdbc).update(org.mockito.ArgumentMatchers.startsWith("INSERT INTO fin_idempotency_record"),
eq(9L), eq(71L), eq("RECONCILIATION_ACTION"), eq("first-key"), hash.capture());
assertThat(first).isNull();
JdbcTemplate replayJdbc = mock(JdbcTemplate.class);
when(replayJdbc.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT request_hash"), eq(9L), eq(71L), eq("RECONCILIATION_ACTION"), eq("replay-key")))
.thenReturn(List.of(Map.of("request_hash", hash.getValue(), "status", "SUCCEEDED",
"result_resource_id", 501L, "updated_at", LocalDateTime.now())));
assertThat((Long) ReflectionTestUtils.invokeMethod(
new ReconciliationStatementService(replayJdbc, new ObjectMapper().findAndRegisterModules()),
"reserve", 9L, 71L, "RECONCILIATION_ACTION", "replay-key", "POST", "/statements/501/actions", body))
.isEqualTo(501L);
JdbcTemplate processingJdbc = mock(JdbcTemplate.class);
when(processingJdbc.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT request_hash"), eq(9L), eq(71L), eq("RECONCILIATION_ACTION"), eq("processing-key")))
.thenReturn(List.of(Map.of("request_hash", hash.getValue(), "status", "PROCESSING", "updated_at", LocalDateTime.now())));
assertThatThrownBy(() -> ReflectionTestUtils.invokeMethod(
new ReconciliationStatementService(processingJdbc, new ObjectMapper().findAndRegisterModules()),
"reserve", 9L, 71L, "RECONCILIATION_ACTION", "processing-key", "POST", "/statements/501/actions", body))
.isInstanceOf(FinanceEventException.class)
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_409");
JdbcTemplate recoveryJdbc = mock(JdbcTemplate.class, invocation ->
"update".equals(invocation.getMethod().getName()) ? 1 : RETURNS_DEFAULTS.answer(invocation));
when(recoveryJdbc.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT request_hash"), eq(9L), eq(71L), eq("RECONCILIATION_ACTION"), eq("failed-key")))
.thenReturn(List.of(Map.of("request_hash", hash.getValue(), "status", "FAILED", "updated_at", LocalDateTime.now())));
when(recoveryJdbc.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT request_hash"), eq(9L), eq(71L), eq("RECONCILIATION_ACTION"), eq("stale-key")))
.thenReturn(List.of(Map.of("request_hash", hash.getValue(), "status", "PROCESSING", "updated_at", LocalDateTime.now().minusMinutes(10))));
ReconciliationStatementService recoveryService = new ReconciliationStatementService(recoveryJdbc, new ObjectMapper().findAndRegisterModules());
assertThat((Long) ReflectionTestUtils.invokeMethod(recoveryService, "reserve", 9L, 71L, "RECONCILIATION_ACTION", "failed-key", "POST", "/statements/501/actions", body)).isNull();
assertThat((Long) ReflectionTestUtils.invokeMethod(recoveryService, "reserve", 9L, 71L, "RECONCILIATION_ACTION", "stale-key", "POST", "/statements/501/actions", body)).isNull();
}
private static JdbcTemplate actionJdbc(String beforeStatus, String afterStatus, String key) throws Exception {
JdbcTemplate jdbc = mock(JdbcTemplate.class, org.mockito.Mockito.withSettings().lenient().defaultAnswer(invocation -> {
if ("update".equals(invocation.getMethod().getName()) && invocation.getMethod().getReturnType() == int.class) {
Object[] arguments = invocation.getArguments();
if (arguments.length > 1 && arguments[0] instanceof String sql
&& sql.startsWith("UPDATE fin_bill SET status=")) {
return arguments[arguments.length - 1].equals(versionOf(beforeStatus)) ? 1 : 0;
}
return 1;
}
return RETURNS_DEFAULTS.answer(invocation);
}));
when(jdbc.query(anyString(), any(RowMapper.class), (Object[]) any())).thenAnswer(invocation -> { when(jdbc.query(anyString(), any(RowMapper.class), (Object[]) any())).thenAnswer(invocation -> {
String sql = invocation.getArgument(0); String sql = invocation.getArgument(0);
@SuppressWarnings("unchecked") RowMapper<Object> mapper = invocation.getArgument(1); @SuppressWarnings("unchecked") RowMapper<Object> mapper = invocation.getArgument(1);
return List.of(mapper.mapRow(billResultSet(beforeStatus, "PENDING_REVIEW".equals(beforeStatus) ? 1 : 0), 0)); return List.of(mapper.mapRow(billResultSet(beforeStatus, versionOf(beforeStatus)), 0));
}); });
when(jdbc.query(org.mockito.ArgumentMatchers.startsWith("SELECT b.*"), any(RowMapper.class), eq(501L), eq(9L))).thenAnswer(invocation -> { when(jdbc.query(org.mockito.ArgumentMatchers.startsWith("SELECT b.*"), any(RowMapper.class), eq(501L), eq(9L))).thenAnswer(invocation -> {
@SuppressWarnings("unchecked") RowMapper<Object> mapper = invocation.getArgument(1); @SuppressWarnings("unchecked") RowMapper<Object> mapper = invocation.getArgument(1);
return List.of(mapper.mapRow(billResultSet(afterStatus, "ISSUED".equals(afterStatus) ? 2 : 1), 0)); return List.of(mapper.mapRow(billResultSet(afterStatus, versionOf(afterStatus)), 0));
}); });
when(jdbc.query(org.mockito.ArgumentMatchers.startsWith("SELECT after_data"), any(RowMapper.class), eq(9L), eq(501L))) when(jdbc.query(org.mockito.ArgumentMatchers.startsWith("SELECT after_data"), any(RowMapper.class), eq(9L), eq(501L)))
.thenReturn(List.of("{\"generationMode\":\"SELECTED\",\"periodStart\":\"2026-08-01\",\"periodEnd\":\"2026-08-31\"}")); .thenReturn(List.of("{\"generationMode\":\"SELECTED\",\"periodStart\":\"2026-08-01\",\"periodEnd\":\"2026-08-31\"}"));
when(jdbc.query(org.mockito.ArgumentMatchers.startsWith("SELECT f.id"), any(RowMapper.class), eq(9L), eq(501L))).thenReturn(List.of()); when(jdbc.query(org.mockito.ArgumentMatchers.startsWith("SELECT f.id"), any(RowMapper.class), eq(9L), eq(501L))).thenReturn(List.of());
when(jdbc.query(org.mockito.ArgumentMatchers.startsWith("SELECT r.id"), any(RowMapper.class), eq(9L), eq(501L))).thenReturn(List.of()); when(jdbc.query(org.mockito.ArgumentMatchers.startsWith("SELECT r.id"), any(RowMapper.class), eq(9L), eq(501L))).thenReturn(List.of());
when(jdbc.query(org.mockito.ArgumentMatchers.startsWith("SELECT action,reason"), any(RowMapper.class), eq(9L), eq(501L))).thenReturn(List.of());
when(jdbc.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT request_hash"), when(jdbc.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT request_hash"),
eq(9L), eq(71L), eq("RECONCILIATION_ACTION"), eq(key))).thenReturn(List.of()); eq(9L), eq(71L), eq("RECONCILIATION_ACTION"), eq(key))).thenReturn(List.of());
when(jdbc.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT * FROM fin_bill_line"), eq(9L), eq(501L))).thenReturn(List.of()); when(jdbc.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT * FROM fin_bill_line"), eq(9L), eq(501L))).thenReturn(List.of());
@@ -168,10 +374,10 @@ class ReconciliationStatementBehaviorTest {
} }
private static JdbcTemplate createJdbc() throws Exception { private static JdbcTemplate createJdbc() throws Exception {
JdbcTemplate jdbc = mock(JdbcTemplate.class, invocation -> { JdbcTemplate jdbc = mock(JdbcTemplate.class, org.mockito.Mockito.withSettings().lenient().defaultAnswer(invocation -> {
if ("update".equals(invocation.getMethod().getName()) && invocation.getMethod().getReturnType() == int.class) return 1; if ("update".equals(invocation.getMethod().getName()) && invocation.getMethod().getReturnType() == int.class) return 1;
return RETURNS_DEFAULTS.answer(invocation); return RETURNS_DEFAULTS.answer(invocation);
}); }));
LocalDate start = LocalDate.of(2026, 8, 1); LocalDate start = LocalDate.of(2026, 8, 1);
LocalDate end = LocalDate.of(2026, 8, 31); LocalDate end = LocalDate.of(2026, 8, 31);
when(jdbc.query(org.mockito.ArgumentMatchers.startsWith("SELECT customer_name_snapshot"), any(RowMapper.class), eq(9L))).thenAnswer(invocation -> { when(jdbc.query(org.mockito.ArgumentMatchers.startsWith("SELECT customer_name_snapshot"), any(RowMapper.class), eq(9L))).thenAnswer(invocation -> {
@@ -192,6 +398,13 @@ class ReconciliationStatementBehaviorTest {
@SuppressWarnings("unchecked") RowMapper<Object> mapper = invocation.getArgument(1); @SuppressWarnings("unchecked") RowMapper<Object> mapper = invocation.getArgument(1);
return List.of(mapper.mapRow(sourceResultSet(), 0)); return List.of(mapper.mapRow(sourceResultSet(), 0));
}); });
when(jdbc.query(org.mockito.ArgumentMatchers.startsWith("SELECT id,source_fee_id"), any(RowMapper.class),
eq(9L), eq(start), eq(end))).thenAnswer(invocation -> {
@SuppressWarnings("unchecked") RowMapper<Object> mapper = invocation.getArgument(1);
return List.of(mapper.mapRow(sourceResultSet(), 0));
});
when(jdbc.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT id,customer_id"), eq(88L)))
.thenReturn(List.of(Map.of("id", 88L, "customer_id", 9L)));
org.mockito.Mockito.lenient().doAnswer(invocation -> { org.mockito.Mockito.lenient().doAnswer(invocation -> {
KeyHolder holder = invocation.getArgument(1); KeyHolder holder = invocation.getArgument(1);
holder.getKeyList().add(Map.of("GENERATED_KEY", 501L)); holder.getKeyList().add(Map.of("GENERATED_KEY", 501L));
@@ -211,8 +424,11 @@ class ReconciliationStatementBehaviorTest {
return List.of(mapper.mapRow(sourceResultSet(), 0)); return List.of(mapper.mapRow(sourceResultSet(), 0));
}); });
when(jdbc.query(org.mockito.ArgumentMatchers.startsWith("SELECT r.id"), any(RowMapper.class), eq(9L), eq(501L))).thenReturn(List.of()); when(jdbc.query(org.mockito.ArgumentMatchers.startsWith("SELECT r.id"), any(RowMapper.class), eq(9L), eq(501L))).thenReturn(List.of());
when(jdbc.query(org.mockito.ArgumentMatchers.startsWith("SELECT action,reason"), any(RowMapper.class), eq(9L), eq(501L))).thenReturn(List.of());
when(jdbc.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT request_hash"), when(jdbc.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT request_hash"),
eq(9L), eq(71L), eq("RECONCILIATION_CREATE"), eq("create-key"))).thenReturn(List.of()); eq(9L), eq(71L), eq("RECONCILIATION_CREATE"), eq("create-key"))).thenReturn(List.of());
when(jdbc.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT request_hash"),
eq(9L), eq(71L), eq("RECONCILIATION_CREATE"), eq("cutoff-key"))).thenReturn(List.of());
return jdbc; return jdbc;
} }
@@ -254,4 +470,15 @@ class ReconciliationStatementBehaviorTest {
when(rs.getTimestamp("updated_at")).thenReturn(timestamp); when(rs.getTimestamp("updated_at")).thenReturn(timestamp);
return rs; return rs;
} }
private static int versionOf(String status) {
return switch (status) {
case "DRAFT" -> 0;
case "PENDING_REVIEW" -> 1;
case "APPROVED" -> 2;
case "ISSUED" -> 3;
case "VOIDED" -> 4;
default -> 1;
};
}
} }
+3 -1
View File
@@ -12,7 +12,8 @@
|---|---| |---|---|
| 履约任务/分配 | `controller/PlatformFulfillmentAssignmentController.java`、`service/FulfillmentTaskService.java` | | 履约任务/分配 | `controller/PlatformFulfillmentAssignmentController.java`、`service/FulfillmentTaskService.java` |
| 入库单查询/聚合 | `controller/PlatformInboundOrderController.java`、`service/InboundOrderQueryService.java` | | 入库单查询/聚合 | `controller/PlatformInboundOrderController.java`、`service/InboundOrderQueryService.java` |
| 测量/OCR/第三方回传 | `controller/PlatformInboundMeasurementController.java`、`OpenApiInboundMeasurementController.java`、`service/InboundMeasurementService.java` | | 平台测量/OCR | `controller/PlatformInboundMeasurementController.java`、`service/InboundMeasurementService.java`、`service/CartonMeasurementService.java` |
| 墨家设备测量上传 | `controller/OpenApiInboundMeasurementController.java`、`dto/MojiaMeasurementDtos.java`、`service/MojiaMeasurementOpenApiService.java`、`security/MeasurementRequestAuthenticator.java`;接口契约见 `src/main/resources/openapi/firstleg-v1.yaml` 与 `../../docs/墨家设备测量上传接口说明-V3.md` |
| 审核/自动完成 | `controller/PlatformInboundMeasurementReviewController.java`、`service/MeasurementReviewService.java`、`InboundCompletionOrchestrator.java` | | 审核/自动完成 | `controller/PlatformInboundMeasurementReviewController.java`、`service/MeasurementReviewService.java`、`InboundCompletionOrchestrator.java` |
| FBA 主数据/计费 | `controller/PlatformFbaMasterDataController.java`、`PlatformFbaPricingController.java`、`service/FbaPricingService.java` | | FBA 主数据/计费 | `controller/PlatformFbaMasterDataController.java`、`PlatformFbaPricingController.java`、`service/FbaPricingService.java` |
| 客户费用查询 | `controller/MerchantInboundBillingController.java`、`service/MerchantInboundBillingService.java` | | 客户费用查询 | `controller/MerchantInboundBillingController.java`、`service/MerchantInboundBillingService.java` |
@@ -41,6 +42,7 @@
## 三期装柜与 PDA 资源结算 ## 三期装柜与 PDA 资源结算
- 智能装柜候选只能按同一国内中转仓合柜;推荐顺序按入库时间先进先出。装柜计划状态为 `EDITING -> LOADING -> COMPLETED`,PDA 全部明细处理完成后自动完成。 - 智能装柜候选只能按同一国内中转仓合柜;推荐顺序按入库时间先进先出。装柜计划状态为 `EDITING -> LOADING -> COMPLETED`,PDA 全部明细处理完成后自动完成。
- PDA 拣货以 `fl_inbound_carton_label.box_code` 对应的稳定箱唛箱码为扫描身份;一个有效且未重复的箱码扫描表示拣出一箱,不得以 SKU、商品件数或页面展示字段替代箱码。任务下发与提交契约见 `src/main/resources/openapi/phase3-pda-convergence-v1.yaml`。
- PDA 部分拣货生成二级子入库单时,数量必须守恒:`已拣数量 + 子单数量 = 拣货前主单可用数量`。子单只允许一层,不能继续拆分。 - PDA 部分拣货生成二级子入库单时,数量必须守恒:`已拣数量 + 子单数量 = 拣货前主单可用数量`。子单只允许一层,不能继续拆分。
- `releaseMode=AUTO` 时,释放资源数为 `ceil(实际拣货箱数 × 原资源数 / 原可用箱数)`;`MANUAL` 按 `locationIds` 选择,空数组表示明确释放 0 个。未传模式时,`locationIds=null` 兼容为 AUTO。 - `releaseMode=AUTO` 时,释放资源数为 `ceil(实际拣货箱数 × 原资源数 / 原可用箱数)`;`MANUAL` 按 `locationIds` 选择,空数组表示明确释放 0 个。未传模式时,`locationIds=null` 兼容为 AUTO。
- 头程快照必须保存仓库 `reservation_id`。A0 是共享临时库位,多个托盘可具有相同 `location_id`,跨服务结算、转移、释放必须使用 `reservationId`,不得把 `locationId` 当作唯一资源标识。 - 头程快照必须保存仓库 `reservation_id`。A0 是共享临时库位,多个托盘可具有相同 `location_id`,跨服务结算、转移、释放必须使用 `reservationId`,不得把 `locationId` 当作唯一资源标识。
@@ -98,7 +98,7 @@ public final class LoadingPickDtos {
) { ) {
} }
public record PickItem(Long id, Long loadingPlanItemId, Long inboundId, Long inboundDetailId, public record PickItem(Long id, Long loadingPlanItemId, Long tenantId, Long inboundId, Long inboundDetailId,
String orderNo, String inboundCode, String cargoName, List<String> boxCodes, String orderNo, String inboundCode, String cargoName, List<String> boxCodes,
Integer plannedPackageCount, Integer pickedPackageCount, Integer plannedPackageCount, Integer pickedPackageCount,
String itemStatus, Integer version) { String itemStatus, Integer version) {
@@ -12,7 +12,7 @@ import java.util.List;
@Mapper @Mapper
public interface LoadingPickItemMapper extends BaseMapper<LoadingPickItemEntity> { public interface LoadingPickItemMapper extends BaseMapper<LoadingPickItemEntity> {
@Select(""" @Select("""
SELECT pick_item.id AS pickItemId, plan_item.order_no AS orderNo, SELECT pick_item.id AS pickItemId, plan_item.account_id AS tenantId, plan_item.order_no AS orderNo,
plan_item.inbound_code AS inboundCode, label.cargo_name AS cargoName, plan_item.inbound_code AS inboundCode, label.cargo_name AS cargoName,
label.box_code AS boxCode label.box_code AS boxCode
FROM fl_loading_pick_item pick_item FROM fl_loading_pick_item pick_item
@@ -27,6 +27,7 @@ public interface LoadingPickItemMapper extends BaseMapper<LoadingPickItemEntity>
interface CartonLabelRow { interface CartonLabelRow {
Long getPickItemId(); Long getPickItemId();
Long getTenantId();
String getOrderNo(); String getOrderNo();
String getInboundCode(); String getInboundCode();
String getCargoName(); String getCargoName();
@@ -491,7 +491,8 @@ public class LoadingPickService {
List<PickItem> items = source.stream().map(item -> { List<PickItem> items = source.stream().map(item -> {
List<CartonLabelRow> cartons = cartonsByItem.getOrDefault(item.getId(), List.of()); List<CartonLabelRow> cartons = cartonsByItem.getOrDefault(item.getId(), List.of());
CartonLabelRow first = cartons.isEmpty() ? null : cartons.get(0); CartonLabelRow first = cartons.isEmpty() ? null : cartons.get(0);
return new PickItem(item.getId(), item.getLoadingPlanItemId(), item.getInboundId(), return new PickItem(item.getId(), item.getLoadingPlanItemId(), first == null ? null : first.getTenantId(),
item.getInboundId(),
item.getInboundDetailId(), first == null ? null : first.getOrderNo(), item.getInboundDetailId(), first == null ? null : first.getOrderNo(),
first == null ? null : first.getInboundCode(), first == null ? null : first.getCargoName(), first == null ? null : first.getInboundCode(), first == null ? null : first.getCargoName(),
cartons.stream().map(CartonLabelRow::getBoxCode).toList(), item.getPlannedPackageCount(), cartons.stream().map(CartonLabelRow::getBoxCode).toList(), item.getPlannedPackageCount(),
@@ -10,7 +10,9 @@ paths:
- {name: containerNo, in: query, required: true, schema: {type: string}} - {name: containerNo, in: query, required: true, schema: {type: string}}
- {name: warehouseId, in: query, required: true, schema: {type: integer, format: int64, minimum: 1}} - {name: warehouseId, in: query, required: true, schema: {type: integer, format: int64, minimum: 1}}
responses: responses:
'200': {description: Task with assignment, review and item-level versions} '200':
description: Task with assignment, review, stable carton codes and item-level versions
content: {application/json: {schema: {$ref: '#/components/schemas/Task'}}}
/api/platform/v1/first-leg/pda-loading/tasks/pending: /api/platform/v1/first-leg/pda-loading/tasks/pending:
get: get:
summary: List active tasks available to the authenticated PDA operator summary: List active tasks available to the authenticated PDA operator
@@ -98,6 +100,41 @@ components:
TaskId: {name: taskId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}} TaskId: {name: taskId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}}
ItemId: {name: itemId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}} ItemId: {name: itemId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}}
schemas: schemas:
Task:
type: object
required: [id, loadingPlanId, containerNo, taskStatus, workflowStatus, version, items]
properties:
id: {type: integer, format: int64}
loadingPlanId: {type: integer, format: int64}
containerNo: {type: string}
taskStatus: {type: string, enum: [LOADING, COMPLETED]}
workflowStatus: {type: string, enum: [UNASSIGNED, IN_PROGRESS, AWAITING_REVIEW, COMPLETED]}
version: {type: integer, minimum: 0}
assignment: {type: object, nullable: true}
review: {type: object, nullable: true}
items:
type: array
items: {$ref: '#/components/schemas/PickItem'}
PickItem:
type: object
required: [id, loadingPlanItemId, tenantId, inboundId, inboundDetailId, boxCodes, plannedPackageCount, pickedPackageCount, itemStatus, version]
properties:
id: {type: integer, format: int64}
loadingPlanItemId: {type: integer, format: int64}
tenantId: {type: integer, format: int64, description: Hidden isolation key for subsequent PDA evidence calls}
inboundId: {type: integer, format: int64}
inboundDetailId: {type: integer, format: int64}
orderNo: {type: string, nullable: true}
inboundCode: {type: string, nullable: true}
cargoName: {type: string, nullable: true}
boxCodes:
type: array
description: Stable carton label codes; one successful scan represents one carton
items: {type: string}
plannedPackageCount: {type: integer, minimum: 1}
pickedPackageCount: {type: integer, minimum: 0}
itemStatus: {type: string, enum: [PENDING, PICKING, PICKED, PARTIAL, SKIPPED]}
version: {type: integer, minimum: 0}
PendingTask: PendingTask:
type: object type: object
required: [taskId, loadingPlanId, containerNo, warehouseId, taskStatus, workflowStatus, version, assignmentVersion, itemCount, pendingItemCount] required: [taskId, loadingPlanId, containerNo, warehouseId, taskStatus, workflowStatus, version, assignmentVersion, itemCount, pendingItemCount]
@@ -0,0 +1,78 @@
-- Finance G3/G4 permissions exposed by the frozen reconciliation and settlement controllers.
-- These resources are assignable to custom platform roles; this migration never grants them by default.
USE baiyue_iam;
SET NAMES utf8mb4;
DROP PROCEDURE IF EXISTS migrate_finance_g3_g4_permissions;
DELIMITER $$
CREATE PROCEDURE migrate_finance_g3_g4_permissions()
BEGIN
IF EXISTS (
SELECT 1
FROM iam_permission
WHERE (id = 5105026 AND permission_code <> 'platform:finance:reconciliation:create')
OR (id = 5105027 AND permission_code <> 'platform:finance:reconciliation:review')
OR (id = 5105028 AND permission_code <> 'platform:finance:reconciliation:issue')
OR (id = 5105029 AND permission_code <> 'platform:finance:reconciliation:adjust')
OR (id = 5105030 AND permission_code <> 'platform:finance:reconciliation:void')
OR (id = 5105031 AND permission_code <> 'platform:finance:settlement-ar:create')
OR (id = 5105032 AND permission_code <> 'platform:finance:settlement-ap:create')
OR (id = 5105033 AND permission_code <> 'platform:finance:settlement-ar:view')
OR (id = 5105034 AND permission_code <> 'platform:finance:settlement-ap:view')
OR (id = 5105035 AND permission_code <> 'platform:finance:payment:create')
OR (permission_code = 'platform:finance:reconciliation:create' AND id <> 5105026)
OR (permission_code = 'platform:finance:reconciliation:review' AND id <> 5105027)
OR (permission_code = 'platform:finance:reconciliation:issue' AND id <> 5105028)
OR (permission_code = 'platform:finance:reconciliation:adjust' AND id <> 5105029)
OR (permission_code = 'platform:finance:reconciliation:void' AND id <> 5105030)
OR (permission_code = 'platform:finance:settlement-ar:create' AND id <> 5105031)
OR (permission_code = 'platform:finance:settlement-ap:create' AND id <> 5105032)
OR (permission_code = 'platform:finance:settlement-ar:view' AND id <> 5105033)
OR (permission_code = 'platform:finance:settlement-ap:view' AND id <> 5105034)
OR (permission_code = 'platform:finance:payment:create' AND id <> 5105035)
OR (id BETWEEN 5105026 AND 5105035 AND (tenant_id <> 0 OR app_code <> 'PLATFORM'))
) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='finance G3/G4 permission id/code conflict';
END IF;
INSERT INTO iam_permission
(id, tenant_id, app_code, permission_type, parent_id, permission_code,
permission_name, route_path, http_method, sort_no, status, built_in, deleted)
VALUES
(5105026, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:reconciliation:create', '生成和提交客户对账单', NULL, 'POST', 170, 'ACTIVE', 1, 0),
(5105027, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:reconciliation:review', '审核客户对账单', NULL, 'POST', 180, 'ACTIVE', 1, 0),
(5105028, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:reconciliation:issue', '出账客户对账单', NULL, 'POST', 190, 'ACTIVE', 1, 0),
(5105029, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:reconciliation:adjust', '调整客户对账单', NULL, 'POST', 200, 'ACTIVE', 1, 0),
(5105030, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:reconciliation:void', '作废或重开客户对账单', NULL, 'POST', 210, 'ACTIVE', 1, 0),
(5105031, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:settlement-ar:create', '生成应收结算单', NULL, 'POST', 220, 'ACTIVE', 1, 0),
(5105032, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:settlement-ap:create', '生成应付结算单', NULL, 'POST', 230, 'ACTIVE', 1, 0),
(5105033, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:settlement-ar:view', '查看应收结算单', NULL, 'GET', 240, 'ACTIVE', 1, 0),
(5105034, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:settlement-ap:view', '查看应付结算单', NULL, 'GET', 250, 'ACTIVE', 1, 0),
(5105035, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:payment:create', '登记应付付款', NULL, 'POST', 260, 'ACTIVE', 1, 0)
ON DUPLICATE KEY UPDATE
permission_type = VALUES(permission_type), parent_id = VALUES(parent_id),
permission_name = VALUES(permission_name), route_path = VALUES(route_path),
http_method = VALUES(http_method), sort_no = VALUES(sort_no),
status = 'ACTIVE', built_in = 1, deleted = 0;
IF (SELECT COUNT(*) FROM iam_permission
WHERE tenant_id=0 AND app_code='PLATFORM' AND parent_id=5105000
AND permission_code IN ('platform:finance:reconciliation:create','platform:finance:reconciliation:review',
'platform:finance:reconciliation:issue','platform:finance:reconciliation:adjust','platform:finance:reconciliation:void',
'platform:finance:settlement-ar:create','platform:finance:settlement-ap:create',
'platform:finance:settlement-ar:view','platform:finance:settlement-ap:view','platform:finance:payment:create')
AND status='ACTIVE' AND deleted=0) <> 10 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='finance G3/G4 permission catalog is incomplete';
END IF;
IF (SELECT COUNT(*) FROM iam_permission
WHERE id=5105000 AND tenant_id=0 AND app_code='PLATFORM'
AND permission_type='MENU' AND permission_code='platform:finance'
AND status='ACTIVE' AND deleted=0) <> 1 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='finance permission parent is invalid';
END IF;
END$$
DELIMITER ;
CALL migrate_finance_g3_g4_permissions();
DROP PROCEDURE IF EXISTS migrate_finance_g3_g4_permissions;
@@ -66,10 +66,10 @@ class FinanceG1G2PermissionSqlContractTest {
.contains("NULL, 'POST', 150, 'ACTIVE', 1, 0") .contains("NULL, 'POST', 150, 'ACTIVE', 1, 0")
.contains("NULL, 'PUT', 160, 'ACTIVE', 1, 0") .contains("NULL, 'PUT', 160, 'ACTIVE', 1, 0")
.contains("permission_code IN (\n 'platform:finance',") .contains("permission_code IN (\n 'platform:finance',")
.contains("permission_code LIKE 'platform:finance:%' AND status='ACTIVE' AND deleted=0) <> 16") .contains("permission_code LIKE 'platform:finance:%' AND status='ACTIVE' AND deleted=0) <> 26")
.contains("p.permission_code IN ('platform:finance:account:view','platform:finance:fee-source:view','platform:finance:fee-source:review','platform:finance:fee-source:settlement')) <> 0") .contains("p.id BETWEEN 5105022 AND 5105035) <> 0")
.contains("平台管理员 Finance 授权不完整") .contains("平台管理员 Finance 授权不完整")
.contains("Finance G1/G2 新权限不得默认授予平台管理员"); .contains("Finance G1-G4 新权限不得默认授予平台管理员");
assertThat(platformAdminGrant) assertThat(platformAdminGrant)
.contains("'platform:finance'") .contains("'platform:finance'")
.contains("'platform:finance:customer-terms:update'") .contains("'platform:finance:customer-terms:update'")
@@ -0,0 +1,61 @@
package com.baiyue.iam.repository;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class FinanceG3G4PermissionSqlContractTest {
private static final String[] CODES = {
"platform:finance:reconciliation:create", "platform:finance:reconciliation:review",
"platform:finance:reconciliation:issue", "platform:finance:reconciliation:adjust",
"platform:finance:reconciliation:void", "platform:finance:settlement-ar:create",
"platform:finance:settlement-ap:create", "platform:finance:settlement-ar:view",
"platform:finance:settlement-ap:view", "platform:finance:payment:create"
};
@Test
void declaresAllFrozenControllerPermissionsWithoutDefaultRoleGrants() throws Exception {
String sql = new ClassPathResource("db/finance-g3-g4-permissions.sql")
.getContentAsString(StandardCharsets.UTF_8);
for (int index = 0; index < CODES.length; index++) {
assertThat(sql).contains("(" + (5105026 + index) + ", 0, 'PLATFORM', 'BUTTON', 5105000, '" + CODES[index] + "'");
}
assertThat(sql).contains("SIGNAL SQLSTATE '45000'")
.contains("id = 5105026 AND permission_code <> 'platform:finance:reconciliation:create'")
.contains("permission_code = 'platform:finance:payment:create' AND id <> 5105035")
.contains("id BETWEEN 5105026 AND 5105035 AND (tenant_id <> 0 OR app_code <> 'PLATFORM')")
.contains("finance G3/G4 permission id/code conflict")
.contains("permission catalog is incomplete")
.contains("finance permission parent is invalid")
.contains("CALL migrate_finance_g3_g4_permissions()")
.doesNotContain("INSERT INTO iam_role_permission");
}
@Test
void unifiedSqlContainsCatalogAndDoesNotGrantItToPlatformAdmin() throws Exception {
String sql = Files.readString(resolveUnifiedSql(), StandardCharsets.UTF_8);
String financeSection = sql.substring(sql.indexOf("-- Finance F1 人工财务权限"),
sql.indexOf("CALL assert_unified_final_schema()"));
for (String code : CODES) assertThat(financeSection).contains(code);
assertThat(financeSection)
.contains("migrate_finance_g1_g4_permissions")
.contains("finance G1-G4 permission id/code conflict")
.contains("permission_code LIKE 'platform:finance:%' AND status='ACTIVE' AND deleted=0) <> 26")
.contains("p.id BETWEEN 5105022 AND 5105035");
String grant = financeSection.substring(
financeSection.indexOf("SELECT 4000001, id, 0 FROM baiyue_iam.iam_permission"),
financeSection.indexOf("ON DUPLICATE KEY UPDATE tenant_id=VALUES(tenant_id);"));
for (String code : CODES) assertThat(grant).doesNotContain(code);
}
private static Path resolveUnifiedSql() {
Path direct = Path.of("deploy", "sql", "头程数据库统一增量-MySQL8.sql");
return Files.exists(direct) ? direct : Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql");
}
}
+1
View File
@@ -12,6 +12,7 @@
|---|---| |---|---|
| 平台订单 API | `controller/PlatformFirstLegOrderController.java` | | 平台订单 API | `controller/PlatformFirstLegOrderController.java` |
| 订单编排/状态/幂等 | `service/OrderApplicationService.java`、`entity/OrderStatus.java` | | 订单编排/状态/幂等 | `service/OrderApplicationService.java`、`entity/OrderStatus.java` |
| 统一订单七状态与操作映射 | `../../docs/统一订单主状态与操作契约.md`;`LOADED` 硬门禁和 `DELIVERED` 可信回传同时核对三期需求确认表与开工门禁 |
| 货物内部查询 | `controller/InternalOrderCargoController.java` | | 货物内部查询 | `controller/InternalOrderCargoController.java` |
| 快照/Outbox | `service/`、`repository/`、`event/` | | 快照/Outbox | `service/`、`repository/`、`event/` |
| DTO/持久化 | `dto/`、`entity/`、`mapper/`、`repository/` | | DTO/持久化 | `dto/`、`entity/`、`mapper/`、`repository/` |
@@ -13,6 +13,7 @@ public final class UnifiedOrderResponse {
public record Row( public record Row(
Long orderId, Long tenantId, String orderNo, String mainStatus, String nextAction, Long orderId, Long tenantId, String orderNo, String mainStatus, String nextAction,
OrderRelationSummary relation,
OrderStatus internalOrderStatus, OrderStatus internalOrderStatus,
Long customerId, String customerName, String channelName, Long customerId, String customerName, String channelName,
Long receivingWarehouseId, String receivingWarehouseName, Long receivingWarehouseId, String receivingWarehouseName,
@@ -26,6 +27,9 @@ public final class UnifiedOrderResponse {
LocalDateTime createdAt, LocalDateTime updatedAt LocalDateTime createdAt, LocalDateTime updatedAt
) {} ) {}
public record OrderRelationSummary(String role, Long rootOrderId, Long parentOrderId,
Integer splitSequence, Integer childCount) {}
public record Detail(Row summary, OrderDetailResponse order, public record Detail(Row summary, OrderDetailResponse order,
List<TimelineEvent> timeline) {} List<TimelineEvent> timeline) {}
@@ -226,6 +226,16 @@ public class OrderRepository {
.in(OrderEntity::getId, orderIds)); .in(OrderEntity::getId, orderIds));
} }
public List<OrderEntity> findChildrenByParentOrders(List<OrderEntity> parentOrders) {
if (parentOrders.isEmpty()) return List.of();
List<Long> tenantIds = parentOrders.stream().map(OrderEntity::getTenantId).distinct().toList();
List<Long> parentOrderIds = parentOrders.stream().map(OrderEntity::getId).distinct().toList();
return orderMapper.selectList(new LambdaQueryWrapper<OrderEntity>()
.select(OrderEntity::getId, OrderEntity::getTenantId, OrderEntity::getParentOrderId)
.in(OrderEntity::getTenantId, tenantIds)
.in(OrderEntity::getParentOrderId, parentOrderIds));
}
public List<OrderInternalNoteEntity> findNotesByOrderIds(Long tenantId, List<Long> orderIds) { public List<OrderInternalNoteEntity> findNotesByOrderIds(Long tenantId, List<Long> orderIds) {
if (orderIds.isEmpty()) return List.of(); if (orderIds.isEmpty()) return List.of();
return noteMapper.selectList(new LambdaQueryWrapper<OrderInternalNoteEntity>() return noteMapper.selectList(new LambdaQueryWrapper<OrderInternalNoteEntity>()
@@ -57,8 +57,9 @@ public class UnifiedOrderQueryService {
? Map.of() ? Map.of()
: summaries(tenantId, source.records().stream().map(OrderEntity::getId).toList()); : summaries(tenantId, source.records().stream().map(OrderEntity::getId).toList());
Map<Long, String> latestNotes = latestNotes(tenantId, source.records().stream().map(OrderEntity::getId).toList()); Map<Long, String> latestNotes = latestNotes(tenantId, source.records().stream().map(OrderEntity::getId).toList());
Map<Long, Integer> childCounts = childCounts(source.records());
List<UnifiedOrderResponse.Row> rows = source.records().stream() List<UnifiedOrderResponse.Row> rows = source.records().stream()
.map(order -> row(order, inboundByOrder.get(order.getId()), latestNotes.get(order.getId()))).toList(); .map(order -> row(order, inboundByOrder.get(order.getId()), latestNotes.get(order.getId()), childCounts)).toList();
return new PageResult<>(rows, source.page(), source.size(), source.total(), source.pages()); return new PageResult<>(rows, source.page(), source.size(), source.total(), source.pages());
} }
@@ -71,7 +72,8 @@ public class UnifiedOrderQueryService {
FirstlegUnifiedOrderClient.Detail inbound = order.getStatus() == OrderStatus.SUBMITTED FirstlegUnifiedOrderClient.Detail inbound = order.getStatus() == OrderStatus.SUBMITTED
? null : firstlegDetail(tenantId, orderId); ? null : firstlegDetail(tenantId, orderId);
String latestNote = orderDetail.internalNotes().isEmpty() ? null : orderDetail.internalNotes().get(0).getContent(); String latestNote = orderDetail.internalNotes().isEmpty() ? null : orderDetail.internalNotes().get(0).getContent();
UnifiedOrderResponse.Row summary = row(order, inbound == null ? null : inbound.summary(), latestNote); UnifiedOrderResponse.Row summary = row(order, inbound == null ? null : inbound.summary(), latestNote,
childCounts(List.of(order)));
List<UnifiedOrderResponse.TimelineEvent> timeline = new ArrayList<>(); List<UnifiedOrderResponse.TimelineEvent> timeline = new ArrayList<>();
orderDetail.statusHistory().forEach(item -> timeline.add(new UnifiedOrderResponse.TimelineEvent( orderDetail.statusHistory().forEach(item -> timeline.add(new UnifiedOrderResponse.TimelineEvent(
"ORDER", "STATUS", orderMainStatus(item.getToStatus()), item.getAction(), item.getReason(), "ORDER", "STATUS", orderMainStatus(item.getToStatus()), item.getAction(), item.getReason(),
@@ -105,8 +107,10 @@ public class UnifiedOrderQueryService {
Map<Long, OrderEntity> orderById = orders.findByIds(tenantId, orderIds).stream() Map<Long, OrderEntity> orderById = orders.findByIds(tenantId, orderIds).stream()
.collect(Collectors.toMap(OrderEntity::getId, Function.identity(), (left, right) -> left)); .collect(Collectors.toMap(OrderEntity::getId, Function.identity(), (left, right) -> left));
Map<Long, String> latestNotes = latestNotes(tenantId, orderIds); Map<Long, String> latestNotes = latestNotes(tenantId, orderIds);
Map<Long, Integer> childCounts = childCounts(List.copyOf(orderById.values()));
List<UnifiedOrderResponse.Row> records = source.records().stream() List<UnifiedOrderResponse.Row> records = source.records().stream()
.map(summary -> row(summary, orderById.get(summary.inbound().orderId()), latestNotes.get(summary.inbound().orderId()))) .map(summary -> row(summary, orderById.get(summary.inbound().orderId()),
latestNotes.get(summary.inbound().orderId()), childCounts))
.toList(); .toList();
return new PageResult<>(records, source.page(), source.size(), source.total(), source.pages()); return new PageResult<>(records, source.page(), source.size(), source.total(), source.pages());
} }
@@ -131,11 +135,33 @@ public class UnifiedOrderQueryService {
OrderInternalNoteEntity::getOrderId, OrderInternalNoteEntity::getContent, (latest, ignored) -> latest)); OrderInternalNoteEntity::getOrderId, OrderInternalNoteEntity::getContent, (latest, ignored) -> latest));
} }
private UnifiedOrderResponse.Row row(OrderEntity order, FirstlegUnifiedOrderClient.Summary summary, String latestInternalNote) { private Map<Long, Integer> childCounts(List<OrderEntity> pageOrders) {
if (pageOrders.isEmpty()) return Map.of();
return orders.findChildrenByParentOrders(pageOrders).stream()
.filter(child -> child.getParentOrderId() != null)
.collect(Collectors.toMap(OrderEntity::getParentOrderId, ignored -> 1, Integer::sum));
}
private UnifiedOrderResponse.OrderRelationSummary relation(OrderEntity order,
Map<Long, Integer> childCounts) {
if (order == null) return null;
if (order.getParentOrderId() != null) {
Long rootOrderId = order.getRootOrderId() == null ? order.getParentOrderId() : order.getRootOrderId();
return new UnifiedOrderResponse.OrderRelationSummary(
"CHILD", rootOrderId, order.getParentOrderId(), order.getSplitSequence(), 0);
}
int childCount = childCounts.getOrDefault(order.getId(), 0);
return childCount == 0 ? null : new UnifiedOrderResponse.OrderRelationSummary(
"PARENT", order.getId(), null, null, childCount);
}
private UnifiedOrderResponse.Row row(OrderEntity order, FirstlegUnifiedOrderClient.Summary summary,
String latestInternalNote, Map<Long, Integer> childCounts) {
if (summary == null) { if (summary == null) {
String mainStatus = orderMainStatus(order.getStatus()); String mainStatus = orderMainStatus(order.getStatus());
return new UnifiedOrderResponse.Row(order.getId(), order.getTenantId(), order.getOrderNo(), mainStatus, return new UnifiedOrderResponse.Row(order.getId(), order.getTenantId(), order.getOrderNo(), mainStatus,
order.getStatus() == OrderStatus.SUBMITTED ? "APPROVE_ORDER" : null, order.getStatus() == OrderStatus.SUBMITTED ? "APPROVE_ORDER" : null,
relation(order, childCounts),
order.getStatus(), order.getCustomerId(), order.getCustomerName(), order.getStatus(), order.getCustomerId(), order.getCustomerName(),
order.getChannelName(), order.getReceivingWarehouseId(), order.getReceivingWarehouseName(), order.getChannelName(), order.getReceivingWarehouseId(), order.getReceivingWarehouseName(),
order.getDestinationWarehouseId(), order.getDestinationWarehouseName(), null, null, order.getDestinationWarehouseId(), order.getDestinationWarehouseName(), null, null,
@@ -146,7 +172,8 @@ public class UnifiedOrderQueryService {
} }
FirstlegUnifiedOrderClient.Inbound inbound = summary.inbound(); FirstlegUnifiedOrderClient.Inbound inbound = summary.inbound();
return new UnifiedOrderResponse.Row(order.getId(), order.getTenantId(), order.getOrderNo(), return new UnifiedOrderResponse.Row(order.getId(), order.getTenantId(), order.getOrderNo(),
summary.mainStatus(), summary.nextAction(), order.getStatus(), order.getCustomerId(), order.getCustomerName(), summary.mainStatus(), summary.nextAction(), relation(order, childCounts), order.getStatus(),
order.getCustomerId(), order.getCustomerName(),
order.getChannelName(), order.getReceivingWarehouseId(), order.getReceivingWarehouseName(), order.getChannelName(), order.getReceivingWarehouseId(), order.getReceivingWarehouseName(),
order.getDestinationWarehouseId(), order.getDestinationWarehouseName(), inbound.id(), inbound.version(), inbound.inboundCode(), order.getDestinationWarehouseId(), order.getDestinationWarehouseName(), inbound.id(), inbound.version(), inbound.inboundCode(),
order.getExpectedShipDate(), inbound.totalPackageCount(), "STANDARD", inbound.locationCodes(), order.getExpectedShipDate(), inbound.totalPackageCount(), "STANDARD", inbound.locationCodes(),
@@ -157,10 +184,11 @@ public class UnifiedOrderQueryService {
} }
private UnifiedOrderResponse.Row row(FirstlegUnifiedOrderClient.Summary summary, OrderEntity order, private UnifiedOrderResponse.Row row(FirstlegUnifiedOrderClient.Summary summary, OrderEntity order,
String latestInternalNote) { String latestInternalNote, Map<Long, Integer> childCounts) {
var inbound = summary.inbound(); var inbound = summary.inbound();
return new UnifiedOrderResponse.Row(inbound.orderId(), inbound.tenantId(), inbound.orderNo(), return new UnifiedOrderResponse.Row(inbound.orderId(), inbound.tenantId(), inbound.orderNo(),
summary.mainStatus(), summary.nextAction(), parseOrderStatus(inbound.orderStatusSnapshot()), summary.mainStatus(), summary.nextAction(), relation(order, childCounts),
parseOrderStatus(inbound.orderStatusSnapshot()),
inbound.customerId(), inbound.customerName(), inbound.channelName(), inbound.warehouseId(), inbound.customerId(), inbound.customerName(), inbound.channelName(), inbound.warehouseId(),
inbound.warehouseName(), inbound.deliveryWarehouseId(), inbound.deliveryWarehouseName(), inbound.warehouseName(), inbound.deliveryWarehouseId(), inbound.deliveryWarehouseName(),
inbound.id(), inbound.version(), inbound.inboundCode(), null, inbound.totalPackageCount(), inbound.id(), inbound.version(), inbound.inboundCode(), null, inbound.totalPackageCount(),
@@ -16,7 +16,8 @@ paths:
才是 C04 loading/complete 的 expectedVersion,loadingVersion 当前等于 pickingVersion(兼容别名)。 才是 C04 loading/complete 的 expectedVersion,loadingVersion 当前等于 pickingVersion(兼容别名)。
tenantId 省略时跨全部客户;mainStatus 省略时按订单创建时间分页,指定 PENDING_REVIEW 时由 Order 查询, tenantId 省略时跨全部客户;mainStatus 省略时按订单创建时间分页,指定 PENDING_REVIEW 时由 Order 查询,
指定其他主状态时由 Firstleg 精确分页。旧 /orders/first-leg 接口保持兼容。 指定其他主状态时由 Firstleg 精确分页。旧 /orders/first-leg 接口保持兼容。
列表行同时返回 customerRemark(客户下单备注)和 latestInternalNote(最新一条平台内部备注)。 列表行同时返回 customerRemark(客户下单备注)、latestInternalNote(最新一条平台内部备注)和
relation(仅有关联时返回;PARENT 含 childCount,CHILD 含 rootOrderId、parentOrderId 与 splitSequence)。
parameters: parameters:
- {name: tenantId, in: query, required: false, schema: {type: string, pattern: '^[1-9][0-9]*$'}} - {name: tenantId, in: query, required: false, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
- $ref: '#/components/parameters/UnifiedKeyword' - $ref: '#/components/parameters/UnifiedKeyword'
@@ -41,6 +41,7 @@ class UnifiedOrderQueryServiceTest {
void stubListEnrichmentQueries() { void stubListEnrichmentQueries() {
when(orders.findNotesByOrderIds(any(), any())).thenReturn(List.of()); when(orders.findNotesByOrderIds(any(), any())).thenReturn(List.of());
when(orders.findByIds(any(), any())).thenReturn(List.of()); when(orders.findByIds(any(), any())).thenReturn(List.of());
when(orders.findChildrenByParentOrders(any())).thenReturn(List.of());
} }
@Test @Test
@@ -67,6 +68,7 @@ class UnifiedOrderQueryServiceTest {
assertThat(row.inboundId()).isNull(); assertThat(row.inboundId()).isNull();
assertThat(row.customerRemark()).isEqualTo("客户要求提货前联系"); assertThat(row.customerRemark()).isEqualTo("客户要求提货前联系");
assertThat(row.latestInternalNote()).isEqualTo("客服已确认提货时间"); assertThat(row.latestInternalNote()).isEqualTo("客服已确认提货时间");
assertThat(row.relation()).isNull();
}); });
verify(firstleg, never()).summaries(any(), any()); verify(firstleg, never()).summaries(any(), any());
} }
@@ -87,6 +89,35 @@ class UnifiedOrderQueryServiceTest {
verify(firstleg, never()).summaries(any(), any()); verify(firstleg, never()).summaries(any(), any());
} }
@Test
void addsRelationSummaryOnlyForOrdersWithParentChildAssociations() {
UnifiedOrderQuery query = query("PENDING_REVIEW");
OrderEntity parent = order(OrderStatus.SUBMITTED);
OrderEntity child = order(OrderStatus.SUBMITTED);
child.setId(78L);
child.setOrderNo("FL-77-1");
child.setOrderRole("CHILD");
child.setParentOrderId(77L);
child.setRootOrderId(77L);
child.setSplitSequence(1);
when(orders.unifiedPage(eq(10001L), eq(query), eq(List.of(OrderStatus.SUBMITTED))))
.thenReturn(new PageResult<>(List.of(parent, child), 1, 20, 2, 1));
when(orders.findChildrenByParentOrders(List.of(parent, child))).thenReturn(List.of(child));
var result = service.page(10001L, query);
assertThat(result.records()).satisfiesExactly(
row -> assertThat(row.relation()).satisfies(relation -> {
assertThat(relation.role()).isEqualTo("PARENT");
assertThat(relation.childCount()).isEqualTo(1);
}),
row -> assertThat(row.relation()).satisfies(relation -> {
assertThat(relation.role()).isEqualTo("CHILD");
assertThat(relation.rootOrderId()).isEqualTo(77L);
assertThat(relation.splitSequence()).isEqualTo(1);
}));
}
@Test @Test
void allStatusInboundCodeFilterIncludesPendingOrdersFromOrderQuery() { void allStatusInboundCodeFilterIncludesPendingOrdersFromOrderQuery() {
UnifiedOrderQuery query = new UnifiedOrderQuery( UnifiedOrderQuery query = new UnifiedOrderQuery(
+3 -3
View File
@@ -11,7 +11,7 @@ import process from 'node:process'
const DEFAULT_GATEWAY = 'http://8.163.30.128:9000' const DEFAULT_GATEWAY = 'http://8.163.30.128:9000'
const DEFAULT_USERNAME = 'platform_admin' const DEFAULT_USERNAME = 'platform_admin'
const DEFAULT_PASSWORD = 'ChangeMe123!' const DEFAULT_PASSWORD = 'ChangeMe123!'
const DEFAULT_MODULES = ['customers', 'overseas-storage-fees', 'system', 'master-data', 'orders', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'phase3-container-loading', 'phase3-loading', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-loading-finalize', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'phase3-warehouse-rbac', 'phase3-order-splitting'] const DEFAULT_MODULES = ['customers', 'overseas-storage-fees', 'system', 'master-data', 'orders', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'phase3-container-loading', 'phase3-loading', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-loading-finalize', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'finance']
const WEIGHT_IMAGE_PATH = 'C:/Users/BY-OY/Desktop/上传图片包/测量图片/测量图片/orig_1cf27695-b2c3-44b3-98e3-c0be8b80f79a.JPG' const WEIGHT_IMAGE_PATH = 'C:/Users/BY-OY/Desktop/上传图片包/测量图片/测量图片/orig_1cf27695-b2c3-44b3-98e3-c0be8b80f79a.JPG'
const CUSTOMER_FIELDS = [ const CUSTOMER_FIELDS = [
'id', 'tenantCode', 'tenantName', 'status', 'legalName', 'legalRepresentative', 'id', 'tenantCode', 'tenantName', 'status', 'legalName', 'legalRepresentative',
@@ -98,8 +98,8 @@ class AcceptanceRun {
this.fatal = null this.fatal = null
} }
async request(method, path, body = undefined, token = this.token) { async request(method, path, body = undefined, token = this.token, extraHeaders = {}) {
const headers = { Accept: 'application/json' } const headers = { Accept: 'application/json', ...extraHeaders }
if (token) headers.Authorization = `Bearer ${token}` if (token) headers.Authorization = `Bearer ${token}`
if (body !== undefined) headers['Content-Type'] = 'application/json; charset=utf-8' if (body !== undefined) headers['Content-Type'] = 'application/json; charset=utf-8'
try { try {
@@ -5,6 +5,7 @@
| 对话 ID | 主题 | 负责文件 / 场景 | | 对话 ID | 主题 | 负责文件 / 场景 |
|---|---|---| |---|---|---|
| 当前总控对话 | Admin 全量脚本 | 总入口、`system.mjs`、`master-data.mjs`、`downloads.mjs`、最终集成与全量报告 | | 当前总控对话 | Admin 全量脚本 | 总入口、`system.mjs`、`master-data.mjs`、`downloads.mjs`、最终集成与全量报告 |
| `019ff0da-7699-7532-ac64-703325c995fd` | Finance 一期验收总控 | `finance.mjs`、Finance 正式全量接入与报告 |
| `019fdbd3-f24f-7903-9124-de79c3ba37d0` | 客户尺寸规则 | 总入口内 `customer-dimension-rules` 子场景;不得改其他 customers 流程 | | `019fdbd3-f24f-7903-9124-de79c3ba37d0` | 客户尺寸规则 | 总入口内 `customer-dimension-rules` 子场景;不得改其他 customers 流程 |
| `019fdb7d-8e29-7c40-bda6-5a29dcb37971` | OCR 扫描 | `inbound-weight-ocr.mjs` | | `019fdb7d-8e29-7c40-bda6-5a29dcb37971` | OCR 扫描 | `inbound-weight-ocr.mjs` |
| `019fdac1-ae04-7032-bac0-968497ea54ad` | 墨家测量 | `inbound-measurement.mjs` | | `019fdac1-ae04-7032-bac0-968497ea54ad` | 墨家测量 | `inbound-measurement.mjs` |
+1 -1
View File
@@ -19,7 +19,7 @@ export async function run(context) {
## 可用上下文 ## 可用上下文
- `request(method, path, body?)`:JSON HTTP 请求,自动使用平台 Token。 - `request(method, path, body?, token?, extraHeaders?)`:JSON HTTP 请求,自动使用平台 Token;幂等写操作通过 `extraHeaders` 传 `Idempotency-Key`。
- `requestBinary(method, path)`:文件下载,不把二进制写入报告。 - `requestBinary(method, path)`:文件下载,不把二进制写入报告。
- `apiSuccess(module, name, response, evidence?, options?)`:断言 HTTP 200 + body code `200`,成功时返回 data。 - `apiSuccess(module, name, response, evidence?, options?)`:断言 HTTP 200 + body code `200`,成功时返回 data。
- `assert(module, name, response, condition, evidence, options?)`:业务断言。 - `assert(module, name, response, condition, evidence, options?)`:业务断言。
@@ -0,0 +1,33 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const here = path.dirname(fileURLToPath(import.meta.url))
const acceptance = fs.readFileSync(path.join(here, '..', 'admin-test-env-acceptance.mjs'), 'utf8')
const finance = fs.readFileSync(path.join(here, 'finance.mjs'), 'utf8')
test('正式全量验收默认包含 Finance 模块', () => {
assert.match(acceptance, /DEFAULT_MODULES\s*=\s*\[[\s\S]*['"]finance['"]/)
})
test('验收请求器支持并透传额外请求头', () => {
assert.match(acceptance, /async request\(method, path, body = undefined, token = this\.token, extraHeaders = \{\}\)/)
assert.match(acceptance, /Accept:\s*['"]application\/json['"][^\n]*\.\.\.extraHeaders/)
})
test('Finance 写操作统一使用 marker 派生的 Idempotency-Key', () => {
assert.match(finance, /function write\(context, method, path, body, action\)/)
assert.match(finance, /context\.request\(method, path, body, context\.token, \{\s*['"]Idempotency-Key['"]:\s*key\(context, action\)/)
assert.match(finance, /key\(context, action\).*context\.marker/)
})
test('Finance 不借用历史来源且缺 fixture 记录 coverage gap', () => {
assert.match(finance, /fixture\(context\)/)
assert.match(finance, /marker === context\.marker/)
assert.match(finance, /禁止借用历史费用来源执行不可逆财务写操作/)
assert.match(finance, /context\.addCoverageGap\(MODULE, ['"]finance-owned-fixture['"]/)
assert.match(finance, /context\.addCoverageGap\(MODULE, ['"]payable-payment['"]/)
assert.match(finance, /context\.addCoverageGap\(MODULE, ['"]receipt-three-settlements['"]/)
})
+115
View File
@@ -0,0 +1,115 @@
const MODULE = 'finance'
const OPTIONS = { scenario: 'finance-phase1-g1-g4', deploymentBlocker: true }
const BASE = '/api/platform/finance/v1'
function id(value) { return value == null ? '' : String(value) }
function rows(value) { return Array.isArray(value) ? value : Array.isArray(value?.records) ? value.records : [] }
function fixture(context) {
const candidates = [context.createdResources?.inboundMeasurement, context.createdResources?.testInbound,
context.createdResources?.orders, context.context?.testInbound, context.context?.createdInbound]
return candidates.find((item) => item?.tenantId && item?.inboundId && item?.marker === context.marker) || null
}
function key(context, action) { return `${context.marker}-${action}`.slice(0, 128) }
function write(context, method, path, body, action) {
return context.request(method, path, body, context.token, { 'Idempotency-Key': key(context, action) })
}
function statementId(detail) { return id(detail?.statementId ?? detail?.id ?? detail?.billId) }
function settlementId(detail) { return id(detail?.summary?.settlementId ?? detail?.summary?.id ?? detail?.settlementId ?? detail?.id) }
export async function run(context) {
const start = context.steps.length
const openapiResponse = await context.request('GET', '/finance-service/v3/api-docs')
const openapi = openapiResponse.body
context.addStep(MODULE, 'Gateway 可访问 Finance OpenAPI', openapiResponse,
openapiResponse.http === 200 && typeof openapi?.openapi === 'string',
'/finance-service/v3/api-docs', OPTIONS)
context.assert(MODULE, 'OpenAPI 暴露 G3/G4 冻结路径', openapiResponse,
Boolean(openapi?.paths?.[`${BASE}/statements`] && openapi?.paths?.[`${BASE}/receivable-settlements`]
&& openapi?.paths?.[`${BASE}/settlements/{settlementId}/transactions`]),
`paths=${Object.keys(openapi?.paths || {}).length}`, OPTIONS)
const accountsResponse = await context.request('GET', `${BASE}/accounts?page=1&pageSize=20`)
const accounts = context.apiSuccess(MODULE, '查询客户账户派生汇总', accountsResponse, '', OPTIONS)
context.assert(MODULE, '客户账户不暴露钱包余额字段', accountsResponse,
rows(accounts).every((item) => !Object.hasOwn(item, 'balance') && !Object.hasOwn(item, 'rechargeAmount') && !Object.hasOwn(item, 'prepayment')),
`records=${rows(accounts).length}`, OPTIONS)
const current = fixture(context)
if (!current) {
context.addCoverageGap(MODULE, 'finance-owned-fixture', 'G2-G4 写操作真实验收',
'本轮未取得同 marker 的客户入库单,禁止借用历史费用来源执行不可逆财务写操作', 'no-marker-owned-fixture')
return
}
const customerId = id(current.tenantId)
const inboundId = id(current.inboundId)
const feePoolResponse = await context.request('GET', `${BASE}/fee-pool?customerId=${encodeURIComponent(customerId)}&direction=RECEIVABLE`)
const feePool = context.apiSuccess(MODULE, '查询本轮客户应收费用来源', feePoolResponse,
`customerId=${customerId}, inboundId=${inboundId}`, OPTIONS)
const source = rows(feePool).find((item) => id(item.inboundId) === inboundId && id(item.feeSourceId || item.id))
context.assert(MODULE, '费用来源仅使用本轮入库单', feePoolResponse, Boolean(source),
source ? `feeSourceId=${id(source.feeSourceId || source.id)}` : '未找到本轮入库单费用来源', OPTIONS)
if (!source) return
const feeSourceId = id(source.feeSourceId || source.id)
let detailResponse = await context.request('GET', `${BASE}/fee-sources/${encodeURIComponent(feeSourceId)}`)
let detail = context.apiSuccess(MODULE, '读取本轮费用来源详情', detailResponse, '', OPTIONS)
if (detail?.reviewStatus === 'PENDING') {
const reviewBody = { action: 'APPROVE', reason: context.marker, expectedVersion: Number(detail.operationVersion ?? detail.version) }
detailResponse = await write(context, 'POST', `${BASE}/fee-sources/${encodeURIComponent(feeSourceId)}/review`, reviewBody, 'fee-review')
detail = context.apiSuccess(MODULE, '审核本轮费用来源', detailResponse, '', OPTIONS)
}
if (detail?.reviewStatus === 'APPROVED' && detail?.settlementIncluded !== true) {
const inclusionBody = { included: true, reason: context.marker, expectedVersion: Number(detail.operationVersion ?? detail.version) }
detailResponse = await write(context, 'PUT', `${BASE}/fee-sources/${encodeURIComponent(feeSourceId)}/settlement-inclusion`, inclusionBody, 'fee-include')
detail = context.apiSuccess(MODULE, '将本轮费用来源纳入结算', detailResponse, '', OPTIONS)
}
context.assert(MODULE, '本轮来源满足对账单生成条件', detailResponse,
detail?.reviewStatus === 'APPROVED' && detail?.settlementIncluded === true && detail?.sourceStatus === 'AVAILABLE' && detail?.currency === 'CNY',
`review=${detail?.reviewStatus}, included=${detail?.settlementIncluded}, status=${detail?.sourceStatus}`, OPTIONS)
const businessDate = detail?.businessDate || new Date().toISOString().slice(0, 10)
const createBody = { customerId, currency: 'CNY', generationMode: 'SELECTED', feeSourceIds: [feeSourceId], periodStart: businessDate, periodEnd: businessDate, remark: context.marker }
const createResponse = await write(context, 'POST', `${BASE}/statements`, createBody, 'statement-create')
let statement = context.apiSuccess(MODULE, '按本轮来源生成对账单', createResponse, '', OPTIONS)
const createdStatementId = statementId(statement)
const replayResponse = await write(context, 'POST', `${BASE}/statements`, createBody, 'statement-create')
const replay = context.apiSuccess(MODULE, '同键同参生成对账单重放原结果', replayResponse, '', OPTIONS)
context.assert(MODULE, '对账单幂等重放保持同一资源', replayResponse,
Boolean(createdStatementId) && statementId(replay) === createdStatementId,
`first=${createdStatementId || 'missing'}, replay=${statementId(replay) || 'missing'}`, OPTIONS)
if (!createdStatementId) return
for (const action of ['SUBMIT', 'APPROVE', 'ISSUE']) {
const actionResponse = await write(context, 'POST', `${BASE}/reconciliation-statements/${createdStatementId}/actions`,
{ action, expectedVersion: Number(statement.version) }, `statement-${action.toLowerCase()}`)
statement = context.apiSuccess(MODULE, `对账单执行 ${action}`, actionResponse, '', OPTIONS)
}
const adjustmentLines = rows(statement?.lines).map((line) => ({ statementLineId: id(line.statementLineId || line.id), adjustedAmount: Number(line.amount), reason: context.marker }))
const adjustResponse = await write(context, 'POST', `${BASE}/reconciliation-statements/${createdStatementId}/actions`,
{ action: 'ADJUST', reason: context.marker, expectedVersion: Number(statement.version), adjustmentLines }, 'statement-adjust')
statement = context.apiSuccess(MODULE, '对账单多行调整并保留 revision', adjustResponse, '', OPTIONS)
context.assert(MODULE, '调整响应包含修订历史', adjustResponse,
adjustmentLines.length > 0 && rows(statement?.revisions).length > 0,
`lines=${adjustmentLines.length}, revisions=${rows(statement?.revisions).length}`, OPTIONS)
const arBody = { reconciliationStatementId: createdStatementId, currency: 'CNY' }
const arResponse = await write(context, 'POST', `${BASE}/receivable-settlements`, arBody, 'settlement-ar-create')
const ar = context.apiSuccess(MODULE, '从已出账对账单生成应收结算单', arResponse, '', OPTIONS)
const arId = settlementId(ar)
const arDetailResponse = arId ? await context.request('GET', `${BASE}/settlements/${arId}`) : arResponse
const arDetail = arId ? context.apiSuccess(MODULE, '查询应收结算单详情', arDetailResponse, '', OPTIONS) : null
context.assert(MODULE, '应收结算单方向和金额正确', arDetailResponse,
Boolean(arId) && arDetail?.summary?.direction === 'RECEIVABLE' && Number(arDetail?.summary?.totalAmount) === Number(statement.totalAmount),
`settlementId=${arId || 'missing'}, direction=${arDetail?.summary?.direction || 'missing'}`, OPTIONS)
const apListResponse = await context.request('GET', `${BASE}/payable-settlements?customerId=${encodeURIComponent(customerId)}`)
context.apiSuccess(MODULE, '查询本轮客户应付结算单列表', apListResponse, '', OPTIONS)
context.addCoverageGap(MODULE, 'payable-payment', '应付生成、部分付款与付清',
'本轮上游 fixture 未承诺生成同客户同供应商 PAYABLE 来源;禁止借用历史应付单登记付款', 'no-marker-owned-payable-source')
context.addCoverageGap(MODULE, 'receipt-three-settlements', '300 元收款分配 3 张 100 元应收单',
'当前模块只有一条本轮独占应收来源,无法安全构造三张可清理应收单', 'insufficient-marker-owned-receivable-sources')
context.createdResources.finance = { marker: context.marker, customerId, inboundId, feeSourceId, statementId: createdStatementId, receivableSettlementId: arId || null }
const steps = context.steps.slice(start)
context.scenarios.push({ name: OPTIONS.scenario, module: MODULE, success: steps.every((step) => step.passed), deploymentBlocked: steps.some((step) => step.deploymentBlocker), passed: steps.filter((step) => step.passed).length, failed: steps.filter((step) => !step.passed).length })
}