From 1cb4fba2768c44ae3f095088cf58b6724d53f4ac Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 12 Aug 2026 09:25:53 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/sql/头程数据库统一增量-MySQL8.sql | 90 +++++- finance/AGENTS.md | 1 + .../ReconciliationStatementController.java | 7 + .../finance/service/FinanceService.java | 9 + .../service/FinanceSettlementService.java | 33 +- .../ReconciliationStatementService.java | 203 +++++++++---- .../finance/FinanceServiceValidationTest.java | 73 ++++- .../FinanceSettlementBehaviorTest.java | 282 +++++++++++++++++- .../FinanceSqlCommentContractTest.java | 15 +- .../ReconciliationStatementBehaviorTest.java | 263 ++++++++++++++-- firstleg/AGENTS.md | 4 +- .../baiyue/firstleg/dto/LoadingPickDtos.java | 2 +- .../mapper/LoadingPickItemMapper.java | 3 +- .../firstleg/service/LoadingPickService.java | 3 +- .../openapi/phase3-pda-convergence-v1.yaml | 39 ++- .../db/finance-g3-g4-permissions.sql | 78 +++++ .../FinanceG1G2PermissionSqlContractTest.java | 6 +- .../FinanceG3G4PermissionSqlContractTest.java | 61 ++++ order/AGENTS.md | 1 + .../order/dto/UnifiedOrderResponse.java | 4 + .../order/repository/OrderRepository.java | 10 + .../service/UnifiedOrderQueryService.java | 42 ++- .../src/main/resources/openapi/order-v1.yaml | 3 +- .../service/UnifiedOrderQueryServiceTest.java | 31 ++ scripts/admin-test-env-acceptance.mjs | 6 +- scripts/admin-test-env-modules/OWNERSHIP.md | 1 + scripts/admin-test-env-modules/README.md | 2 +- .../finance-contract.test.mjs | 33 ++ scripts/admin-test-env-modules/finance.mjs | 115 +++++++ 29 files changed, 1311 insertions(+), 109 deletions(-) create mode 100644 iam/src/main/resources/db/finance-g3-g4-permissions.sql create mode 100644 iam/src/test/java/com/baiyue/iam/repository/FinanceG3G4PermissionSqlContractTest.java create mode 100644 scripts/admin-test-env-modules/finance-contract.test.mjs create mode 100644 scripts/admin-test-env-modules/finance.mjs diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 18892e41a..cea834d1c 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -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 只读授权不完整'; diff --git a/finance/AGENTS.md b/finance/AGENTS.md index a8a355241..91a7c0bde 100644 --- a/finance/AGENTS.md +++ b/finance/AGENTS.md @@ -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` | ## 契约与边界 diff --git a/finance/src/main/java/com/baiyue/finance/controller/ReconciliationStatementController.java b/finance/src/main/java/com/baiyue/finance/controller/ReconciliationStatementController.java index 3a23d733b..776e93b21 100644 --- a/finance/src/main/java/com/baiyue/finance/controller/ReconciliationStatementController.java +++ b/finance/src/main/java/com/baiyue/finance/controller/ReconciliationStatementController.java @@ -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 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'))" diff --git a/finance/src/main/java/com/baiyue/finance/service/FinanceService.java b/finance/src/main/java/com/baiyue/finance/service/FinanceService.java index 4f514a95d..a9ce7b013 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinanceService.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceService.java @@ -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); diff --git a/finance/src/main/java/com/baiyue/finance/service/FinanceSettlementService.java b/finance/src/main/java/com/baiyue/finance/service/FinanceSettlementService.java index 3062a9e77..dcbd5fc5f 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinanceSettlementService.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceSettlementService.java @@ -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 transactions = jdbc.queryForList( + List 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> 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> 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) { diff --git a/finance/src/main/java/com/baiyue/finance/service/ReconciliationStatementService.java b/finance/src/main/java/com/baiyue/finance/service/ReconciliationStatementService.java index 859c4ae9e..22d29a4af 100644 --- a/finance/src/main/java/com/baiyue/finance/service/ReconciliationStatementService.java +++ b/finance/src/main/java/com/baiyue/finance/service/ReconciliationStatementService.java @@ -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 MODES = Set.of("BY_CUTOFF", "SELECTED"); - private static final Set ACTIONS = Set.of("SUBMIT", "APPROVE", "REJECT", "VOID"); + private static final Set ACTIONS = Set.of("SUBMIT", "APPROVE", "REJECT", "ISSUE", "ADJUST", "VOID", "REOPEN"); + private static final Set 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 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 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 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 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 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 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> 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 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 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 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 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 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> 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 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 lines = request.adjustmentLines(); + if ("ADJUST".equals(action)) { + if (lines == null || lines.isEmpty()) throw error("FIN_400", "调整必须包含至少一条明细"); + Set 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 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) { diff --git a/finance/src/test/java/com/baiyue/finance/FinanceServiceValidationTest.java b/finance/src/test/java/com/baiyue/finance/FinanceServiceValidationTest.java index 097386b4e..aff765540 100644 --- a/finance/src/test/java/com/baiyue/finance/FinanceServiceValidationTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinanceServiceValidationTest.java @@ -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); diff --git a/finance/src/test/java/com/baiyue/finance/FinanceSettlementBehaviorTest.java b/finance/src/test/java/com/baiyue/finance/FinanceSettlementBehaviorTest.java index 18331c671..359b8eb4c 100644 --- a/finance/src/test/java/com/baiyue/finance/FinanceSettlementBehaviorTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinanceSettlementBehaviorTest.java @@ -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 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 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 before = settlementRow("PAYABLE", "PARTIALLY_PAID", new BigDecimal("40.00"), new BigDecimal("60.00"), 1); + Map 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 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 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 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 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 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 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 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), diff --git a/finance/src/test/java/com/baiyue/finance/FinanceSqlCommentContractTest.java b/finance/src/test/java/com/baiyue/finance/FinanceSqlCommentContractTest.java index 92830596e..e6481dac1 100644 --- a/finance/src/test/java/com/baiyue/finance/FinanceSqlCommentContractTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinanceSqlCommentContractTest.java @@ -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'"); + } } diff --git a/finance/src/test/java/com/baiyue/finance/ReconciliationStatementBehaviorTest.java b/finance/src/test/java/com/baiyue/finance/ReconciliationStatementBehaviorTest.java index 983b13b88..ce1111c58 100644 --- a/finance/src/test/java/com/baiyue/finance/ReconciliationStatementBehaviorTest.java +++ b/finance/src/test/java/com/baiyue/finance/ReconciliationStatementBehaviorTest.java @@ -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 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 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 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 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 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 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 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; + }; + } } diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md index 3c3844a9b..b965a043c 100644 --- a/firstleg/AGENTS.md +++ b/firstleg/AGENTS.md @@ -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` 当作唯一资源标识。 diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingPickDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingPickDtos.java index 4ec527551..bd13fdacc 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingPickDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingPickDtos.java @@ -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 boxCodes, Integer plannedPackageCount, Integer pickedPackageCount, String itemStatus, Integer version) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickItemMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickItemMapper.java index cada43494..52a79a6bd 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickItemMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickItemMapper.java @@ -12,7 +12,7 @@ import java.util.List; @Mapper public interface LoadingPickItemMapper extends BaseMapper { @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 interface CartonLabelRow { Long getPickItemId(); + Long getTenantId(); String getOrderNo(); String getInboundCode(); String getCargoName(); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java index 1bfc0d408..5746fc90a 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java @@ -491,7 +491,8 @@ public class LoadingPickService { List items = source.stream().map(item -> { List 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(), diff --git a/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml b/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml index 6cc0fa0c2..9510a3756 100644 --- a/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml +++ b/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml @@ -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] diff --git a/iam/src/main/resources/db/finance-g3-g4-permissions.sql b/iam/src/main/resources/db/finance-g3-g4-permissions.sql new file mode 100644 index 000000000..92a43abd1 --- /dev/null +++ b/iam/src/main/resources/db/finance-g3-g4-permissions.sql @@ -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; diff --git a/iam/src/test/java/com/baiyue/iam/repository/FinanceG1G2PermissionSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/FinanceG1G2PermissionSqlContractTest.java index 45921f858..d92493c30 100644 --- a/iam/src/test/java/com/baiyue/iam/repository/FinanceG1G2PermissionSqlContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/repository/FinanceG1G2PermissionSqlContractTest.java @@ -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'") diff --git a/iam/src/test/java/com/baiyue/iam/repository/FinanceG3G4PermissionSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/FinanceG3G4PermissionSqlContractTest.java new file mode 100644 index 000000000..0c9360552 --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/repository/FinanceG3G4PermissionSqlContractTest.java @@ -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"); + } +} diff --git a/order/AGENTS.md b/order/AGENTS.md index 927ae3edb..b4fdb321e 100644 --- a/order/AGENTS.md +++ b/order/AGENTS.md @@ -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/` | diff --git a/order/src/main/java/com/baiyue/order/dto/UnifiedOrderResponse.java b/order/src/main/java/com/baiyue/order/dto/UnifiedOrderResponse.java index bd7f1e00b..4ea88fa45 100644 --- a/order/src/main/java/com/baiyue/order/dto/UnifiedOrderResponse.java +++ b/order/src/main/java/com/baiyue/order/dto/UnifiedOrderResponse.java @@ -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 timeline) {} diff --git a/order/src/main/java/com/baiyue/order/repository/OrderRepository.java b/order/src/main/java/com/baiyue/order/repository/OrderRepository.java index 925338837..f86981e3e 100644 --- a/order/src/main/java/com/baiyue/order/repository/OrderRepository.java +++ b/order/src/main/java/com/baiyue/order/repository/OrderRepository.java @@ -226,6 +226,16 @@ public class OrderRepository { .in(OrderEntity::getId, orderIds)); } + public List findChildrenByParentOrders(List parentOrders) { + if (parentOrders.isEmpty()) return List.of(); + List tenantIds = parentOrders.stream().map(OrderEntity::getTenantId).distinct().toList(); + List parentOrderIds = parentOrders.stream().map(OrderEntity::getId).distinct().toList(); + return orderMapper.selectList(new LambdaQueryWrapper() + .select(OrderEntity::getId, OrderEntity::getTenantId, OrderEntity::getParentOrderId) + .in(OrderEntity::getTenantId, tenantIds) + .in(OrderEntity::getParentOrderId, parentOrderIds)); + } + public List findNotesByOrderIds(Long tenantId, List orderIds) { if (orderIds.isEmpty()) return List.of(); return noteMapper.selectList(new LambdaQueryWrapper() diff --git a/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java b/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java index aa95828c4..461b4c5af 100644 --- a/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java +++ b/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java @@ -57,8 +57,9 @@ public class UnifiedOrderQueryService { ? Map.of() : summaries(tenantId, source.records().stream().map(OrderEntity::getId).toList()); Map latestNotes = latestNotes(tenantId, source.records().stream().map(OrderEntity::getId).toList()); + Map childCounts = childCounts(source.records()); List 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 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 orderById = orders.findByIds(tenantId, orderIds).stream() .collect(Collectors.toMap(OrderEntity::getId, Function.identity(), (left, right) -> left)); Map latestNotes = latestNotes(tenantId, orderIds); + Map childCounts = childCounts(List.copyOf(orderById.values())); List 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 childCounts(List 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 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 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 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(), diff --git a/order/src/main/resources/openapi/order-v1.yaml b/order/src/main/resources/openapi/order-v1.yaml index 88299ff39..893157603 100644 --- a/order/src/main/resources/openapi/order-v1.yaml +++ b/order/src/main/resources/openapi/order-v1.yaml @@ -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' diff --git a/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java b/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java index c2ffc4cfe..cbd5956fb 100644 --- a/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java +++ b/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java @@ -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( diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index 26e63b061..78fed36ad 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -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 { diff --git a/scripts/admin-test-env-modules/OWNERSHIP.md b/scripts/admin-test-env-modules/OWNERSHIP.md index b57ada418..74025e00f 100644 --- a/scripts/admin-test-env-modules/OWNERSHIP.md +++ b/scripts/admin-test-env-modules/OWNERSHIP.md @@ -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` | diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index f8e327377..d7388eaa6 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -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?)`:业务断言。 diff --git a/scripts/admin-test-env-modules/finance-contract.test.mjs b/scripts/admin-test-env-modules/finance-contract.test.mjs new file mode 100644 index 000000000..91d8cdf0e --- /dev/null +++ b/scripts/admin-test-env-modules/finance-contract.test.mjs @@ -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['"]/) +}) diff --git a/scripts/admin-test-env-modules/finance.mjs b/scripts/admin-test-env-modules/finance.mjs new file mode 100644 index 000000000..3220720e0 --- /dev/null +++ b/scripts/admin-test-env-modules/finance.mjs @@ -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 }) +}