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_name VARCHAR(200) NOT NULL COMMENT '出账时费用项目名称快照',
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',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '账单明细创建时间',
PRIMARY KEY (id),
@@ -10155,6 +10156,16 @@ CREATE TABLE IF NOT EXISTS fin_bill_line (
CONSTRAINT chk_fin_bill_line_amount CHECK (amount >= 0)
) 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 (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT 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_by', '作废操作人ID,未作废时为空');
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', 'created_at', '账单明细创建时间');
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)
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 新权限只进入可分配权限目录,不默认授予平台管理员。
DROP PROCEDURE IF EXISTS migrate_finance_g1_g2_permissions;
-- Finance G1-G4 新权限只进入可分配权限目录,不默认授予平台管理员。
DROP PROCEDURE IF EXISTS migrate_finance_g1_g4_permissions;
DELIMITER $$
CREATE PROCEDURE migrate_finance_g1_g2_permissions()
CREATE PROCEDURE migrate_finance_g1_g4_permissions()
BEGIN
IF EXISTS (
SELECT 1
@@ -13772,15 +13784,35 @@ BEGIN
OR (id = 5105023 AND permission_code <> 'platform:finance:fee-source:view')
OR (id = 5105024 AND permission_code <> 'platform:finance:fee-source:review')
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:fee-source:view' AND id <> 5105023)
OR (permission_code = 'platform:finance:fee-source:review' AND id <> 5105024)
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'))
) THEN
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;
INSERT INTO baiyue_iam.iam_permission
@@ -13794,7 +13826,17 @@ BEGIN
(5105024, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:fee-source:review',
'审核财务费用来源', NULL, 'POST', 150, 'ACTIVE', 1, 0),
(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
permission_type = VALUES(permission_type), parent_id = VALUES(parent_id),
permission_name = VALUES(permission_name), route_path = VALUES(route_path),
@@ -13814,14 +13856,24 @@ BEGIN
'platform:finance:account:view',
'platform:finance:fee-source:view',
'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'
SET MESSAGE_TEXT = 'finance G1/G2 permission catalog is incomplete';
SET MESSAGE_TEXT = 'finance G1-G4 permission catalog is incomplete';
END IF;
END$$
DELIMITER ;
CALL migrate_finance_g1_g2_permissions();
DROP PROCEDURE IF EXISTS migrate_finance_g1_g2_permissions;
CALL migrate_finance_g1_g4_permissions();
DROP PROCEDURE IF EXISTS migrate_finance_g1_g4_permissions;
INSERT INTO baiyue_iam.iam_role_permission (role_id, permission_id, tenant_id)
SELECT 4000001, id, 0 FROM baiyue_iam.iam_permission
@@ -13844,7 +13896,17 @@ DELETE rp
'platform:finance:account:view',
'platform:finance:fee-source:view',
'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)
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:%')
@@ -13854,7 +13916,7 @@ ON DUPLICATE KEY UPDATE tenant_id=VALUES(tenant_id);
DELIMITER $$
CREATE PROCEDURE assert_finance_permissions()
BEGIN
IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission WHERE tenant_id=0 AND app_code='PLATFORM' AND permission_code LIKE 'platform:finance:%' AND status='ACTIVE' AND deleted=0) <> 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 权限不完整';
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
@@ -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
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:平台管理员 Finance 授权不完整';
END IF;
IF (SELECT COUNT(*) FROM baiyue_iam.iam_role_permission rp JOIN baiyue_iam.iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id WHERE rp.role_id=4000001 AND rp.tenant_id=0 AND p.permission_code IN ('platform:finance:account:view','platform:finance:fee-source:view','platform:finance:fee-source:review','platform:finance:fee-source:settlement')) <> 0 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:Finance G1/G2 新权限不得默认授予平台管理员';
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-G4 新权限不得默认授予平台管理员';
END IF;
IF (SELECT COUNT(*) FROM baiyue_iam.iam_role_permission rp JOIN baiyue_iam.iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id WHERE rp.role_id=4000101 AND rp.tenant_id=10001 AND (p.permission_code='merchant:finance:statement' OR p.permission_code LIKE 'merchant:finance:%')) <> 3 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:客户管理员 Finance 只读授权不完整';
+1
View File
@@ -20,6 +20,7 @@
| 异常与响应包装 | `controller/FinanceExceptionHandler.java`、`controller/FinanceResponses.java` |
| 数据库 | `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` |
| 独立服务部署链 | `../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` |
## 契约与边界
@@ -9,6 +9,7 @@ import com.baiyue.finance.service.ReconciliationStatementService;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
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.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
@@ -34,6 +35,12 @@ public class ReconciliationStatementController {
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")
@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'))"
@@ -188,6 +188,15 @@ public class FinanceService {
BigDecimal applied = entry.getValue();
jdbc.update("INSERT INTO fin_receipt_bill(tenant_id,receipt_id,bill_id,applied_amount,created_by) VALUES(?,?,?,?,?)", tenantId, receiptId, entry.getKey(), applied, operatorId);
jdbc.update("UPDATE fin_bill SET received_amount=received_amount+?, outstanding_amount=outstanding_amount-?, status=CASE WHEN outstanding_amount-?=0 THEN 'PAID' ELSE 'PARTIALLY_PAID' END, updated_by=?, version=version+1 WHERE id=? AND tenant_id=?", applied, applied, applied, operatorId, entry.getKey(), tenantId);
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);
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 " +
"WHERE sl.tenant_id=? AND sl.settlement_id=? ORDER BY sl.id", summary.customerId(), settlementId)
.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 " +
"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(
"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",
@@ -306,7 +313,7 @@ public class FinanceSettlementService {
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));
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=?",
customerId, operatorId, operation, key);
if (!rows.isEmpty()) {
@@ -315,6 +322,18 @@ public class FinanceSettlementService {
if ("SUCCEEDED".equals(text(row, "status")) && row.get("result_resource_id") != null) {
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", "相同请求正在处理中或等待恢复");
}
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) {
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'",
resourceId, customerId, operatorId, operation, key);
if (changed != 1) throw error("FIN_409", "幂等请求完成状态冲突");
}
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;
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 boolean truth(Object value) { return value != null && Boolean.parseBoolean(value.toString()); }
private static String jsonText(JsonNode node, String key) {
@@ -1,10 +1,11 @@
package com.baiyue.finance.service;
import com.baiyue.finance.dto.ReconciliationStatementDtos.CreateReconciliationRequest;
import com.baiyue.finance.dto.ReconciliationStatementDtos.DocumentActionRequest;
import com.baiyue.finance.dto.ReconciliationStatementDtos.FeeSourceLine;
import com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationActionRequest;
import com.baiyue.finance.dto.ReconciliationStatementDtos.PaymentHistory;
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.ObjectMapper;
import org.slf4j.MDC;
@@ -37,7 +38,8 @@ import java.util.UUID;
@Service
public class ReconciliationStatementService {
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 ObjectMapper objectMapper;
@@ -56,7 +58,7 @@ public class ReconciliationStatementService {
CustomerProfile customer = customer(request.customerId());
long subjectId = activeSubject();
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);
LocalDate dueDate = request.periodEnd().plusDays(customer.creditDays());
@@ -79,7 +81,7 @@ public class ReconciliationStatementService {
statement.setLong(12, operatorId);
return statement;
}, holder);
if (holder.getKey() == null) throw error("FIN-DATA-500", "对账单主键生成失败");
if (holder.getKey() == null) throw error("FIN_001", "对账单主键生成失败");
long statementId = holder.getKey().longValue();
jdbc.update("UPDATE fin_bill SET bill_no=? WHERE id=?", stableNo(statementId), statementId);
for (Source source : sources) {
@@ -88,7 +90,7 @@ public class ReconciliationStatementService {
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'",
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),
request.periodStart(), request.periodEnd());
@@ -98,7 +100,7 @@ public class ReconciliationStatementService {
}
@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);
Bill before = bill(statementId);
Long replay = reserve(before.customerId(), operatorId, "RECONCILIATION_ACTION", key,
@@ -106,16 +108,23 @@ public class ReconciliationStatementService {
if (replay != null) return detail(before.customerId(), replay);
String action = request.action().toUpperCase(Locale.ROOT);
if ("ADJUST".equals(action)) {
return adjust(operatorId, statementId, key, request, before);
}
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);
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(), request.reason(), operatorId, statementId, before.customerId(), transition.from(), request.expectedVersion());
if (changed != 1) throw error("FIN-REC-409", "对账单状态或版本已变化");
if ("APPROVE".equals(action)) {
if (changed != 1) throw error("FIN_409", "对账单状态或版本已变化");
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'",
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);
audit(before.customerId(), statementId, "RECONCILIATION_" + action, request.reason(), before, after, operatorId);
@@ -123,19 +132,49 @@ public class ReconciliationStatementService {
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) {
Bill bill = bill(customerId, statementId);
CreationMetadata metadata = creationMetadata(customerId, statementId);
List<FeeSourceLine> 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",
List<ReconciliationLine> lines = jdbc.query(
"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);
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);
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(),
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.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) {
@@ -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 ?");
args.add(request.customerId()); args.add(request.periodStart()); args.add(request.periodEnd());
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 (");
for (int index = 0; index < request.feeSourceIds().size(); index++) {
if (index > 0) sql.append(',');
@@ -155,7 +202,7 @@ public class ReconciliationStatementService {
if ("SELECTED".equalsIgnoreCase(request.generationMode())) {
Set<Long> actual = new HashSet<>(rows.stream().map(Source::id).toList());
if (!actual.equals(new HashSet<>(request.feeSourceIds()))) {
throw error("FIN-REC-409", "所选费用来源不存在、跨客户或不符合结算条件");
throw error("FIN_409", "所选费用来源不存在、跨客户或不符合结算条件");
}
}
return rows;
@@ -169,33 +216,33 @@ public class ReconciliationStatementService {
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));
if (rows.isEmpty()) throw error("FIN-REC-409", "平台财务主体未配置");
if (rows.isEmpty()) throw error("FIN_409", "平台财务主体未配置");
return rows.get(0);
}
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=?",
(rs, row) -> bill(rs), statementId);
if (rows.isEmpty()) throw error("FIN-REC-404", "对账单不存在");
if (rows.isEmpty()) throw error("FIN_404", "对账单不存在");
return rows.get(0);
}
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=?",
(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);
}
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",
(rs, row) -> rs.getString(1), customerId, statementId);
if (rows.isEmpty()) throw error("FIN-DATA-500", "对账单期间审计快照缺失");
if (rows.isEmpty()) throw error("FIN_001", "对账单期间审计快照缺失");
try {
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()));
} 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);
}
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);
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,
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));
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);
if (!rows.isEmpty()) {
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 ("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;
@@ -226,7 +284,7 @@ public class ReconciliationStatementService {
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=?",
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')",
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) {
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);
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,
@@ -248,47 +306,76 @@ public class ReconciliationStatementService {
private static void validateCreate(CreateReconciliationRequest request) {
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);
if (!MODES.contains(mode)) throw error("FIN-REQ-400", "对账单生成方式无效");
if (request.remark() != null && request.remark().length() > 500) throw error("FIN-REQ-400", "备注不能超过500个字符");
if (!MODES.contains(mode)) throw error("FIN_400", "对账单生成方式无效");
if (request.remark() != null && request.remark().length() > 500) throw error("FIN_400", "备注不能超过500个字符");
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)
|| 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()) {
throw error("FIN-REQ-400", "按截止期生成不能同时指定费用来源");
throw error("FIN_400", "按截止期生成不能同时指定费用来源");
}
}
private static void validateAction(long statementId, DocumentActionRequest request) {
if (statementId <= 0 || request == null || request.expectedVersion() < 0 || !StringUtils.hasText(request.reason())
|| request.reason().length() > 500) throw error("FIN-REQ-400", "对账单动作请求无效");
if (request.action() == null || !ACTIONS.contains(request.action().toUpperCase(Locale.ROOT))) throw error("FIN-REQ-400", "对账单动作无效");
private static void validateAction(long statementId, ReconciliationActionRequest request) {
if (statementId <= 0 || request == null || request.expectedVersion() < 0) throw error("FIN_400", "对账单动作请求无效");
if (request.action() == null || !ACTIONS.contains(request.action().toUpperCase(Locale.ROOT))) throw error("FIN_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) {
return switch (action) {
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 "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" -> {
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");
}
default -> throw error("FIN-REQ-400", "对账单动作无效");
default -> throw error("FIN_400", "对账单动作无效");
};
}
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);
}
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 {
@@ -298,21 +385,37 @@ public class ReconciliationStatementService {
rs.getString("fee_item_name"), rs.getBigDecimal("amount"));
}
private static FeeSourceLine line(ResultSet rs) throws SQLException {
return new FeeSourceLine(rs.getLong("id"), rs.getString("source_type"), rs.getLong("source_fee_id"),
rs.getInt("source_fee_version"), rs.getString("direction"), rs.getLong("customer_id"),
nullableLong(rs, "order_id"), nullableLong(rs, "inbound_id"), nullableLong(rs, "container_id"),
rs.getString("fee_item_code"), rs.getString("fee_item_name"), rs.getBigDecimal("amount"),
rs.getString("currency_code"), rs.getString("status"));
private static ReconciliationLine line(ResultSet rs) throws SQLException {
return new ReconciliationLine(rs.getLong("statement_line_id"), rs.getLong("id"),
rs.getInt("source_fee_version"), rs.getString("source_hash"), nullableLong(rs, "inbound_id"),
nullableLong(rs, "container_id"), rs.getString("fee_item_name"), rs.getBigDecimal("source_amount"),
rs.getBigDecimal("statement_amount"), rs.getString("currency_code"), rs.getString("adjustment_reason"));
}
private static PaymentHistory payment(ResultSet rs) throws SQLException {
Timestamp received = rs.getTimestamp("received_at");
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"));
}
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 {
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"),
@@ -323,12 +426,12 @@ public class ReconciliationStatementService {
private String json(Object 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) {
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) {
@@ -92,13 +92,84 @@ class FinanceServiceValidationTest {
FinanceFileClient files = mock(FinanceFileClient.class);
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)));
FinanceService service = new FinanceService(jdbc, new ObjectMapper());
FinanceService service = new FinanceService(jdbc, new ObjectMapper().findAndRegisterModules());
ReflectionTestUtils.setField(service, "fileClient", files);
var request = new FinanceService.ReceiptRequest("R-3", 1L, null, "BANK_TRANSFER", null, List.of("77"), null,
List.of(new FinanceService.ReceiptAllocation(10L, BigDecimal.ONE)));
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
void rejectsSingleOrderWhenOrderNotOwnedByCustomer() {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
@@ -17,6 +17,8 @@ import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
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.test.util.ReflectionTestUtils;
@@ -35,9 +37,11 @@ import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.startsWith;
import org.mockito.ArgumentCaptor;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.RETURNS_DEFAULTS;
@ExtendWith(MockitoExtension.class)
class FinanceSettlementBehaviorTest {
@@ -99,6 +103,25 @@ class FinanceSettlementBehaviorTest {
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
void partialPaymentUpdatesOutstandingAndReturnsTypedDetail() {
JdbcTemplate jdbc = org.mockito.Mockito.mock(JdbcTemplate.class, invocation ->
@@ -136,6 +159,232 @@ class FinanceSettlementBehaviorTest {
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) {
FinanceSettlementService service = new FinanceSettlementService(
jdbc, new ObjectMapper().findAndRegisterModules(), fileClient);
@@ -148,6 +397,37 @@ class FinanceSettlementBehaviorTest {
"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,
BigDecimal outstanding, int version) {
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("supplier_id", 20L), Map.entry("supplier_name_snapshot", "供应商20"),
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("due_date", Date.valueOf("2026-09-10")),
Map.entry("payment_status", paid.signum() == 0 ? "UNPAID" : status), Map.entry("status", status),
@@ -44,7 +44,7 @@ class FinanceSqlCommentContractTest {
}
}
assertThat(tableCount).isEqualTo(13);
assertThat(columnCount).isEqualTo(184);
assertThat(columnCount).isEqualTo(190);
assertThat(missing).as("缺少中文业务注释的Finance字段").isEmpty();
}
@@ -57,4 +57,17 @@ class FinanceSqlCommentContractTest {
assertThat(sql).contains("Firstleg费用来源完整不可变JSON快照");
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.finance.controller.ReconciliationStatementController;
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.ReconciliationStatementService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.ArgumentCaptor;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.PreparedStatementCreator;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.jdbc.support.KeyHolder;
import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
import java.sql.ResultSet;
@@ -45,9 +48,9 @@ class ReconciliationStatementBehaviorTest {
void controllerUsesAuthenticatedOperatorAndStableTraceResponse() {
ReconciliationStatementController controller = new ReconciliationStatementController(service);
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), "八月对账");
DocumentActionRequest action = new DocumentActionRequest("SUBMIT", "提交审核", 0);
ReconciliationActionRequest action = new ReconciliationActionRequest("SUBMIT", "提交审核", 0, List.of());
var created = controller.createReconciliationStatement(user, "create-key", create);
var acted = controller.actionReconciliationStatement(user, 501L, "action-key", action);
@@ -64,20 +67,35 @@ class ReconciliationStatementBehaviorTest {
void selectedModeRejectsDuplicateFeeSourcesBeforeWriting() {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
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);
assertThatThrownBy(() -> target.create(71L, "create-key", request))
.isInstanceOf(FinanceEventException.class)
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-REQ-400");
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_400");
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
void selectedEligibleCnySourceCreatesStableDraftAndLocksSource() throws Exception {
JdbcTemplate jdbc = createJdbc();
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), "八月对账");
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));
}
@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
void submitUsesExpectedVersionAndPersistsRevisionBeforeStateChange() throws Exception {
JdbcTemplate jdbc = actionJdbc("DRAFT", "PENDING_REVIEW", "submit-key");
ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules());
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.status()).isEqualTo("PENDING_REVIEW");
@@ -112,14 +157,27 @@ class ReconciliationStatementBehaviorTest {
@Test
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());
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.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));
}
@@ -137,30 +195,178 @@ class ReconciliationStatementBehaviorTest {
ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules());
assertThatThrownBy(() -> target.action(71L, 501L, "used-key",
new DocumentActionRequest("SUBMIT", "提交审核", 0)))
new ReconciliationActionRequest("SUBMIT", "提交审核", 0, List.of())))
.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());
}
@Test
void invalidActionAndVersionConflictAreRejectedBeforeMutation() throws Exception {
JdbcTemplate jdbc = actionJdbc("DRAFT", "DRAFT", "invalid-key");
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, invocation -> {
if ("update".equals(invocation.getMethod().getName()) && invocation.getMethod().getReturnType() == int.class) return 1;
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 -> {
String sql = invocation.getArgument(0);
@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 -> {
@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)))
.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 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"),
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());
@@ -168,10 +374,10 @@ class ReconciliationStatementBehaviorTest {
}
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;
return RETURNS_DEFAULTS.answer(invocation);
});
}));
LocalDate start = LocalDate.of(2026, 8, 1);
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 -> {
@@ -192,6 +398,13 @@ class ReconciliationStatementBehaviorTest {
@SuppressWarnings("unchecked") RowMapper<Object> mapper = invocation.getArgument(1);
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 -> {
KeyHolder holder = invocation.getArgument(1);
holder.getKeyList().add(Map.of("GENERATED_KEY", 501L));
@@ -211,8 +424,11 @@ class ReconciliationStatementBehaviorTest {
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 action,reason"), any(RowMapper.class), eq(9L), eq(501L))).thenReturn(List.of());
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.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT request_hash"),
eq(9L), eq(71L), eq("RECONCILIATION_CREATE"), eq("cutoff-key"))).thenReturn(List.of());
return jdbc;
}
@@ -254,4 +470,15 @@ class ReconciliationStatementBehaviorTest {
when(rs.getTimestamp("updated_at")).thenReturn(timestamp);
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/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` |
| FBA 主数据/计费 | `controller/PlatformFbaMasterDataController.java`、`PlatformFbaPricingController.java`、`service/FbaPricingService.java` |
| 客户费用查询 | `controller/MerchantInboundBillingController.java`、`service/MerchantInboundBillingService.java` |
@@ -41,6 +42,7 @@
## 三期装柜与 PDA 资源结算
- 智能装柜候选只能按同一国内中转仓合柜;推荐顺序按入库时间先进先出。装柜计划状态为 `EDITING -> LOADING -> COMPLETED`,PDA 全部明细处理完成后自动完成。
- PDA 拣货以 `fl_inbound_carton_label.box_code` 对应的稳定箱唛箱码为扫描身份;一个有效且未重复的箱码扫描表示拣出一箱,不得以 SKU、商品件数或页面展示字段替代箱码。任务下发与提交契约见 `src/main/resources/openapi/phase3-pda-convergence-v1.yaml`。
- PDA 部分拣货生成二级子入库单时,数量必须守恒:`已拣数量 + 子单数量 = 拣货前主单可用数量`。子单只允许一层,不能继续拆分。
- `releaseMode=AUTO` 时,释放资源数为 `ceil(实际拣货箱数 × 原资源数 / 原可用箱数)`;`MANUAL` 按 `locationIds` 选择,空数组表示明确释放 0 个。未传模式时,`locationIds=null` 兼容为 AUTO。
- 头程快照必须保存仓库 `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,
Integer plannedPackageCount, Integer pickedPackageCount,
String itemStatus, Integer version) {
@@ -12,7 +12,7 @@ import java.util.List;
@Mapper
public interface LoadingPickItemMapper extends BaseMapper<LoadingPickItemEntity> {
@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,
label.box_code AS boxCode
FROM fl_loading_pick_item pick_item
@@ -27,6 +27,7 @@ public interface LoadingPickItemMapper extends BaseMapper<LoadingPickItemEntity>
interface CartonLabelRow {
Long getPickItemId();
Long getTenantId();
String getOrderNo();
String getInboundCode();
String getCargoName();
@@ -491,7 +491,8 @@ public class LoadingPickService {
List<PickItem> items = source.stream().map(item -> {
List<CartonLabelRow> cartons = cartonsByItem.getOrDefault(item.getId(), List.of());
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(),
first == null ? null : first.getInboundCode(), first == null ? null : first.getCargoName(),
cartons.stream().map(CartonLabelRow::getBoxCode).toList(), item.getPlannedPackageCount(),
@@ -10,7 +10,9 @@ paths:
- {name: containerNo, in: query, required: true, schema: {type: string}}
- {name: warehouseId, in: query, required: true, schema: {type: integer, format: int64, minimum: 1}}
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:
get:
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}}
ItemId: {name: itemId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}}
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:
type: object
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, 'PUT', 160, 'ACTIVE', 1, 0")
.contains("permission_code IN (\n 'platform:finance',")
.contains("permission_code LIKE 'platform:finance:%' AND status='ACTIVE' AND deleted=0) <> 16")
.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("permission_code LIKE 'platform:finance:%' AND status='ACTIVE' AND deleted=0) <> 26")
.contains("p.id BETWEEN 5105022 AND 5105035) <> 0")
.contains("平台管理员 Finance 授权不完整")
.contains("Finance G1/G2 新权限不得默认授予平台管理员");
.contains("Finance G1-G4 新权限不得默认授予平台管理员");
assertThat(platformAdminGrant)
.contains("'platform:finance'")
.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` |
| 订单编排/状态/幂等 | `service/OrderApplicationService.java`、`entity/OrderStatus.java` |
| 统一订单七状态与操作映射 | `../../docs/统一订单主状态与操作契约.md`;`LOADED` 硬门禁和 `DELIVERED` 可信回传同时核对三期需求确认表与开工门禁 |
| 货物内部查询 | `controller/InternalOrderCargoController.java` |
| 快照/Outbox | `service/`、`repository/`、`event/` |
| DTO/持久化 | `dto/`、`entity/`、`mapper/`、`repository/` |
@@ -13,6 +13,7 @@ public final class UnifiedOrderResponse {
public record Row(
Long orderId, Long tenantId, String orderNo, String mainStatus, String nextAction,
OrderRelationSummary relation,
OrderStatus internalOrderStatus,
Long customerId, String customerName, String channelName,
Long receivingWarehouseId, String receivingWarehouseName,
@@ -26,6 +27,9 @@ public final class UnifiedOrderResponse {
LocalDateTime createdAt, LocalDateTime updatedAt
) {}
public record OrderRelationSummary(String role, Long rootOrderId, Long parentOrderId,
Integer splitSequence, Integer childCount) {}
public record Detail(Row summary, OrderDetailResponse order,
List<TimelineEvent> timeline) {}
@@ -226,6 +226,16 @@ public class OrderRepository {
.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) {
if (orderIds.isEmpty()) return List.of();
return noteMapper.selectList(new LambdaQueryWrapper<OrderInternalNoteEntity>()
@@ -57,8 +57,9 @@ public class UnifiedOrderQueryService {
? Map.of()
: summaries(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()
.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());
}
@@ -71,7 +72,8 @@ public class UnifiedOrderQueryService {
FirstlegUnifiedOrderClient.Detail inbound = order.getStatus() == OrderStatus.SUBMITTED
? null : firstlegDetail(tenantId, orderId);
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<>();
orderDetail.statusHistory().forEach(item -> timeline.add(new UnifiedOrderResponse.TimelineEvent(
"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()
.collect(Collectors.toMap(OrderEntity::getId, Function.identity(), (left, right) -> left));
Map<Long, String> latestNotes = latestNotes(tenantId, orderIds);
Map<Long, Integer> childCounts = childCounts(List.copyOf(orderById.values()));
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();
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));
}
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) {
String mainStatus = orderMainStatus(order.getStatus());
return new UnifiedOrderResponse.Row(order.getId(), order.getTenantId(), order.getOrderNo(), mainStatus,
order.getStatus() == OrderStatus.SUBMITTED ? "APPROVE_ORDER" : null,
relation(order, childCounts),
order.getStatus(), order.getCustomerId(), order.getCustomerName(),
order.getChannelName(), order.getReceivingWarehouseId(), order.getReceivingWarehouseName(),
order.getDestinationWarehouseId(), order.getDestinationWarehouseName(), null, null,
@@ -146,7 +172,8 @@ public class UnifiedOrderQueryService {
}
FirstlegUnifiedOrderClient.Inbound inbound = summary.inbound();
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.getDestinationWarehouseId(), order.getDestinationWarehouseName(), inbound.id(), inbound.version(), inbound.inboundCode(),
order.getExpectedShipDate(), inbound.totalPackageCount(), "STANDARD", inbound.locationCodes(),
@@ -157,10 +184,11 @@ public class UnifiedOrderQueryService {
}
private UnifiedOrderResponse.Row row(FirstlegUnifiedOrderClient.Summary summary, OrderEntity order,
String latestInternalNote) {
String latestInternalNote, Map<Long, Integer> childCounts) {
var inbound = summary.inbound();
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.warehouseName(), inbound.deliveryWarehouseId(), inbound.deliveryWarehouseName(),
inbound.id(), inbound.version(), inbound.inboundCode(), null, inbound.totalPackageCount(),
@@ -16,7 +16,8 @@ paths:
才是 C04 loading/complete 的 expectedVersion,loadingVersion 当前等于 pickingVersion(兼容别名)。
tenantId 省略时跨全部客户;mainStatus 省略时按订单创建时间分页,指定 PENDING_REVIEW 时由 Order 查询,
指定其他主状态时由 Firstleg 精确分页。旧 /orders/first-leg 接口保持兼容。
列表行同时返回 customerRemark(客户下单备注)和 latestInternalNote(最新一条平台内部备注)。
列表行同时返回 customerRemark(客户下单备注)、latestInternalNote(最新一条平台内部备注)和
relation(仅有关联时返回;PARENT 含 childCount,CHILD 含 rootOrderId、parentOrderId 与 splitSequence)。
parameters:
- {name: tenantId, in: query, required: false, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
- $ref: '#/components/parameters/UnifiedKeyword'
@@ -41,6 +41,7 @@ class UnifiedOrderQueryServiceTest {
void stubListEnrichmentQueries() {
when(orders.findNotesByOrderIds(any(), any())).thenReturn(List.of());
when(orders.findByIds(any(), any())).thenReturn(List.of());
when(orders.findChildrenByParentOrders(any())).thenReturn(List.of());
}
@Test
@@ -67,6 +68,7 @@ class UnifiedOrderQueryServiceTest {
assertThat(row.inboundId()).isNull();
assertThat(row.customerRemark()).isEqualTo("客户要求提货前联系");
assertThat(row.latestInternalNote()).isEqualTo("客服已确认提货时间");
assertThat(row.relation()).isNull();
});
verify(firstleg, never()).summaries(any(), any());
}
@@ -87,6 +89,35 @@ class UnifiedOrderQueryServiceTest {
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
void allStatusInboundCodeFilterIncludesPendingOrdersFromOrderQuery() {
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_USERNAME = 'platform_admin'
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 CUSTOMER_FIELDS = [
'id', 'tenantCode', 'tenantName', 'status', 'legalName', 'legalRepresentative',
@@ -98,8 +98,8 @@ class AcceptanceRun {
this.fatal = null
}
async request(method, path, body = undefined, token = this.token) {
const headers = { Accept: 'application/json' }
async request(method, path, body = undefined, token = this.token, extraHeaders = {}) {
const headers = { Accept: 'application/json', ...extraHeaders }
if (token) headers.Authorization = `Bearer ${token}`
if (body !== undefined) headers['Content-Type'] = 'application/json; charset=utf-8'
try {
@@ -5,6 +5,7 @@
| 对话 ID | 主题 | 负责文件 / 场景 |
|---|---|---|
| 当前总控对话 | 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 流程 |
| `019fdb7d-8e29-7c40-bda6-5a29dcb37971` | OCR 扫描 | `inbound-weight-ocr.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)`:文件下载,不把二进制写入报告。
- `apiSuccess(module, name, response, evidence?, options?)`:断言 HTTP 200 + body code `200`,成功时返回 data。
- `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 })
}