From c8b01517690c5dc2f9755af389d4cd7da7be2722 Mon Sep 17 00:00:00 2001 From: Qiufeng Date: Mon, 17 Aug 2026 14:08:40 +0800 Subject: [PATCH] feat: deliver first finance preview with setup wizard --- .gitattributes | 2 + .gitea/workflows/release.yml | 116 + .gitignore | 15 + AGENTS.md | 32 + README.md | 281 +- THIRD_PARTY_NOTICES.md | 12 + backend/.mvn/wrapper/maven-wrapper.properties | 3 + backend/mvnw | 295 + backend/mvnw.cmd | 189 + backend/pom.xml | 186 + .../com/kaidi/finance/FinanceApplication.java | 36 + .../accounting/api/AccountingContracts.java | 92 + .../accounting/api/AccountingController.java | 262 + .../accounting/api/AccountingViews.java | 149 + .../AccountingApplicationService.java | 1116 ++ .../infrastructure/AccountingMapper.java | 757 + .../finance/archive/api/ArchiveContracts.java | 96 + .../archive/api/ArchiveController.java | 354 + .../finance/archive/api/ArchiveViews.java | 206 + .../ArchiveApplicationService.java | 1261 ++ .../application/ArchiveWatermarkService.java | 234 + .../archive/infrastructure/ArchiveMapper.java | 1559 ++ .../finance/audit/api/AuditContracts.java | 28 + .../finance/audit/api/AuditController.java | 100 + .../kaidi/finance/audit/api/AuditViews.java | 34 + .../application/AuditApplicationService.java | 302 + .../infrastructure/AuditQueryMapper.java | 104 + .../fund/api/FundLedgerController.java | 41 + .../finance/fund/api/FundLedgerViews.java | 63 + .../FundLedgerApplicationService.java | 311 + .../fund/infrastructure/FundLedgerMapper.java | 213 + .../governance/api/GovernanceContracts.java | 63 + .../governance/api/GovernanceController.java | 94 + .../governance/api/GovernanceViews.java | 51 + .../GovernanceApplicationService.java | 936 + .../infrastructure/GovernanceMapper.java | 300 + .../kaidi/finance/iam/api/AuthController.java | 89 + .../iam/api/AuthorizationScopeView.java | 19 + .../com/kaidi/finance/iam/api/CsrfView.java | 4 + .../kaidi/finance/iam/api/LoginRequest.java | 13 + .../iam/api/PasswordChangeRequest.java | 12 + .../finance/iam/api/RecentLoginView.java | 7 + .../com/kaidi/finance/iam/api/RoleView.java | 4 + .../finance/iam/api/SelectRoleRequest.java | 6 + .../finance/iam/api/SessionSummaryView.java | 8 + .../kaidi/finance/iam/api/SessionView.java | 10 + .../com/kaidi/finance/iam/api/UserView.java | 13 + .../application/AuthApplicationService.java | 417 + .../iam/application/BootstrapProperties.java | 7 + .../application/BootstrapUserInitializer.java | 217 + .../finance/iam/domain/FinancePrincipal.java | 32 + .../finance/iam/domain/RoleAssignment.java | 6 + .../kaidi/finance/iam/domain/UserAccount.java | 41 + .../iam/infrastructure/UserAccountMapper.java | 86 + .../api/BankAccountCreateRequest.java | 18 + .../api/BankAccountUpdateRequest.java | 18 + .../masterdata/api/BankAccountView.java | 24 + .../masterdata/api/CompanyCreateRequest.java | 11 + .../masterdata/api/CompanyUpdateRequest.java | 13 + .../finance/masterdata/api/CompanyView.java | 9 + .../masterdata/api/ContractCreateRequest.java | 16 + .../masterdata/api/ContractUpdateRequest.java | 18 + .../finance/masterdata/api/ContractView.java | 23 + .../api/CounterpartyCreateRequest.java | 14 + .../api/CounterpartyUpdateRequest.java | 16 + .../masterdata/api/CounterpartyView.java | 9 + .../api/DictionaryCreateRequest.java | 14 + .../api/DictionaryUpdateRequest.java | 16 + .../masterdata/api/DictionaryView.java | 21 + .../api/LegacyIdentifierCreateRequest.java | 10 + .../api/LegacyIdentifierResolutionView.java | 10 + .../masterdata/api/LegacyIdentifierView.java | 18 + .../masterdata/api/MasterDataController.java | 385 + .../api/MasterDataReferenceOptionsView.java | 11 + .../api/MasterDataReferenceView.java | 4 + .../masterdata/api/MasterDataVersionView.java | 16 + .../finance/masterdata/api/ReviewRequest.java | 13 + .../masterdata/api/VersionCommandRequest.java | 7 + .../application/BankAccountProtector.java | 61 + .../CatalogMasterDataApplicationService.java | 852 + .../LegacyIdentifierApplicationService.java | 317 + .../MasterDataApplicationService.java | 461 + .../MasterDataVersionApplicationService.java | 182 + .../masterdata/domain/BankAccountRecord.java | 94 + .../masterdata/domain/CompanyRecord.java | 55 + .../domain/ContractMasterRecord.java | 92 + .../masterdata/domain/CounterpartyRecord.java | 64 + .../masterdata/domain/DictionaryRecord.java | 71 + .../domain/MasterDataReferenceRecord.java | 5 + .../CatalogMasterDataMapper.java | 609 + .../LegacyIdentifierMapper.java | 97 + .../infrastructure/MasterDataMapper.java | 279 + .../MasterDataVersionMapper.java | 36 + .../operations/api/ContractCostContracts.java | 55 + .../api/ContractCostController.java | 173 + .../operations/api/ContractCostViews.java | 101 + .../api/FinanceOperationsController.java | 103 + .../api/FinanceOperationsViews.java | 45 + .../ApprovedPayableProjectionService.java | 245 + .../ApprovedSettlementProjectionService.java | 217 + .../ContractCostApplicationService.java | 754 + .../FinanceOperationsApplicationService.java | 296 + .../infrastructure/ContractCostMapper.java | 871 + .../FinanceOperationsMapper.java | 567 + .../finance/payment/api/PaymentContracts.java | 160 + .../payment/api/PaymentController.java | 300 + .../finance/payment/api/PaymentViews.java | 194 + .../PaymentApplicationService.java | 1973 +++ .../domain/PaymentAttachmentMatrix.java | 125 + .../payment/infrastructure/PaymentMapper.java | 1431 ++ .../project/api/ProjectController.java | 152 + .../project/api/ProjectCreateRequest.java | 19 + .../project/api/ProjectDetailView.java | 31 + .../project/api/ProjectExportRequest.java | 23 + .../project/api/ProjectExportView.java | 14 + .../project/api/ProjectImportContracts.java | 42 + .../project/api/ProjectImportViews.java | 29 + .../project/api/ProjectListItemView.java | 30 + .../project/api/ProjectOverviewView.java | 22 + .../api/ProjectReferenceOptionsView.java | 10 + .../project/api/ProjectReferenceView.java | 4 + .../api/ProjectRiskFlagCreateRequest.java | 15 + .../api/ProjectRiskFlagResolveRequest.java | 12 + .../project/api/ProjectRiskFlagView.java | 22 + .../api/ProjectSubresourceController.java | 334 + .../project/api/ProjectTimelineView.java | 7 + .../project/api/ProjectUpdateRequest.java | 19 + .../ProjectApplicationService.java | 827 + .../ProjectImportApplicationService.java | 470 + .../application/ProjectImportCsvParser.java | 236 + .../project/domain/ProjectImportRecords.java | 95 + .../finance/project/domain/ProjectRecord.java | 138 + .../project/domain/ProjectRiskFlagRecord.java | 64 + .../project/domain/ProjectTimelineRecord.java | 7 + .../infrastructure/ProjectImportMapper.java | 111 + .../project/infrastructure/ProjectMapper.java | 716 + .../receivable/api/ReceivableContracts.java | 109 + .../receivable/api/ReceivableController.java | 287 + .../receivable/api/ReceivableViews.java | 129 + .../ApprovedInvoiceProjectionService.java | 253 + .../ApprovedReceiptProjectionService.java | 193 + .../ReceivableApplicationService.java | 950 + .../infrastructure/ReceivableMapper.java | 1147 ++ .../reporting/api/ReportContracts.java | 34 + .../reporting/api/ReportController.java | 96 + .../finance/reporting/api/ReportViews.java | 373 + .../application/ReportApplicationService.java | 866 + .../ReportExportRequestStateService.java | 21 + .../infrastructure/ReportMapper.java | 951 + .../com/kaidi/finance/setup/DatabaseType.java | 6 + .../kaidi/finance/setup/SetupContracts.java | 45 + .../kaidi/finance/setup/SetupController.java | 42 + .../kaidi/finance/setup/SetupException.java | 23 + .../finance/setup/SetupExceptionHandler.java | 38 + .../finance/setup/SetupLockedController.java | 22 + .../kaidi/finance/setup/SetupProperties.java | 13 + .../finance/setup/SetupSecurityConfig.java | 26 + .../com/kaidi/finance/setup/SetupService.java | 417 + .../com/kaidi/finance/setup/SetupViews.java | 34 + .../kaidi/finance/shared/api/ApiResponse.java | 15 + .../finance/shared/api/BusinessException.java | 34 + .../kaidi/finance/shared/api/ErrorCode.java | 42 + .../shared/api/GlobalExceptionHandler.java | 122 + .../kaidi/finance/shared/api/PageResult.java | 17 + .../finance/shared/audit/AuditEntry.java | 8 + .../finance/shared/audit/AuditMapper.java | 39 + .../finance/shared/audit/AuditService.java | 109 + .../audit/DeniedAccessAuditService.java | 57 + .../shared/audit/DeniedAccessAuditWriter.java | 27 + .../config/OpenApiProblemConfiguration.java | 81 + .../finance/shared/config/SecurityConfig.java | 55 + .../shared/file/ClamAvFileScanner.java | 84 + .../shared/file/FileApplicationService.java | 603 + .../finance/shared/file/FileController.java | 118 + .../kaidi/finance/shared/file/FileMapper.java | 79 + .../finance/shared/file/FileObjectRecord.java | 50 + .../shared/file/FileRecordService.java | 28 + .../finance/shared/file/FileScanner.java | 29 + .../shared/file/FileScannerConfiguration.java | 32 + .../shared/file/FileScannerProperties.java | 16 + .../file/FileScannerUnavailableException.java | 20 + .../shared/file/FileStorageProperties.java | 16 + .../kaidi/finance/shared/file/FileView.java | 14 + .../finance/shared/id/UlidGenerator.java | 33 + .../IdempotencyApplicationService.java | 271 + .../shared/idempotency/IdempotencyMapper.java | 52 + .../shared/idempotency/IdempotencyRecord.java | 16 + .../shared/infrastructure/RequestContext.java | 22 + .../infrastructure/RequestIdFilter.java | 42 + .../shared/security/AuthorizationMapper.java | 103 + .../shared/security/AuthorizationService.java | 139 + .../shared/security/FieldEncryptionKey.java | 33 + .../shared/security/IdentityContext.java | 78 + .../finance/shared/security/ScopeGrant.java | 7 + .../security/SecurityProblemWriter.java | 39 + .../shared/security/SessionLifecycle.java | 15 + .../finance/source/api/SourceContracts.java | 270 + .../source/api/SourceFormController.java | 220 + .../source/api/WorkflowController.java | 159 + .../ApprovedSourceProjectionService.java | 782 + .../application/SensitiveValueCipher.java | 75 + .../SourceFormApplicationService.java | 1335 ++ .../SourceImportApplicationService.java | 274 + .../WorkflowApplicationService.java | 486 + .../finance/source/domain/SourceRecords.java | 355 + .../ApprovedSourceProjectionMapper.java | 220 + .../infrastructure/SourceWorkflowMapper.java | 999 ++ .../update/api/SystemUpdateContracts.java | 26 + .../update/api/SystemUpdateController.java | 54 + .../finance/update/api/SystemUpdateView.java | 19 + .../SystemUpdateApplicationService.java | 562 + .../application/SystemUpdateProperties.java | 21 + .../workbench/api/WorkbenchActivityView.java | 15 + .../api/WorkbenchArchiveItemView.java | 17 + .../workbench/api/WorkbenchController.java | 33 + .../workbench/api/WorkbenchFundView.java | 14 + .../workbench/api/WorkbenchIssueView.java | 11 + .../workbench/api/WorkbenchMetricView.java | 4 + .../workbench/api/WorkbenchProjectView.java | 16 + .../workbench/api/WorkbenchTodoView.java | 19 + .../finance/workbench/api/WorkbenchView.java | 21 + .../WorkbenchApplicationService.java | 227 + .../infrastructure/WorkbenchMapper.java | 680 + .../com/kaidi/setup/SetupApplication.java | 38 + .../src/main/resources/application-local.yml | 18 + .../main/resources/application-production.yml | 34 + .../src/main/resources/application-setup.yml | 21 + backend/src/main/resources/application.yml | 104 + .../V001__iam_session_and_audit_baseline.sql | 200 + .../V002__shared_controls_and_files.sql | 71 + ...03__project_masterdata_source_workflow.sql | 551 + ...ation_scope_and_masterdata_permissions.sql | 120 + .../V005__masterdata_review_metadata.sql | 17 + .../V006__file_scan_state_policy.sql | 17 + .../V007__project_management_foundation.sql | 42 + .../V008__isolate_system_admin_role.sql | 6 + ...V009__source_form_and_workflow_runtime.sql | 277 + ...ance_operations_archive_and_governance.sql | 734 + .../V011__receivable_workflow_controls.sql | 52 + .../V012__payment_workflow_controls.sql | 119 + .../V013__accounting_preparation_workflow.sql | 33 + ..._archive_reporting_governance_contract.sql | 130 + .../V015__governance_parameter_version.sql | 6 + .../V016__contract_cost_command_controls.sql | 7 + ...receivable_ledger_and_invoice_controls.sql | 92 + .../V018__archive_workflow_hardening.sql | 2 + .../V019__reporting_scope_backfill.sql | 26 + .../V020__project_timeline_scope_index.sql | 17 + .../V021__project_import_workflow.sql | 46 + ...V022__project_import_source_file_guard.sql | 4 + .../V023__payment_partial_result.sql | 42 + .../V024__payment_refund_accounting_event.sql | 12 + .../V025__payment_result_return_workflow.sql | 33 + ...nting_export_evidence_and_regeneration.sql | 63 + ...archive_package_revision_and_na_review.sql | 93 + .../V028__archive_package_object_snapshot.sql | 25 + ...rchive_file_series_and_borrow_controls.sql | 137 + .../V030__archive_file_series_constraints.sql | 75 + ...031__archive_borrow_series_constraints.sql | 47 + .../V032__report_export_two_phase.sql | 26 + ...033__payable_accounting_event_backfill.sql | 25 + ...034__payable_create_confirm_separation.sql | 75 + ...tract_cost_approved_source_constraints.sql | 5 + .../V036__payment_attachment_matrix.sql | 90 + .../db/migration/V037__fund_ledger.sql | 77 + .../V038__payment_attachment_exceptions.sql | 48 + ...39__payment_attachment_exception_names.sql | 2 + ...ent_attachment_exception_name_backfill.sql | 20 + ...counting_result_pending_state_backfill.sql | 19 + ...043__report_export_view_scope_snapshot.sql | 13 + ...governance_version_restore_permissions.sql | 16 + ...contract_cost_source_version_snapshots.sql | 11 + ..._audit_log_permissions_to_system_admin.sql | 15 + .../V047__project_disabled_status.sql | 4 + ...approved_source_masterdata_projections.sql | 153 + ...approved_oa05_receipt_source_preflight.sql | 37 + ...49__approved_oa07_company_bank_account.sql | 51 + ...V050__approved_oa05_receipt_projection.sql | 55 + ...V051__approved_oa08_invoice_projection.sql | 152 + ...V052__approved_oa04_payable_projection.sql | 84 + .../V053__payment_control_exceptions.sql | 65 + ...payment_control_exception_revision_key.sql | 3 + ..._source_projection_integrity_preflight.sql | 29 + ...rce_document_current_version_integrity.sql | 27 + .../V057__accounting_source_event_key.sql | 15 + ...058__accounting_rule_template_snapshot.sql | 45 + .../V059__file_rescan_promotion_intent.sql | 2 + ..._project_finance_completion_permission.sql | 27 + .../V061__accounting_result_notes.sql | 14 + .../migration/V062__department_masterdata.sql | 28 + .../V063__dictionary_scope_backfill.sql | 36 + ...__masterdata_legacy_identifier_mapping.sql | 35 + ..._accounting_result_reversal_and_reopen.sql | 92 + .../V066__audit_global_timeline_index.sql | 2 + .../V067__system_update_permissions.sql | 10 + ...ource_table_column_validation_metadata.sql | 252 + .../V069__preserve_oa02_v1_table_contract.sql | 11 + ...V070__approved_oa06_payment_projection.sql | 150 + ...system_administrator_super_permissions.sql | 43 + .../V072__setup_installation_state.sql | 12 + .../accounting/AccountingIntegrationTest.java | 1183 ++ .../ControllerAuthorizationTest.java | 51 + .../archive/ArchiveIntegrationTest.java | 1358 ++ .../finance/audit/AuditIntegrationTest.java | 545 + .../governance/GovernanceIntegrationTest.java | 343 + .../finance/iam/AuthIntegrationTest.java | 603 + .../masterdata/MasterDataIntegrationTest.java | 868 + .../V0491ReceiptPreflightMigrationTest.java | 149 + .../V051MigrationIntegrationTest.java | 88 + ...tCurrentVersionIntegrityMigrationTest.java | 145 + ...AccountingSourceEventKeyMigrationTest.java | 108 + .../FinanceOperationsIntegrationTest.java | 1307 ++ .../payment/PaymentIntegrationTest.java | 1864 ++ .../project/ProjectIntegrationTest.java | 1110 ++ .../receivable/ReceivableIntegrationTest.java | 444 + .../reporting/ReportingIntegrationTest.java | 661 + .../SetupApplicationIntegrationTest.java | 141 + .../finance/setup/SetupContextSmokeTest.java | 59 + .../audit/DeniedAccessAuditServiceTest.java | 59 + .../shared/file/FileIntegrationTest.java | 390 + .../file/FileScannerConfigurationTest.java | 40 + .../security/FieldEncryptionKeyTest.java | 30 + ...ctionFieldEncryptionConfigurationTest.java | 85 + .../source/SourceWorkflowIntegrationTest.java | 3262 ++++ .../SystemUpdateApplicationServiceTest.java | 269 + .../workbench/WorkbenchIntegrationTest.java | 329 + .../src/test/resources/application-test.yml | 4 + .../src/test/resources/reporting-fixture.sql | 422 + deploy/compose.yaml | 27 + deploy/env.production.example | 25 + deploy/install-from-git.sh | 85 + deploy/install.sh | 820 + deploy/nginx/kaidi-finance.conf | 40 + deploy/systemd/kaidi-finance.service | 26 + deploy/systemd/kaidi-update.path | 12 + deploy/systemd/kaidi-update.service | 24 + deploy/update.sh | 577 + .../r1/archive-borrow-approved-1366x768.jpg | Bin 0 -> 65141 bytes .../r1/archive-borrow-returned-1366x768.jpg | Bin 0 -> 63261 bytes docs/evidence/r1/archive-rescan-1366x612.jpg | Bin 0 -> 49629 bytes docs/财务系统开发交付总方案.md | 3166 ++++ frontend/.cnb.yml | 37 + frontend/.cnb/Dockerfile | 23 + frontend/.cnb/mcp-servers.json | 14 + frontend/.editorconfig | 14 + frontend/.env | 5 + frontend/.env.development | 5 + frontend/.env.site | 5 + frontend/.env.test | 5 + frontend/.gitattributes | 6 + .../ISSUE_TEMPLATE/bug-report.zh-CN.yml | 79 + frontend/.github/ISSUE_TEMPLATE/config.yml | 5 + .../ISSUE_TEMPLATE/feature-report.zh-CN.yml | 30 + frontend/.github/PULL_REQUEST_TEMPLATE.md | 52 + frontend/.github/dependabot.yml | 20 + frontend/.github/workflows/auto-release.yml | 96 + .../.github/workflows/issue-help-wanted.yml | 10 + .../workflows/issue-mark-duplicate.yml | 10 + .../workflows/issue-need-reproduce.yml | 10 + frontend/.github/workflows/issue-welcome.yml | 10 + .../workflows/pr-spelling.template.yml | 12 + .../.github/workflows/preview-publish.yml | 15 + frontend/.github/workflows/pull-request.yml | 13 + frontend/.github/workflows/sync-cnb.yml | 33 + frontend/.github/workflows/tag-push.yml | 24 + frontend/.gitignore | 37 + frontend/.husky/commit-msg | 5 + frontend/.husky/pre-commit | 1 + frontend/.husky/prepare-commit-msg | 11 + frontend/.npmrc | 1 + frontend/.prettierrc.js | 39 + frontend/.stylelintignore | 8 + frontend/.yarnrc.yml | 1 + frontend/CHANGELOG.md | 399 + frontend/LICENSE | 21 + frontend/PUBLISH.md | 10 + frontend/README-zh_CN.md | 33 + frontend/README.md | 33 + frontend/UPSTREAM.md | 29 + frontend/commitlint.config.js | 11 + frontend/contracts/components.json | 34 + frontend/contracts/operations.json | 1874 ++ frontend/contracts/routes.json | 281 + frontend/docs/starter.png | Bin 0 -> 86639 bytes frontend/e2e/accessibility.e2e.ts | 407 + frontend/e2e/accounting.e2e.ts | 571 + frontend/e2e/archive-files.e2e.ts | 176 + frontend/e2e/audit-logs.e2e.ts | 269 + frontend/e2e/contract-cost-ledger.e2e.ts | 272 + frontend/e2e/form-center.e2e.ts | 220 + frontend/e2e/form-editor-preview.e2e.ts | 423 + frontend/e2e/master-data-departments.e2e.ts | 375 + frontend/e2e/payments.e2e.ts | 206 + frontend/e2e/project-archives.e2e.ts | 293 + frontend/e2e/project-detail-drilldown.e2e.ts | 354 + frontend/e2e/project-list.e2e.ts | 249 + frontend/e2e/receivables-ledger.e2e.ts | 264 + frontend/e2e/setup-wizard.e2e.ts | 76 + frontend/e2e/system-update.e2e.ts | 218 + frontend/e2e/task-center.e2e.ts | 368 + frontend/eslint.config.js | 151 + frontend/index.html | 25 + frontend/package-lock.json | 13196 ++++++++++++++ frontend/package.json | 108 + frontend/playwright.config.ts | 38 + frontend/public/favicon.ico | Bin 0 -> 16958 bytes frontend/scripts/check-production-hygiene.mjs | 132 + frontend/src/App.vue | 26 + frontend/src/api/accounting.ts | 405 + frontend/src/api/archive.ts | 555 + frontend/src/api/audit.ts | 108 + frontend/src/api/auth.ts | 177 + frontend/src/api/command.ts | 11 + frontend/src/api/contract-cost.ts | 315 + frontend/src/api/governance.ts | 151 + frontend/src/api/masterdata.ts | 396 + frontend/src/api/payment.ts | 554 + frontend/src/api/project.ts | 350 + frontend/src/api/receivable.ts | 320 + frontend/src/api/reports.ts | 191 + frontend/src/api/setup.ts | 61 + frontend/src/api/source.ts | 564 + frontend/src/api/system-update.ts | 56 + frontend/src/api/workbench.ts | 113 + frontend/src/assets/assets-login-bg-black.png | Bin 0 -> 807777 bytes frontend/src/assets/assets-login-bg-white.png | Bin 0 -> 995753 bytes frontend/src/assets/assets-result-403.svg | 32 + frontend/src/assets/assets-result-404.svg | 36 + frontend/src/assets/assets-result-500.svg | 52 + frontend/src/assets/assets-result-ie.svg | 33 + .../src/assets/assets-result-maintenance.svg | 48 + frontend/src/assets/assets-result-wifi.svg | 23 + frontend/src/components/LanguageSwitcher.vue | 28 + .../src/components/business/MoneyInput.vue | 74 + .../business/project/ProjectFormDialog.vue | 255 + .../project/ProjectSubresourcePanel.vue | 138 + frontend/src/components/color/index.vue | 32 + frontend/src/components/result/index.vue | 110 + frontend/src/components/thumbnail/index.vue | 45 + .../composables/useTDesignAccessibility.ts | 30 + frontend/src/config/color.ts | 30 + frontend/src/config/global.ts | 1 + frontend/src/config/style.ts | 16 + frontend/src/constants/index.ts | 37 + frontend/src/hooks/index.ts | 61 + frontend/src/layouts/blank.vue | 12 + .../src/layouts/components/Breadcrumb.vue | 34 + frontend/src/layouts/components/Content.vue | 62 + frontend/src/layouts/components/Footer.vue | 13 + .../src/layouts/components/FrameBlank.vue | 10 + frontend/src/layouts/components/Header.vue | 289 + .../src/layouts/components/LayoutContent.vue | 173 + .../src/layouts/components/LayoutHeader.vue | 36 + .../src/layouts/components/LayoutSideNav.vue | 39 + .../src/layouts/components/MenuContent.vue | 99 + frontend/src/layouts/components/Notice.vue | 167 + frontend/src/layouts/components/Search.vue | 136 + frontend/src/layouts/components/SideNav.vue | 198 + frontend/src/layouts/index.vue | 72 + frontend/src/locales/index.ts | 60 + frontend/src/locales/lang/en_US.json | 219 + frontend/src/locales/lang/zh_CN.json | 219 + frontend/src/locales/useLocale.ts | 35 + frontend/src/main.ts | 21 + .../src/pages/archives/ArchiveFilesPage.vue | 1277 ++ .../pages/archives/ProjectArchivesPage.vue | 1167 ++ frontend/src/pages/finance/AccountingPage.vue | 2160 +++ .../pages/finance/ContractCostLedgerPage.vue | 1521 ++ frontend/src/pages/finance/MasterDataPage.vue | 2133 +++ frontend/src/pages/finance/PaymentsPage.vue | 3143 ++++ .../pages/finance/ReceiptsInvoicesPage.vue | 1571 ++ frontend/src/pages/forms/FormCenterPage.vue | 676 + frontend/src/pages/forms/FormEditorPage.vue | 1638 ++ .../src/pages/governance/AuditLogsPage.vue | 644 + .../pages/governance/SystemSettingsPage.vue | 996 ++ .../src/pages/governance/SystemUpdatePage.vue | 575 + frontend/src/pages/login/components/Login.vue | 88 + frontend/src/pages/login/index.less | 194 + frontend/src/pages/login/index.vue | 65 + frontend/src/pages/profile/index.vue | 342 + .../src/pages/projects/ProjectDetailPage.vue | 1603 ++ .../src/pages/projects/ProjectListPage.vue | 935 + frontend/src/pages/reports/ReportsPage.vue | 1253 ++ frontend/src/pages/result/403/index.vue | 19 + frontend/src/pages/result/404/index.vue | 17 + frontend/src/pages/result/500/index.vue | 17 + .../result/browser-incompatible/index.vue | 82 + frontend/src/pages/result/fail/index.vue | 59 + .../src/pages/result/maintenance/index.vue | 17 + .../src/pages/result/network-error/index.vue | 20 + frontend/src/pages/result/success/index.vue | 61 + frontend/src/pages/role-select/index.vue | 190 + frontend/src/pages/setup/index.vue | 513 + frontend/src/pages/tasks/TaskCenterPage.vue | 836 + .../pages/workbench/WorkbenchActivityCard.vue | 41 + .../src/pages/workbench/WorkbenchPage.vue | 602 + frontend/src/pages/workbench/archive.vue | 6 + frontend/src/pages/workbench/finance.vue | 6 + frontend/src/pages/workbench/project.vue | 6 + .../pages/workbench/workbench-navigation.ts | 42 + frontend/src/permission.ts | 101 + frontend/src/router/index.ts | 37 + frontend/src/router/modules/finance.ts | 317 + frontend/src/router/modules/result.ts | 38 + frontend/src/router/modules/system.ts | 49 + frontend/src/router/route-contract.ts | 37 + frontend/src/store/index.ts | 15 + frontend/src/store/modules/menu-access.ts | 57 + frontend/src/store/modules/notification.ts | 26 + frontend/src/store/modules/permission-fe.ts | 71 + frontend/src/store/modules/permission.ts | 37 + frontend/src/store/modules/setting.ts | 111 + frontend/src/store/modules/tabs-router.ts | 92 + frontend/src/store/modules/user.ts | 96 + frontend/src/style/font-family.less | 7 + frontend/src/style/index.less | 115 + frontend/src/style/layout.less | 231 + frontend/src/style/reset.less | 39 + frontend/src/style/variables.less | 26 + frontend/src/types/axios.d.ts | 98 + frontend/src/types/env.d.ts | 9 + frontend/src/types/globals.d.ts | 18 + frontend/src/types/interface.d.ts | 57 + frontend/src/types/router.d.ts | 32 + frontend/src/utils/charts.ts | 38 + frontend/src/utils/color.ts | 110 + frontend/src/utils/date.ts | 12 + frontend/src/utils/request/Axios.ts | 339 + frontend/src/utils/request/AxiosCancel.ts | 67 + frontend/src/utils/request/AxiosTransform.ts | 66 + frontend/src/utils/request/index.ts | 224 + frontend/src/utils/request/utils.ts | 53 + frontend/src/utils/route/constant.ts | 14 + frontend/stylelint.config.js | 30 + frontend/tests/archive-frontend-r4.test.ts | 65 + frontend/tests/contracts.test.ts | 422 + .../tests/forms-projects-workflow.test.ts | 86 + .../tests/payment-control-exceptions.test.ts | 44 + frontend/tests/production-hygiene.test.ts | 21 + .../receivable-source-correction.test.ts | 28 + .../tests/reports-governance-frontend.test.ts | 31 + frontend/tests/router-menu-contract.test.ts | 146 + frontend/tests/super-admin-access.test.ts | 17 + frontend/tests/workbench-r3.test.ts | 33 + frontend/tsconfig.json | 34 + frontend/vite.config.ts | 82 + openapi.yaml | 14413 ++++++++++++++++ scripts/check-openapi.sh | 281 + scripts/check-semver.sh | 10 + scripts/export-openapi.sh | 48 + scripts/generate-release-key.sh | 15 + scripts/package-release.sh | 221 + scripts/publish-gitea-release.sh | 128 + scripts/test-git-install-fixture.sh | 69 + scripts/test-gitea-publish-fixture.sh | 112 + scripts/test-install-fixture.sh | 221 + scripts/test-update-fixture.sh | 444 + scripts/verify-release.sh | 170 + 558 files changed, 140774 insertions(+), 3 deletions(-) create mode 100644 .gitattributes create mode 100644 .gitea/workflows/release.yml create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 THIRD_PARTY_NOTICES.md create mode 100644 backend/.mvn/wrapper/maven-wrapper.properties create mode 100755 backend/mvnw create mode 100644 backend/mvnw.cmd create mode 100644 backend/pom.xml create mode 100644 backend/src/main/java/com/kaidi/finance/FinanceApplication.java create mode 100644 backend/src/main/java/com/kaidi/finance/accounting/api/AccountingContracts.java create mode 100644 backend/src/main/java/com/kaidi/finance/accounting/api/AccountingController.java create mode 100644 backend/src/main/java/com/kaidi/finance/accounting/api/AccountingViews.java create mode 100644 backend/src/main/java/com/kaidi/finance/accounting/application/AccountingApplicationService.java create mode 100644 backend/src/main/java/com/kaidi/finance/accounting/infrastructure/AccountingMapper.java create mode 100644 backend/src/main/java/com/kaidi/finance/archive/api/ArchiveContracts.java create mode 100644 backend/src/main/java/com/kaidi/finance/archive/api/ArchiveController.java create mode 100644 backend/src/main/java/com/kaidi/finance/archive/api/ArchiveViews.java create mode 100644 backend/src/main/java/com/kaidi/finance/archive/application/ArchiveApplicationService.java create mode 100644 backend/src/main/java/com/kaidi/finance/archive/application/ArchiveWatermarkService.java create mode 100644 backend/src/main/java/com/kaidi/finance/archive/infrastructure/ArchiveMapper.java create mode 100644 backend/src/main/java/com/kaidi/finance/audit/api/AuditContracts.java create mode 100644 backend/src/main/java/com/kaidi/finance/audit/api/AuditController.java create mode 100644 backend/src/main/java/com/kaidi/finance/audit/api/AuditViews.java create mode 100644 backend/src/main/java/com/kaidi/finance/audit/application/AuditApplicationService.java create mode 100644 backend/src/main/java/com/kaidi/finance/audit/infrastructure/AuditQueryMapper.java create mode 100644 backend/src/main/java/com/kaidi/finance/fund/api/FundLedgerController.java create mode 100644 backend/src/main/java/com/kaidi/finance/fund/api/FundLedgerViews.java create mode 100644 backend/src/main/java/com/kaidi/finance/fund/application/FundLedgerApplicationService.java create mode 100644 backend/src/main/java/com/kaidi/finance/fund/infrastructure/FundLedgerMapper.java create mode 100644 backend/src/main/java/com/kaidi/finance/governance/api/GovernanceContracts.java create mode 100644 backend/src/main/java/com/kaidi/finance/governance/api/GovernanceController.java create mode 100644 backend/src/main/java/com/kaidi/finance/governance/api/GovernanceViews.java create mode 100644 backend/src/main/java/com/kaidi/finance/governance/application/GovernanceApplicationService.java create mode 100644 backend/src/main/java/com/kaidi/finance/governance/infrastructure/GovernanceMapper.java create mode 100644 backend/src/main/java/com/kaidi/finance/iam/api/AuthController.java create mode 100644 backend/src/main/java/com/kaidi/finance/iam/api/AuthorizationScopeView.java create mode 100644 backend/src/main/java/com/kaidi/finance/iam/api/CsrfView.java create mode 100644 backend/src/main/java/com/kaidi/finance/iam/api/LoginRequest.java create mode 100644 backend/src/main/java/com/kaidi/finance/iam/api/PasswordChangeRequest.java create mode 100644 backend/src/main/java/com/kaidi/finance/iam/api/RecentLoginView.java create mode 100644 backend/src/main/java/com/kaidi/finance/iam/api/RoleView.java create mode 100644 backend/src/main/java/com/kaidi/finance/iam/api/SelectRoleRequest.java create mode 100644 backend/src/main/java/com/kaidi/finance/iam/api/SessionSummaryView.java create mode 100644 backend/src/main/java/com/kaidi/finance/iam/api/SessionView.java create mode 100644 backend/src/main/java/com/kaidi/finance/iam/api/UserView.java create mode 100644 backend/src/main/java/com/kaidi/finance/iam/application/AuthApplicationService.java create mode 100644 backend/src/main/java/com/kaidi/finance/iam/application/BootstrapProperties.java create mode 100644 backend/src/main/java/com/kaidi/finance/iam/application/BootstrapUserInitializer.java create mode 100644 backend/src/main/java/com/kaidi/finance/iam/domain/FinancePrincipal.java create mode 100644 backend/src/main/java/com/kaidi/finance/iam/domain/RoleAssignment.java create mode 100644 backend/src/main/java/com/kaidi/finance/iam/domain/UserAccount.java create mode 100644 backend/src/main/java/com/kaidi/finance/iam/infrastructure/UserAccountMapper.java create mode 100644 backend/src/main/java/com/kaidi/finance/masterdata/api/BankAccountCreateRequest.java create mode 100644 backend/src/main/java/com/kaidi/finance/masterdata/api/BankAccountUpdateRequest.java create mode 100644 backend/src/main/java/com/kaidi/finance/masterdata/api/BankAccountView.java create mode 100644 backend/src/main/java/com/kaidi/finance/masterdata/api/CompanyCreateRequest.java create mode 100644 backend/src/main/java/com/kaidi/finance/masterdata/api/CompanyUpdateRequest.java create mode 100644 backend/src/main/java/com/kaidi/finance/masterdata/api/CompanyView.java create mode 100644 backend/src/main/java/com/kaidi/finance/masterdata/api/ContractCreateRequest.java create mode 100644 backend/src/main/java/com/kaidi/finance/masterdata/api/ContractUpdateRequest.java create mode 100644 backend/src/main/java/com/kaidi/finance/masterdata/api/ContractView.java create mode 100644 backend/src/main/java/com/kaidi/finance/masterdata/api/CounterpartyCreateRequest.java create mode 100644 backend/src/main/java/com/kaidi/finance/masterdata/api/CounterpartyUpdateRequest.java create mode 100644 backend/src/main/java/com/kaidi/finance/masterdata/api/CounterpartyView.java create mode 100644 backend/src/main/java/com/kaidi/finance/masterdata/api/DictionaryCreateRequest.java create mode 100644 backend/src/main/java/com/kaidi/finance/masterdata/api/DictionaryUpdateRequest.java create mode 100644 backend/src/main/java/com/kaidi/finance/masterdata/api/DictionaryView.java create mode 100644 backend/src/main/java/com/kaidi/finance/masterdata/api/LegacyIdentifierCreateRequest.java create mode 100644 backend/src/main/java/com/kaidi/finance/masterdata/api/LegacyIdentifierResolutionView.java create mode 100644 backend/src/main/java/com/kaidi/finance/masterdata/api/LegacyIdentifierView.java create mode 100644 backend/src/main/java/com/kaidi/finance/masterdata/api/MasterDataController.java create mode 100644 backend/src/main/java/com/kaidi/finance/masterdata/api/MasterDataReferenceOptionsView.java create mode 100644 backend/src/main/java/com/kaidi/finance/masterdata/api/MasterDataReferenceView.java create mode 100644 backend/src/main/java/com/kaidi/finance/masterdata/api/MasterDataVersionView.java create mode 100644 backend/src/main/java/com/kaidi/finance/masterdata/api/ReviewRequest.java create mode 100644 backend/src/main/java/com/kaidi/finance/masterdata/api/VersionCommandRequest.java create mode 100644 backend/src/main/java/com/kaidi/finance/masterdata/application/BankAccountProtector.java create mode 100644 backend/src/main/java/com/kaidi/finance/masterdata/application/CatalogMasterDataApplicationService.java create mode 100644 backend/src/main/java/com/kaidi/finance/masterdata/application/LegacyIdentifierApplicationService.java create mode 100644 backend/src/main/java/com/kaidi/finance/masterdata/application/MasterDataApplicationService.java create mode 100644 backend/src/main/java/com/kaidi/finance/masterdata/application/MasterDataVersionApplicationService.java create mode 100644 backend/src/main/java/com/kaidi/finance/masterdata/domain/BankAccountRecord.java create mode 100644 backend/src/main/java/com/kaidi/finance/masterdata/domain/CompanyRecord.java create mode 100644 backend/src/main/java/com/kaidi/finance/masterdata/domain/ContractMasterRecord.java create mode 100644 backend/src/main/java/com/kaidi/finance/masterdata/domain/CounterpartyRecord.java create mode 100644 backend/src/main/java/com/kaidi/finance/masterdata/domain/DictionaryRecord.java create mode 100644 backend/src/main/java/com/kaidi/finance/masterdata/domain/MasterDataReferenceRecord.java create mode 100644 backend/src/main/java/com/kaidi/finance/masterdata/infrastructure/CatalogMasterDataMapper.java create mode 100644 backend/src/main/java/com/kaidi/finance/masterdata/infrastructure/LegacyIdentifierMapper.java create mode 100644 backend/src/main/java/com/kaidi/finance/masterdata/infrastructure/MasterDataMapper.java create mode 100644 backend/src/main/java/com/kaidi/finance/masterdata/infrastructure/MasterDataVersionMapper.java create mode 100644 backend/src/main/java/com/kaidi/finance/operations/api/ContractCostContracts.java create mode 100644 backend/src/main/java/com/kaidi/finance/operations/api/ContractCostController.java create mode 100644 backend/src/main/java/com/kaidi/finance/operations/api/ContractCostViews.java create mode 100644 backend/src/main/java/com/kaidi/finance/operations/api/FinanceOperationsController.java create mode 100644 backend/src/main/java/com/kaidi/finance/operations/api/FinanceOperationsViews.java create mode 100644 backend/src/main/java/com/kaidi/finance/operations/application/ApprovedPayableProjectionService.java create mode 100644 backend/src/main/java/com/kaidi/finance/operations/application/ApprovedSettlementProjectionService.java create mode 100644 backend/src/main/java/com/kaidi/finance/operations/application/ContractCostApplicationService.java create mode 100644 backend/src/main/java/com/kaidi/finance/operations/application/FinanceOperationsApplicationService.java create mode 100644 backend/src/main/java/com/kaidi/finance/operations/infrastructure/ContractCostMapper.java create mode 100644 backend/src/main/java/com/kaidi/finance/operations/infrastructure/FinanceOperationsMapper.java create mode 100644 backend/src/main/java/com/kaidi/finance/payment/api/PaymentContracts.java create mode 100644 backend/src/main/java/com/kaidi/finance/payment/api/PaymentController.java create mode 100644 backend/src/main/java/com/kaidi/finance/payment/api/PaymentViews.java create mode 100644 backend/src/main/java/com/kaidi/finance/payment/application/PaymentApplicationService.java create mode 100644 backend/src/main/java/com/kaidi/finance/payment/domain/PaymentAttachmentMatrix.java create mode 100644 backend/src/main/java/com/kaidi/finance/payment/infrastructure/PaymentMapper.java create mode 100644 backend/src/main/java/com/kaidi/finance/project/api/ProjectController.java create mode 100644 backend/src/main/java/com/kaidi/finance/project/api/ProjectCreateRequest.java create mode 100644 backend/src/main/java/com/kaidi/finance/project/api/ProjectDetailView.java create mode 100644 backend/src/main/java/com/kaidi/finance/project/api/ProjectExportRequest.java create mode 100644 backend/src/main/java/com/kaidi/finance/project/api/ProjectExportView.java create mode 100644 backend/src/main/java/com/kaidi/finance/project/api/ProjectImportContracts.java create mode 100644 backend/src/main/java/com/kaidi/finance/project/api/ProjectImportViews.java create mode 100644 backend/src/main/java/com/kaidi/finance/project/api/ProjectListItemView.java create mode 100644 backend/src/main/java/com/kaidi/finance/project/api/ProjectOverviewView.java create mode 100644 backend/src/main/java/com/kaidi/finance/project/api/ProjectReferenceOptionsView.java create mode 100644 backend/src/main/java/com/kaidi/finance/project/api/ProjectReferenceView.java create mode 100644 backend/src/main/java/com/kaidi/finance/project/api/ProjectRiskFlagCreateRequest.java create mode 100644 backend/src/main/java/com/kaidi/finance/project/api/ProjectRiskFlagResolveRequest.java create mode 100644 backend/src/main/java/com/kaidi/finance/project/api/ProjectRiskFlagView.java create mode 100644 backend/src/main/java/com/kaidi/finance/project/api/ProjectSubresourceController.java create mode 100644 backend/src/main/java/com/kaidi/finance/project/api/ProjectTimelineView.java create mode 100644 backend/src/main/java/com/kaidi/finance/project/api/ProjectUpdateRequest.java create mode 100644 backend/src/main/java/com/kaidi/finance/project/application/ProjectApplicationService.java create mode 100644 backend/src/main/java/com/kaidi/finance/project/application/ProjectImportApplicationService.java create mode 100644 backend/src/main/java/com/kaidi/finance/project/application/ProjectImportCsvParser.java create mode 100644 backend/src/main/java/com/kaidi/finance/project/domain/ProjectImportRecords.java create mode 100644 backend/src/main/java/com/kaidi/finance/project/domain/ProjectRecord.java create mode 100644 backend/src/main/java/com/kaidi/finance/project/domain/ProjectRiskFlagRecord.java create mode 100644 backend/src/main/java/com/kaidi/finance/project/domain/ProjectTimelineRecord.java create mode 100644 backend/src/main/java/com/kaidi/finance/project/infrastructure/ProjectImportMapper.java create mode 100644 backend/src/main/java/com/kaidi/finance/project/infrastructure/ProjectMapper.java create mode 100644 backend/src/main/java/com/kaidi/finance/receivable/api/ReceivableContracts.java create mode 100644 backend/src/main/java/com/kaidi/finance/receivable/api/ReceivableController.java create mode 100644 backend/src/main/java/com/kaidi/finance/receivable/api/ReceivableViews.java create mode 100644 backend/src/main/java/com/kaidi/finance/receivable/application/ApprovedInvoiceProjectionService.java create mode 100644 backend/src/main/java/com/kaidi/finance/receivable/application/ApprovedReceiptProjectionService.java create mode 100644 backend/src/main/java/com/kaidi/finance/receivable/application/ReceivableApplicationService.java create mode 100644 backend/src/main/java/com/kaidi/finance/receivable/infrastructure/ReceivableMapper.java create mode 100644 backend/src/main/java/com/kaidi/finance/reporting/api/ReportContracts.java create mode 100644 backend/src/main/java/com/kaidi/finance/reporting/api/ReportController.java create mode 100644 backend/src/main/java/com/kaidi/finance/reporting/api/ReportViews.java create mode 100644 backend/src/main/java/com/kaidi/finance/reporting/application/ReportApplicationService.java create mode 100644 backend/src/main/java/com/kaidi/finance/reporting/application/ReportExportRequestStateService.java create mode 100644 backend/src/main/java/com/kaidi/finance/reporting/infrastructure/ReportMapper.java create mode 100644 backend/src/main/java/com/kaidi/finance/setup/DatabaseType.java create mode 100644 backend/src/main/java/com/kaidi/finance/setup/SetupContracts.java create mode 100644 backend/src/main/java/com/kaidi/finance/setup/SetupController.java create mode 100644 backend/src/main/java/com/kaidi/finance/setup/SetupException.java create mode 100644 backend/src/main/java/com/kaidi/finance/setup/SetupExceptionHandler.java create mode 100644 backend/src/main/java/com/kaidi/finance/setup/SetupLockedController.java create mode 100644 backend/src/main/java/com/kaidi/finance/setup/SetupProperties.java create mode 100644 backend/src/main/java/com/kaidi/finance/setup/SetupSecurityConfig.java create mode 100644 backend/src/main/java/com/kaidi/finance/setup/SetupService.java create mode 100644 backend/src/main/java/com/kaidi/finance/setup/SetupViews.java create mode 100644 backend/src/main/java/com/kaidi/finance/shared/api/ApiResponse.java create mode 100644 backend/src/main/java/com/kaidi/finance/shared/api/BusinessException.java create mode 100644 backend/src/main/java/com/kaidi/finance/shared/api/ErrorCode.java create mode 100644 backend/src/main/java/com/kaidi/finance/shared/api/GlobalExceptionHandler.java create mode 100644 backend/src/main/java/com/kaidi/finance/shared/api/PageResult.java create mode 100644 backend/src/main/java/com/kaidi/finance/shared/audit/AuditEntry.java create mode 100644 backend/src/main/java/com/kaidi/finance/shared/audit/AuditMapper.java create mode 100644 backend/src/main/java/com/kaidi/finance/shared/audit/AuditService.java create mode 100644 backend/src/main/java/com/kaidi/finance/shared/audit/DeniedAccessAuditService.java create mode 100644 backend/src/main/java/com/kaidi/finance/shared/audit/DeniedAccessAuditWriter.java create mode 100644 backend/src/main/java/com/kaidi/finance/shared/config/OpenApiProblemConfiguration.java create mode 100644 backend/src/main/java/com/kaidi/finance/shared/config/SecurityConfig.java create mode 100644 backend/src/main/java/com/kaidi/finance/shared/file/ClamAvFileScanner.java create mode 100644 backend/src/main/java/com/kaidi/finance/shared/file/FileApplicationService.java create mode 100644 backend/src/main/java/com/kaidi/finance/shared/file/FileController.java create mode 100644 backend/src/main/java/com/kaidi/finance/shared/file/FileMapper.java create mode 100644 backend/src/main/java/com/kaidi/finance/shared/file/FileObjectRecord.java create mode 100644 backend/src/main/java/com/kaidi/finance/shared/file/FileRecordService.java create mode 100644 backend/src/main/java/com/kaidi/finance/shared/file/FileScanner.java create mode 100644 backend/src/main/java/com/kaidi/finance/shared/file/FileScannerConfiguration.java create mode 100644 backend/src/main/java/com/kaidi/finance/shared/file/FileScannerProperties.java create mode 100644 backend/src/main/java/com/kaidi/finance/shared/file/FileScannerUnavailableException.java create mode 100644 backend/src/main/java/com/kaidi/finance/shared/file/FileStorageProperties.java create mode 100644 backend/src/main/java/com/kaidi/finance/shared/file/FileView.java create mode 100644 backend/src/main/java/com/kaidi/finance/shared/id/UlidGenerator.java create mode 100644 backend/src/main/java/com/kaidi/finance/shared/idempotency/IdempotencyApplicationService.java create mode 100644 backend/src/main/java/com/kaidi/finance/shared/idempotency/IdempotencyMapper.java create mode 100644 backend/src/main/java/com/kaidi/finance/shared/idempotency/IdempotencyRecord.java create mode 100644 backend/src/main/java/com/kaidi/finance/shared/infrastructure/RequestContext.java create mode 100644 backend/src/main/java/com/kaidi/finance/shared/infrastructure/RequestIdFilter.java create mode 100644 backend/src/main/java/com/kaidi/finance/shared/security/AuthorizationMapper.java create mode 100644 backend/src/main/java/com/kaidi/finance/shared/security/AuthorizationService.java create mode 100644 backend/src/main/java/com/kaidi/finance/shared/security/FieldEncryptionKey.java create mode 100644 backend/src/main/java/com/kaidi/finance/shared/security/IdentityContext.java create mode 100644 backend/src/main/java/com/kaidi/finance/shared/security/ScopeGrant.java create mode 100644 backend/src/main/java/com/kaidi/finance/shared/security/SecurityProblemWriter.java create mode 100644 backend/src/main/java/com/kaidi/finance/shared/security/SessionLifecycle.java create mode 100644 backend/src/main/java/com/kaidi/finance/source/api/SourceContracts.java create mode 100644 backend/src/main/java/com/kaidi/finance/source/api/SourceFormController.java create mode 100644 backend/src/main/java/com/kaidi/finance/source/api/WorkflowController.java create mode 100644 backend/src/main/java/com/kaidi/finance/source/application/ApprovedSourceProjectionService.java create mode 100644 backend/src/main/java/com/kaidi/finance/source/application/SensitiveValueCipher.java create mode 100644 backend/src/main/java/com/kaidi/finance/source/application/SourceFormApplicationService.java create mode 100644 backend/src/main/java/com/kaidi/finance/source/application/SourceImportApplicationService.java create mode 100644 backend/src/main/java/com/kaidi/finance/source/application/WorkflowApplicationService.java create mode 100644 backend/src/main/java/com/kaidi/finance/source/domain/SourceRecords.java create mode 100644 backend/src/main/java/com/kaidi/finance/source/infrastructure/ApprovedSourceProjectionMapper.java create mode 100644 backend/src/main/java/com/kaidi/finance/source/infrastructure/SourceWorkflowMapper.java create mode 100644 backend/src/main/java/com/kaidi/finance/update/api/SystemUpdateContracts.java create mode 100644 backend/src/main/java/com/kaidi/finance/update/api/SystemUpdateController.java create mode 100644 backend/src/main/java/com/kaidi/finance/update/api/SystemUpdateView.java create mode 100644 backend/src/main/java/com/kaidi/finance/update/application/SystemUpdateApplicationService.java create mode 100644 backend/src/main/java/com/kaidi/finance/update/application/SystemUpdateProperties.java create mode 100644 backend/src/main/java/com/kaidi/finance/workbench/api/WorkbenchActivityView.java create mode 100644 backend/src/main/java/com/kaidi/finance/workbench/api/WorkbenchArchiveItemView.java create mode 100644 backend/src/main/java/com/kaidi/finance/workbench/api/WorkbenchController.java create mode 100644 backend/src/main/java/com/kaidi/finance/workbench/api/WorkbenchFundView.java create mode 100644 backend/src/main/java/com/kaidi/finance/workbench/api/WorkbenchIssueView.java create mode 100644 backend/src/main/java/com/kaidi/finance/workbench/api/WorkbenchMetricView.java create mode 100644 backend/src/main/java/com/kaidi/finance/workbench/api/WorkbenchProjectView.java create mode 100644 backend/src/main/java/com/kaidi/finance/workbench/api/WorkbenchTodoView.java create mode 100644 backend/src/main/java/com/kaidi/finance/workbench/api/WorkbenchView.java create mode 100644 backend/src/main/java/com/kaidi/finance/workbench/application/WorkbenchApplicationService.java create mode 100644 backend/src/main/java/com/kaidi/finance/workbench/infrastructure/WorkbenchMapper.java create mode 100644 backend/src/main/java/com/kaidi/setup/SetupApplication.java create mode 100644 backend/src/main/resources/application-local.yml create mode 100644 backend/src/main/resources/application-production.yml create mode 100644 backend/src/main/resources/application-setup.yml create mode 100644 backend/src/main/resources/application.yml create mode 100644 backend/src/main/resources/db/migration/V001__iam_session_and_audit_baseline.sql create mode 100644 backend/src/main/resources/db/migration/V002__shared_controls_and_files.sql create mode 100644 backend/src/main/resources/db/migration/V003__project_masterdata_source_workflow.sql create mode 100644 backend/src/main/resources/db/migration/V004__authorization_scope_and_masterdata_permissions.sql create mode 100644 backend/src/main/resources/db/migration/V005__masterdata_review_metadata.sql create mode 100644 backend/src/main/resources/db/migration/V006__file_scan_state_policy.sql create mode 100644 backend/src/main/resources/db/migration/V007__project_management_foundation.sql create mode 100644 backend/src/main/resources/db/migration/V008__isolate_system_admin_role.sql create mode 100644 backend/src/main/resources/db/migration/V009__source_form_and_workflow_runtime.sql create mode 100644 backend/src/main/resources/db/migration/V010__finance_operations_archive_and_governance.sql create mode 100644 backend/src/main/resources/db/migration/V011__receivable_workflow_controls.sql create mode 100644 backend/src/main/resources/db/migration/V012__payment_workflow_controls.sql create mode 100644 backend/src/main/resources/db/migration/V013__accounting_preparation_workflow.sql create mode 100644 backend/src/main/resources/db/migration/V014__archive_reporting_governance_contract.sql create mode 100644 backend/src/main/resources/db/migration/V015__governance_parameter_version.sql create mode 100644 backend/src/main/resources/db/migration/V016__contract_cost_command_controls.sql create mode 100644 backend/src/main/resources/db/migration/V017__receivable_ledger_and_invoice_controls.sql create mode 100644 backend/src/main/resources/db/migration/V018__archive_workflow_hardening.sql create mode 100644 backend/src/main/resources/db/migration/V019__reporting_scope_backfill.sql create mode 100644 backend/src/main/resources/db/migration/V020__project_timeline_scope_index.sql create mode 100644 backend/src/main/resources/db/migration/V021__project_import_workflow.sql create mode 100644 backend/src/main/resources/db/migration/V022__project_import_source_file_guard.sql create mode 100644 backend/src/main/resources/db/migration/V023__payment_partial_result.sql create mode 100644 backend/src/main/resources/db/migration/V024__payment_refund_accounting_event.sql create mode 100644 backend/src/main/resources/db/migration/V025__payment_result_return_workflow.sql create mode 100644 backend/src/main/resources/db/migration/V026__accounting_export_evidence_and_regeneration.sql create mode 100644 backend/src/main/resources/db/migration/V027__archive_package_revision_and_na_review.sql create mode 100644 backend/src/main/resources/db/migration/V028__archive_package_object_snapshot.sql create mode 100644 backend/src/main/resources/db/migration/V029__archive_file_series_and_borrow_controls.sql create mode 100644 backend/src/main/resources/db/migration/V030__archive_file_series_constraints.sql create mode 100644 backend/src/main/resources/db/migration/V031__archive_borrow_series_constraints.sql create mode 100644 backend/src/main/resources/db/migration/V032__report_export_two_phase.sql create mode 100644 backend/src/main/resources/db/migration/V033__payable_accounting_event_backfill.sql create mode 100644 backend/src/main/resources/db/migration/V034__payable_create_confirm_separation.sql create mode 100644 backend/src/main/resources/db/migration/V035__contract_cost_approved_source_constraints.sql create mode 100644 backend/src/main/resources/db/migration/V036__payment_attachment_matrix.sql create mode 100644 backend/src/main/resources/db/migration/V037__fund_ledger.sql create mode 100644 backend/src/main/resources/db/migration/V038__payment_attachment_exceptions.sql create mode 100644 backend/src/main/resources/db/migration/V039__payment_attachment_exception_names.sql create mode 100644 backend/src/main/resources/db/migration/V040__payment_attachment_exception_name_backfill.sql create mode 100644 backend/src/main/resources/db/migration/V041__accounting_result_pending_state_backfill.sql create mode 100644 backend/src/main/resources/db/migration/V043__report_export_view_scope_snapshot.sql create mode 100644 backend/src/main/resources/db/migration/V044__governance_version_restore_permissions.sql create mode 100644 backend/src/main/resources/db/migration/V045__contract_cost_source_version_snapshots.sql create mode 100644 backend/src/main/resources/db/migration/V046__restrict_audit_log_permissions_to_system_admin.sql create mode 100644 backend/src/main/resources/db/migration/V047__project_disabled_status.sql create mode 100644 backend/src/main/resources/db/migration/V048__approved_source_masterdata_projections.sql create mode 100644 backend/src/main/resources/db/migration/V049_1__approved_oa05_receipt_source_preflight.sql create mode 100644 backend/src/main/resources/db/migration/V049__approved_oa07_company_bank_account.sql create mode 100644 backend/src/main/resources/db/migration/V050__approved_oa05_receipt_projection.sql create mode 100644 backend/src/main/resources/db/migration/V051__approved_oa08_invoice_projection.sql create mode 100644 backend/src/main/resources/db/migration/V052__approved_oa04_payable_projection.sql create mode 100644 backend/src/main/resources/db/migration/V053__payment_control_exceptions.sql create mode 100644 backend/src/main/resources/db/migration/V054__payment_control_exception_revision_key.sql create mode 100644 backend/src/main/resources/db/migration/V055__source_projection_integrity_preflight.sql create mode 100644 backend/src/main/resources/db/migration/V056__source_document_current_version_integrity.sql create mode 100644 backend/src/main/resources/db/migration/V057__accounting_source_event_key.sql create mode 100644 backend/src/main/resources/db/migration/V058__accounting_rule_template_snapshot.sql create mode 100644 backend/src/main/resources/db/migration/V059__file_rescan_promotion_intent.sql create mode 100644 backend/src/main/resources/db/migration/V060__project_finance_completion_permission.sql create mode 100644 backend/src/main/resources/db/migration/V061__accounting_result_notes.sql create mode 100644 backend/src/main/resources/db/migration/V062__department_masterdata.sql create mode 100644 backend/src/main/resources/db/migration/V063__dictionary_scope_backfill.sql create mode 100644 backend/src/main/resources/db/migration/V064__masterdata_legacy_identifier_mapping.sql create mode 100644 backend/src/main/resources/db/migration/V065__accounting_result_reversal_and_reopen.sql create mode 100644 backend/src/main/resources/db/migration/V066__audit_global_timeline_index.sql create mode 100644 backend/src/main/resources/db/migration/V067__system_update_permissions.sql create mode 100644 backend/src/main/resources/db/migration/V068__source_table_column_validation_metadata.sql create mode 100644 backend/src/main/resources/db/migration/V069__preserve_oa02_v1_table_contract.sql create mode 100644 backend/src/main/resources/db/migration/V070__approved_oa06_payment_projection.sql create mode 100644 backend/src/main/resources/db/migration/V071__system_administrator_super_permissions.sql create mode 100644 backend/src/main/resources/db/migration/V072__setup_installation_state.sql create mode 100644 backend/src/test/java/com/kaidi/finance/accounting/AccountingIntegrationTest.java create mode 100644 backend/src/test/java/com/kaidi/finance/architecture/ControllerAuthorizationTest.java create mode 100644 backend/src/test/java/com/kaidi/finance/archive/ArchiveIntegrationTest.java create mode 100644 backend/src/test/java/com/kaidi/finance/audit/AuditIntegrationTest.java create mode 100644 backend/src/test/java/com/kaidi/finance/governance/GovernanceIntegrationTest.java create mode 100644 backend/src/test/java/com/kaidi/finance/iam/AuthIntegrationTest.java create mode 100644 backend/src/test/java/com/kaidi/finance/masterdata/MasterDataIntegrationTest.java create mode 100644 backend/src/test/java/com/kaidi/finance/migration/V0491ReceiptPreflightMigrationTest.java create mode 100644 backend/src/test/java/com/kaidi/finance/migration/V051MigrationIntegrationTest.java create mode 100644 backend/src/test/java/com/kaidi/finance/migration/V056SourceDocumentCurrentVersionIntegrityMigrationTest.java create mode 100644 backend/src/test/java/com/kaidi/finance/migration/V057AccountingSourceEventKeyMigrationTest.java create mode 100644 backend/src/test/java/com/kaidi/finance/operations/FinanceOperationsIntegrationTest.java create mode 100644 backend/src/test/java/com/kaidi/finance/payment/PaymentIntegrationTest.java create mode 100644 backend/src/test/java/com/kaidi/finance/project/ProjectIntegrationTest.java create mode 100644 backend/src/test/java/com/kaidi/finance/receivable/ReceivableIntegrationTest.java create mode 100644 backend/src/test/java/com/kaidi/finance/reporting/ReportingIntegrationTest.java create mode 100644 backend/src/test/java/com/kaidi/finance/setup/SetupApplicationIntegrationTest.java create mode 100644 backend/src/test/java/com/kaidi/finance/setup/SetupContextSmokeTest.java create mode 100644 backend/src/test/java/com/kaidi/finance/shared/audit/DeniedAccessAuditServiceTest.java create mode 100644 backend/src/test/java/com/kaidi/finance/shared/file/FileIntegrationTest.java create mode 100644 backend/src/test/java/com/kaidi/finance/shared/file/FileScannerConfigurationTest.java create mode 100644 backend/src/test/java/com/kaidi/finance/shared/security/FieldEncryptionKeyTest.java create mode 100644 backend/src/test/java/com/kaidi/finance/shared/security/ProductionFieldEncryptionConfigurationTest.java create mode 100644 backend/src/test/java/com/kaidi/finance/source/SourceWorkflowIntegrationTest.java create mode 100644 backend/src/test/java/com/kaidi/finance/update/application/SystemUpdateApplicationServiceTest.java create mode 100644 backend/src/test/java/com/kaidi/finance/workbench/WorkbenchIntegrationTest.java create mode 100644 backend/src/test/resources/application-test.yml create mode 100644 backend/src/test/resources/reporting-fixture.sql create mode 100644 deploy/compose.yaml create mode 100644 deploy/env.production.example create mode 100755 deploy/install-from-git.sh create mode 100755 deploy/install.sh create mode 100644 deploy/nginx/kaidi-finance.conf create mode 100644 deploy/systemd/kaidi-finance.service create mode 100644 deploy/systemd/kaidi-update.path create mode 100644 deploy/systemd/kaidi-update.service create mode 100755 deploy/update.sh create mode 100644 docs/evidence/r1/archive-borrow-approved-1366x768.jpg create mode 100644 docs/evidence/r1/archive-borrow-returned-1366x768.jpg create mode 100644 docs/evidence/r1/archive-rescan-1366x612.jpg create mode 100644 docs/财务系统开发交付总方案.md create mode 100644 frontend/.cnb.yml create mode 100644 frontend/.cnb/Dockerfile create mode 100644 frontend/.cnb/mcp-servers.json create mode 100644 frontend/.editorconfig create mode 100644 frontend/.env create mode 100644 frontend/.env.development create mode 100644 frontend/.env.site create mode 100644 frontend/.env.test create mode 100644 frontend/.gitattributes create mode 100644 frontend/.github/ISSUE_TEMPLATE/bug-report.zh-CN.yml create mode 100644 frontend/.github/ISSUE_TEMPLATE/config.yml create mode 100644 frontend/.github/ISSUE_TEMPLATE/feature-report.zh-CN.yml create mode 100644 frontend/.github/PULL_REQUEST_TEMPLATE.md create mode 100644 frontend/.github/dependabot.yml create mode 100644 frontend/.github/workflows/auto-release.yml create mode 100644 frontend/.github/workflows/issue-help-wanted.yml create mode 100644 frontend/.github/workflows/issue-mark-duplicate.yml create mode 100644 frontend/.github/workflows/issue-need-reproduce.yml create mode 100644 frontend/.github/workflows/issue-welcome.yml create mode 100644 frontend/.github/workflows/pr-spelling.template.yml create mode 100644 frontend/.github/workflows/preview-publish.yml create mode 100644 frontend/.github/workflows/pull-request.yml create mode 100644 frontend/.github/workflows/sync-cnb.yml create mode 100644 frontend/.github/workflows/tag-push.yml create mode 100755 frontend/.gitignore create mode 100755 frontend/.husky/commit-msg create mode 100755 frontend/.husky/pre-commit create mode 100755 frontend/.husky/prepare-commit-msg create mode 100644 frontend/.npmrc create mode 100644 frontend/.prettierrc.js create mode 100644 frontend/.stylelintignore create mode 100644 frontend/.yarnrc.yml create mode 100644 frontend/CHANGELOG.md create mode 100644 frontend/LICENSE create mode 100644 frontend/PUBLISH.md create mode 100644 frontend/README-zh_CN.md create mode 100644 frontend/README.md create mode 100644 frontend/UPSTREAM.md create mode 100644 frontend/commitlint.config.js create mode 100644 frontend/contracts/components.json create mode 100644 frontend/contracts/operations.json create mode 100644 frontend/contracts/routes.json create mode 100644 frontend/docs/starter.png create mode 100644 frontend/e2e/accessibility.e2e.ts create mode 100644 frontend/e2e/accounting.e2e.ts create mode 100644 frontend/e2e/archive-files.e2e.ts create mode 100644 frontend/e2e/audit-logs.e2e.ts create mode 100644 frontend/e2e/contract-cost-ledger.e2e.ts create mode 100644 frontend/e2e/form-center.e2e.ts create mode 100644 frontend/e2e/form-editor-preview.e2e.ts create mode 100644 frontend/e2e/master-data-departments.e2e.ts create mode 100644 frontend/e2e/payments.e2e.ts create mode 100644 frontend/e2e/project-archives.e2e.ts create mode 100644 frontend/e2e/project-detail-drilldown.e2e.ts create mode 100644 frontend/e2e/project-list.e2e.ts create mode 100644 frontend/e2e/receivables-ledger.e2e.ts create mode 100644 frontend/e2e/setup-wizard.e2e.ts create mode 100644 frontend/e2e/system-update.e2e.ts create mode 100644 frontend/e2e/task-center.e2e.ts create mode 100644 frontend/eslint.config.js create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/playwright.config.ts create mode 100644 frontend/public/favicon.ico create mode 100644 frontend/scripts/check-production-hygiene.mjs create mode 100644 frontend/src/App.vue create mode 100644 frontend/src/api/accounting.ts create mode 100644 frontend/src/api/archive.ts create mode 100644 frontend/src/api/audit.ts create mode 100644 frontend/src/api/auth.ts create mode 100644 frontend/src/api/command.ts create mode 100644 frontend/src/api/contract-cost.ts create mode 100644 frontend/src/api/governance.ts create mode 100644 frontend/src/api/masterdata.ts create mode 100644 frontend/src/api/payment.ts create mode 100644 frontend/src/api/project.ts create mode 100644 frontend/src/api/receivable.ts create mode 100644 frontend/src/api/reports.ts create mode 100644 frontend/src/api/setup.ts create mode 100644 frontend/src/api/source.ts create mode 100644 frontend/src/api/system-update.ts create mode 100644 frontend/src/api/workbench.ts create mode 100644 frontend/src/assets/assets-login-bg-black.png create mode 100644 frontend/src/assets/assets-login-bg-white.png create mode 100644 frontend/src/assets/assets-result-403.svg create mode 100644 frontend/src/assets/assets-result-404.svg create mode 100644 frontend/src/assets/assets-result-500.svg create mode 100644 frontend/src/assets/assets-result-ie.svg create mode 100644 frontend/src/assets/assets-result-maintenance.svg create mode 100644 frontend/src/assets/assets-result-wifi.svg create mode 100644 frontend/src/components/LanguageSwitcher.vue create mode 100644 frontend/src/components/business/MoneyInput.vue create mode 100644 frontend/src/components/business/project/ProjectFormDialog.vue create mode 100644 frontend/src/components/business/project/ProjectSubresourcePanel.vue create mode 100644 frontend/src/components/color/index.vue create mode 100644 frontend/src/components/result/index.vue create mode 100644 frontend/src/components/thumbnail/index.vue create mode 100644 frontend/src/composables/useTDesignAccessibility.ts create mode 100644 frontend/src/config/color.ts create mode 100644 frontend/src/config/global.ts create mode 100644 frontend/src/config/style.ts create mode 100644 frontend/src/constants/index.ts create mode 100644 frontend/src/hooks/index.ts create mode 100644 frontend/src/layouts/blank.vue create mode 100644 frontend/src/layouts/components/Breadcrumb.vue create mode 100644 frontend/src/layouts/components/Content.vue create mode 100644 frontend/src/layouts/components/Footer.vue create mode 100644 frontend/src/layouts/components/FrameBlank.vue create mode 100644 frontend/src/layouts/components/Header.vue create mode 100644 frontend/src/layouts/components/LayoutContent.vue create mode 100644 frontend/src/layouts/components/LayoutHeader.vue create mode 100644 frontend/src/layouts/components/LayoutSideNav.vue create mode 100644 frontend/src/layouts/components/MenuContent.vue create mode 100644 frontend/src/layouts/components/Notice.vue create mode 100644 frontend/src/layouts/components/Search.vue create mode 100644 frontend/src/layouts/components/SideNav.vue create mode 100644 frontend/src/layouts/index.vue create mode 100644 frontend/src/locales/index.ts create mode 100644 frontend/src/locales/lang/en_US.json create mode 100644 frontend/src/locales/lang/zh_CN.json create mode 100644 frontend/src/locales/useLocale.ts create mode 100644 frontend/src/main.ts create mode 100644 frontend/src/pages/archives/ArchiveFilesPage.vue create mode 100644 frontend/src/pages/archives/ProjectArchivesPage.vue create mode 100644 frontend/src/pages/finance/AccountingPage.vue create mode 100644 frontend/src/pages/finance/ContractCostLedgerPage.vue create mode 100644 frontend/src/pages/finance/MasterDataPage.vue create mode 100644 frontend/src/pages/finance/PaymentsPage.vue create mode 100644 frontend/src/pages/finance/ReceiptsInvoicesPage.vue create mode 100644 frontend/src/pages/forms/FormCenterPage.vue create mode 100644 frontend/src/pages/forms/FormEditorPage.vue create mode 100644 frontend/src/pages/governance/AuditLogsPage.vue create mode 100644 frontend/src/pages/governance/SystemSettingsPage.vue create mode 100644 frontend/src/pages/governance/SystemUpdatePage.vue create mode 100644 frontend/src/pages/login/components/Login.vue create mode 100644 frontend/src/pages/login/index.less create mode 100644 frontend/src/pages/login/index.vue create mode 100644 frontend/src/pages/profile/index.vue create mode 100644 frontend/src/pages/projects/ProjectDetailPage.vue create mode 100644 frontend/src/pages/projects/ProjectListPage.vue create mode 100644 frontend/src/pages/reports/ReportsPage.vue create mode 100644 frontend/src/pages/result/403/index.vue create mode 100644 frontend/src/pages/result/404/index.vue create mode 100644 frontend/src/pages/result/500/index.vue create mode 100644 frontend/src/pages/result/browser-incompatible/index.vue create mode 100644 frontend/src/pages/result/fail/index.vue create mode 100644 frontend/src/pages/result/maintenance/index.vue create mode 100644 frontend/src/pages/result/network-error/index.vue create mode 100644 frontend/src/pages/result/success/index.vue create mode 100644 frontend/src/pages/role-select/index.vue create mode 100644 frontend/src/pages/setup/index.vue create mode 100644 frontend/src/pages/tasks/TaskCenterPage.vue create mode 100644 frontend/src/pages/workbench/WorkbenchActivityCard.vue create mode 100644 frontend/src/pages/workbench/WorkbenchPage.vue create mode 100644 frontend/src/pages/workbench/archive.vue create mode 100644 frontend/src/pages/workbench/finance.vue create mode 100644 frontend/src/pages/workbench/project.vue create mode 100644 frontend/src/pages/workbench/workbench-navigation.ts create mode 100644 frontend/src/permission.ts create mode 100644 frontend/src/router/index.ts create mode 100644 frontend/src/router/modules/finance.ts create mode 100644 frontend/src/router/modules/result.ts create mode 100644 frontend/src/router/modules/system.ts create mode 100644 frontend/src/router/route-contract.ts create mode 100644 frontend/src/store/index.ts create mode 100644 frontend/src/store/modules/menu-access.ts create mode 100644 frontend/src/store/modules/notification.ts create mode 100644 frontend/src/store/modules/permission-fe.ts create mode 100644 frontend/src/store/modules/permission.ts create mode 100644 frontend/src/store/modules/setting.ts create mode 100644 frontend/src/store/modules/tabs-router.ts create mode 100644 frontend/src/store/modules/user.ts create mode 100644 frontend/src/style/font-family.less create mode 100644 frontend/src/style/index.less create mode 100644 frontend/src/style/layout.less create mode 100644 frontend/src/style/reset.less create mode 100644 frontend/src/style/variables.less create mode 100644 frontend/src/types/axios.d.ts create mode 100644 frontend/src/types/env.d.ts create mode 100644 frontend/src/types/globals.d.ts create mode 100644 frontend/src/types/interface.d.ts create mode 100644 frontend/src/types/router.d.ts create mode 100644 frontend/src/utils/charts.ts create mode 100644 frontend/src/utils/color.ts create mode 100644 frontend/src/utils/date.ts create mode 100644 frontend/src/utils/request/Axios.ts create mode 100644 frontend/src/utils/request/AxiosCancel.ts create mode 100644 frontend/src/utils/request/AxiosTransform.ts create mode 100644 frontend/src/utils/request/index.ts create mode 100644 frontend/src/utils/request/utils.ts create mode 100644 frontend/src/utils/route/constant.ts create mode 100644 frontend/stylelint.config.js create mode 100644 frontend/tests/archive-frontend-r4.test.ts create mode 100644 frontend/tests/contracts.test.ts create mode 100644 frontend/tests/forms-projects-workflow.test.ts create mode 100644 frontend/tests/payment-control-exceptions.test.ts create mode 100644 frontend/tests/production-hygiene.test.ts create mode 100644 frontend/tests/receivable-source-correction.test.ts create mode 100644 frontend/tests/reports-governance-frontend.test.ts create mode 100644 frontend/tests/router-menu-contract.test.ts create mode 100644 frontend/tests/super-admin-access.test.ts create mode 100644 frontend/tests/workbench-r3.test.ts create mode 100644 frontend/tsconfig.json create mode 100644 frontend/vite.config.ts create mode 100644 openapi.yaml create mode 100755 scripts/check-openapi.sh create mode 100755 scripts/check-semver.sh create mode 100755 scripts/export-openapi.sh create mode 100755 scripts/generate-release-key.sh create mode 100755 scripts/package-release.sh create mode 100755 scripts/publish-gitea-release.sh create mode 100755 scripts/test-git-install-fixture.sh create mode 100755 scripts/test-gitea-publish-fixture.sh create mode 100755 scripts/test-install-fixture.sh create mode 100755 scripts/test-update-fixture.sh create mode 100755 scripts/verify-release.sh diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..40cf554 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +backend/mvnw.cmd text eol=crlf whitespace=-trailing-space +frontend/CHANGELOG.md text whitespace=-trailing-space diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..4d01a0a --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,116 @@ +name: Release + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Validate release tag + id: release_meta + env: + GITEA_REF_NAME: ${{ github.ref_name }} + GITEA_SHA: ${{ github.sha }} + run: | + VERSION=${GITEA_REF_NAME#v} + test "$GITEA_REF_NAME" = "v$VERSION" + ./scripts/check-semver.sh "$VERSION" + test "$(git rev-parse "refs/tags/$GITEA_REF_NAME^{commit}")" = "$GITEA_SHA" + test -z "$(git status --porcelain --untracked-files=all)" + printf 'version=%s\n' "$VERSION" >> "$GITHUB_OUTPUT" + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + cache: maven + + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Verify backend + env: + RELEASE_VERSION: ${{ steps.release_meta.outputs.version }} + run: ./backend/mvnw -f backend/pom.xml -Drevision="$RELEASE_VERSION" test + + - name: Verify frontend + working-directory: frontend + run: | + HUSKY=0 npm ci + npm run lint -- --no-fix + npm run stylelint + npm test + npm run audit:dependencies + npm run build + npx playwright install --with-deps chromium + npm run test:e2e + + - name: Verify release contracts and scripts + run: | + ./scripts/check-openapi.sh openapi.yaml + ./scripts/test-install-fixture.sh + ./scripts/test-git-install-fixture.sh + ./scripts/test-update-fixture.sh + ./scripts/test-gitea-publish-fixture.sh + shellcheck deploy/install.sh deploy/install-from-git.sh deploy/update.sh scripts/check-semver.sh \ + scripts/package-release.sh scripts/publish-gitea-release.sh \ + scripts/generate-release-key.sh scripts/test-install-fixture.sh \ + scripts/test-git-install-fixture.sh \ + scripts/test-update-fixture.sh scripts/test-gitea-publish-fixture.sh \ + scripts/verify-release.sh + + - name: Build and sign release assets + env: + RELEASE_SIGNING_KEY_B64: ${{ secrets.RELEASE_SIGNING_KEY_B64 }} + RELEASE_VERSION: ${{ steps.release_meta.outputs.version }} + KAIDI_TRUSTED_RELEASE_PUBLIC_KEY_SHA256: ${{ vars.KAIDI_RELEASE_PUBLIC_KEY_SHA256 }} + KAIDI_RELEASE_NOTES: Kaidi Finance ${{ github.ref_name }} + KAIDI_SOURCE_REVISION: ${{ github.sha }} + KAIDI_SOURCE_REF: ${{ github.ref_name }} + KAIDI_SOURCE_DIRTY: 'false' + run: | + test -n "$RELEASE_SIGNING_KEY_B64" + trap 'shred -u "$RUNNER_TEMP/release-key.pem" 2>/dev/null || rm -f "$RUNNER_TEMP/release-key.pem"' EXIT + printf '%s' "$RELEASE_SIGNING_KEY_B64" | base64 --decode > "$RUNNER_TEMP/release-key.pem" + chmod 600 "$RUNNER_TEMP/release-key.pem" + KAIDI_RELEASE_SIGNING_KEY="$RUNNER_TEMP/release-key.pem" \ + ./scripts/package-release.sh "$RELEASE_VERSION" + + - name: Verify signed release assets + env: + KAIDI_TRUSTED_RELEASE_PUBLIC_KEY_SHA256: ${{ vars.KAIDI_RELEASE_PUBLIC_KEY_SHA256 }} + KAIDI_EXPECTED_SOURCE_REVISION: ${{ github.sha }} + KAIDI_EXPECTED_SOURCE_REF: ${{ github.ref_name }} + KAIDI_EXPECTED_SOURCE_DIRTY: 'false' + run: ./scripts/verify-release.sh dist/release + + - name: Publish Gitea release + env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + GITEA_SERVER_URL: ${{ github.server_url }} + GITEA_REPOSITORY: ${{ github.repository }} + GITEA_REF_NAME: ${{ github.ref_name }} + GITEA_SHA: ${{ github.sha }} + run: ./scripts/publish-gitea-release.sh + + - name: Upload Playwright report after failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-report-${{ github.run_id }} + path: frontend/playwright-report + if-no-files-found: ignore + retention-days: 7 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5c75742 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +.DS_Store +backend/target/ +frontend/dist/ +frontend/node_modules/ +frontend/playwright-report/ +frontend/test-results/ +.idea/ +.vscode/ +*.iml +*.log +.env.local +runtime/ +/dist/ +test-results/ +*signing-private.pem diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..bb3e396 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,32 @@ +# 项目执行约束 + +## 禁止空跑 + +- 禁止执行 `true`、`:`、无内容的 `echo` / `printf`、仅用于占位的 `sleep`,以及其他不产生验证证据或项目进展的命令。 +- 禁止空 `functions.exec` 脚本、仅含注释的脚本(如 `// placeholder`)、只输出空白的调用(如 `text(" ")`),以及只为制造一条工具记录而输出“正在委派”等状态文字的调用。 +- `functions.exec`、`exec_command` 及 shell 不能用作子代理编排的心跳、轮次衔接、保活或等待屏障;编排状态使用对话更新,等待只使用对应的 `wait_agent` / `wait` / `write_stdin`。 +- 禁止为了等待子代理、工具或长任务而插入占位命令。等待运行中命令必须使用对应会话的 `write_stdin` / `wait`;等待子代理必须使用代理状态或等待工具。 +- 每次命令调用前必须能明确回答:要验证什么、预期得到什么证据、结果将影响哪个下一步。回答不出来就不执行。 +- 对成功时本来静默的有效检查,必须显式输出结论,例如 `git diff --check && printf 'diff-check: PASS\n'`,避免把“检查通过”与“无意义空跑”混在一起。 + +## 截止与接管 + +- 同一任务连续两次没有新增证据、文件变化、错误定位或可执行结论时,停止继续轮询,由主代理立即接管。 +- 子代理等待以最多 120 秒为一个时间片。单个子代理累计运行 10 分钟仍未交付时,主代理必须只检查一次现有状态:有部分证据就接收后终止,无证据就直接终止,并由主代理接管或拆成更小任务;不得继续盲等。 +- 同一子代理连续两个等待时间片没有任何新增信息时,不等满 10 分钟,立即执行上述接管规则。状态检查本身不算项目进展,禁止用更多状态检查延长截止时间。 +- 一旦出现空命令、空工具脚本或空白输出占位,立即停止该轮委派/轮询节奏;不得换成另一种空操作重试,由主代理直接继续实际工作。 +- 长时间运行的构建、测试和服务只跟踪原会话,不另起占位命令。30 秒以上无新输出时只报告当前阶段;确认卡死后终止并诊断。 +- 同一失败命令最多原样重试一次。再次失败时必须改变诊断手段、缩小范围或由主代理直接处理。 +- 所有截止均由工具自身的超时参数或对应等待工具实现,不得用 `true`、空输出、`sleep` 或新建无关命令模拟截止。 + +## 有效进展标准 + +以下至少满足一项,才算一次有效执行: + +- 读取到完成当前判断所需的文件或运行状态; +- 产生明确的测试、构建、Lint、类型检查、迁移或接口结果; +- 定位到具体错误、文件、行号或根因; +- 完成文件修改并得到针对性验证; +- 获得子代理可核验的结论或明确的阻断条件。 + +状态更新只报告上述有效进展,不报告占位、空轮询或没有信息增量的动作。 diff --git a/README.md b/README.md index cbdd4f5..bd9c549 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,282 @@ -# Kaifi +# Kaidi 财务项目基础系统 -ERP 团队项目。 +本仓库当前处于 R1 开发执行阶段,需求基线见下方唯一总方案。 + +唯一需求、开发、测试和交付基线: + +- [财务项目基础系统开发交付总方案](docs/财务系统开发交付总方案.md) ## 本地开发 -项目环境与启动方式将在代码接入后补充。 +后端默认连接本机 MySQL `127.0.0.1:3307`,启动 Java 服务: + +```bash +cd backend +./mvnw spring-boot:run +``` + +前端使用官方 TDesign Vue Next Starter,来源记录见 [`frontend/UPSTREAM.md`](frontend/UPSTREAM.md)。 + +```bash +cd frontend +HUSKY=0 npm ci +npm run dev:linux -- --host 0.0.0.0 --port 3002 +``` + +访问: + +本地 `local` profile 会创建 `admin`、`project`、`finance`、`archive`、`demo` 演示账号,初始密码均为 +`LocalOnly@123`;该固定密码只用于本机开发,生产安装会生成随机管理员密码。 + +正式构建: + +```bash +cd frontend +npm run build +``` + +后端运行后更新唯一 OpenAPI 机器制品: + +```bash +./scripts/export-openapi.sh http://127.0.0.1:18080 +``` + +## R1 Preview 一键安装 + +Release 发布后,在 Linux 服务器执行下面一组命令即可安装。代码仓库和更新源固定为私有 Gitea +`https://git.awaioi.com/ERP-Team/kaidi`;服务器读取 +`https://git.awaioi.com/api/v1/repos/ERP-Team/kaidi/releases/latest`,不在生产机执行 `git pull` 或现场编译。 +先从受信任的 CI 输出或内部发布记录取得 `RELEASE_TAG`、`INSTALLER_SHA256` 和 `PUBLIC_KEY_SHA256`。 +后两项也会写入 Release 的 `bootstrap-checksums.txt`,但首次安装必须通过独立渠道核对,不能把同源下载值 +直接当作信任根。私有仓库 Token 只授予仓库/Release 读取权限,不得复用 CI 的发布写 Token。 + +执行命令的机器需预装 `bash`、`sudo`、`curl`、`mktemp` 和 `sha256sum`,并能访问目标 Gitea;`jq`、Java、 +Nginx 和数据库客户端由安装器补齐。应用固定安装到 `/opt/kaidi`、`/var/lib/kaidi`、 +`/var/lib/kaidi-update` 和 `/etc/kaidi`。目标机应为专用主机,或确认现有 Nginx 默认站点可以被替换且 +80 端口可用;安装器会接管默认 HTTP 站点。 + +### 直接 curl 安装 + +```bash +( + set -Eeuo pipefail + RELEASE_TAG=v1.0.0-preview.8 + INSTALLER_SHA256=33d4921bcad7ef2be12f20bc0512a5f6df4227ea6dd3b7e6225164077d9cd9e7 + PUBLIC_KEY_SHA256=807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9 + REINSTALL=false + SETUP_WIZARD=true + read -rsp 'Gitea 只读 Token: ' GITEA_READ_TOKEN; printf '\n' + token_file=$(mktemp); header_file=$(mktemp); installer=$(mktemp) + trap 'rm -f "$token_file" "$header_file" "$installer"' EXIT + printf '%s' "$GITEA_READ_TOKEN" > "$token_file" + printf 'Authorization: token %s\n' "$GITEA_READ_TOKEN" > "$header_file" + unset GITEA_READ_TOKEN + chmod 0600 "$token_file" "$header_file" "$installer" + curl --fail --silent --show-error --proto '=https' --tlsv1.2 \ + --header "@$header_file" \ + "https://git.awaioi.com/ERP-Team/kaidi/releases/download/$RELEASE_TAG/install.sh" \ + -o "$installer" + printf '%s %s\n' "$INSTALLER_SHA256" "$installer" | sha256sum -c - + sudo env \ + KAIDI_RELEASE_API_URL=https://git.awaioi.com/api/v1/repos/ERP-Team/kaidi/releases/latest \ + KAIDI_RELEASE_TOKEN_FILE="$token_file" \ + KAIDI_RELEASE_PUBLIC_KEY_SHA256="$PUBLIC_KEY_SHA256" \ + KAIDI_REINSTALL="$REINSTALL" \ + KAIDI_SETUP_WIZARD="$SETUP_WIZARD" \ + bash "$installer" +) +``` + +### 从固定 Git tag 拉取后安装 + +需要保留源码快照时,可以拉取与 Release 对应的固定 tag,再运行仓库内包装器。私有仓库会在 `git clone` +时要求 Gitea 凭据,包装器随后单独要求只读 Release Token;它会在 `sudo` 前校验 `deploy/install.sh` +的固定 SHA-256,再按同一公钥信任链安装最新签名 Release。 + +```bash +git clone --branch v1.0.0-preview.8 --depth 1 https://git.awaioi.com/ERP-Team/kaidi.git kaidi-preview +cd kaidi-preview +KAIDI_SETUP_WIZARD=true ./deploy/install-from-git.sh +``` + +首次安装使用向导时不要传 `KAIDI_DB_URL`、`KAIDI_DB_USERNAME` 或 `KAIDI_DB_PASSWORD`;这些值在浏览器中填写。 +只有已完成安装的修复性重装才从运行时配置读取数据库值,详见下文。 + +```bash +KAIDI_DB_URL='jdbc:mysql://MYSQL_HOST:3306/kaidi_finance?useUnicode=true&characterEncoding=utf8&connectionTimeZone=UTC&serverTimezone=UTC' \ +KAIDI_DB_USERNAME=kaidi KAIDI_DB_PASSWORD='DB_PASSWORD' ./deploy/install-from-git.sh +``` + +安装器会完成以下动作: + +- 仅在 Linux + systemd 环境执行;首版 32 位支持基线为带 systemd 的 Debian/Ubuntu x86 32 位 Linux。 +- 识别 `x86_64`、`aarch64`、`armv7` 或 32 位 `i386/i486/i586/i686`,校验 SHA-256 后安装对应的 Azul Java 17 JRE。 +- 使用安装命令固定的 SHA-256 指纹校验 Release 公钥,再用该公钥验证发布清单 RSA 签名。 +- 首次安装默认启用 `/setup` 向导,不在命令行保存数据库密码;向导只接受 MySQL 8.4.x,并在提交前验证 DDL/DML 权限。 +- 安装签名 Release 到 `/opt/kaidi/releases/`,以 `/opt/kaidi/current` 原子切换当前版本。 +- 安装 Nginx、`kaidi-finance.service`、更新监听服务和健康检查。 +- 向导只初始化一个由操作者填写的 `SYSTEM_ADMIN` 管理员,不创建项目、财务、资料或演示账号。 +- 安装器把一次性安装码写入仅 root 可读的 `/root/kaidi-first-login.txt`;完成向导后写入锁定标记并切换正式应用。 + +安装完成后先执行 `sudo cat /root/kaidi-first-login.txt`,访问其中的 `/setup` 地址完成数据库和管理员配置;完成后再访问 +`http://SERVER_IP/` 登录。Preview 使用 HTTP 时安装器默认设置 +`SESSION_COOKIE_SECURE=false`;配置 HTTPS 反向代理后,应在 `/etc/kaidi/kaidi.env` 改为 +`SESSION_COOKIE_SECURE=true` 并执行 `sudo systemctl restart kaidi-finance`。 + +安装后执行以下命令确认应用、反向代理和首次登录信息: + +```bash +curl -fsS http://127.0.0.1/actuator/health | jq -e '.status == "UP"' +sudo systemctl --no-pager --full status kaidi-finance kaidi-update.path +sudo cat /root/kaidi-first-login.txt +``` + +### Linux 32 位 + +应用已按 Java 17 字节码构建,安装器会在 32 位 Linux 下载 `i686` JRE。MySQL 8.4 没有可用于该部署方式的 +32 位服务端镜像,因此 32 位主机需要预先连接一台 MySQL 8.4 数据库,之后仍然只执行一个安装命令: + +```bash +( + set -Eeuo pipefail + RELEASE_TAG=v1.0.0-preview.8; INSTALLER_SHA256=33d4921bcad7ef2be12f20bc0512a5f6df4227ea6dd3b7e6225164077d9cd9e7; PUBLIC_KEY_SHA256=807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9 + read -rsp 'Gitea 只读 Token: ' GITEA_READ_TOKEN; printf '\n' + token_file=$(mktemp); header_file=$(mktemp); installer=$(mktemp) + trap 'rm -f "$token_file" "$header_file" "$installer"' EXIT + printf '%s' "$GITEA_READ_TOKEN" > "$token_file" + printf 'Authorization: token %s\n' "$GITEA_READ_TOKEN" > "$header_file" + unset GITEA_READ_TOKEN; chmod 0600 "$token_file" "$header_file" "$installer" + curl --fail --silent --show-error --proto '=https' --tlsv1.2 --header "@$header_file" \ + "https://git.awaioi.com/ERP-Team/kaidi/releases/download/$RELEASE_TAG/install.sh" -o "$installer" + printf '%s %s\n' "$INSTALLER_SHA256" "$installer" | sha256sum -c - + sudo env KAIDI_RELEASE_TOKEN_FILE="$token_file" KAIDI_RELEASE_PUBLIC_KEY_SHA256="$PUBLIC_KEY_SHA256" \ + KAIDI_SETUP_WIZARD=true bash "$installer" +) +``` + +32 位主机不能运行安装器自动创建的 MySQL 容器,因此在打开向导前,需要预先创建 `kaidi_finance`,并授予安装账号该库的 +DDL、DML 权限。向导会连接数据库、核验 MySQL 8.4.x 版本并用临时表验证权限,全部通过后 Flyway 才会建表。 +数据库管理员可在 MySQL 8.4 中按实际应用服务器地址执行以下基线 SQL: + +```sql +CREATE DATABASE kaidi_finance CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; +CREATE USER 'kaidi'@'KAIDI_SERVER_IP' IDENTIFIED BY 'DB_PASSWORD'; +GRANT ALL PRIVILEGES ON kaidi_finance.* TO 'kaidi'@'KAIDI_SERVER_IP'; +``` + +### 修复性重装 + +正常升级统一使用后台“在线更新”。只有安装文件损坏且后台更新不可用时,才在原服务器执行修复性重装; +安装器会优先读取向导完成后生成的 `/var/lib/kaidi/setup/application.env`,再回退到 `/etc/kaidi/kaidi.env`,保留数据库、字段加密密钥、 +管理员数据和运维人员新增的环境变量: + +```bash +# 使用上方同一安装命令,把 REINSTALL=false 改成 REINSTALL=true。 +``` + +重装失败会恢复原应用链接、Java 运行时、环境文件、systemd 单元和 Nginx 配置;脚本会明确报告回滚不完整, +不会把恢复失败吞掉。 + +如果安装器已完成但向导尚未提交,可使用同一命令同时设置 `REINSTALL=true` 和 `KAIDI_SETUP_WIZARD=true` 重新生成一次性安装码; +该恢复路径只接受仍处于向导模式且未锁定的安装,正式模式不会被覆盖。 + +### 停用并移除程序 + +以下命令移除应用程序和服务,但保留 `/var/lib/kaidi`、`/var/lib/kaidi-update`、`/etc/kaidi` 以及数据库, +便于审计、备份或重新安装。确认数据备份前不要删除这些保留目录或 MySQL 数据卷。 +安装器已经删除原 Nginx 默认站点;停用后需按该主机原有配置恢复或另行创建默认站点。 + +```bash +sudo systemctl disable --now kaidi-update.path kaidi-update.service kaidi-finance.service +sudo rm -f /etc/systemd/system/kaidi-finance.service /etc/systemd/system/kaidi-update.service /etc/systemd/system/kaidi-update.path +sudo rm -f /etc/nginx/conf.d/kaidi-finance.conf +sudo systemctl daemon-reload +sudo nginx -t && sudo systemctl reload nginx +sudo rm -rf /opt/kaidi +``` + +## Release 与在线更新 + +首次建立发布仓库时生成一次签名密钥: + +```bash +./scripts/generate-release-key.sh release-signing-private.pem +base64 < release-signing-private.pem | tr -d '\n' +``` + +将私钥的 Base64 内容保存为 Gitea Actions Secret `RELEASE_SIGNING_KEY_B64`,并将命令输出的公钥 SHA-256 +保存为 Gitea Actions Variable `KAIDI_RELEASE_PUBLIC_KEY_SHA256`。Gitea 内建 `GITEA_TOKEN` 只用于该工作流创建 +Release;生产服务器使用另一个只读 Token。私钥不得提交到 Git。`.gitea/workflows/release.yml` 要求 +act_runner 提供 `ubuntu-24.04` 标签,并在 tag 发布时执行后端、前端、OpenAPI、Shell、安装/更新和浏览器门禁。 + +工作流先建立不可见草稿,再显式上传并核对 9 个资产的名称和大小,最后才发布为 `/releases/latest`。Gitea 的 +`latest` 会排除 `prerelease=true`,因此即使 tag 名含 `preview`,发布记录的 `prerelease` 也固定为 `false`; +Preview 属性由 SemVer 版本名表达。之后推送 tag 即会构建、测试、签名并发布: + +```bash +git tag v1.0.0-preview.8 +git push origin v1.0.0-preview.8 +``` + +在线更新使用独立的 TDesign 页面:隔离的系统管理员进入“系统治理 → 系统更新”。权限与配置页只管理用户、角色、数据范围、表单模板和参数版本,不配置系统名称或域名。 +更新源由 root 在 `/etc/kaidi/update.env` 固定为私有 Gitea Latest Release API;页面和普通 API 都看不到 Token, +也不能提交 URL、脚本或命令。更新流程固定为: + +1. 点击“获取更新”,后端先读取 Release 元数据,再自动排队 `DOWNLOAD`;root 更新器从 Gitea 下载 manifest、签名和应用包,执行 RSA、 + SHA-256、版本、文件名和压缩包路径校验后缓存到 `/var/lib/kaidi-update/cache/`。业务服务不停机。 +3. 页面显示 `READY/等待重启` 后才出现“立即重启”;管理员点击后提交安装。未缓存或版本不一致 + 的包不能进入安装。 +4. 安装请求持久领取到 `/var/lib/kaidi-update/processing`;进程或主机中断后由 systemd 恢复未完成事务。 +5. root 更新器重新验签和验哈希,确认 `mysqldump` 成功并生成权限为 `0600` 的备份,默认保留最近 5 份。 +6. 校验更新脚本和 systemd 单元后,原子切换 updater、systemd、Nginx 和应用版本。 +7. 同时检查后端直连、Nginx 健康端点、更新 path unit 和静态首页。全部通过后页面显示 10 秒倒计时并自动 + 刷新;刷新或短暂断线发生在安装中时,页面会恢复 3 秒轮询。任一检查失败则恢复并验证上一版本。 + +忙碌期间检查、下载和安装按钮保持禁用,防止重复请求;这就是更新执行冷却。10 秒只用于成功后的页面刷新, +不会延迟服务端切换。systemd 在 300 秒内连续失败 3 次后停止自动重试,避免失败任务空跑。 + +安装器会把 API 地址和只读 Token 同步写入 root-only 的 `/etc/kaidi/kaidi.env` 与 +`/etc/kaidi/update.env`,两者权限均为 `0600`:前者供 Java 后端“检查更新”读取,后者供 root 更新器下载资产。 +轮换 Token 时必须同时更新两个文件,再执行 `sudo systemctl restart kaidi-finance`;Token 不写入页面、状态 JSON、 +审计参数或更新日志。 + +数据库迁移必须保持至少一个版本的向后兼容。查看状态和日志: + +```bash +sudo systemctl status kaidi-finance kaidi-update.path +sudo journalctl -u kaidi-update.service -n 100 --no-pager +cat /var/lib/kaidi-update/status.json +``` + +如果 `status.json` 显示 `FAILED` 且日志提示回滚未完成,不要删除 +`/var/lib/kaidi-update/processing/request.json` 或活动事务目录。systemd 在 300 秒内连续失败 3 次后会停止自动重试, +修复日志所示的磁盘、权限、Nginx 或旧版本健康问题后执行: + +```bash +sudo systemctl reset-failed kaidi-update.service kaidi-update.path +sudo systemctl start kaidi-update.service +sudo journalctl -u kaidi-update.service -n 100 --no-pager +cat /var/lib/kaidi-update/status.json +``` + +只有状态恢复为 `SUCCEEDED`、`CURRENT` 或确定性的终态 `FAILED`,且 `transactions/active` 已处理完成后, +才算本次恢复结束。后台会保留真实失败状态,不会把待恢复的 processing 请求误显示成普通排队。 + +## 手工生成 Release + +```bash +KAIDI_RELEASE_SIGNING_KEY=/secure/release-signing-private.pem \ +KAIDI_TRUSTED_RELEASE_PUBLIC_KEY_SHA256=807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9 \ + ./scripts/package-release.sh 1.0.0-preview.8 +KAIDI_TRUSTED_RELEASE_PUBLIC_KEY_SHA256=807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9 \ + ./scripts/verify-release.sh dist/release +``` + +输出位于 `dist/release/`,包含安装脚本、签名清单、公钥、SHA-256 清单、前后端 CycloneDX SBOM、 +首次安装指纹文件和完整应用压缩包。 +打包脚本会把 Maven `revision` 与 npm 包版本临时绑定到 Release SemVer,构建结束后恢复工作区源文件; +JAR、两个 SBOM、签名清单或 tag 的版本只要有一项不一致,发布即失败。 +签名清单同时绑定应用包、两份 SBOM、安装器、公钥、bootstrap 指纹和 Git 源码修订;本地脏工作区会明确记录 +`source.dirty=true`,tag 工作流只接受干净 checkout 并记录 `source.dirty=false`。 +发布命令同时在终端输出 `Trusted release public-key SHA-256` 与 `Installer SHA-256`;把这两个值写入 +受控部署记录,再替换上述一键安装命令中的占位符。 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..2398280 --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,12 @@ +# Third-Party Notices + +## TDesign Vue Next Starter + +- Project: `Tencent/tdesign-vue-next-starter` +- Source: https://github.com/Tencent/tdesign-vue-next-starter +- Baseline commit: `1f183fa089d07183235dc69dbd76b8b2c4a6d8bb` +- Imported: 2026-08-07 +- License: MIT +- Local license file: `frontend/LICENSE` + +The upstream source is used as the frontend application starter and will be modified for this project's authentication, authorization, workflows, pages, and deployment requirements. diff --git a/backend/.mvn/wrapper/maven-wrapper.properties b/backend/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 0000000..216df05 --- /dev/null +++ b/backend/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,3 @@ +wrapperVersion=3.3.4 +distributionType=only-script +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.16/apache-maven-3.9.16-bin.zip diff --git a/backend/mvnw b/backend/mvnw new file mode 100755 index 0000000..bd8896b --- /dev/null +++ b/backend/mvnw @@ -0,0 +1,295 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Apache Maven Wrapper startup batch script, version 3.3.4 +# +# Optional ENV vars +# ----------------- +# JAVA_HOME - location of a JDK home dir, required when download maven via java source +# MVNW_REPOURL - repo url base for downloading maven distribution +# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output +# ---------------------------------------------------------------------------- + +set -euf +[ "${MVNW_VERBOSE-}" != debug ] || set -x + +# OS specific support. +native_path() { printf %s\\n "$1"; } +case "$(uname)" in +CYGWIN* | MINGW*) + [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")" + native_path() { cygpath --path --windows "$1"; } + ;; +esac + +# set JAVACMD and JAVACCMD +set_java_home() { + # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched + if [ -n "${JAVA_HOME-}" ]; then + if [ -x "$JAVA_HOME/jre/sh/java" ]; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACCMD="$JAVA_HOME/jre/sh/javac" + else + JAVACMD="$JAVA_HOME/bin/java" + JAVACCMD="$JAVA_HOME/bin/javac" + + if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then + echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2 + echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2 + return 1 + fi + fi + else + JAVACMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v java + )" || : + JAVACCMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v javac + )" || : + + if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then + echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2 + return 1 + fi + fi +} + +# hash string like Java String::hashCode +hash_string() { + str="${1:-}" h=0 + while [ -n "$str" ]; do + char="${str%"${str#?}"}" + h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296)) + str="${str#?}" + done + printf %x\\n $h +} + +verbose() { :; } +[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; } + +die() { + printf %s\\n "$1" >&2 + exit 1 +} + +trim() { + # MWRAPPER-139: + # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds. + # Needed for removing poorly interpreted newline sequences when running in more + # exotic environments such as mingw bash on Windows. + printf "%s" "${1}" | tr -d '[:space:]' +} + +scriptDir="$(dirname "$0")" +scriptName="$(basename "$0")" + +# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties +while IFS="=" read -r key value; do + case "${key-}" in + distributionUrl) distributionUrl=$(trim "${value-}") ;; + distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;; + esac +done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties" +[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" + +case "${distributionUrl##*/}" in +maven-mvnd-*bin.*) + MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ + case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in + *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;; + :Darwin*x86_64) distributionPlatform=darwin-amd64 ;; + :Darwin*arm64) distributionPlatform=darwin-aarch64 ;; + :Linux*x86_64*) distributionPlatform=linux-amd64 ;; + *) + echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2 + distributionPlatform=linux-amd64 + ;; + esac + distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip" + ;; +maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;; +*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;; +esac + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}" +distributionUrlName="${distributionUrl##*/}" +distributionUrlNameMain="${distributionUrlName%.*}" +distributionUrlNameMain="${distributionUrlNameMain%-bin}" +MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}" +MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")" + +exec_maven() { + unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || : + exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD" +} + +if [ -d "$MAVEN_HOME" ]; then + verbose "found existing MAVEN_HOME at $MAVEN_HOME" + exec_maven "$@" +fi + +case "${distributionUrl-}" in +*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;; +*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;; +esac + +# prepare tmp dir +if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then + clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; } + trap clean HUP INT TERM EXIT +else + die "cannot create temp dir" +fi + +mkdir -p -- "${MAVEN_HOME%/*}" + +# Download and Install Apache Maven +verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +verbose "Downloading from: $distributionUrl" +verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +# select .zip or .tar.gz +if ! command -v unzip >/dev/null; then + distributionUrl="${distributionUrl%.zip}.tar.gz" + distributionUrlName="${distributionUrl##*/}" +fi + +# verbose opt +__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR='' +[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v + +# normalize http auth +case "${MVNW_PASSWORD:+has-password}" in +'') MVNW_USERNAME='' MVNW_PASSWORD='' ;; +has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;; +esac + +if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then + verbose "Found wget ... using wget" + wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl" +elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then + verbose "Found curl ... using curl" + curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl" +elif set_java_home; then + verbose "Falling back to use Java to download" + javaSource="$TMP_DOWNLOAD_DIR/Downloader.java" + targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName" + cat >"$javaSource" <<-END + public class Downloader extends java.net.Authenticator + { + protected java.net.PasswordAuthentication getPasswordAuthentication() + { + return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() ); + } + public static void main( String[] args ) throws Exception + { + setDefault( new Downloader() ); + java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() ); + } + } + END + # For Cygwin/MinGW, switch paths to Windows format before running javac and java + verbose " - Compiling Downloader.java ..." + "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java" + verbose " - Running Downloader.java ..." + "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")" +fi + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +if [ -n "${distributionSha256Sum-}" ]; then + distributionSha256Result=false + if [ "$MVN_CMD" = mvnd.sh ]; then + echo "Checksum validation is not supported for maven-mvnd." >&2 + echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + elif command -v sha256sum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then + distributionSha256Result=true + fi + elif command -v shasum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then + distributionSha256Result=true + fi + else + echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2 + echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + fi + if [ $distributionSha256Result = false ]; then + echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2 + echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2 + exit 1 + fi +fi + +# unzip and move +if command -v unzip >/dev/null; then + unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip" +else + tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar" +fi + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +actualDistributionDir="" + +# First try the expected directory name (for regular distributions) +if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then + if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then + actualDistributionDir="$distributionUrlNameMain" + fi +fi + +# If not found, search for any directory with the Maven executable (for snapshots) +if [ -z "$actualDistributionDir" ]; then + # enable globbing to iterate over items + set +f + for dir in "$TMP_DOWNLOAD_DIR"/*; do + if [ -d "$dir" ]; then + if [ -f "$dir/bin/$MVN_CMD" ]; then + actualDistributionDir="$(basename "$dir")" + break + fi + fi + done + set -f +fi + +if [ -z "$actualDistributionDir" ]; then + verbose "Contents of $TMP_DOWNLOAD_DIR:" + verbose "$(ls -la "$TMP_DOWNLOAD_DIR")" + die "Could not find Maven distribution directory in extracted archive" +fi + +verbose "Found extracted Maven distribution directory: $actualDistributionDir" +printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url" +mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" + +clean || : +exec_maven "$@" diff --git a/backend/mvnw.cmd b/backend/mvnw.cmd new file mode 100644 index 0000000..5761d94 --- /dev/null +++ b/backend/mvnw.cmd @@ -0,0 +1,189 @@ +<# : batch portion +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Apache Maven Wrapper startup batch script, version 3.3.4 +@REM +@REM Optional ENV vars +@REM MVNW_REPOURL - repo url base for downloading maven distribution +@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output +@REM ---------------------------------------------------------------------------- + +@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0) +@SET __MVNW_CMD__= +@SET __MVNW_ERROR__= +@SET __MVNW_PSMODULEP_SAVE=%PSModulePath% +@SET PSModulePath= +@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @( + IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B) +) +@SET PSModulePath=%__MVNW_PSMODULEP_SAVE% +@SET __MVNW_PSMODULEP_SAVE= +@SET __MVNW_ARG0_NAME__= +@SET MVNW_USERNAME= +@SET MVNW_PASSWORD= +@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*) +@echo Cannot start maven from wrapper >&2 && exit /b 1 +@GOTO :EOF +: end batch / begin powershell #> + +$ErrorActionPreference = "Stop" +if ($env:MVNW_VERBOSE -eq "true") { + $VerbosePreference = "Continue" +} + +# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties +$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl +if (!$distributionUrl) { + Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" +} + +switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) { + "maven-mvnd-*" { + $USE_MVND = $true + $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip" + $MVN_CMD = "mvnd.cmd" + break + } + default { + $USE_MVND = $false + $MVN_CMD = $script -replace '^mvnw','mvn' + break + } +} + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +if ($env:MVNW_REPOURL) { + $MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" } + $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')" +} +$distributionUrlName = $distributionUrl -replace '^.*/','' +$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$','' + +$MAVEN_M2_PATH = "$HOME/.m2" +if ($env:MAVEN_USER_HOME) { + $MAVEN_M2_PATH = "$env:MAVEN_USER_HOME" +} + +if (-not (Test-Path -Path $MAVEN_M2_PATH)) { + New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null +} + +$MAVEN_WRAPPER_DISTS = $null +if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) { + $MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists" +} else { + $MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists" +} + +$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain" +$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' +$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME" + +if (Test-Path -Path "$MAVEN_HOME" -PathType Container) { + Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME" + Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" + exit $? +} + +if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) { + Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl" +} + +# prepare tmp dir +$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile +$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir" +$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null +trap { + if ($TMP_DOWNLOAD_DIR.Exists) { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } + } +} + +New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null + +# Download and Install Apache Maven +Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +Write-Verbose "Downloading from: $distributionUrl" +Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +$webclient = New-Object System.Net.WebClient +if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) { + $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD) +} +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 +$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum +if ($distributionSha256Sum) { + if ($USE_MVND) { + Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." + } + Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash + if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) { + Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property." + } +} + +# unzip and move +Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +$actualDistributionDir = "" + +# First try the expected directory name (for regular distributions) +$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain" +$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD" +if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) { + $actualDistributionDir = $distributionUrlNameMain +} + +# If not found, search for any directory with the Maven executable (for snapshots) +if (!$actualDistributionDir) { + Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object { + $testPath = Join-Path $_.FullName "bin/$MVN_CMD" + if (Test-Path -Path $testPath -PathType Leaf) { + $actualDistributionDir = $_.Name + } + } +} + +if (!$actualDistributionDir) { + Write-Error "Could not find Maven distribution directory in extracted archive" +} + +Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir" +Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null +try { + Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null +} catch { + if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) { + Write-Error "fail to move MAVEN_HOME" + } +} finally { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } +} + +Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" diff --git a/backend/pom.xml b/backend/pom.xml new file mode 100644 index 0000000..519450f --- /dev/null +++ b/backend/pom.xml @@ -0,0 +1,186 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.5.16 + + + + com.kaidi + finance-system + ${revision} + Kaidi Finance System + Project finance workflow, accounting preparation and archive system + + + 1.0.0-SNAPSHOT + com.kaidi.finance.FinanceApplication + 17 + 3.0.5 + 11.20.3 + 2.8.14 + 1.21.4 + 1.4.1 + 1.80 + 3.0.6 + 2.9.3 + + + + + + org.testcontainers + testcontainers-bom + ${testcontainers.version} + pom + import + + + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-security + + + org.springframework.session + spring-session-jdbc + + + org.springframework.boot + spring-boot-starter-actuator + + + org.mybatis.spring.boot + mybatis-spring-boot-starter + ${mybatis-spring-boot.version} + + + org.flywaydb + flyway-core + + + org.flywaydb + flyway-mysql + + + com.mysql + mysql-connector-j + runtime + + + org.bouncycastle + bcprov-jdk18on + ${bouncycastle.version} + + + org.apache.pdfbox + pdfbox + ${pdfbox.version} + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + ${springdoc.version} + + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.security + spring-security-test + test + + + org.testcontainers + junit-jupiter + test + + + org.testcontainers + mysql + test + + + com.tngtech.archunit + archunit-junit5 + ${archunit.version} + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + org.apache.maven.plugins + maven-enforcer-plugin + 3.6.2 + + + enforce-java + enforce + + + [17,22) + + + + + + + org.jacoco + jacoco-maven-plugin + 0.8.13 + + prepare-agent + reportverifyreport + + + + org.cyclonedx + cyclonedx-maven-plugin + ${cyclonedx.version} + + + make-runtime-sbom + package + makeAggregateBom + + application + 1.6 + true + true + true + true + true + false + json + backend-sbom + + + + + + + diff --git a/backend/src/main/java/com/kaidi/finance/FinanceApplication.java b/backend/src/main/java/com/kaidi/finance/FinanceApplication.java new file mode 100644 index 0000000..f938809 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/FinanceApplication.java @@ -0,0 +1,36 @@ +package com.kaidi.finance; + +import com.kaidi.finance.iam.application.BootstrapProperties; +import com.kaidi.finance.shared.file.FileScannerProperties; +import com.kaidi.finance.shared.file.FileStorageProperties; +import com.kaidi.finance.update.application.SystemUpdateProperties; +import com.kaidi.setup.SetupApplication; +import java.util.Arrays; +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration; +import org.springframework.boot.context.properties.EnableConfigurationProperties; + +@SpringBootApplication(exclude = UserDetailsServiceAutoConfiguration.class) +@MapperScan(basePackages = "com.kaidi.finance", annotationClass = org.apache.ibatis.annotations.Mapper.class) +@EnableConfigurationProperties({BootstrapProperties.class, FileStorageProperties.class, FileScannerProperties.class, + SystemUpdateProperties.class}) +public class FinanceApplication { + + public static void main(String[] args) { + if (setupModeEnabled(args)) { + SetupApplication.main(args); + return; + } + SpringApplication.run(FinanceApplication.class, args); + } + + private static boolean setupModeEnabled(String[] args) { + if (Boolean.parseBoolean(System.getenv().getOrDefault("FINANCE_SETUP_ENABLED", "false"))) { + return true; + } + return Arrays.stream(args).anyMatch(argument -> "--finance.setup.enabled=true".equals(argument) + || "--FINANCE_SETUP_ENABLED=true".equals(argument)); + } +} diff --git a/backend/src/main/java/com/kaidi/finance/accounting/api/AccountingContracts.java b/backend/src/main/java/com/kaidi/finance/accounting/api/AccountingContracts.java new file mode 100644 index 0000000..e48e8d0 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/accounting/api/AccountingContracts.java @@ -0,0 +1,92 @@ +package com.kaidi.finance.accounting.api; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; + +public final class AccountingContracts { + + private AccountingContracts() { + } + + public record GenerateDraftRequest( + @NotNull Long version, + @NotBlank @Size(max = 64) String ruleVersion + ) { + } + + public record VoucherEntryRequest( + @NotNull Integer lineNo, + @NotBlank @Pattern(regexp = "DEBIT|CREDIT") String direction, + @NotBlank @Size(max = 26) String accountId, + @NotNull @DecimalMin("0.01") BigDecimal amount, + @NotBlank @Size(max = 500) String summary, + @Size(max = 4000) String auxiliaryJson + ) { + } + + public record VoucherUpdateRequest( + @NotNull Long version, + @NotBlank @Pattern(regexp = "\\d{4}-\\d{2}") String period, + @NotNull LocalDate businessDate, + @NotBlank @Size(max = 500) String summary, + @NotEmpty @Size(max = 100) List<@Valid VoucherEntryRequest> entries, + @NotBlank @Size(max = 1000) String changeReason + ) { + } + + public record VersionCommandRequest(@NotNull Long version) { + } + + public record ReturnRequest( + @NotNull Long version, + @NotBlank @Size(max = 1000) String opinion + ) { + } + + public record ResultRequest( + @NotNull Long version, + @NotBlank @Size(max = 100) String externalVoucherNo, + @NotNull LocalDate resultAt, + @NotEmpty @Size(max = 20) List<@NotBlank @Size(max = 26) String> evidenceFileIds, + @Size(max = 1000) String remark + ) { + } + + public record VerifyResultRequest( + @NotNull Long version, + @NotBlank @Size(min = 2, max = 1000) + @Pattern(regexp = "(?s).*\\S.*\\S.*", message = "must contain at least 2 non-whitespace characters") + String opinion + ) { + } + + public record ReverseResultRequest( + @NotNull Long version, + @NotBlank @Size(min = 2, max = 1000) + @Pattern(regexp = "(?s).*\\S.*\\S.*", message = "must contain at least 2 non-whitespace characters") + String reason + ) { + } + + public record ReopenResultRequest( + @NotNull Long version, + @NotBlank @Size(min = 2, max = 1000) + @Pattern(regexp = "(?s).*\\S.*\\S.*", message = "must contain at least 2 non-whitespace characters") + String reason + ) { + } + + public record VoidRequest( + @NotNull Long version, + @NotBlank @Size(max = 1000) String reason + ) { + } +} diff --git a/backend/src/main/java/com/kaidi/finance/accounting/api/AccountingController.java b/backend/src/main/java/com/kaidi/finance/accounting/api/AccountingController.java new file mode 100644 index 0000000..f812c16 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/accounting/api/AccountingController.java @@ -0,0 +1,262 @@ +package com.kaidi.finance.accounting.api; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.kaidi.finance.accounting.api.AccountingContracts.GenerateDraftRequest; +import com.kaidi.finance.accounting.api.AccountingContracts.ResultRequest; +import com.kaidi.finance.accounting.api.AccountingContracts.ReopenResultRequest; +import com.kaidi.finance.accounting.api.AccountingContracts.ReverseResultRequest; +import com.kaidi.finance.accounting.api.AccountingContracts.ReturnRequest; +import com.kaidi.finance.accounting.api.AccountingContracts.VerifyResultRequest; +import com.kaidi.finance.accounting.api.AccountingContracts.VersionCommandRequest; +import com.kaidi.finance.accounting.api.AccountingContracts.VoidRequest; +import com.kaidi.finance.accounting.api.AccountingContracts.VoucherUpdateRequest; +import com.kaidi.finance.accounting.api.AccountingViews.AccountView; +import com.kaidi.finance.accounting.api.AccountingViews.AccountingEventView; +import com.kaidi.finance.accounting.api.AccountingViews.VoucherDetailView; +import com.kaidi.finance.accounting.api.AccountingViews.VoucherExportView; +import com.kaidi.finance.accounting.api.AccountingViews.VoucherView; +import com.kaidi.finance.accounting.application.AccountingApplicationService; +import com.kaidi.finance.shared.api.ApiResponse; +import com.kaidi.finance.shared.api.PageResult; +import com.kaidi.finance.shared.file.FileApplicationService.FileDownload; +import com.kaidi.finance.shared.idempotency.IdempotencyApplicationService; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import java.nio.charset.StandardCharsets; +import java.util.List; +import io.swagger.v3.oas.annotations.Operation; +import org.springframework.core.io.Resource; +import org.springframework.http.ContentDisposition; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/accounting") +public class AccountingController { + + private final AccountingApplicationService service; + private final IdempotencyApplicationService idempotencyService; + + public AccountingController(AccountingApplicationService service, + IdempotencyApplicationService idempotencyService) { + this.service = service; + this.idempotencyService = idempotencyService; + } + + @GetMapping("/accounts") + @Operation(operationId = "listAccountingAccounts", summary = "查询会计科目") + @PreAuthorize("@authorizationService.hasPermission('accounting:voucher:view')") + public ApiResponse> accounts() { + return ApiResponse.ok(service.accounts()); + } + + @GetMapping("/events") + @Operation(operationId = "listAccountingEvents", summary = "查询财务事件") + @PreAuthorize("@authorizationService.hasPermission('accounting:event:view')") + public ApiResponse> events( + @RequestParam(required = false) String period, + @RequestParam(required = false) String projectId, + @RequestParam(required = false) String eventType, + @RequestParam(required = false) String keyword, + @RequestParam(defaultValue = "businessDate,asc") String sort, + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int size) { + PageResult result = service.listEvents(period, projectId, eventType, keyword, sort, + page, size); + return ApiResponse.ok(result.items(), result.meta()); + } + + @GetMapping("/vouchers") + @Operation(operationId = "listVouchers", summary = "查询凭证草稿") + @PreAuthorize("@authorizationService.hasPermission('accounting:voucher:view')") + public ApiResponse> vouchers( + @RequestParam(required = false) String tab, + @RequestParam(required = false) String period, + @RequestParam(required = false) String projectId, + @RequestParam(required = false) String eventType, + @RequestParam(required = false) String status, + @RequestParam(required = false) String externalVoucherNo, + @RequestParam(required = false) String keyword, + @RequestParam(defaultValue = "updatedAt,desc") String sort, + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int size) { + PageResult result = service.listVouchers(tab, period, projectId, eventType, status, + externalVoucherNo, keyword, sort, page, size); + return ApiResponse.ok(result.items(), result.meta()); + } + + @GetMapping("/vouchers/{publicId}") + @Operation(operationId = "getVoucher", summary = "查询凭证详情") + @PreAuthorize("@authorizationService.hasPermission('accounting:voucher:view')") + public ApiResponse detail(@PathVariable String publicId) { + return ApiResponse.ok(service.getVoucher(publicId)); + } + + @PostMapping("/events/{publicId}/generate-draft") + @Operation(operationId = "generateVoucherDraft", summary = "生成凭证草稿") + @PreAuthorize("@authorizationService.hasPermission('accounting:event:generate')") + public ApiResponse generateDraft(@PathVariable String publicId, + @Valid @RequestBody GenerateDraftRequest request, + @RequestHeader(value = "Idempotency-Key", required = false) String key, + HttpServletRequest httpRequest) { + return ApiResponse.ok(command(key, httpRequest, request, new TypeReference() { }, + () -> service.generateDraft(publicId, request))); + } + + @PatchMapping("/vouchers/{publicId}") + @Operation(operationId = "updateVoucher", summary = "修改凭证草稿") + @PreAuthorize("@authorizationService.hasPermission('accounting:voucher:edit')") + public ApiResponse update(@PathVariable String publicId, + @Valid @RequestBody VoucherUpdateRequest request, + @RequestHeader(value = "Idempotency-Key", required = false) String key, + HttpServletRequest httpRequest) { + return ApiResponse.ok(command(key, httpRequest, request, new TypeReference() { }, + () -> service.updateVoucher(publicId, request))); + } + + @PostMapping("/vouchers/{publicId}/submit") + @Operation(operationId = "submitVoucher", summary = "提交凭证复核") + @PreAuthorize("@authorizationService.hasPermission('accounting:voucher:edit')") + public ApiResponse submit(@PathVariable String publicId, + @Valid @RequestBody VersionCommandRequest request, + @RequestHeader(value = "Idempotency-Key", required = false) String key, + HttpServletRequest httpRequest) { + return ApiResponse.ok(command(key, httpRequest, request, new TypeReference() { }, + () -> service.submitVoucher(publicId, request.version()))); + } + + @PostMapping("/vouchers/{publicId}/approve") + @Operation(operationId = "approveVoucher", summary = "复核通过凭证") + @PreAuthorize("@authorizationService.hasPermission('accounting:voucher:review')") + public ApiResponse approve(@PathVariable String publicId, + @Valid @RequestBody VersionCommandRequest request, + @RequestHeader(value = "Idempotency-Key", required = false) String key, + HttpServletRequest httpRequest) { + return ApiResponse.ok(command(key, httpRequest, request, new TypeReference() { }, + () -> service.approveVoucher(publicId, request.version()))); + } + + @PostMapping("/vouchers/{publicId}/return") + @Operation(operationId = "returnVoucher", summary = "退回凭证") + @PreAuthorize("@authorizationService.hasPermission('accounting:voucher:review')") + public ApiResponse returnVoucher(@PathVariable String publicId, + @Valid @RequestBody ReturnRequest request, + @RequestHeader(value = "Idempotency-Key", required = false) String key, + HttpServletRequest httpRequest) { + return ApiResponse.ok(command(key, httpRequest, request, new TypeReference() { }, + () -> service.returnVoucher(publicId, request))); + } + + @PostMapping("/vouchers/{publicId}/export") + @Operation(operationId = "exportVoucher", summary = "导出凭证") + @PreAuthorize("@authorizationService.hasPermission('accounting:voucher:export')") + public ApiResponse export(@PathVariable String publicId, + @Valid @RequestBody VersionCommandRequest request, + @RequestHeader(value = "Idempotency-Key", required = false) String key, + HttpServletRequest httpRequest) { + return ApiResponse.ok(command(key, httpRequest, request, new TypeReference() { }, + () -> service.exportVoucher(publicId, request.version()))); + } + + @GetMapping("/vouchers/{publicId}/export-file") + @Operation(operationId = "downloadVoucherExport", summary = "下载凭证导出文件") + @PreAuthorize("@authorizationService.hasPermission('accounting:voucher:export')") + public ResponseEntity exportFile(@PathVariable String publicId) { + FileDownload download = service.downloadExport(publicId); + return downloadResponse(download); + } + + @GetMapping("/vouchers/{voucherPublicId}/result-files/{filePublicId}/content") + @Operation(operationId = "downloadVoucherResultEvidence", summary = "下载凭证结果附件") + @PreAuthorize("@authorizationService.hasPermission('accounting:voucher:view')") + public ResponseEntity resultEvidenceFile(@PathVariable String voucherPublicId, + @PathVariable String filePublicId) { + FileDownload download = service.downloadResultEvidence(voucherPublicId, filePublicId); + return downloadResponse(download); + } + + private ResponseEntity downloadResponse(FileDownload download) { + var metadata = download.metadata(); + ContentDisposition disposition = ContentDisposition.attachment() + .filename(metadata.getOriginalName(), StandardCharsets.UTF_8).build(); + return ResponseEntity.ok() + .contentType(MediaType.parseMediaType(metadata.getMediaType())) + .contentLength(metadata.getSizeBytes()) + .header(HttpHeaders.CONTENT_DISPOSITION, disposition.toString()) + .header(HttpHeaders.CACHE_CONTROL, "private, no-store") + .header("X-File-SHA256", metadata.getSha256()) + .header("X-Content-Type-Options", "nosniff") + .body(download.resource()); + } + + @PostMapping("/vouchers/{publicId}/record-result") + @Operation(operationId = "recordVoucherResult", summary = "登记人工记账结果") + @PreAuthorize("@authorizationService.hasPermission('accounting:voucher:record-result')") + public ApiResponse recordResult(@PathVariable String publicId, + @Valid @RequestBody ResultRequest request, + @RequestHeader(value = "Idempotency-Key", required = false) String key, + HttpServletRequest httpRequest) { + return ApiResponse.ok(command(key, httpRequest, request, new TypeReference() { }, + () -> service.recordResult(publicId, request))); + } + + @PostMapping("/vouchers/{publicId}/verify-result") + @Operation(operationId = "verifyVoucherResult", summary = "复核人工记账结果") + @PreAuthorize("@authorizationService.hasPermission('accounting:voucher:verify-result')") + public ApiResponse verifyResult(@PathVariable String publicId, + @Valid @RequestBody VerifyResultRequest request, + @RequestHeader(value = "Idempotency-Key", required = false) String key, + HttpServletRequest httpRequest) { + return ApiResponse.ok(command(key, httpRequest, request, new TypeReference() { }, + () -> service.verifyResult(publicId, request))); + } + + @PostMapping("/vouchers/{publicId}/reverse-result") + @Operation(operationId = "reverseVoucherResult", summary = "冲销已复核人工记账结果") + @PreAuthorize("@authorizationService.hasPermission('accounting:voucher:reverse-result')") + public ApiResponse reverseResult(@PathVariable String publicId, + @Valid @RequestBody ReverseResultRequest request, + @RequestHeader(value = "Idempotency-Key", required = false) String key, + HttpServletRequest httpRequest) { + return ApiResponse.ok(command(key, httpRequest, request, new TypeReference() { }, + () -> service.reverseResult(publicId, request))); + } + + @PostMapping("/vouchers/{publicId}/reopen-result") + @Operation(operationId = "reopenVoucherResult", summary = "重开已冲销人工记账结果") + @PreAuthorize("@authorizationService.hasPermission('accounting:voucher:reopen-result')") + public ApiResponse reopenResult(@PathVariable String publicId, + @Valid @RequestBody ReopenResultRequest request, + @RequestHeader(value = "Idempotency-Key", required = false) String key, + HttpServletRequest httpRequest) { + return ApiResponse.ok(command(key, httpRequest, request, new TypeReference() { }, + () -> service.reopenResult(publicId, request))); + } + + @PostMapping("/vouchers/{publicId}/void") + @Operation(operationId = "voidVoucher", summary = "作废凭证") + @PreAuthorize("@authorizationService.hasPermission('accounting:voucher:void')") + public ApiResponse voidVoucher(@PathVariable String publicId, + @Valid @RequestBody VoidRequest request, + @RequestHeader(value = "Idempotency-Key", required = false) String key, + HttpServletRequest httpRequest) { + return ApiResponse.ok(command(key, httpRequest, request, new TypeReference() { }, + () -> service.voidVoucher(publicId, request))); + } + + private T command(String key, HttpServletRequest request, Object body, TypeReference type, + java.util.function.Supplier action) { + return idempotencyService.execute(key, request.getMethod(), request.getRequestURI(), body, type, action); + } +} diff --git a/backend/src/main/java/com/kaidi/finance/accounting/api/AccountingViews.java b/backend/src/main/java/com/kaidi/finance/accounting/api/AccountingViews.java new file mode 100644 index 0000000..f651c00 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/accounting/api/AccountingViews.java @@ -0,0 +1,149 @@ +package com.kaidi.finance.accounting.api; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +public final class AccountingViews { + + private AccountingViews() { + } + + public record ReferenceView(String publicId, String code, String name) { + } + + public record AccountView( + String publicId, + String code, + String name, + String accountType, + boolean auxiliaryRequired + ) { + } + + public record AccountingEventView( + String publicId, + String businessNo, + String eventType, + String sourceType, + String sourcePublicId, + String sourceBusinessNo, + String sourceVersion, + int eventSequence, + String sourceEventKey, + ReferenceView company, + ReferenceView project, + ReferenceView counterparty, + LocalDate businessDate, + String period, + String amount, + String currency, + String status, + String voucherPublicId, + String voucherStatus, + long version, + LocalDateTime createdAt, + List allowedActions + ) { + } + + public record VoucherEntryView( + String publicId, + int lineNo, + String direction, + ReferenceView account, + String amount, + String summary, + String auxiliaryJson + ) { + } + + public record VoucherView( + String publicId, + String businessNo, + String eventPublicId, + String eventBusinessNo, + ReferenceView company, + ReferenceView project, + String period, + LocalDate businessDate, + String summary, + String debitTotal, + String creditTotal, + String balanceStatus, + String status, + String submittedByName, + String reviewedByName, + LocalDateTime exportedAt, + String exportBatchNo, + String exportSha256, + String exportFileId, + String ruleVersion, + String externalVoucherNo, + LocalDate resultAt, + int resultCycleNo, + List evidenceFileIds, + String resultRecordedByName, + LocalDateTime resultRecordedAt, + String resultRecordRemark, + String resultVerifiedByName, + LocalDateTime resultVerifiedAt, + String resultVerifyOpinion, + String voidReason, + long version, + LocalDateTime createdAt, + LocalDateTime updatedAt, + List allowedActions + ) { + } + + public record VoucherDetailView( + VoucherView voucher, + AccountingEventView event, + List entries, + List evidenceFiles, + List resultActions + ) { + } + + public record ResultEvidenceFileView( + String publicId, + String originalName, + String mediaType, + long sizeBytes, + String sha256, + String scanStatus + ) { + } + + public record VoucherResultActionView( + String publicId, + int sequenceNo, + int resultCycleNo, + String actionType, + String reason, + String externalVoucherNo, + LocalDate resultAt, + String resultRecordedByName, + LocalDateTime resultRecordedAt, + String resultRecordRemark, + String resultVerifiedByName, + LocalDateTime resultVerifiedAt, + String resultVerifyOpinion, + List evidenceFiles, + String operatedByName, + LocalDateTime operatedAt, + long versionBefore, + long versionAfter + ) { + } + + public record VoucherExportView( + VoucherView voucher, + String batchNo, + String sha256, + String fileId, + int rowCount + ) { + } +} diff --git a/backend/src/main/java/com/kaidi/finance/accounting/application/AccountingApplicationService.java b/backend/src/main/java/com/kaidi/finance/accounting/application/AccountingApplicationService.java new file mode 100644 index 0000000..26442c0 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/accounting/application/AccountingApplicationService.java @@ -0,0 +1,1116 @@ +package com.kaidi.finance.accounting.application; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.kaidi.finance.accounting.api.AccountingContracts.GenerateDraftRequest; +import com.kaidi.finance.accounting.api.AccountingContracts.ResultRequest; +import com.kaidi.finance.accounting.api.AccountingContracts.ReopenResultRequest; +import com.kaidi.finance.accounting.api.AccountingContracts.ReverseResultRequest; +import com.kaidi.finance.accounting.api.AccountingContracts.ReturnRequest; +import com.kaidi.finance.accounting.api.AccountingContracts.VerifyResultRequest; +import com.kaidi.finance.accounting.api.AccountingContracts.VoidRequest; +import com.kaidi.finance.accounting.api.AccountingContracts.VoucherEntryRequest; +import com.kaidi.finance.accounting.api.AccountingContracts.VoucherUpdateRequest; +import com.kaidi.finance.accounting.api.AccountingViews.AccountView; +import com.kaidi.finance.accounting.api.AccountingViews.AccountingEventView; +import com.kaidi.finance.accounting.api.AccountingViews.ReferenceView; +import com.kaidi.finance.accounting.api.AccountingViews.ResultEvidenceFileView; +import com.kaidi.finance.accounting.api.AccountingViews.VoucherDetailView; +import com.kaidi.finance.accounting.api.AccountingViews.VoucherEntryView; +import com.kaidi.finance.accounting.api.AccountingViews.VoucherExportView; +import com.kaidi.finance.accounting.api.AccountingViews.VoucherResultActionView; +import com.kaidi.finance.accounting.api.AccountingViews.VoucherView; +import com.kaidi.finance.accounting.infrastructure.AccountingMapper; +import com.kaidi.finance.accounting.infrastructure.AccountingMapper.AccountRow; +import com.kaidi.finance.accounting.infrastructure.AccountingMapper.EntryRow; +import com.kaidi.finance.accounting.infrastructure.AccountingMapper.EvidenceFileRow; +import com.kaidi.finance.accounting.infrastructure.AccountingMapper.EventRow; +import com.kaidi.finance.accounting.infrastructure.AccountingMapper.ExportBatchRow; +import com.kaidi.finance.accounting.infrastructure.AccountingMapper.ResultFileRow; +import com.kaidi.finance.accounting.infrastructure.AccountingMapper.ResultActionRow; +import com.kaidi.finance.accounting.infrastructure.AccountingMapper.RuleParameterRow; +import com.kaidi.finance.accounting.infrastructure.AccountingMapper.VoucherLock; +import com.kaidi.finance.accounting.infrastructure.AccountingMapper.VoucherRow; +import com.kaidi.finance.iam.domain.FinancePrincipal; +import com.kaidi.finance.shared.api.BusinessException; +import com.kaidi.finance.shared.api.ErrorCode; +import com.kaidi.finance.shared.api.PageResult; +import com.kaidi.finance.shared.audit.AuditService; +import com.kaidi.finance.shared.file.FileApplicationService; +import com.kaidi.finance.shared.file.FileApplicationService.FileDownload; +import com.kaidi.finance.shared.file.FileView; +import com.kaidi.finance.shared.id.UlidGenerator; +import com.kaidi.finance.shared.security.AuthorizationService; +import com.kaidi.finance.shared.security.IdentityContext; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.LocalDate; +import java.time.YearMonth; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.regex.Pattern; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class AccountingApplicationService { + + public static final String RULE_VERSION = "ACCOUNTING-RULE-V1"; + + private static final Pattern PUBLIC_ID = Pattern.compile("[0-9A-HJKMNP-TV-Z]{26}"); + private static final Pattern PERIOD = Pattern.compile("\\d{4}-(0[1-9]|1[0-2])"); + private static final Set TABS = Set.of( + "draft", "review", "exported", "result", "completed", "reversed", "void" + ); + private static final Set EVENT_TYPES = Set.of( + "RECEIPT", "INVOICE", "PAYABLE", "PAYMENT", "PAYMENT_REFUND" + ); + private static final Set VOUCHER_STATUSES = Set.of( + "DRAFT", "REVIEWING", "RETURNED", "APPROVED", "EXPORTED", "RESULT_RECORDED", "COMPLETED", + "RESULT_REVERSED", "VOID" + ); + private static final DateTimeFormatter BATCH_TIME = DateTimeFormatter.ofPattern("yyyyMMddHHmmss"); + + private final AccountingMapper mapper; + private final AuthorizationService authorizationService; + private final IdentityContext identityContext; + private final UlidGenerator ulidGenerator; + private final AuditService auditService; + private final FileApplicationService fileService; + private final ObjectMapper objectMapper; + + public AccountingApplicationService(AccountingMapper mapper, AuthorizationService authorizationService, + IdentityContext identityContext, UlidGenerator ulidGenerator, + AuditService auditService, FileApplicationService fileService, + ObjectMapper objectMapper) { + this.mapper = mapper; + this.authorizationService = authorizationService; + this.identityContext = identityContext; + this.ulidGenerator = ulidGenerator; + this.auditService = auditService; + this.fileService = fileService; + this.objectMapper = objectMapper; + } + + @Transactional(readOnly = true) + public List accounts() { + authorizationService.requirePermission("accounting:voucher:view"); + return mapper.listAccounts().stream().map(this::accountView).toList(); + } + + @Transactional(readOnly = true) + public PageResult listEvents(String period, String projectId, String eventType, + String keyword, String sort, int page, int size) { + return listEventPage("PENDING", period, projectId, eventType, keyword, sort, page, size); + } + + @Transactional(readOnly = true) + public PageResult listProjectLifecycleEvents( + String period, String projectId, String eventType, String keyword, String sort, int page, int size) { + return listEventPage(null, period, projectId, eventType, keyword, sort, page, size); + } + + private PageResult listEventPage( + String status, String period, String projectId, String eventType, + String keyword, String sort, int page, int size) { + authorizationService.requirePermission("accounting:event:view"); + Actor actor = actor(); + Page requestedPage = page(page, size); + String effectivePeriod = optionalPeriod(period); + String effectiveProjectId = optionalId(projectId); + String effectiveEventType = optionalEventType(eventType); + String effectiveKeyword = clean(keyword, 100); + List items = mapper.listEvents(actor.userId(), actor.roleCode(), status, + effectivePeriod, effectiveProjectId, effectiveEventType, effectiveKeyword, eventOrderBy(sort), + requestedPage.size(), requestedPage.offset()).stream().map(this::eventView).toList(); + long total = mapper.countEvents(actor.userId(), actor.roleCode(), status, effectivePeriod, + effectiveProjectId, effectiveEventType, effectiveKeyword); + return new PageResult<>(items, total, requestedPage.page(), requestedPage.size()); + } + + @Transactional(readOnly = true) + public PageResult listVouchers(String tab, String period, String projectId, String eventType, + String status, String externalVoucherNo, String keyword, + String sort, int page, int size) { + String effectiveTab = blank(tab) == null ? "draft" : blank(tab).toLowerCase(Locale.ROOT); + if (!TABS.contains(effectiveTab)) throw validation("记账页签参数无效"); + return listVoucherPage(effectiveTab, period, projectId, eventType, status, externalVoucherNo, keyword, + sort, page, size); + } + + @Transactional(readOnly = true) + public PageResult listProjectLifecycleVouchers( + String tab, String period, String projectId, String eventType, String status, + String externalVoucherNo, String keyword, String sort, int page, int size) { + String requestedTab = blank(tab); + String effectiveTab = requestedTab == null ? "all" : requestedTab.toLowerCase(Locale.ROOT); + if (!"all".equals(effectiveTab) && !TABS.contains(effectiveTab)) { + throw validation("记账页签参数无效"); + } + return listVoucherPage(effectiveTab, period, projectId, eventType, status, externalVoucherNo, keyword, + sort, page, size); + } + + private PageResult listVoucherPage( + String effectiveTab, String period, String projectId, String eventType, String status, + String externalVoucherNo, String keyword, String sort, int page, int size) { + authorizationService.requirePermission("accounting:voucher:view"); + Actor actor = actor(); + Page requestedPage = page(page, size); + String effectiveStatus = optionalStatus(status); + String effectivePeriod = optionalPeriod(period); + String effectiveProjectId = optionalId(projectId); + String effectiveEventType = optionalEventType(eventType); + String effectiveExternalNo = clean(externalVoucherNo, 100); + String effectiveKeyword = clean(keyword, 100); + List items = mapper.listVouchers(actor.userId(), actor.roleCode(), effectiveTab, + effectivePeriod, effectiveProjectId, effectiveEventType, effectiveStatus, effectiveExternalNo, + effectiveKeyword, voucherOrderBy(sort), requestedPage.size(), requestedPage.offset()) + .stream().map(this::voucherView).toList(); + long total = mapper.countVouchers(actor.userId(), actor.roleCode(), effectiveTab, effectivePeriod, + effectiveProjectId, effectiveEventType, effectiveStatus, effectiveExternalNo, effectiveKeyword); + return new PageResult<>(items, total, requestedPage.page(), requestedPage.size()); + } + + @Transactional(readOnly = true) + public VoucherDetailView getVoucher(String publicId) { + VoucherRow row = scopedVoucher(publicId, "accounting:voucher:view"); + return detail(row); + } + + @Transactional + public VoucherDetailView generateDraft(String eventPublicId, GenerateDraftRequest request) { + authorizationService.requirePermission("accounting:event:generate"); + Actor actor = actor(); + EventRow found = event(eventPublicId); + authorizationService.requireScope("accounting:event:generate", found.companyPublicId(), found.projectPublicId(), + found.amount()); + EventRow event = mapper.lockEvent(found.id()); + requireVersion(event.version(), request.version()); + RuleTemplate template = ruleTemplate(request.ruleVersion()); + if (event.voucherId() != null) { + VoucherRow current = mapper.findVoucherById(event.voucherId()); + if (current != null && !"VOID".equals(current.status())) { + return detail(current); + } + throw invalidState("财务事件当前凭证引用异常,请刷新后重试"); + } + if (!"PENDING".equals(event.status())) throw invalidState("当前财务事件不能生成凭证草稿"); + + Rule rule = template.rule(event.eventType()); + AccountRow debit = activeAccount(rule.debitAccount()); + AccountRow credit = activeAccount(rule.creditAccount()); + String summary = eventSummary(event); + String voucherPublicId = ulidGenerator.next(); + String businessNo = "VCH-" + event.period().replace("-", "") + "-" + voucherPublicId.substring(18); + mapper.insertVoucher(voucherPublicId, businessNo, event.id(), event.companyId(), event.projectId(), + template.version(), template.snapshotJson(), event.period(), event.businessDate(), summary, + money(event.amount()), actor.userId()); + long voucherId = mapper.voucherId(voucherPublicId); + mapper.insertEntry(ulidGenerator.next(), voucherId, 1, "DEBIT", debit.id(), money(event.amount()), summary, + auxiliaryJson(debit, event)); + mapper.insertEntry(ulidGenerator.next(), voucherId, 2, "CREDIT", credit.id(), money(event.amount()), summary, + auxiliaryJson(credit, event)); + if (mapper.markEventDrafted(event.id(), voucherId, event.version()) != 1) throw conflict(); + VoucherRow created = mapper.findVoucherById(voucherId); + auditService.recordScoped(event.companyPublicId(), event.projectPublicId(), + "ACCOUNTING_DRAFT_GENERATE", "ACCOUNTING_EVENT", event.publicId(), "SUCCESS", + template.version(), eventView(event), voucherView(created)); + return detail(created); + } + + @Transactional + public VoucherDetailView updateVoucher(String publicId, VoucherUpdateRequest request) { + VoucherRow found = scopedVoucher(publicId, "accounting:voucher:edit"); + Actor actor = actor(); + VoucherLock voucher = lock(found.id(), request.version()); + requireState(voucher.status(), "DRAFT", "RETURNED"); + EventRow event = sourceEvent(voucher.eventId()); + requireSourcePeriod(event, request.period(), request.businessDate()); + List entries = resolveEntries(request.entries(), event); + Totals totals = totals(entries); + Map before = snapshot(voucher, mapper.listEntries(voucher.id())); + if (mapper.updateVoucher(voucher.id(), voucher.version(), request.period(), request.businessDate(), + request.summary().trim(), totals.debit(), totals.credit(), request.changeReason().trim(), actor.userId()) != 1) { + throw conflict(); + } + mapper.deleteEntries(voucher.id()); + for (ResolvedEntry entry : entries) { + mapper.insertEntry(ulidGenerator.next(), voucher.id(), entry.lineNo(), entry.direction(), + entry.account().id(), entry.amount(), entry.summary(), entry.auxiliaryJson()); + } + VoucherRow updated = mapper.findVoucherById(voucher.id()); + Map after = snapshot(updated, mapper.listEntries(voucher.id())); + mapper.insertChange(ulidGenerator.next(), voucher.id(), request.changeReason().trim(), json(before), json(after), + actor.userId()); + auditService.recordScoped(found.companyPublicId(), found.projectPublicId(), + "ACCOUNTING_VOUCHER_EDIT", "ACCOUNTING_VOUCHER", voucher.publicId(), "SUCCESS", + request.changeReason().trim(), before, after); + return detail(updated); + } + + @Transactional + public VoucherDetailView submitVoucher(String publicId, long version) { + VoucherRow found = scopedVoucher(publicId, "accounting:voucher:edit"); + Actor actor = actor(); + VoucherLock voucher = lock(found.id(), version); + requireState(voucher.status(), "DRAFT", "RETURNED"); + if (mapper.submitVoucher(voucher.id(), voucher.version(), actor.userId()) != 1) throw conflict(); + VoucherRow updated = mapper.findVoucherById(voucher.id()); + auditService.recordScoped(found.companyPublicId(), found.projectPublicId(), + "ACCOUNTING_VOUCHER_SUBMIT", "ACCOUNTING_VOUCHER", publicId, "SUCCESS", null, voucher, updated); + return detail(updated); + } + + @Transactional + public VoucherDetailView approveVoucher(String publicId, long version) { + VoucherRow found = scopedVoucher(publicId, "accounting:voucher:review"); + Actor actor = actor(); + VoucherLock voucher = lock(found.id(), version); + requireState(voucher.status(), "REVIEWING"); + requireDifferentVoucherReviewer(voucher, actor.userId()); + validateForApproval(voucher); + if (mapper.approveVoucher(voucher.id(), voucher.version(), actor.userId()) != 1) throw conflict(); + VoucherRow updated = mapper.findVoucherById(voucher.id()); + auditService.recordScoped(found.companyPublicId(), found.projectPublicId(), + "ACCOUNTING_VOUCHER_APPROVE", "ACCOUNTING_VOUCHER", publicId, "SUCCESS", null, voucher, updated); + return detail(updated); + } + + @Transactional + public VoucherDetailView returnVoucher(String publicId, ReturnRequest request) { + VoucherRow found = scopedVoucher(publicId, "accounting:voucher:review"); + Actor actor = actor(); + VoucherLock voucher = lock(found.id(), request.version()); + requireState(voucher.status(), "REVIEWING"); + requireDifferentVoucherReviewer(voucher, actor.userId()); + if (mapper.returnVoucher(voucher.id(), voucher.version(), request.opinion().trim(), actor.userId()) != 1) { + throw conflict(); + } + VoucherRow updated = mapper.findVoucherById(voucher.id()); + auditService.recordScoped(found.companyPublicId(), found.projectPublicId(), + "ACCOUNTING_VOUCHER_RETURN", "ACCOUNTING_VOUCHER", publicId, "SUCCESS", + request.opinion().trim(), voucher, updated); + return detail(updated); + } + + @Transactional + public VoucherExportView exportVoucher(String publicId, long version) { + VoucherRow found = scopedVoucher(publicId, "accounting:voucher:export"); + Actor actor = actor(); + VoucherLock voucher = lock(found.id(), version); + if ("EXPORTED".equals(voucher.status())) { + VoucherRow exported = mapper.findVoucherById(voucher.id()); + return exportView(exported, actor, true); + } + requireState(voucher.status(), "APPROVED"); + validateForApproval(voucher); + VoucherRow before = mapper.findVoucherById(voucher.id()); + List entries = mapper.listEntries(voucher.id()); + String content = csv(before, entries); + byte[] bytes = content.getBytes(StandardCharsets.UTF_8); + String sha256 = sha256(bytes); + String batchNo = "ACC-" + java.time.LocalDateTime.now().format(BATCH_TIME) + "-" + + publicId.substring(publicId.length() - 6); + FileView file = fileService.storeGenerated(before.businessNo() + "-" + batchNo + ".csv", + "text/csv;charset=UTF-8", "csv", bytes); + Map range = new LinkedHashMap<>(); + range.put("voucherPublicIds", List.of(before.publicId())); + range.put("companyPublicId", before.companyPublicId()); + range.put("projectPublicId", before.projectPublicId()); + range.put("period", before.period()); + if (mapper.insertExportBatch(ulidGenerator.next(), batchNo, voucher.id(), before.ruleVersion(), json(range), + file.publicId(), sha256, entries.size(), actor.userId()) != 1) { + throw new IllegalStateException("Accounting export file was not persisted"); + } + if (mapper.markExported(voucher.id(), voucher.version(), sha256, batchNo, actor.userId()) != 1) { + throw conflict(); + } + VoucherRow updated = mapper.findVoucherById(voucher.id()); + auditService.recordScoped(found.companyPublicId(), found.projectPublicId(), + "ACCOUNTING_VOUCHER_EXPORT", "ACCOUNTING_VOUCHER", publicId, "SUCCESS", batchNo, + before, Map.of("batchNo", batchNo, "sha256", sha256, "fileId", file.publicId(), + "rowCount", entries.size())); + return new VoucherExportView(voucherView(updated), batchNo, sha256, file.publicId(), entries.size()); + } + + @Transactional + public FileDownload downloadExport(String publicId) { + VoucherRow voucher = scopedVoucher(publicId, "accounting:voucher:export"); + ExportBatchRow batch = mapper.findExportBatch(voucher.id()); + if (batch == null) throw notFound("记账导出文件不存在"); + FileDownload download = fileService.downloadForBusiness(batch.filePublicId()); + auditService.recordScoped(voucher.companyPublicId(), voucher.projectPublicId(), + "ACCOUNTING_VOUCHER_EXPORT_DOWNLOAD", "ACCOUNTING_VOUCHER", publicId, "SUCCESS", batch.batchNo(), + null, Map.of("batchNo", batch.batchNo(), "fileId", batch.filePublicId(), "sha256", batch.sha256())); + return download; + } + + @Transactional + public FileDownload downloadResultEvidence(String voucherPublicId, String filePublicId) { + VoucherRow voucher = scopedVoucher(voucherPublicId, "accounting:voucher:view"); + requireState(voucher.status(), "EXPORTED", "RESULT_RECORDED", "COMPLETED", "RESULT_REVERSED"); + ResultFileRow file = mapper.findResultFile(voucher.id(), publicId(filePublicId)); + if (file == null) throw notFound("记账结果证据不存在"); + FileDownload download = fileService.downloadForBusiness(file.publicId()); + auditService.recordScoped(voucher.companyPublicId(), voucher.projectPublicId(), + "ACCOUNTING_RESULT_EVIDENCE_DOWNLOAD", "ACCOUNTING_VOUCHER", voucherPublicId, "SUCCESS", + file.originalName(), null, + Map.of("fileId", file.publicId(), "sha256", file.sha256(), "status", voucher.status())); + return download; + } + + @Transactional + public VoucherDetailView recordResult(String publicId, ResultRequest request) { + VoucherRow found = scopedVoucher(publicId, "accounting:voucher:record-result"); + Actor actor = actor(); + VoucherLock voucher = lock(found.id(), request.version()); + requireState(voucher.status(), "EXPORTED"); + if (request.resultAt().isAfter(LocalDate.now())) throw validation("结果日期不能晚于当前日期"); + String externalVoucherNo = request.externalVoucherNo().trim(); + if (mapper.historicalExternalVoucherNoExists(found.companyId(), voucher.period(), externalVoucherNo) > 0) { + throw duplicate("同一公司和期间的外部凭证号已经登记"); + } + List evidenceFiles = resolveEvidenceFiles(request.evidenceFileIds(), voucher, + found.companyPublicId(), found.projectPublicId()); + try { + if (mapper.recordResult(voucher.id(), voucher.version(), externalVoucherNo, + request.resultAt(), clean(request.remark(), 1000), actor.userId()) != 1) throw conflict(); + for (EvidenceFileRow file : evidenceFiles) { + mapper.insertResultFile(ulidGenerator.next(), voucher.id(), voucher.resultCycleNo(), file.id(), + actor.userId()); + } + } catch (DataIntegrityViolationException exception) { + throw duplicate("同一公司和期间的外部凭证号已经登记"); + } + VoucherRow updated = mapper.findVoucherById(voucher.id()); + auditService.recordScoped(found.companyPublicId(), found.projectPublicId(), + "ACCOUNTING_RESULT_RECORD", "ACCOUNTING_VOUCHER", publicId, "SUCCESS", + request.remark(), voucher, updated); + return detail(updated); + } + + @Transactional + public VoucherDetailView verifyResult(String publicId, VerifyResultRequest request) { + VoucherRow found = scopedVoucher(publicId, "accounting:voucher:verify-result"); + Actor actor = actor(); + VoucherLock voucher = lock(found.id(), request.version()); + requireState(voucher.status(), "RESULT_RECORDED"); + if (voucher.resultRecordedBy() != null && voucher.resultRecordedBy() == actor.userId()) { + throw sod("人工记账结果登记人与复核人不能是同一人"); + } + if (mapper.verifyResult(voucher.id(), voucher.version(), request.opinion().trim(), actor.userId()) != 1) { + throw conflict(); + } + if (mapper.completeEvent(voucher.eventId(), "COMPLETED") != 1) throw conflict(); + VoucherRow updated = mapper.findVoucherById(voucher.id()); + auditService.recordScoped(found.companyPublicId(), found.projectPublicId(), + "ACCOUNTING_RESULT_VERIFY", "ACCOUNTING_VOUCHER", publicId, "SUCCESS", + request.opinion().trim(), voucher, updated); + return detail(updated); + } + + @Transactional + public VoucherDetailView reverseResult(String publicId, ReverseResultRequest request) { + VoucherRow found = scopedVoucher(publicId, "accounting:voucher:reverse-result"); + Actor actor = actor(); + VoucherLock voucher = lock(found.id(), request.version()); + requireState(voucher.status(), "COMPLETED"); + if (voucher.resultRecordedBy() != null && voucher.resultRecordedBy() == actor.userId()) { + throw sod("人工记账结果登记人不能冲销本人登记的结果"); + } + VoucherRow before = mapper.findVoucherById(voucher.id()); + ResultSnapshot snapshot = resultSnapshot(before, mapper.listResultFiles(voucher.id())); + String reason = request.reason().trim(); + int sequence = mapper.nextResultActionSequence(voucher.id()); + if (mapper.reverseResult(voucher.id(), voucher.version(), actor.userId()) != 1) throw conflict(); + if (mapper.reopenCompletedEvent(voucher.eventId(), voucher.id()) != 1) throw conflict(); + VoucherRow updated = mapper.findVoucherById(voucher.id()); + if (mapper.insertResultAction(ulidGenerator.next(), voucher.id(), found.companyId(), voucher.period(), + sequence, voucher.resultCycleNo(), "REVERSE", reason, before.externalVoucherNo(), json(snapshot), + actor.userId(), voucher.version(), updated.version()) != 1) { + throw new IllegalStateException("Accounting result reversal was not persisted"); + } + auditService.recordScoped(found.companyPublicId(), found.projectPublicId(), + "ACCOUNTING_RESULT_REVERSE", "ACCOUNTING_VOUCHER", publicId, "SUCCESS", reason, before, updated); + return detail(updated); + } + + @Transactional + public VoucherDetailView reopenResult(String publicId, ReopenResultRequest request) { + VoucherRow found = scopedVoucher(publicId, "accounting:voucher:reopen-result"); + Actor actor = actor(); + VoucherLock voucher = lock(found.id(), request.version()); + requireState(voucher.status(), "RESULT_REVERSED"); + ResultActionRow reversal = mapper.latestResultAction(voucher.id()); + if (reversal == null || !"REVERSE".equals(reversal.actionType()) + || reversal.resultCycleNo() != voucher.resultCycleNo()) { + throw invalidState("当前凭证缺少可重开的冲销记录"); + } + if (reversal.operatedBy() == actor.userId()) { + throw sod("记账结果冲销人与重开人不能是同一人"); + } + requireState(sourceEvent(voucher.eventId()).status(), "DRAFTED"); + VoucherRow before = mapper.findVoucherById(voucher.id()); + ResultSnapshot snapshot = resultSnapshot(before, mapper.listResultFiles(voucher.id())); + String reason = request.reason().trim(); + int sequence = mapper.nextResultActionSequence(voucher.id()); + if (mapper.reopenResult(voucher.id(), voucher.version(), actor.userId()) != 1) throw conflict(); + VoucherRow updated = mapper.findVoucherById(voucher.id()); + if (mapper.insertResultAction(ulidGenerator.next(), voucher.id(), found.companyId(), voucher.period(), + sequence, voucher.resultCycleNo(), "REOPEN", reason, null, json(snapshot), actor.userId(), + voucher.version(), updated.version()) != 1) { + throw new IllegalStateException("Accounting result reopen was not persisted"); + } + auditService.recordScoped(found.companyPublicId(), found.projectPublicId(), + "ACCOUNTING_RESULT_REOPEN", "ACCOUNTING_VOUCHER", publicId, "SUCCESS", reason, before, updated); + return detail(updated); + } + + @Transactional + public VoucherDetailView voidVoucher(String publicId, VoidRequest request) { + VoucherRow found = scopedVoucher(publicId, "accounting:voucher:void"); + Actor actor = actor(); + VoucherLock voucher = lock(found.id(), request.version()); + requireState(voucher.status(), "DRAFT", "RETURNED"); + if (mapper.voidVoucher(voucher.id(), voucher.version(), request.reason().trim(), actor.userId()) != 1) { + throw conflict(); + } + if (mapper.releaseVoidedEvent(voucher.eventId(), voucher.id()) != 1) throw conflict(); + VoucherRow updated = mapper.findVoucherById(voucher.id()); + auditService.recordScoped(found.companyPublicId(), found.projectPublicId(), + "ACCOUNTING_VOUCHER_VOID", "ACCOUNTING_VOUCHER", publicId, "SUCCESS", + request.reason().trim(), voucher, updated); + return detail(updated); + } + + private void validateForApproval(VoucherLock voucher) { + EventRow event = sourceEvent(voucher.eventId()); + requireSourcePeriod(event, voucher.period(), voucher.businessDate()); + List entries = mapper.listEntries(voucher.id()); + if (entries.isEmpty()) throw validation("凭证至少需要一条借方和一条贷方分录"); + BigDecimal debit = BigDecimal.ZERO; + BigDecimal credit = BigDecimal.ZERO; + boolean hasDebit = false; + boolean hasCredit = false; + for (EntryRow entry : entries) { + if (!"ACTIVE".equals(entry.accountStatus())) { + throw validation("科目 " + entry.accountCode() + " 已停用,不能审批或导出凭证"); + } + String auxiliary = normalizedAuxiliary(entry.auxiliaryJson(), event); + if (entry.auxiliaryRequired() && !hasAuxiliary(auxiliary)) { + throw validation("科目 " + entry.accountCode() + " 缺少项目或往来单位等辅助核算信息"); + } + if ("DEBIT".equals(entry.direction())) { + debit = debit.add(entry.amount()); + hasDebit = true; + } else { + credit = credit.add(entry.amount()); + hasCredit = true; + } + } + if (!hasDebit || !hasCredit) throw validation("凭证必须同时包含借方和贷方分录"); + if (debit.compareTo(credit) != 0) throw validation("借方合计与贷方合计不平衡"); + if (debit.compareTo(event.amount()) != 0) throw validation("凭证借贷金额必须与来源财务事件金额一致"); + } + + private List resolveEntries(List requests, EventRow event) { + Set lineNumbers = new HashSet<>(); + List entries = new ArrayList<>(); + for (VoucherEntryRequest request : requests) { + if (request.lineNo() == null || request.lineNo() < 1 || !lineNumbers.add(request.lineNo())) { + throw validation("分录行号必须为不重复的正整数"); + } + AccountRow account = mapper.findAccount(publicId(request.accountId())); + if (account == null || !"ACTIVE".equals(account.status())) throw validation("分录科目不存在或未生效"); + String auxiliary = normalizedAuxiliary(request.auxiliaryJson(), event); + entries.add(new ResolvedEntry(request.lineNo(), request.direction(), account, money(request.amount()), + request.summary().trim(), auxiliary)); + } + entries.sort(java.util.Comparator.comparingInt(ResolvedEntry::lineNo)); + return entries; + } + + private Totals totals(List entries) { + BigDecimal debit = BigDecimal.ZERO; + BigDecimal credit = BigDecimal.ZERO; + boolean hasDebit = false; + boolean hasCredit = false; + for (ResolvedEntry entry : entries) { + if ("DEBIT".equals(entry.direction())) { + debit = debit.add(entry.amount()); + hasDebit = true; + } else if ("CREDIT".equals(entry.direction())) { + credit = credit.add(entry.amount()); + hasCredit = true; + } else { + throw validation("分录方向无效"); + } + } + if (!hasDebit || !hasCredit) throw validation("凭证必须同时包含借方和贷方分录"); + return new Totals(money(debit), money(credit)); + } + + private String normalizedAuxiliary(String value, EventRow event) { + String cleaned = blank(value); + if (cleaned == null) return null; + try { + JsonNode node = objectMapper.readTree(cleaned); + if (!node.isObject()) throw validation("辅助核算信息必须是 JSON 对象"); + var fields = node.fields(); + while (fields.hasNext()) { + Map.Entry field = fields.next(); + String key = field.getKey(); + if (!Set.of("projectId", "counterpartyId").contains(key)) { + throw validation("辅助核算只允许项目和往来单位维度"); + } + JsonNode raw = field.getValue(); + if (!raw.isTextual()) throw validation("辅助核算维度值必须是公开编号"); + String valueId = publicId(raw.textValue()); + String expected = "projectId".equals(key) ? event.projectPublicId() : event.counterpartyPublicId(); + if (expected == null || !expected.equalsIgnoreCase(valueId)) { + throw validation("辅助核算维度必须与来源财务事件一致"); + } + ((com.fasterxml.jackson.databind.node.ObjectNode) node).put(key, valueId); + } + return objectMapper.writeValueAsString(node); + } catch (JsonProcessingException exception) { + throw validation("辅助核算信息不是有效 JSON"); + } + } + + private boolean hasAuxiliary(String value) { + if (blank(value) == null) return false; + try { + JsonNode node = objectMapper.readTree(value); + return node.isObject() && node.size() > 0; + } catch (JsonProcessingException exception) { + return false; + } + } + + private String auxiliaryJson(AccountRow account, EventRow event) { + if (!account.auxiliaryRequired()) return null; + Map dimensions = new LinkedHashMap<>(); + if (event.projectPublicId() != null) dimensions.put("projectId", event.projectPublicId()); + if (event.counterpartyPublicId() != null) dimensions.put("counterpartyId", event.counterpartyPublicId()); + return dimensions.isEmpty() ? null : json(dimensions); + } + + private RuleTemplate ruleTemplate(String requestedVersion) { + String version = blank(requestedVersion); + if (version == null) throw validation("凭证规则版本不能为空"); + RuleParameterRow row = mapper.findAccountingRuleTemplate(version); + if (row == null) throw validation("凭证规则版本无效或未生效"); + try { + JsonNode root = objectMapper.readTree(row.valueJson()); + if (root == null || !root.isObject()) throw validation("凭证规则模板必须是 JSON 对象"); + String declaredVersion = root.path("ruleVersion").asText(null); + if (!version.equals(declaredVersion)) throw validation("凭证规则版本标识不一致"); + JsonNode eventTypes = root.path("eventTypes"); + if (!eventTypes.isObject()) throw validation("凭证规则模板缺少 eventTypes"); + Map rules = new LinkedHashMap<>(); + for (String eventType : EVENT_TYPES) { + JsonNode item = eventTypes.path(eventType); + String debit = item.path("debitAccount").asText(null); + String credit = item.path("creditAccount").asText(null); + if (debit == null || credit == null || debit.isBlank() || credit.isBlank()) { + throw validation("凭证规则模板缺少 " + eventType + " 科目配置"); + } + rules.put(eventType, new Rule(debit.trim(), credit.trim())); + } + return new RuleTemplate(version, objectMapper.writeValueAsString(root), rules); + } catch (JsonProcessingException exception) { + throw validation("凭证规则模板不是有效 JSON"); + } + } + + private AccountRow activeAccount(String code) { + Long id = mapper.activeAccountId(code); + if (id == null) throw validation("凭证规则引用的会计科目未生效:" + code); + return mapper.listAccounts().stream().filter(item -> item.id() == id).findFirst() + .orElseThrow(() -> validation("凭证规则引用的会计科目不存在:" + code)); + } + + private String eventSummary(EventRow event) { + String type = switch (event.eventType()) { + case "RECEIPT" -> "收款"; + case "INVOICE" -> "开票"; + case "PAYABLE" -> "应付"; + case "PAYMENT" -> "付款"; + case "PAYMENT_REFUND" -> "退汇冲销"; + default -> event.eventType(); + }; + return type + "记账准备 - " + event.businessNo(); + } + + private List resolveEvidenceFiles(List fileIds, VoucherLock voucher, + String companyPublicId, String projectPublicId) { + if (fileIds == null || fileIds.isEmpty()) throw validation("必须上传人工记账结果证据"); + FinancePrincipal principal = identityContext.requirePrincipal(); + Set unique = new HashSet<>(); + List files = new ArrayList<>(); + for (String value : fileIds) { + String fileId = publicId(value); + if (!unique.add(fileId)) continue; + EvidenceFileRow file = mapper.findEvidenceFile(fileId, principal.publicId(), voucher.id(), + companyPublicId, projectPublicId); + if (file == null) throw notFound("结果证据文件不存在"); + if (!file.uploadedByActor() && !file.attachedToVoucher() && !file.linkedToTarget()) { + throw new BusinessException(HttpStatus.FORBIDDEN, ErrorCode.DATA_SCOPE_DENIED, + "结果证据不属于当前账号或目标公司/项目"); + } + if (!"AVAILABLE".equals(file.scanStatus())) { + throw new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.FILE_REJECTED, + "结果证据尚未通过文件检查"); + } + files.add(file); + } + if (files.isEmpty()) throw validation("必须上传人工记账结果证据"); + return files; + } + + private VoucherExportView exportView(VoucherRow voucher, Actor actor, boolean rebuildMissingBatch) { + ExportBatchRow batch = mapper.findExportBatch(voucher.id()); + List entries = mapper.listEntries(voucher.id()); + byte[] bytes = csv(voucher, entries).getBytes(StandardCharsets.UTF_8); + String calculated = sha256(bytes); + if (voucher.exportSha256() == null || !calculated.equals(voucher.exportSha256())) { + throw new BusinessException(HttpStatus.CONFLICT, ErrorCode.INVALID_STATE_TRANSITION, + "当前凭证内容与已导出文件哈希不一致"); + } + if (batch == null) { + if (!rebuildMissingBatch) throw notFound("记账导出批次不存在"); + batch = rebuildExportBatch(voucher, actor, entries, bytes, calculated); + } + if (!calculated.equals(batch.sha256())) { + throw new BusinessException(HttpStatus.CONFLICT, ErrorCode.INVALID_STATE_TRANSITION, + "持久化导出批次与当前凭证哈希不一致"); + } + return new VoucherExportView(voucherView(voucher), batch.batchNo(), batch.sha256(), batch.filePublicId(), + batch.rowCount()); + } + + private ExportBatchRow rebuildExportBatch(VoucherRow voucher, Actor actor, List entries, + byte[] bytes, String sha256) { + if (entries.isEmpty()) throw validation("已导出凭证没有可重建的分录"); + String batchNo = voucher.exportBatchNo(); + if (batchNo == null || batchNo.isBlank()) batchNo = "ACC-LEGACY-" + voucher.publicId(); + FileView file = fileService.storeGenerated(voucher.businessNo() + "-" + batchNo + ".csv", + "text/csv;charset=UTF-8", "csv", bytes); + Map range = new LinkedHashMap<>(); + range.put("voucherPublicIds", List.of(voucher.publicId())); + range.put("companyPublicId", voucher.companyPublicId()); + range.put("projectPublicId", voucher.projectPublicId()); + range.put("period", voucher.period()); + try { + if (mapper.insertExportBatch(ulidGenerator.next(), batchNo, voucher.id(), voucher.ruleVersion(), + json(range), file.publicId(), sha256, entries.size(), actor.userId()) != 1) { + throw new IllegalStateException("Accounting export evidence was not rebuilt"); + } + } catch (DataIntegrityViolationException exception) { + ExportBatchRow concurrent = mapper.findExportBatch(voucher.id()); + if (concurrent == null || !sha256.equals(concurrent.sha256())) throw exception; + return concurrent; + } + ExportBatchRow rebuilt = mapper.findExportBatch(voucher.id()); + if (rebuilt == null) throw new IllegalStateException("Accounting export evidence was not persisted"); + auditService.recordScoped(voucher.companyPublicId(), voucher.projectPublicId(), + "ACCOUNTING_VOUCHER_EXPORT_EVIDENCE_REBUILD", "ACCOUNTING_VOUCHER", voucher.publicId(), "SUCCESS", + batchNo, null, Map.of("batchNo", batchNo, "fileId", file.publicId(), "sha256", sha256, + "rowCount", entries.size())); + return rebuilt; + } + + private String csv(VoucherRow voucher, List entries) { + StringBuilder csv = new StringBuilder(); + csv.append("凭证号,期间,业务日期,行号,方向,科目编码,科目名称,金额,摘要,辅助核算,公司,项目,来源事件\r\n"); + for (EntryRow entry : entries) { + csv.append(csvCell(voucher.businessNo())).append(',') + .append(csvCell(voucher.period())).append(',') + .append(csvCell(voucher.businessDate().toString())).append(',') + .append(entry.lineNo()).append(',') + .append(csvCell(entry.direction())).append(',') + .append(csvCell(entry.accountCode())).append(',') + .append(csvCell(entry.accountName())).append(',') + .append(entry.amount().setScale(2, RoundingMode.UNNECESSARY).toPlainString()).append(',') + .append(csvCell(entry.summary())).append(',') + .append(csvCell(entry.auxiliaryJson())).append(',') + .append(csvCell(voucher.companyCode())).append(',') + .append(csvCell(voucher.projectCode())).append(',') + .append(csvCell(voucher.eventBusinessNo())).append("\r\n"); + } + return csv.toString(); + } + + private String csvCell(String value) { + if (value == null) return ""; + return '"' + value.replace("\"", "\"\"") + '"'; + } + + private String sha256(byte[] content) { + try { + byte[] digest = MessageDigest.getInstance("SHA-256").digest(content); + return java.util.HexFormat.of().formatHex(digest); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 is not available", exception); + } + } + + private VoucherDetailView detail(VoucherRow voucher) { + EventRow event = mapper.findEventById(voucher.eventId()); + return new VoucherDetailView(voucherView(voucher), eventView(event), + mapper.listEntries(voucher.id()).stream().map(this::entryView).toList(), + mapper.listResultFiles(voucher.id()).stream().map(this::evidenceFileView).toList(), + mapper.listResultActions(voucher.id()).stream().map(this::resultActionView).toList()); + } + + private VoucherRow scopedVoucher(String publicId, String permission) { + authorizationService.requirePermission(permission); + VoucherRow row = mapper.findVoucher(publicId(publicId)); + if (row == null) throw notFound("凭证不存在"); + authorizationService.requireScope(permission, row.companyPublicId(), row.projectPublicId(), row.eventAmount()); + return row; + } + + private void requireDifferentVoucherReviewer(VoucherLock voucher, long reviewerUserId) { + if (voucher.createdBy() == reviewerUserId || + (voucher.submittedBy() != null && voucher.submittedBy() == reviewerUserId)) { + throw sod("凭证制单人或提交人与凭证复核人不能是同一人"); + } + } + + private EventRow event(String publicId) { + EventRow row = mapper.findEvent(publicId(publicId)); + if (row == null) throw notFound("财务事件不存在"); + return row; + } + + private VoucherLock lock(long id, long version) { + VoucherLock row = mapper.lockVoucher(id); + if (row == null) throw notFound("凭证不存在"); + requireVersion(row.version(), version); + return row; + } + + private AccountingEventView eventView(EventRow row) { + List actions = new ArrayList<>(); + if ("PENDING".equals(row.status()) + && authorizationService.hasScope("accounting:event:generate", row.companyPublicId(), row.projectPublicId(), + row.amount())) { + actions.add("GENERATE_DRAFT"); + } + return new AccountingEventView(row.publicId(), row.businessNo(), row.eventType(), row.sourceType(), + row.sourcePublicId(), row.sourceBusinessNo(), row.sourceVersion(), row.eventSequence(), + row.sourceEventKey(), + reference(row.companyPublicId(), row.companyCode(), row.companyName()), + reference(row.projectPublicId(), row.projectCode(), row.projectName()), + reference(row.counterpartyPublicId(), row.counterpartyCode(), row.counterpartyName()), row.businessDate(), + row.period(), amount(row.amount()), row.currency(), row.status(), row.voucherPublicId(), + row.voucherStatus(), row.version(), row.createdAt(), actions); + } + + private VoucherView voucherView(VoucherRow row) { + Actor actor = actor(); + List actions = new ArrayList<>(); + boolean editable = Set.of("DRAFT", "RETURNED").contains(row.status()); + if (editable && authorizationService.hasScope("accounting:voucher:edit", row.companyPublicId(), + row.projectPublicId(), row.eventAmount())) { + actions.add("EDIT"); + actions.add("SUBMIT"); + } + if (editable && authorizationService.hasScope("accounting:voucher:void", row.companyPublicId(), + row.projectPublicId(), row.eventAmount())) actions.add("VOID"); + if ("REVIEWING".equals(row.status()) && row.createdBy() != actor.userId() + && (row.submittedBy() == null || row.submittedBy() != actor.userId()) + && authorizationService.hasScope("accounting:voucher:review", row.companyPublicId(), + row.projectPublicId(), row.eventAmount())) { + actions.add("APPROVE"); + actions.add("RETURN"); + } + if ("APPROVED".equals(row.status()) + && authorizationService.hasScope("accounting:voucher:export", + row.companyPublicId(), row.projectPublicId(), row.eventAmount())) actions.add("EXPORT"); + if ("EXPORTED".equals(row.status()) && authorizationService.hasScope("accounting:voucher:record-result", + row.companyPublicId(), row.projectPublicId(), row.eventAmount())) actions.add("RECORD_RESULT"); + if ("EXPORTED".equals(row.status()) && row.exportFileId() == null + && authorizationService.hasScope("accounting:voucher:export", + row.companyPublicId(), row.projectPublicId(), row.eventAmount())) actions.add("EXPORT"); + if ("RESULT_RECORDED".equals(row.status()) + && (row.resultRecordedBy() == null || row.resultRecordedBy() != actor.userId()) + && authorizationService.hasScope("accounting:voucher:verify-result", row.companyPublicId(), + row.projectPublicId(), row.eventAmount())) actions.add("VERIFY_RESULT"); + if ("COMPLETED".equals(row.status()) + && (row.resultRecordedBy() == null || row.resultRecordedBy() != actor.userId()) + && authorizationService.hasScope("accounting:voucher:reverse-result", row.companyPublicId(), + row.projectPublicId(), row.eventAmount())) actions.add("REVERSE_RESULT"); + if ("RESULT_REVERSED".equals(row.status())) { + ResultActionRow reversal = mapper.latestResultAction(row.id()); + if (reversal != null && "REVERSE".equals(reversal.actionType()) + && reversal.operatedBy() != actor.userId() + && authorizationService.hasScope("accounting:voucher:reopen-result", row.companyPublicId(), + row.projectPublicId(), row.eventAmount())) { + actions.add("REOPEN_RESULT"); + } + } + String balance = row.debitTotal().compareTo(row.creditTotal()) == 0 ? "BALANCED" : "UNBALANCED"; + return new VoucherView(row.publicId(), row.businessNo(), row.eventPublicId(), row.eventBusinessNo(), + reference(row.companyPublicId(), row.companyCode(), row.companyName()), + reference(row.projectPublicId(), row.projectCode(), row.projectName()), row.period(), row.businessDate(), + row.summary(), amount(row.debitTotal()), amount(row.creditTotal()), balance, row.status(), + row.submittedByName(), row.reviewedByName(), row.exportedAt(), row.exportBatchNo(), row.exportSha256(), + row.exportFileId(), row.ruleVersion(), row.externalVoucherNo(), row.resultAt(), + row.resultCycleNo(), + mapper.listResultFiles(row.id()).stream().map(ResultFileRow::publicId).toList(), + row.resultRecordedByName(), row.resultRecordedAt(), row.resultRecordRemark(), + row.resultVerifiedByName(), row.resultVerifiedAt(), row.resultVerifyOpinion(), row.voidReason(), + row.version(), row.createdAt(), row.updatedAt(), actions); + } + + private VoucherEntryView entryView(EntryRow row) { + return new VoucherEntryView(row.publicId(), row.lineNo(), row.direction(), + reference(row.accountPublicId(), row.accountCode(), row.accountName()), amount(row.amount()), + row.summary(), row.auxiliaryJson()); + } + + private ResultEvidenceFileView evidenceFileView(ResultFileRow row) { + return new ResultEvidenceFileView(row.publicId(), row.originalName(), row.mediaType(), row.sizeBytes(), + row.sha256(), row.scanStatus()); + } + + private VoucherResultActionView resultActionView(ResultActionRow row) { + ResultSnapshot snapshot; + try { + snapshot = objectMapper.readValue(row.resultSnapshotJson(), ResultSnapshot.class); + } catch (JsonProcessingException exception) { + throw new IllegalStateException("Accounting result action snapshot cannot be read", exception); + } + return new VoucherResultActionView(row.publicId(), row.sequenceNo(), row.resultCycleNo(), row.actionType(), + row.reason(), snapshot.externalVoucherNo(), snapshot.resultAt(), snapshot.resultRecordedByName(), + snapshot.resultRecordedAt(), snapshot.resultRecordRemark(), snapshot.resultVerifiedByName(), + snapshot.resultVerifiedAt(), snapshot.resultVerifyOpinion(), snapshot.evidenceFiles(), + row.operatedByName(), row.operatedAt(), row.versionBefore(), row.versionAfter()); + } + + private AccountView accountView(AccountRow row) { + return new AccountView(row.publicId(), row.code(), row.name(), row.accountType(), row.auxiliaryRequired()); + } + + private ReferenceView reference(String publicId, String code, String name) { + return publicId == null ? null : new ReferenceView(publicId, code, name); + } + + private Map snapshot(VoucherLock voucher, List entries) { + return Map.of("voucher", voucher, "entries", entries); + } + + private Map snapshot(VoucherRow voucher, List entries) { + return Map.of("voucher", voucher, "entries", entries); + } + + private ResultSnapshot resultSnapshot(VoucherRow voucher, List files) { + return new ResultSnapshot(voucher.externalVoucherNo(), voucher.resultAt(), voucher.resultRecordedByName(), + voucher.resultRecordedAt(), voucher.resultRecordRemark(), voucher.resultVerifiedByName(), + voucher.resultVerifiedAt(), voucher.resultVerifyOpinion(), + files.stream().map(this::evidenceFileView).toList()); + } + + private String json(Object value) { + try { + return objectMapper.writeValueAsString(value); + } catch (JsonProcessingException exception) { + throw new IllegalStateException("Accounting audit snapshot cannot be serialized", exception); + } + } + + private Actor actor() { + FinancePrincipal principal = identityContext.requirePrincipal(); + return new Actor(principal.userId(), identityContext.requireActiveRole()); + } + + private Page page(int page, int size) { + if (page < 1 || !Set.of(20, 50, 100).contains(size)) throw validation("分页参数无效"); + long offset = (long) (page - 1) * size; + if (offset > Integer.MAX_VALUE) throw validation("分页参数超出范围"); + return new Page(page, size, (int) offset); + } + + private String eventOrderBy(String sort) { + return orderBy(sort, "businessDate,asc", Map.of( + "period", "event.period", "businessDate", "event.business_date", + "amount", "event.amount", "updatedAt", "event.created_at" + )); + } + + private String voucherOrderBy(String sort) { + return orderBy(sort, "updatedAt,desc", Map.of( + "period", "voucher.period", "businessDate", "voucher.business_date", + "amount", "voucher.debit_total", "updatedAt", "voucher.updated_at" + )); + } + + private String orderBy(String sort, String defaultSort, Map columns) { + String value = blank(sort) == null ? defaultSort : blank(sort); + String[] parts = value.split(",", -1); + if (parts.length != 2 || !columns.containsKey(parts[0]) + || !("asc".equalsIgnoreCase(parts[1]) || "desc".equalsIgnoreCase(parts[1]))) { + throw validation("排序参数无效"); + } + return columns.get(parts[0]) + " " + parts[1].toUpperCase(Locale.ROOT); + } + + private String optionalPeriod(String value) { + String cleaned = blank(value); + if (cleaned != null && !PERIOD.matcher(cleaned).matches()) throw validation("会计期间格式无效"); + return cleaned; + } + + private String optionalId(String value) { + String cleaned = blank(value); + return cleaned == null ? null : publicId(cleaned); + } + + private String optionalEventType(String value) { + String cleaned = blank(value); + if (cleaned == null) return null; + String normalized = cleaned.toUpperCase(Locale.ROOT); + if (!EVENT_TYPES.contains(normalized)) throw validation("财务事件类型无效"); + return normalized; + } + + private String optionalStatus(String value) { + String cleaned = blank(value); + if (cleaned == null) return null; + String normalized = cleaned.toUpperCase(Locale.ROOT); + if (!VOUCHER_STATUSES.contains(normalized)) throw validation("凭证状态无效"); + return normalized; + } + + private String publicId(String value) { + String cleaned = blank(value); + String normalized = cleaned == null ? null : cleaned.toUpperCase(Locale.ROOT); + if (normalized == null || !PUBLIC_ID.matcher(normalized).matches()) throw validation("公开编号格式无效"); + return normalized; + } + + private String clean(String value, int maxLength) { + String cleaned = blank(value); + if (cleaned != null && cleaned.length() > maxLength) throw validation("查询文本长度超出限制"); + return cleaned; + } + + private String blank(String value) { + if (value == null) return null; + String cleaned = value.trim(); + return cleaned.isEmpty() ? null : cleaned; + } + + private BigDecimal money(BigDecimal value) { + if (value == null || value.signum() <= 0 || value.scale() > 2) throw validation("金额必须大于零且最多两位小数"); + return value.setScale(2, RoundingMode.UNNECESSARY); + } + + private String amount(BigDecimal value) { + return value.setScale(2, RoundingMode.UNNECESSARY).toPlainString(); + } + + private void requirePeriodMatchesDate(String period, java.time.LocalDate date) { + if (!PERIOD.matcher(period).matches()) throw validation("会计期间格式无效"); + if (!YearMonth.from(date).equals(YearMonth.parse(period))) throw validation("会计期间必须与业务日期一致"); + } + + private EventRow sourceEvent(long eventId) { + EventRow event = mapper.findEventById(eventId); + if (event == null) throw invalidState("凭证来源财务事件不存在"); + return event; + } + + private void requireSourcePeriod(EventRow event, String period, java.time.LocalDate date) { + requirePeriodMatchesDate(period, date); + if (!event.period().equals(period) || !event.businessDate().equals(date)) { + throw validation("凭证期间和业务日期必须与来源财务事件一致"); + } + } + + private void requireVersion(long actual, long expected) { + if (actual != expected) throw conflict(); + } + + private void requireState(String current, String... allowed) { + if (!Set.of(allowed).contains(current)) throw invalidState("当前凭证状态不允许执行该操作"); + } + + private BusinessException validation(String message) { + return new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.VALIDATION_FAILED, message); + } + + private BusinessException notFound(String message) { + return new BusinessException(HttpStatus.NOT_FOUND, ErrorCode.RESOURCE_NOT_FOUND, message); + } + + private BusinessException conflict() { + return new BusinessException(HttpStatus.CONFLICT, ErrorCode.VERSION_CONFLICT, "数据已被其他人更新,请刷新后重试"); + } + + private BusinessException invalidState(String message) { + return new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.INVALID_STATE_TRANSITION, message); + } + + private BusinessException sod(String message) { + return new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.SOD_VIOLATION, message); + } + + private BusinessException duplicate(String message) { + return new BusinessException(HttpStatus.CONFLICT, ErrorCode.DUPLICATE_RESOURCE, message); + } + + private record Actor(long userId, String roleCode) { + } + + private record Page(int page, int size, int offset) { + } + + private record Rule(String debitAccount, String creditAccount) { + } + + private record RuleTemplate(String version, String snapshotJson, Map rules) { + private Rule rule(String eventType) { + Rule result = rules.get(eventType); + if (result == null) throw new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, + ErrorCode.VALIDATION_FAILED, "财务事件类型没有可用的凭证规则"); + return result; + } + } + + private record ResolvedEntry(int lineNo, String direction, AccountRow account, BigDecimal amount, + String summary, String auxiliaryJson) { + } + + private record Totals(BigDecimal debit, BigDecimal credit) { + } + + private record ResultSnapshot( + String externalVoucherNo, + LocalDate resultAt, + String resultRecordedByName, + java.time.LocalDateTime resultRecordedAt, + String resultRecordRemark, + String resultVerifiedByName, + java.time.LocalDateTime resultVerifiedAt, + String resultVerifyOpinion, + List evidenceFiles + ) { + } +} diff --git a/backend/src/main/java/com/kaidi/finance/accounting/infrastructure/AccountingMapper.java b/backend/src/main/java/com/kaidi/finance/accounting/infrastructure/AccountingMapper.java new file mode 100644 index 0000000..41aed24 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/accounting/infrastructure/AccountingMapper.java @@ -0,0 +1,757 @@ +package com.kaidi.finance.accounting.infrastructure; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import org.apache.ibatis.annotations.Delete; +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +@org.apache.ibatis.annotations.Mapper +public interface AccountingMapper { + + String EVENT_SELECT = """ + SELECT event.id, event.public_id, event.business_no, event.event_type, event.source_type, + event.source_public_id, + COALESCE(source_receipt.business_no, source_invoice.business_no, + source_payable.business_no, source_payment.business_no) AS source_business_no, + event.source_version, event.event_sequence, + event.source_event_key, event.company_id, company.public_id AS company_public_id, + company.business_no AS company_code, company.name AS company_name, + event.project_id, project.public_id AS project_public_id, + project.business_no AS project_code, project.name AS project_name, + event.counterparty_id, counterparty.public_id AS counterparty_public_id, + counterparty.business_no AS counterparty_code, counterparty.name AS counterparty_name, + event.business_date, event.period, event.amount, event.currency, event.status, + event.voucher_id, voucher.public_id AS voucher_public_id, voucher.status AS voucher_status, + event.version, event.created_at + FROM acc_event event + JOIN md_company company ON company.id = event.company_id + LEFT JOIN md_project project ON project.id = event.project_id + LEFT JOIN md_counterparty counterparty ON counterparty.id = event.counterparty_id + LEFT JOIN acc_voucher voucher ON voucher.id = event.voucher_id + LEFT JOIN fin_receipt source_receipt + ON event.source_type = 'RECEIPT' AND source_receipt.public_id = event.source_public_id + LEFT JOIN fin_invoice source_invoice + ON event.source_type = 'INVOICE' AND source_invoice.public_id = event.source_public_id + LEFT JOIN fin_payable source_payable + ON event.source_type = 'PAYABLE' AND source_payable.public_id = event.source_public_id + LEFT JOIN fin_payment_request source_payment + ON event.source_type IN ('PAYMENT', 'PAYMENT_REFUND') + AND source_payment.public_id = event.source_public_id + """ + " "; + + String VOUCHER_SELECT = """ + SELECT voucher.id, voucher.public_id, voucher.business_no, voucher.event_id, + event.public_id AS event_public_id, event.business_no AS event_business_no, + voucher.company_id, company.public_id AS company_public_id, + company.business_no AS company_code, company.name AS company_name, + voucher.project_id, project.public_id AS project_public_id, + project.business_no AS project_code, project.name AS project_name, + voucher.rule_version, CAST(voucher.rule_snapshot_json AS CHAR) AS rule_snapshot_json, + voucher.period, voucher.business_date, voucher.summary, event.amount AS event_amount, + voucher.debit_total, voucher.credit_total, voucher.status, + voucher.submitted_by, submitter.display_name AS submitted_by_name, + voucher.reviewed_by, reviewer.display_name AS reviewed_by_name, + voucher.exported_at, voucher.export_sha256, voucher.export_batch_no, + export_file.public_id AS export_file_id, + voucher.external_voucher_no, voucher.result_at, voucher.result_recorded_by, + recorder.display_name AS result_recorded_by_name, voucher.result_recorded_at, + voucher.result_record_remark, + voucher.result_verified_by, verifier.display_name AS result_verified_by_name, + voucher.result_verified_at, voucher.result_verify_opinion, voucher.result_cycle_no, + voucher.void_reason, + voucher.last_change_reason, voucher.created_by, voucher.updated_by, + voucher.version, voucher.created_at, voucher.updated_at + FROM acc_voucher voucher + JOIN acc_event event ON event.id = voucher.event_id + JOIN md_company company ON company.id = voucher.company_id + LEFT JOIN md_project project ON project.id = voucher.project_id + LEFT JOIN iam_user submitter ON submitter.id = voucher.submitted_by + LEFT JOIN iam_user reviewer ON reviewer.id = voucher.reviewed_by + LEFT JOIN iam_user recorder ON recorder.id = voucher.result_recorded_by + LEFT JOIN iam_user verifier ON verifier.id = voucher.result_verified_by + LEFT JOIN acc_export_batch export_batch ON export_batch.voucher_id = voucher.id + LEFT JOIN file_object export_file ON export_file.id = export_batch.file_id + """ + " "; + + String EVENT_SCOPE = " " + """ + EXISTS ( + SELECT 1 + FROM iam_scope scope + JOIN iam_role role ON role.id = scope.role_id + JOIN iam_permission permission ON permission.id = scope.permission_id + WHERE scope.user_id = #{userId} + AND role.code = #{roleCode} + AND permission.code = 'accounting:event:view' + AND scope.status = 'ACTIVE' + AND scope.valid_from <= UTC_TIMESTAMP(3) + AND (scope.valid_to IS NULL OR scope.valid_to >= UTC_TIMESTAMP(3)) + AND ( + scope.scope_type = 'GLOBAL' + OR (scope.scope_type = 'COMPANY' AND scope.company_public_id = company.public_id) + OR (scope.scope_type = 'PROJECT' AND scope.project_public_id = project.public_id) + ) + AND (scope.amount_limit IS NULL OR event.amount <= scope.amount_limit) + ) + """ + " "; + + String VOUCHER_SCOPE = " " + """ + EXISTS ( + SELECT 1 + FROM iam_scope scope + JOIN iam_role role ON role.id = scope.role_id + JOIN iam_permission permission ON permission.id = scope.permission_id + WHERE scope.user_id = #{userId} + AND role.code = #{roleCode} + AND permission.code = 'accounting:voucher:view' + AND scope.status = 'ACTIVE' + AND scope.valid_from <= UTC_TIMESTAMP(3) + AND (scope.valid_to IS NULL OR scope.valid_to >= UTC_TIMESTAMP(3)) + AND ( + scope.scope_type = 'GLOBAL' + OR (scope.scope_type = 'COMPANY' AND scope.company_public_id = company.public_id) + OR (scope.scope_type = 'PROJECT' AND scope.project_public_id = project.public_id) + ) + AND (scope.amount_limit IS NULL OR event.amount <= scope.amount_limit) + ) + """ + " "; + + @Select(""" + SELECT account.id, account.public_id, account.code, account.name, + account.account_type, account.auxiliary_required, account.status + FROM md_account account + WHERE account.status = 'ACTIVE' + ORDER BY account.code + """) + List listAccounts(); + + @Select(""" + + """) + List listEvents(@Param("userId") long userId, + @Param("roleCode") String roleCode, + @Param("status") String status, + @Param("period") String period, + @Param("projectId") String projectId, + @Param("eventType") String eventType, + @Param("keyword") String keyword, + @Param("orderBy") String orderBy, + @Param("limit") int limit, + @Param("offset") int offset); + + @Select(""" + + """) + long countEvents(@Param("userId") long userId, + @Param("roleCode") String roleCode, + @Param("status") String status, + @Param("period") String period, + @Param("projectId") String projectId, + @Param("eventType") String eventType, + @Param("keyword") String keyword); + + @Select(""" + + """) + List listVouchers(@Param("userId") long userId, + @Param("roleCode") String roleCode, + @Param("tab") String tab, + @Param("period") String period, + @Param("projectId") String projectId, + @Param("eventType") String eventType, + @Param("status") String status, + @Param("externalVoucherNo") String externalVoucherNo, + @Param("keyword") String keyword, + @Param("orderBy") String orderBy, + @Param("limit") int limit, + @Param("offset") int offset); + + @Select(""" + + """) + long countVouchers(@Param("userId") long userId, + @Param("roleCode") String roleCode, + @Param("tab") String tab, + @Param("period") String period, + @Param("projectId") String projectId, + @Param("eventType") String eventType, + @Param("status") String status, + @Param("externalVoucherNo") String externalVoucherNo, + @Param("keyword") String keyword); + + @Select(EVENT_SELECT + " WHERE event.public_id = #{publicId}") + EventRow findEvent(String publicId); + + @Select(EVENT_SELECT + " WHERE event.id = #{id}") + EventRow findEventById(long id); + + @Select(EVENT_SELECT + " WHERE event.id = #{id} FOR UPDATE") + EventRow lockEvent(long id); + + @Select(VOUCHER_SELECT + " WHERE voucher.public_id = #{publicId}") + VoucherRow findVoucher(String publicId); + + @Select(VOUCHER_SELECT + " WHERE voucher.id = #{id}") + VoucherRow findVoucherById(long id); + + @Select(""" + SELECT id, public_id, event_id, company_id, project_id, period, business_date, + summary, debit_total, credit_total, status, submitted_by, reviewed_by, + result_recorded_by, result_verified_by, result_cycle_no, version, created_by + FROM acc_voucher WHERE id = #{id} FOR UPDATE + """) + VoucherLock lockVoucher(long id); + + @Select(""" + SELECT entry.id, entry.public_id, entry.line_no, entry.direction, entry.account_id, + account.public_id AS account_public_id, account.code AS account_code, + account.name AS account_name, account.auxiliary_required, + account.status AS account_status, + entry.amount, entry.summary, CAST(entry.auxiliary_json AS CHAR) AS auxiliary_json + FROM acc_voucher_entry entry + JOIN md_account account ON account.id = entry.account_id + WHERE entry.voucher_id = #{voucherId} + ORDER BY entry.line_no + """) + List listEntries(long voucherId); + + @Select(""" + SELECT account.id, account.public_id, account.code, account.name, + account.account_type, account.auxiliary_required, account.status + FROM md_account account WHERE account.public_id = #{publicId} + """) + AccountRow findAccount(String publicId); + + @Select("SELECT id FROM md_account WHERE code = #{code} AND status = 'ACTIVE'") + Long activeAccountId(String code); + + @Select(""" + SELECT id, public_id, version_no, CAST(value_json AS CHAR) AS value_json, status + FROM sys_parameter_version + WHERE parameter_group = 'ACCOUNTING_RULE_TEMPLATE' + AND status = 'ACTIVE' AND effective_at IS NOT NULL + AND effective_at <= UTC_TIMESTAMP(3) + AND JSON_UNQUOTE(JSON_EXTRACT(value_json, '$.ruleVersion')) = #{ruleVersion} + ORDER BY version_no DESC + LIMIT 1 + """) + RuleParameterRow findAccountingRuleTemplate(String ruleVersion); + + @Insert(""" + INSERT INTO acc_voucher ( + public_id, business_no, event_id, company_id, project_id, rule_version, rule_snapshot_json, + period, business_date, + summary, debit_total, credit_total, status, created_by, updated_by + ) VALUES ( + #{publicId}, #{businessNo}, #{eventId}, #{companyId}, #{projectId}, #{ruleVersion}, + CAST(#{ruleSnapshotJson} AS JSON), #{period}, #{businessDate}, + #{summary}, #{amount}, #{amount}, 'DRAFT', #{actorId}, #{actorId} + ) + """) + int insertVoucher(@Param("publicId") String publicId, + @Param("businessNo") String businessNo, + @Param("eventId") long eventId, + @Param("companyId") long companyId, + @Param("projectId") Long projectId, + @Param("ruleVersion") String ruleVersion, + @Param("ruleSnapshotJson") String ruleSnapshotJson, + @Param("period") String period, + @Param("businessDate") LocalDate businessDate, + @Param("summary") String summary, + @Param("amount") BigDecimal amount, + @Param("actorId") long actorId); + + @Select("SELECT id FROM acc_voucher WHERE public_id = #{publicId}") + Long voucherId(String publicId); + + @Insert(""" + INSERT INTO acc_voucher_entry + (public_id, voucher_id, line_no, direction, account_id, amount, summary, auxiliary_json) + VALUES + (#{publicId}, #{voucherId}, #{lineNo}, #{direction}, #{accountId}, #{amount}, #{summary}, + CASE WHEN #{auxiliaryJson} IS NULL THEN NULL ELSE CAST(#{auxiliaryJson} AS JSON) END) + """) + int insertEntry(@Param("publicId") String publicId, + @Param("voucherId") long voucherId, + @Param("lineNo") int lineNo, + @Param("direction") String direction, + @Param("accountId") long accountId, + @Param("amount") BigDecimal amount, + @Param("summary") String summary, + @Param("auxiliaryJson") String auxiliaryJson); + + @Update(""" + UPDATE acc_event + SET status = 'DRAFTED', voucher_id = #{voucherId}, version = version + 1 + WHERE id = #{eventId} AND version = #{version} AND status = 'PENDING' + """) + int markEventDrafted(@Param("eventId") long eventId, + @Param("voucherId") long voucherId, + @Param("version") long version); + + @Update(""" + UPDATE acc_voucher + SET period = #{period}, business_date = #{businessDate}, summary = #{summary}, + debit_total = #{debitTotal}, credit_total = #{creditTotal}, + last_change_reason = #{changeReason}, updated_by = #{actorId}, version = version + 1 + WHERE id = #{id} AND version = #{version} AND status IN ('DRAFT', 'RETURNED') + """) + int updateVoucher(@Param("id") long id, + @Param("version") long version, + @Param("period") String period, + @Param("businessDate") LocalDate businessDate, + @Param("summary") String summary, + @Param("debitTotal") BigDecimal debitTotal, + @Param("creditTotal") BigDecimal creditTotal, + @Param("changeReason") String changeReason, + @Param("actorId") long actorId); + + @Delete("DELETE FROM acc_voucher_entry WHERE voucher_id = #{voucherId}") + int deleteEntries(long voucherId); + + @Insert(""" + INSERT INTO acc_voucher_change + (public_id, voucher_id, reason, before_json, after_json, changed_by) + VALUES + (#{publicId}, #{voucherId}, #{reason}, CAST(#{beforeJson} AS JSON), CAST(#{afterJson} AS JSON), #{actorId}) + """) + int insertChange(@Param("publicId") String publicId, + @Param("voucherId") long voucherId, + @Param("reason") String reason, + @Param("beforeJson") String beforeJson, + @Param("afterJson") String afterJson, + @Param("actorId") long actorId); + + @Update(""" + UPDATE acc_voucher + SET status = 'REVIEWING', submitted_by = #{actorId}, reviewed_by = NULL, + updated_by = #{actorId}, version = version + 1 + WHERE id = #{id} AND version = #{version} AND status IN ('DRAFT', 'RETURNED') + """) + int submitVoucher(@Param("id") long id, @Param("version") long version, @Param("actorId") long actorId); + + @Update(""" + UPDATE acc_voucher + SET status = 'RETURNED', reviewed_by = #{actorId}, last_change_reason = #{opinion}, + updated_by = #{actorId}, version = version + 1 + WHERE id = #{id} AND version = #{version} AND status = 'REVIEWING' + """) + int returnVoucher(@Param("id") long id, + @Param("version") long version, + @Param("opinion") String opinion, + @Param("actorId") long actorId); + + @Update(""" + UPDATE acc_voucher + SET status = 'APPROVED', reviewed_by = #{actorId}, updated_by = #{actorId}, version = version + 1 + WHERE id = #{id} AND version = #{version} AND status = 'REVIEWING' + """) + int approveVoucher(@Param("id") long id, @Param("version") long version, @Param("actorId") long actorId); + + @Update(""" + UPDATE acc_voucher + SET status = 'EXPORTED', exported_at = UTC_TIMESTAMP(3), export_sha256 = #{sha256}, + export_batch_no = #{batchNo}, updated_by = #{actorId}, version = version + 1 + WHERE id = #{id} AND version = #{version} AND status = 'APPROVED' + """) + int markExported(@Param("id") long id, + @Param("version") long version, + @Param("sha256") String sha256, + @Param("batchNo") String batchNo, + @Param("actorId") long actorId); + + @Update(""" + UPDATE acc_voucher + SET status = 'RESULT_RECORDED', external_voucher_no = #{externalVoucherNo}, + result_at = #{resultAt}, result_record_remark = #{remark}, result_recorded_by = #{actorId}, + result_recorded_at = UTC_TIMESTAMP(3), result_verified_by = NULL, + result_verified_at = NULL, result_verify_opinion = NULL, + updated_by = #{actorId}, version = version + 1 + WHERE id = #{id} AND version = #{version} AND status = 'EXPORTED' + """) + int recordResult(@Param("id") long id, + @Param("version") long version, + @Param("externalVoucherNo") String externalVoucherNo, + @Param("resultAt") LocalDate resultAt, + @Param("remark") String remark, + @Param("actorId") long actorId); + + @Update(""" + UPDATE acc_voucher + SET status = 'COMPLETED', result_verified_by = #{actorId}, + result_verified_at = UTC_TIMESTAMP(3), result_verify_opinion = #{opinion}, + updated_by = #{actorId}, version = version + 1 + WHERE id = #{id} AND version = #{version} AND status = 'RESULT_RECORDED' + """) + int verifyResult(@Param("id") long id, + @Param("version") long version, + @Param("opinion") String opinion, + @Param("actorId") long actorId); + + @Update(""" + UPDATE acc_voucher + SET status = 'RESULT_REVERSED', updated_by = #{actorId}, version = version + 1 + WHERE id = #{id} AND version = #{version} AND status = 'COMPLETED' + """) + int reverseResult(@Param("id") long id, + @Param("version") long version, + @Param("actorId") long actorId); + + @Update(""" + UPDATE acc_event + SET status = 'DRAFTED', version = version + 1 + WHERE id = #{eventId} AND voucher_id = #{voucherId} AND status = 'COMPLETED' + """) + int reopenCompletedEvent(@Param("eventId") long eventId, @Param("voucherId") long voucherId); + + @Update(""" + UPDATE acc_voucher + SET status = 'EXPORTED', external_voucher_no = NULL, result_at = NULL, + result_recorded_by = NULL, result_recorded_at = NULL, result_record_remark = NULL, + result_verified_by = NULL, result_verified_at = NULL, result_verify_opinion = NULL, + result_cycle_no = result_cycle_no + 1, + updated_by = #{actorId}, version = version + 1 + WHERE id = #{id} AND version = #{version} AND status = 'RESULT_REVERSED' + """) + int reopenResult(@Param("id") long id, + @Param("version") long version, + @Param("actorId") long actorId); + + @Update(""" + UPDATE acc_voucher + SET status = 'VOID', void_reason = #{reason}, updated_by = #{actorId}, version = version + 1 + WHERE id = #{id} AND version = #{version} AND status IN ('DRAFT', 'RETURNED') + """) + int voidVoucher(@Param("id") long id, + @Param("version") long version, + @Param("reason") String reason, + @Param("actorId") long actorId); + + @Update(""" + UPDATE acc_event SET status = #{status}, version = version + 1 + WHERE id = #{eventId} AND status = 'DRAFTED' + """) + int completeEvent(@Param("eventId") long eventId, @Param("status") String status); + + @Select(""" + SELECT COUNT(*) + FROM acc_voucher_result_action + WHERE company_id = #{companyId} AND period = #{period} + AND historical_external_voucher_no = #{externalVoucherNo} + """) + int historicalExternalVoucherNoExists(@Param("companyId") long companyId, + @Param("period") String period, + @Param("externalVoucherNo") String externalVoucherNo); + + @Select(""" + SELECT COALESCE(MAX(sequence_no), 0) + 1 + FROM acc_voucher_result_action + WHERE voucher_id = #{voucherId} + """) + int nextResultActionSequence(long voucherId); + + @Insert(""" + INSERT INTO acc_voucher_result_action ( + public_id, voucher_id, company_id, period, sequence_no, result_cycle_no, action_type, + reason, historical_external_voucher_no, result_snapshot_json, operated_by, + version_before, version_after + ) VALUES ( + #{publicId}, #{voucherId}, #{companyId}, #{period}, #{sequenceNo}, #{resultCycleNo}, #{actionType}, + #{reason}, #{historicalExternalVoucherNo}, CAST(#{resultSnapshotJson} AS JSON), #{actorId}, + #{versionBefore}, #{versionAfter} + ) + """) + int insertResultAction(@Param("publicId") String publicId, + @Param("voucherId") long voucherId, + @Param("companyId") long companyId, + @Param("period") String period, + @Param("sequenceNo") int sequenceNo, + @Param("resultCycleNo") int resultCycleNo, + @Param("actionType") String actionType, + @Param("reason") String reason, + @Param("historicalExternalVoucherNo") String historicalExternalVoucherNo, + @Param("resultSnapshotJson") String resultSnapshotJson, + @Param("actorId") long actorId, + @Param("versionBefore") long versionBefore, + @Param("versionAfter") long versionAfter); + + @Select(""" + SELECT action.id, action.public_id, action.voucher_id, action.sequence_no, + action.result_cycle_no, action.action_type, action.reason, + CAST(action.result_snapshot_json AS CHAR) AS result_snapshot_json, + action.operated_by, operator.display_name AS operated_by_name, + action.operated_at, action.version_before, action.version_after + FROM acc_voucher_result_action action + JOIN iam_user operator ON operator.id = action.operated_by + WHERE action.voucher_id = #{voucherId} + ORDER BY action.sequence_no DESC + """) + List listResultActions(long voucherId); + + @Select(""" + SELECT action.id, action.public_id, action.voucher_id, action.sequence_no, + action.result_cycle_no, action.action_type, action.reason, + CAST(action.result_snapshot_json AS CHAR) AS result_snapshot_json, + action.operated_by, operator.display_name AS operated_by_name, + action.operated_at, action.version_before, action.version_after + FROM acc_voucher_result_action action + JOIN iam_user operator ON operator.id = action.operated_by + WHERE action.voucher_id = #{voucherId} + ORDER BY action.sequence_no DESC + LIMIT 1 + """) + ResultActionRow latestResultAction(long voucherId); + + @Update(""" + UPDATE acc_event + SET status = 'PENDING', voucher_id = NULL, version = version + 1 + WHERE id = #{eventId} AND status = 'DRAFTED' AND voucher_id = #{voucherId} + """) + int releaseVoidedEvent(@Param("eventId") long eventId, @Param("voucherId") long voucherId); + + @Select(""" + SELECT batch.id, batch.public_id, batch.batch_no, batch.voucher_id, batch.rule_version, + CAST(batch.range_json AS CHAR) AS range_json, file.public_id AS file_public_id, + batch.sha256, batch.row_count, batch.exported_by, batch.exported_at + FROM acc_export_batch batch + JOIN file_object file ON file.id = batch.file_id + WHERE batch.voucher_id = #{voucherId} + """) + ExportBatchRow findExportBatch(long voucherId); + + @Insert(""" + INSERT INTO acc_export_batch + (public_id, batch_no, voucher_id, rule_version, range_json, file_id, sha256, row_count, + exported_by) + SELECT #{publicId}, #{batchNo}, #{voucherId}, #{ruleVersion}, CAST(#{rangeJson} AS JSON), file.id, + #{sha256}, #{rowCount}, #{actorId} + FROM file_object file + WHERE file.public_id = #{filePublicId} AND file.scan_status = 'AVAILABLE' + """) + int insertExportBatch(@Param("publicId") String publicId, + @Param("batchNo") String batchNo, + @Param("voucherId") long voucherId, + @Param("ruleVersion") String ruleVersion, + @Param("rangeJson") String rangeJson, + @Param("filePublicId") String filePublicId, + @Param("sha256") String sha256, + @Param("rowCount") int rowCount, + @Param("actorId") long actorId); + + @Select(""" + SELECT file.id, file.public_id, file.original_name, file.scan_status, + (file.uploaded_by = #{actorPublicId}) AS uploaded_by_actor, + EXISTS ( + SELECT 1 + FROM acc_voucher_result_file result_file + WHERE result_file.voucher_id = #{voucherId} AND result_file.file_id = file.id + ) AS attached_to_voucher, + EXISTS ( + SELECT 1 + FROM file_link link + WHERE link.file_id = file.id AND link.active = TRUE + AND link.archive_status IN ('ACTIVE', 'ARCHIVED', 'FROZEN') + AND (link.company_public_id = #{companyPublicId} + OR link.project_public_id = #{projectPublicId}) + ) AS linked_to_target + FROM file_object file + WHERE file.public_id = #{publicId} + """) + EvidenceFileRow findEvidenceFile(@Param("publicId") String publicId, + @Param("actorPublicId") String actorPublicId, + @Param("voucherId") long voucherId, + @Param("companyPublicId") String companyPublicId, + @Param("projectPublicId") String projectPublicId); + + @Insert(""" + INSERT INTO acc_voucher_result_file + (public_id, voucher_id, result_cycle_no, file_id, created_by) + VALUES (#{publicId}, #{voucherId}, #{resultCycleNo}, #{fileId}, #{actorId}) + """) + int insertResultFile(@Param("publicId") String publicId, + @Param("voucherId") long voucherId, + @Param("resultCycleNo") int resultCycleNo, + @Param("fileId") long fileId, + @Param("actorId") long actorId); + + @Select(""" + SELECT file.public_id, file.original_name, file.media_type, file.size_bytes, + file.sha256, file.scan_status + FROM acc_voucher_result_file result_file + JOIN acc_voucher voucher ON voucher.id = result_file.voucher_id + JOIN file_object file ON file.id = result_file.file_id + WHERE result_file.voucher_id = #{voucherId} + AND result_file.result_cycle_no = voucher.result_cycle_no + ORDER BY result_file.created_at, result_file.public_id + """) + List listResultFiles(long voucherId); + + @Select(""" + SELECT file.public_id, file.original_name, file.media_type, file.size_bytes, + file.sha256, file.scan_status + FROM acc_voucher_result_file result_file + JOIN file_object file ON file.id = result_file.file_id + WHERE result_file.voucher_id = #{voucherId} AND file.public_id = #{filePublicId} + ORDER BY result_file.result_cycle_no DESC + LIMIT 1 + """) + ResultFileRow findResultFile(@Param("voucherId") long voucherId, + @Param("filePublicId") String filePublicId); + + record AccountRow(long id, String publicId, String code, String name, String accountType, + boolean auxiliaryRequired, String status) { + } + + record RuleParameterRow(long id, String publicId, int versionNo, String valueJson, String status) { + } + + record EventRow(long id, String publicId, String businessNo, String eventType, String sourceType, + String sourcePublicId, String sourceBusinessNo, String sourceVersion, int eventSequence, + String sourceEventKey, + long companyId, String companyPublicId, String companyCode, + String companyName, Long projectId, String projectPublicId, String projectCode, + String projectName, Long counterpartyId, String counterpartyPublicId, + String counterpartyCode, String counterpartyName, LocalDate businessDate, + String period, BigDecimal amount, String currency, String status, Long voucherId, + String voucherPublicId, String voucherStatus, long version, LocalDateTime createdAt) { + } + + record VoucherRow(long id, String publicId, String businessNo, long eventId, + String eventPublicId, String eventBusinessNo, long companyId, + String companyPublicId, String companyCode, String companyName, Long projectId, + String projectPublicId, String projectCode, String projectName, String ruleVersion, + String ruleSnapshotJson, String period, + LocalDate businessDate, String summary, BigDecimal eventAmount, BigDecimal debitTotal, + BigDecimal creditTotal, + String status, Long submittedBy, String submittedByName, Long reviewedBy, + String reviewedByName, LocalDateTime exportedAt, String exportSha256, + String exportBatchNo, String exportFileId, String externalVoucherNo, LocalDate resultAt, + Long resultRecordedBy, + String resultRecordedByName, LocalDateTime resultRecordedAt, String resultRecordRemark, + Long resultVerifiedBy, String resultVerifiedByName, LocalDateTime resultVerifiedAt, + String resultVerifyOpinion, int resultCycleNo, + String voidReason, String lastChangeReason, long createdBy, long updatedBy, + long version, LocalDateTime createdAt, LocalDateTime updatedAt) { + } + + record VoucherLock(long id, String publicId, long eventId, long companyId, Long projectId, + String period, LocalDate businessDate, String summary, BigDecimal debitTotal, + BigDecimal creditTotal, String status, Long submittedBy, Long reviewedBy, + Long resultRecordedBy, Long resultVerifiedBy, int resultCycleNo, + long version, long createdBy) { + } + + record EntryRow(long id, String publicId, int lineNo, String direction, long accountId, + String accountPublicId, String accountCode, String accountName, + boolean auxiliaryRequired, String accountStatus, BigDecimal amount, + String summary, String auxiliaryJson) { + } + + record ExportBatchRow(long id, String publicId, String batchNo, long voucherId, String ruleVersion, + String rangeJson, String filePublicId, String sha256, int rowCount, + long exportedBy, LocalDateTime exportedAt) { + } + + record EvidenceFileRow(long id, String publicId, String originalName, String scanStatus, + boolean uploadedByActor, boolean attachedToVoucher, boolean linkedToTarget) { + } + + record ResultFileRow(String publicId, String originalName, String mediaType, long sizeBytes, + String sha256, String scanStatus) { + } + + record ResultActionRow(long id, String publicId, long voucherId, int sequenceNo, + int resultCycleNo, String actionType, String reason, + String resultSnapshotJson, long operatedBy, String operatedByName, + LocalDateTime operatedAt, long versionBefore, long versionAfter) { + } +} diff --git a/backend/src/main/java/com/kaidi/finance/archive/api/ArchiveContracts.java b/backend/src/main/java/com/kaidi/finance/archive/api/ArchiveContracts.java new file mode 100644 index 0000000..85f3c7d --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/archive/api/ArchiveContracts.java @@ -0,0 +1,96 @@ +package com.kaidi.finance.archive.api; + +import jakarta.validation.constraints.FutureOrPresent; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +public final class ArchiveContracts { + + private ArchiveContracts() { + } + + public record PackageCreateRequest( + @NotBlank @Size(max = 26) String projectId, + @Min(1) Integer retentionYears, + @Size(max = 500) String physicalLocation + ) { + } + + public record PackageRevisionRequest( + @NotNull Long version, + @NotBlank @Size(min = 2, max = 1000) String reason + ) { + } + + public record PackageCommandRequest( + @NotNull Long version, + @Size(max = 1000) String opinion, + @Size(max = 50) List<@Pattern(regexp = "[0-9A-HJKMNP-TV-Z]{26}") String> returnItemIds + ) { + } + + public record FreezeRequest( + @NotNull Long version, + @NotBlank @Size(max = 1000) String reason + ) { + } + + public record NotApplicableRequest( + @NotNull Long version, + @NotBlank @Size(min = 2, max = 500) String reason + ) { + } + + public record NotApplicableReviewRequest( + @NotNull Long version, + @NotNull Boolean approved, + @NotBlank @Size(min = 2, max = 1000) String opinion + ) { + } + + public record FileLinkMetadata( + @NotBlank @Size(max = 26) String projectId, + @Size(max = 26) String packageId, + @Size(max = 26) String packageItemId, + @Size(max = 26) String contractId, + @Size(max = 26) String counterpartyId, + @Size(max = 32) String formType, + @NotBlank @Size(max = 64) String fileType, + @Size(max = 64) String originalType, + @Size(max = 26) String replacesFileId, + @Size(max = 500) String replacementReason, + @Size(max = 500) String notApplicableReason + ) { + } + + public record BorrowCreateRequest( + @NotBlank @Size(max = 26) String fileId, + @NotBlank @Size(min = 2, max = 500) String purpose, + @NotNull @FutureOrPresent LocalDateTime dueAt + ) { + } + + public record BorrowCommandRequest( + @NotNull Long version, + @Size(min = 2, max = 1000) String opinion, + @Size(max = 500) String returnCondition + ) { + } + + public record ReportExportRequest( + @Size(max = 26) String companyId, + @Size(max = 26) String projectId, + @Size(max = 100) String keyword, + @Size(max = 32) String status, + LocalDate dateFrom, + LocalDate dateTo, + @Size(max = 20) String[] columns + ) { + } +} diff --git a/backend/src/main/java/com/kaidi/finance/archive/api/ArchiveController.java b/backend/src/main/java/com/kaidi/finance/archive/api/ArchiveController.java new file mode 100644 index 0000000..8045ff0 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/archive/api/ArchiveController.java @@ -0,0 +1,354 @@ +package com.kaidi.finance.archive.api; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.kaidi.finance.archive.api.ArchiveContracts.BorrowCommandRequest; +import com.kaidi.finance.archive.api.ArchiveContracts.BorrowCreateRequest; +import com.kaidi.finance.archive.api.ArchiveContracts.FileLinkMetadata; +import com.kaidi.finance.archive.api.ArchiveContracts.FreezeRequest; +import com.kaidi.finance.archive.api.ArchiveContracts.NotApplicableRequest; +import com.kaidi.finance.archive.api.ArchiveContracts.NotApplicableReviewRequest; +import com.kaidi.finance.archive.api.ArchiveContracts.PackageCommandRequest; +import com.kaidi.finance.archive.api.ArchiveContracts.PackageCreateRequest; +import com.kaidi.finance.archive.api.ArchiveContracts.PackageRevisionRequest; +import com.kaidi.finance.archive.api.ArchiveViews.ArchiveFileDetailView; +import com.kaidi.finance.archive.api.ArchiveViews.ArchiveFileView; +import com.kaidi.finance.archive.api.ArchiveViews.BorrowView; +import com.kaidi.finance.archive.api.ArchiveViews.PackageView; +import com.kaidi.finance.archive.application.ArchiveApplicationService; +import com.kaidi.finance.shared.api.ApiResponse; +import com.kaidi.finance.shared.api.BusinessException; +import com.kaidi.finance.shared.api.ErrorCode; +import com.kaidi.finance.shared.api.PageResult; +import com.kaidi.finance.shared.idempotency.IdempotencyApplicationService; +import io.swagger.v3.oas.annotations.Operation; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import java.nio.charset.StandardCharsets; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import org.springframework.core.io.Resource; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.http.ContentDisposition; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +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; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.util.UriUtils; + +@RestController +@RequestMapping({"/api/v1/archive", "/api/v1/archives"}) +public class ArchiveController { + + private final ArchiveApplicationService service; + private final IdempotencyApplicationService idempotencyService; + + public ArchiveController(ArchiveApplicationService service, + IdempotencyApplicationService idempotencyService) { + this.service = service; + this.idempotencyService = idempotencyService; + } + + @GetMapping("/packages") + @Operation(operationId = "listArchivePackages", summary = "查询项目档案包") + @PreAuthorize("@authorizationService.hasPermission('archive:package:view')") + public ApiResponse> packages( + @RequestParam(required = false) String view, + @RequestParam(required = false) String companyId, + @RequestParam(required = false) String projectId, + @RequestParam(required = false) String status, + @RequestParam(required = false) String completeness, + @RequestParam(required = false) Boolean frozen, + @RequestParam(required = false) String keyword, + @RequestParam(defaultValue = "updatedAt,desc") String sort, + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int size) { + PageResult result = service.listPackages(view, companyId, projectId, status, completeness, + frozen, keyword, sort, page, size); + return ApiResponse.ok(result.items(), result.meta()); + } + + @GetMapping("/packages/{publicId}") + @Operation(operationId = "getArchivePackage", summary = "查询档案包详情") + @PreAuthorize("@authorizationService.hasPermission('archive:package:view')") + public ApiResponse packageDetail(@PathVariable String publicId) { + return ApiResponse.ok(service.getPackage(publicId)); + } + + @PostMapping("/packages") + @Operation(operationId = "createArchivePackage", summary = "生成档案包") + @PreAuthorize("@authorizationService.hasPermission('archive:package:create')") + public ApiResponse createPackage(@Valid @RequestBody PackageCreateRequest request, + @RequestHeader(value = "Idempotency-Key", required = false) String key, + HttpServletRequest httpRequest) { + return ApiResponse.ok(command(key, httpRequest, request, new TypeReference() { }, + () -> service.createPackage(request))); + } + + @PostMapping("/packages/{publicId}/check") + @Operation(operationId = "checkArchivePackage", summary = "检查档案包完整性") + @PreAuthorize("@authorizationService.hasPermission('archive:package:submit')") + public ApiResponse checkPackage(@PathVariable String publicId, + @Valid @RequestBody PackageCommandRequest request, + @RequestHeader(value = "Idempotency-Key", required = false) String key, + HttpServletRequest httpRequest) { + return ApiResponse.ok(command(key, httpRequest, request, new TypeReference() { }, + () -> service.checkPackage(publicId, request))); + } + + @PostMapping("/packages/{publicId}/submit") + @Operation(operationId = "submitArchivePackage", summary = "提交档案包审核") + @PreAuthorize("@authorizationService.hasPermission('archive:package:submit')") + public ApiResponse submitPackage(@PathVariable String publicId, + @Valid @RequestBody PackageCommandRequest request, + @RequestHeader(value = "Idempotency-Key", required = false) String key, + HttpServletRequest httpRequest) { + return ApiResponse.ok(command(key, httpRequest, request, new TypeReference() { }, + () -> service.submitPackage(publicId, request))); + } + + @PostMapping("/packages/{publicId}/return") + @Operation(operationId = "returnArchivePackage", summary = "退回档案包") + @PreAuthorize("@authorizationService.hasPermission('archive:package:review')") + public ApiResponse returnPackage(@PathVariable String publicId, + @Valid @RequestBody PackageCommandRequest request, + @RequestHeader(value = "Idempotency-Key", required = false) String key, + HttpServletRequest httpRequest) { + return ApiResponse.ok(command(key, httpRequest, request, new TypeReference() { }, + () -> service.returnPackage(publicId, request))); + } + + @PostMapping("/packages/{publicId}/revise") + @Operation(operationId = "reviseArchivePackage", summary = "生成档案包补件版本") + @PreAuthorize("@authorizationService.hasPermission('archive:package:submit')") + public ApiResponse revisePackage(@PathVariable String publicId, + @Valid @RequestBody PackageRevisionRequest request, + @RequestHeader(value = "Idempotency-Key", required = false) String key, + HttpServletRequest httpRequest) { + return ApiResponse.ok(command(key, httpRequest, request, new TypeReference() { }, + () -> service.revisePackage(publicId, request))); + } + + @PostMapping("/packages/{publicId}/archive") + @Operation(operationId = "archivePackage", summary = "确认归档") + @PreAuthorize("@authorizationService.hasPermission('archive:package:archive')") + public ApiResponse archivePackage(@PathVariable String publicId, + @Valid @RequestBody PackageCommandRequest request, + @RequestHeader(value = "Idempotency-Key", required = false) String key, + HttpServletRequest httpRequest) { + return ApiResponse.ok(command(key, httpRequest, request, new TypeReference() { }, + () -> service.archivePackage(publicId, request))); + } + + @PostMapping("/packages/{publicId}/freeze") + @Operation(operationId = "freezeArchivePackage", summary = "冻结档案包") + @PreAuthorize("@authorizationService.hasPermission('archive:package:freeze')") + public ApiResponse freezePackage(@PathVariable String publicId, + @Valid @RequestBody FreezeRequest request, + @RequestHeader(value = "Idempotency-Key", required = false) String key, + HttpServletRequest httpRequest) { + return ApiResponse.ok(command(key, httpRequest, request, new TypeReference() { }, + () -> service.freezePackage(publicId, request))); + } + + @PostMapping("/packages/{publicId}/unfreeze") + @Operation(operationId = "unfreezeArchivePackage", summary = "解冻档案包") + @PreAuthorize("@authorizationService.hasPermission('archive:package:unfreeze')") + public ApiResponse unfreezePackage(@PathVariable String publicId, + @Valid @RequestBody PackageCommandRequest request, + @RequestHeader(value = "Idempotency-Key", required = false) String key, + HttpServletRequest httpRequest) { + return ApiResponse.ok(command(key, httpRequest, request, new TypeReference() { }, + () -> service.unfreezePackage(publicId, request))); + } + + @PostMapping("/packages/{packageId}/items/{itemId}/not-applicable") + @Operation(operationId = "markArchiveItemNotApplicable", summary = "申请档案项不涉及") + @PreAuthorize("@authorizationService.hasPermission('archive:package:submit')") + public ApiResponse notApplicable(@PathVariable String packageId, @PathVariable String itemId, + @Valid @RequestBody NotApplicableRequest request, + @RequestHeader(value = "Idempotency-Key", required = false) String key, + HttpServletRequest httpRequest) { + return ApiResponse.ok(command(key, httpRequest, request, new TypeReference() { }, + () -> service.markNotApplicable(packageId, itemId, request))); + } + + @PostMapping("/packages/{packageId}/items/{itemId}/not-applicable/review") + @Operation(operationId = "reviewArchiveItemNotApplicable", summary = "复核档案项不涉及") + @PreAuthorize("@authorizationService.hasPermission('archive:package:na-review')") + public ApiResponse reviewNotApplicable( + @PathVariable String packageId, @PathVariable String itemId, + @Valid @RequestBody NotApplicableReviewRequest request, + @RequestHeader(value = "Idempotency-Key", required = false) String key, + HttpServletRequest httpRequest) { + return ApiResponse.ok(command(key, httpRequest, request, new TypeReference() { }, + () -> service.reviewNotApplicable(packageId, itemId, request))); + } + + @PostMapping(value = "/files", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @Operation(operationId = "uploadArchiveFile", summary = "上传档案文件") + @PreAuthorize("@authorizationService.hasPermission('archive:file:upload')") + public ApiResponse uploadFile(@Valid @RequestPart("metadata") FileLinkMetadata metadata, + @RequestPart("file") MultipartFile file, + @RequestHeader(value = "Idempotency-Key", required = false) String key, + HttpServletRequest httpRequest) { + ArchiveFileView result = idempotencyService.executeMultipart(key, httpRequest.getMethod(), + httpRequest.getRequestURI(), metadata, file, new TypeReference() { }, + view -> "QUARANTINED".equals(view.scanStatus()) ? 503 : 200, + () -> service.uploadFile(metadata, file)); + if ("QUARANTINED".equals(result.scanStatus())) { + throw new BusinessException(HttpStatus.SERVICE_UNAVAILABLE, ErrorCode.FILE_SCANNER_UNAVAILABLE, + "文件安全检查暂不可用,文件已隔离,请稍后重试"); + } + return ApiResponse.ok(result); + } + + @GetMapping("/files") + @Operation(operationId = "listArchiveFiles", summary = "查询档案文件") + @PreAuthorize("@authorizationService.hasPermission('archive:file:view')") + public ApiResponse> files( + @RequestParam(required = false) String companyId, + @RequestParam(required = false) String projectId, + @RequestParam(required = false) String contractId, + @RequestParam(required = false) String counterpartyId, + @RequestParam(required = false) String formType, + @RequestParam(required = false) String fileName, + @RequestParam(required = false) String uploadedBy, + @RequestParam(required = false) LocalDate uploadedDateFrom, + @RequestParam(required = false) LocalDate uploadedDateTo, + @RequestParam(required = false) String originalType, + @RequestParam(required = false) String archiveStatus, + @RequestParam(required = false) String scanStatus, + @RequestParam(defaultValue = "uploadedAt,desc") String sort, + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int size) { + PageResult result = service.listFiles(companyId, projectId, contractId, counterpartyId, + formType, fileName, uploadedBy, uploadedDateFrom, uploadedDateTo, originalType, archiveStatus, + scanStatus, sort, page, size); + return ApiResponse.ok(result.items(), result.meta()); + } + + @GetMapping("/files/{publicId}") + @Operation(operationId = "getArchiveFile", summary = "查询档案文件详情") + @PreAuthorize("@authorizationService.hasPermission('archive:file:view')") + public ApiResponse fileDetail(@PathVariable String publicId) { + return ApiResponse.ok(service.getFile(publicId)); + } + + @PostMapping("/files/{publicId}/rescan") + @Operation(operationId = "rescanArchiveFile", summary = "重新扫描业务范围内的隔离档案") + @PreAuthorize("@authorizationService.hasPermission('archive:file:upload')") + public ApiResponse rescanFile( + @PathVariable String publicId, + @RequestHeader(value = "Idempotency-Key", required = false) String key, + HttpServletRequest httpRequest) { + ArchiveFileView result = idempotencyService.execute(key, httpRequest.getMethod(), + httpRequest.getRequestURI(), Map.of("filePublicId", publicId), + new TypeReference() { }, + view -> "QUARANTINED".equals(view.scanStatus()) ? 503 + : "REJECTED".equals(view.scanStatus()) ? 422 : 200, + () -> service.rescanFile(publicId)); + if ("QUARANTINED".equals(result.scanStatus())) { + throw new BusinessException(HttpStatus.SERVICE_UNAVAILABLE, ErrorCode.FILE_SCANNER_UNAVAILABLE, + "文件安全检查暂不可用,文件仍处于隔离状态"); + } + if ("REJECTED".equals(result.scanStatus())) { + throw new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.FILE_REJECTED, + "文件未通过安全检查,已拒绝接收"); + } + return ApiResponse.ok(result); + } + + @GetMapping("/files/{publicId}/content") + @Operation(operationId = "getArchiveFileContent", summary = "读取档案文件内容") + @PreAuthorize("#preview ? @authorizationService.hasPermission('archive:file:preview') : " + + "@authorizationService.hasPermission('archive:file:download')") + public ResponseEntity fileContent(@PathVariable String publicId, + @RequestParam(defaultValue = "false") boolean preview) { + ArchiveApplicationService.ControlledDownload controlled = service.downloadFile(publicId, preview); + var content = controlled.content(); + ContentDisposition disposition = (controlled.preview() ? ContentDisposition.inline() : ContentDisposition.attachment()) + .filename(content.fileName(), StandardCharsets.UTF_8).build(); + return ResponseEntity.ok() + .contentType(MediaType.parseMediaType(content.mediaType())) + .contentLength(content.bytes().length) + .header(HttpHeaders.CONTENT_DISPOSITION, disposition.toString()) + .header("X-Archive-Watermark", UriUtils.encode(controlled.watermark(), StandardCharsets.UTF_8)) + .header("X-Archive-Watermark-Encoding", "uri-component") + .header("X-File-SHA256", content.sourceSha256()) + .header("X-Derived-File-SHA256", content.derivedSha256()) + .header("X-Content-Type-Options", "nosniff") + .body(new ByteArrayResource(content.bytes())); + } + + @GetMapping("/borrows") + @Operation(operationId = "listBorrows", summary = "查询档案借阅") + @PreAuthorize("@authorizationService.hasPermission('archive:file:view')") + public ApiResponse> borrows(@RequestParam(required = false) String status, + @RequestParam(required = false) String keyword, + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int size) { + PageResult result = service.listBorrows(status, keyword, page, size); + return ApiResponse.ok(result.items(), result.meta()); + } + + @PostMapping("/borrows") + @Operation(operationId = "createBorrow", summary = "申请档案借阅") + @PreAuthorize("@authorizationService.hasPermission('archive:borrow:apply')") + public ApiResponse createBorrow(@Valid @RequestBody BorrowCreateRequest request, + @RequestHeader(value = "Idempotency-Key", required = false) String key, + HttpServletRequest httpRequest) { + return ApiResponse.ok(command(key, httpRequest, request, new TypeReference() { }, + () -> service.createBorrow(request))); + } + + @PostMapping("/borrows/{publicId}/approve") + @Operation(operationId = "approveBorrow", summary = "批准档案借阅") + @PreAuthorize("@authorizationService.hasPermission('archive:borrow:approve')") + public ApiResponse approveBorrow(@PathVariable String publicId, + @Valid @RequestBody BorrowCommandRequest request, + @RequestHeader(value = "Idempotency-Key", required = false) String key, + HttpServletRequest httpRequest) { + return ApiResponse.ok(command(key, httpRequest, request, new TypeReference() { }, + () -> service.approveBorrow(publicId, request))); + } + + @PostMapping("/borrows/{publicId}/reject") + @Operation(operationId = "rejectBorrow", summary = "驳回档案借阅") + @PreAuthorize("@authorizationService.hasPermission('archive:borrow:reject')") + public ApiResponse rejectBorrow(@PathVariable String publicId, + @Valid @RequestBody BorrowCommandRequest request, + @RequestHeader(value = "Idempotency-Key", required = false) String key, + HttpServletRequest httpRequest) { + return ApiResponse.ok(command(key, httpRequest, request, new TypeReference() { }, + () -> service.rejectBorrow(publicId, request))); + } + + @PostMapping("/borrows/{publicId}/return") + @Operation(operationId = "returnBorrow", summary = "登记档案归还") + @PreAuthorize("@authorizationService.hasPermission('archive:borrow:return')") + public ApiResponse returnBorrow(@PathVariable String publicId, + @Valid @RequestBody BorrowCommandRequest request, + @RequestHeader(value = "Idempotency-Key", required = false) String key, + HttpServletRequest httpRequest) { + return ApiResponse.ok(command(key, httpRequest, request, new TypeReference() { }, + () -> service.returnBorrow(publicId, request))); + } + + private T command(String key, HttpServletRequest request, Object body, TypeReference type, + java.util.function.Supplier action) { + return idempotencyService.execute(key, request.getMethod(), request.getRequestURI(), body, type, action); + } +} diff --git a/backend/src/main/java/com/kaidi/finance/archive/api/ArchiveViews.java b/backend/src/main/java/com/kaidi/finance/archive/api/ArchiveViews.java new file mode 100644 index 0000000..6e53b75 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/archive/api/ArchiveViews.java @@ -0,0 +1,206 @@ +package com.kaidi.finance.archive.api; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +public final class ArchiveViews { + + private ArchiveViews() { + } + + public record ReferenceView(String publicId, String businessNo, String name) { + } + + public record PackageItemView( + String publicId, + String itemType, + String itemName, + String objectType, + String objectPublicId, + String filePublicId, + String fileName, + String fileSha256, + boolean required, + String status, + String missingReason, + String returnReason, + String notApplicableReason, + String notApplicableRequestedByName, + LocalDateTime notApplicableRequestedAt, + String notApplicableReviewedByName, + LocalDateTime notApplicableReviewedAt, + String notApplicableReviewOpinion, + List allowedActions + ) { + } + + public record PackageActionView( + int sequenceNo, + String actionCode, + String fromStatus, + String toStatus, + String opinion, + String actorName, + LocalDateTime occurredAt, + String manifestSha256 + ) { + } + + public record PackageObjectSnapshotView( + String publicId, + String itemType, + String objectType, + String objectPublicId, + String objectBusinessNo, + String objectName, + String objectStatus, + Long objectVersion, + String objectFormType, + String sourceVersionPublicId, + String snapshotSha256, + LocalDateTime capturedAt + ) { + } + + public record PackageView( + String publicId, + String businessNo, + ReferenceView company, + ReferenceView project, + int packageVersion, + String rootPackagePublicId, + String supersedesPackagePublicId, + String revisionReason, + String ruleVersion, + String completeness, + int missingCount, + int retentionYears, + LocalDate retentionExpiresOn, + String retentionStatus, + String physicalLocation, + boolean frozen, + String status, + String manifestSha256, + String submittedByName, + String reviewedByName, + LocalDateTime submittedAt, + LocalDateTime archivedAt, + LocalDateTime updatedAt, + long version, + List allowedActions, + List items, + List objectSnapshots, + List actions + ) { + } + + public record ArchiveFileView( + String publicId, + String displayName, + String mediaType, + String originalType, + long sizeBytes, + String sha256, + String scanStatus, + String companyPublicId, + String companyName, + String projectPublicId, + String projectBusinessNo, + String projectName, + String contractPublicId, + String counterpartyPublicId, + String formType, + String documentType, + String seriesPublicId, + int versionNo, + String archiveStatus, + String borrowStatus, + String packagePublicId, + String packageStatus, + String uploadedBy, + LocalDateTime uploadedAt, + boolean sensitive, + List allowedActions + ) { + } + + public record ArchiveFileDetailView( + ArchiveFileView file, + List versions, + List borrows + ) { + } + + public record BorrowView( + String publicId, + String businessNo, + String filePublicId, + String fileName, + String projectPublicId, + String projectName, + String purpose, + LocalDateTime dueAt, + String status, + String applicantName, + String approvedByName, + LocalDateTime approvedAt, + String reviewOpinion, + String returnCondition, + String returnedByName, + LocalDateTime returnedAt, + long version, + List allowedActions + ) { + } + + public record ArchiveContentView( + String filePublicId, + String displayName, + String mediaType, + long sizeBytes, + String sha256, + String watermark, + boolean preview + ) { + } + + public record ReportRow( + String rowId, + Map values + ) { + } + + public record ReportView( + String reportCode, + String definitionVersion, + String filterHash, + List columns, + List rows, + Map summary, + long totalElements, + int page, + int size, + int totalPages, + List allowedActions + ) { + } + + public record ReportDrilldownView( + String reportCode, + String rowId, + List> groups + ) { + } + + public record ReportExportView( + String exportId, + String reportCode, + String filterHash, + int rowCount, + String sha256, + String content + ) { + } +} diff --git a/backend/src/main/java/com/kaidi/finance/archive/application/ArchiveApplicationService.java b/backend/src/main/java/com/kaidi/finance/archive/application/ArchiveApplicationService.java new file mode 100644 index 0000000..4dbfa8d --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/archive/application/ArchiveApplicationService.java @@ -0,0 +1,1261 @@ +package com.kaidi.finance.archive.application; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.kaidi.finance.archive.api.ArchiveContracts.BorrowCommandRequest; +import com.kaidi.finance.archive.api.ArchiveContracts.BorrowCreateRequest; +import com.kaidi.finance.archive.api.ArchiveContracts.FileLinkMetadata; +import com.kaidi.finance.archive.api.ArchiveContracts.FreezeRequest; +import com.kaidi.finance.archive.api.ArchiveContracts.NotApplicableRequest; +import com.kaidi.finance.archive.api.ArchiveContracts.NotApplicableReviewRequest; +import com.kaidi.finance.archive.api.ArchiveContracts.PackageCommandRequest; +import com.kaidi.finance.archive.api.ArchiveContracts.PackageCreateRequest; +import com.kaidi.finance.archive.api.ArchiveContracts.PackageRevisionRequest; +import com.kaidi.finance.archive.api.ArchiveViews.ArchiveFileDetailView; +import com.kaidi.finance.archive.api.ArchiveViews.ArchiveFileView; +import com.kaidi.finance.archive.api.ArchiveViews.BorrowView; +import com.kaidi.finance.archive.api.ArchiveViews.PackageActionView; +import com.kaidi.finance.archive.api.ArchiveViews.PackageItemView; +import com.kaidi.finance.archive.api.ArchiveViews.PackageObjectSnapshotView; +import com.kaidi.finance.archive.api.ArchiveViews.PackageView; +import com.kaidi.finance.archive.api.ArchiveViews.ReferenceView; +import com.kaidi.finance.archive.infrastructure.ArchiveMapper; +import com.kaidi.finance.archive.infrastructure.ArchiveMapper.BorrowLock; +import com.kaidi.finance.archive.infrastructure.ArchiveMapper.BorrowRow; +import com.kaidi.finance.archive.infrastructure.ArchiveMapper.FileLinkRow; +import com.kaidi.finance.archive.infrastructure.ArchiveMapper.FileRow; +import com.kaidi.finance.archive.infrastructure.ArchiveMapper.PackageItemRow; +import com.kaidi.finance.archive.infrastructure.ArchiveMapper.PackageItemTargetRow; +import com.kaidi.finance.archive.infrastructure.ArchiveMapper.ObjectSnapshotRow; +import com.kaidi.finance.archive.infrastructure.ArchiveMapper.PackageRow; +import com.kaidi.finance.archive.infrastructure.ArchiveMapper.ProjectRow; +import com.kaidi.finance.archive.application.ArchiveWatermarkService.WatermarkedContent; +import com.kaidi.finance.iam.domain.FinancePrincipal; +import com.kaidi.finance.shared.api.BusinessException; +import com.kaidi.finance.shared.api.ErrorCode; +import com.kaidi.finance.shared.api.PageResult; +import com.kaidi.finance.shared.audit.AuditService; +import com.kaidi.finance.shared.file.FileApplicationService; +import com.kaidi.finance.shared.file.FileObjectRecord; +import com.kaidi.finance.shared.file.FileView; +import com.kaidi.finance.shared.file.FileScannerUnavailableException; +import com.kaidi.finance.shared.id.UlidGenerator; +import com.kaidi.finance.shared.security.AuthorizationService; +import com.kaidi.finance.shared.security.IdentityContext; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.regex.Pattern; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +@Service +public class ArchiveApplicationService { + + public static final String RULE_VERSION = "ARCHIVE-RULE-V1"; + + private static final Pattern PUBLIC_ID = Pattern.compile("[0-9A-HJKMNP-TV-Z]{26}"); + + /** + * The project detail drill-down needs one read-only aggregate view so an + * archived project does not hide the package that contains its history. + * The standalone archive workbench still defaults to {@code prepare}. + */ + private static final Set PACKAGE_VIEWS = Set.of("prepare", "check", "returned", "archived", "all"); + private static final Set PACKAGE_STATUSES = Set.of( + "PREPARING", "CHECKED", "REVIEWING", "RETURNED", "ARCHIVED", "FROZEN" + ); + private static final Set PACKAGE_COMPLETENESS = Set.of("COMPLETE", "INCOMPLETE"); + private static final Set PACKAGE_SORT_FIELDS = Set.of("updatedAt", "completeness", "archivedAt"); + private static final Set FILE_STATUSES = Set.of("ACTIVE", "SUPERSEDED", "ARCHIVED", "FROZEN"); + private static final Set FILE_SCAN_STATUSES = Set.of( + "SCANNING", "AVAILABLE", "REJECTED", "QUARANTINED", "SUPERSEDED"); + private static final Set FILE_SORT_FIELDS = Set.of("uploadedAt", "displayName", "sizeBytes"); + private static final Set BORROW_STATUSES = Set.of( + "APPLIED", "APPROVED", "BORROWED", "RETURNED", "REJECTED", "OVERDUE" + ); + private static final List FORM_TYPES = List.of( + "OA-01", "OA-02", "OA-03", "OA-04", "OA-05", "OA-06", "OA-07", + "OA-08", "OA-09", "OA-10", "OA-11", "OA-12", "OA-13", "OA-14" + ); + + private final ArchiveMapper mapper; + private final AuthorizationService authorizationService; + private final IdentityContext identityContext; + private final UlidGenerator ulidGenerator; + private final AuditService auditService; + private final ObjectMapper objectMapper; + private final FileApplicationService fileService; + private final ArchiveWatermarkService watermarkService; + + public ArchiveApplicationService(ArchiveMapper mapper, AuthorizationService authorizationService, + IdentityContext identityContext, UlidGenerator ulidGenerator, + AuditService auditService, ObjectMapper objectMapper, + FileApplicationService fileService, + ArchiveWatermarkService watermarkService) { + this.mapper = mapper; + this.authorizationService = authorizationService; + this.identityContext = identityContext; + this.ulidGenerator = ulidGenerator; + this.auditService = auditService; + this.objectMapper = objectMapper; + this.fileService = fileService; + this.watermarkService = watermarkService; + } + + @Transactional + public PageResult listPackages(String view, String companyId, String projectId, String status, + String completeness, Boolean frozen, String keyword, String sort, + int page, int size) { + authorizationService.requirePermission("archive:package:view"); + Actor actor = actor(); + Page requestedPage = page(page, size); + String effectiveView = blank(view) == null ? "prepare" : blank(view).toLowerCase(Locale.ROOT); + if (!PACKAGE_VIEWS.contains(effectiveView)) throw validation("项目归档视图参数无效"); + String effectiveCompanyId = optionalId(companyId); + String effectiveProjectId = optionalId(projectId); + String effectiveStatus = optionalEnum(status, PACKAGE_STATUSES, "档案包状态"); + String effectiveCompleteness = optionalEnum(completeness, PACKAGE_COMPLETENESS, "档案完整性"); + String effectiveKeyword = clean(keyword, 100); + String effectiveSort = sort(sort, PACKAGE_SORT_FIELDS, "档案包排序"); + List items = mapper.listPackages(actor.userId(), actor.roleCode(), effectiveView, + effectiveCompanyId, effectiveProjectId, effectiveStatus, effectiveCompleteness, frozen, + effectiveKeyword, effectiveSort, + requestedPage.size(), requestedPage.offset()) + .stream().map(this::packageView).toList(); + long total = mapper.countPackages(actor.userId(), actor.roleCode(), effectiveView, effectiveCompanyId, + effectiveProjectId, effectiveStatus, effectiveCompleteness, frozen, effectiveKeyword); + return new PageResult<>(items, total, requestedPage.page(), requestedPage.size()); + } + + @Transactional(readOnly = true) + public PackageView getPackage(String publicId) { + return packageView(scopedPackage(publicId, "archive:package:view")); + } + + @Transactional + public PackageView createPackage(PackageCreateRequest request) { + String projectPublicId = requiredId(request.projectId(), "项目编号无效"); + ProjectRow project = mapper.findProject(projectPublicId); + if (project == null) throw notFound("项目不存在"); + authorizationService.requireScope("archive:package:create", project.companyPublicId(), project.publicId()); + if (!"COMPLETED".equals(project.financeStatus())) { + throw invalidState("项目财务资料尚未确认,不能生成档案包"); + } + if ("FROZEN".equals(project.archiveStatus())) throw invalidState("项目档案当前处于冻结状态"); + if (Set.of("CLOSED", "ARCHIVED").contains(project.status())) { + throw invalidState("项目已关闭或已归档,不能再生成新档案包"); + } + if (mapper.activePackageCount(project.id()) > 0) { + throw invalidState("该项目已有正在处理或待补件的档案包,请继续处理当前版本"); + } + Actor actor = actor(); + int packageVersion = mapper.nextPackageVersion(project.id()); + String publicId = ulidGenerator.next(); + String businessNo = businessNo(project.businessNo(), packageVersion); + int retentionYears = request.retentionYears() == null ? 10 : request.retentionYears(); + try { + mapper.insertPackage(publicId, businessNo, project.companyId(), project.id(), null, null, null, packageVersion, + RULE_VERSION, retentionYears, clean(request.physicalLocation(), 500), actor.userId()); + } catch (DataIntegrityViolationException exception) { + throw duplicate("该项目的档案包版本已经存在,请刷新后重试"); + } + long packageId = requirePackageId(publicId); + mapper.initializePackageRoot(packageId); + createExpectedItems(packageId, project); + refreshObjectSnapshots(packageId, project.id(), project.publicId()); + initializeManifest(packageId, false); + mapper.markProjectArchiveStatus(project.id(), "PREPARING", actor.userId()); + PackageRow created = mapper.findPackage(publicId); + insertAction(created, "GENERATE", null, created.status(), "生成项目档案包", null); + PackageView result = packageView(mapper.findPackage(publicId)); + auditService.recordScoped(created.companyPublicId(), created.projectPublicId(), + "ARCHIVE_PACKAGE_GENERATE", "ARCHIVE_PACKAGE", publicId, "SUCCESS", RULE_VERSION, null, result); + return result; + } + + @Transactional + public PackageView checkPackage(String publicId, PackageCommandRequest request) { + PackageRow found = scopedPackage(publicId, "archive:package:submit"); + Actor actor = actor(); + PackageRow locked = lock(found.id(), request.version()); + requireState(locked.status(), "PREPARING", "CHECKED"); + refreshObjectSnapshots(locked.id(), locked.projectId(), locked.projectPublicId()); + List items = mapper.listItems(locked.id()); + Manifest manifest = manifest(locked.id(), items); + if (mapper.markChecked(locked.id(), locked.version(), manifest.completeness(), manifest.missingCount(), + manifest.json(), manifest.sha256(), actor.userId()) != 1) throw conflict(); + PackageRow updated = mapper.findPackage(publicId); + mapper.markProjectArchiveStatus(locked.projectId(), "PREPARING", actor.userId()); + insertAction(updated, "CHECK", locked.status(), updated.status(), request.opinion(), manifest.missingJson()); + PackageView result = packageView(mapper.findPackage(publicId)); + auditService.recordScoped(locked.companyPublicId(), locked.projectPublicId(), + "ARCHIVE_PACKAGE_CHECK", "ARCHIVE_PACKAGE", publicId, "SUCCESS", request.opinion(), + packageView(locked), result); + return result; + } + + @Transactional + public PackageView submitPackage(String publicId, PackageCommandRequest request) { + PackageRow found = scopedPackage(publicId, "archive:package:submit"); + Actor actor = actor(); + PackageRow locked = lock(found.id(), request.version()); + requireState(locked.status(), "CHECKED"); + refreshObjectSnapshots(locked.id(), locked.projectId(), locked.projectPublicId()); + Manifest manifest = requireCompleteManifest(locked.id()); + if (locked.frozen()) throw invalidState("冻结的档案包不能提交审核"); + if (mapper.submitPackage(locked.id(), locked.version(), manifest.completeness(), manifest.missingCount(), + manifest.json(), manifest.sha256(), actor.userId()) != 1) throw conflict(); + PackageRow updated = mapper.findPackage(publicId); + mapper.markProjectArchiveStatus(locked.projectId(), "CHECKING", actor.userId()); + insertAction(updated, "SUBMIT", locked.status(), updated.status(), request.opinion(), null); + PackageView result = packageView(mapper.findPackage(publicId)); + auditService.recordScoped(locked.companyPublicId(), locked.projectPublicId(), + "ARCHIVE_PACKAGE_SUBMIT", "ARCHIVE_PACKAGE", publicId, "SUCCESS", request.opinion(), + packageView(locked), result); + return result; + } + + @Transactional + public PackageView returnPackage(String publicId, PackageCommandRequest request) { + PackageRow found = scopedPackage(publicId, "archive:package:review"); + Actor actor = actor(); + PackageRow locked = lock(found.id(), request.version()); + requireState(locked.status(), "REVIEWING"); + String opinion = requiredText(request.opinion(), 1000, "退回档案包必须填写补件意见"); + if (locked.submittedBy() != null && locked.submittedBy() == actor.userId()) { + throw sod("档案包提交人与资料审核人不能是同一人"); + } + if (request.returnItemIds() == null || request.returnItemIds().isEmpty()) { + throw validation("退回档案包必须选择至少一个需补充的清单项"); + } + Set returnItemIds = new LinkedHashSet<>(request.returnItemIds()); + for (String itemPublicId : returnItemIds) { + Long itemId = mapper.itemId(requiredId(itemPublicId, "退回清单项编号无效"), locked.id()); + if (itemId == null) throw notFound("退回清单项不存在"); + mapper.insertReturnedItem(locked.id(), itemId, opinion, actor.userId()); + } + Manifest manifest = manifest(locked.id(), mapper.listItems(locked.id()), returnItemIds); + if (mapper.returnPackage(locked.id(), locked.version(), manifest.completeness(), manifest.missingCount(), + manifest.json(), manifest.sha256(), opinion, actor.userId()) != 1) throw conflict(); + PackageRow updated = mapper.findPackage(publicId); + mapper.markProjectArchiveStatus(locked.projectId(), "RETURNED", actor.userId()); + insertAction(updated, "RETURN", locked.status(), updated.status(), opinion, null); + PackageView result = packageView(mapper.findPackage(publicId)); + auditService.recordScoped(locked.companyPublicId(), locked.projectPublicId(), + "ARCHIVE_PACKAGE_RETURN", "ARCHIVE_PACKAGE", publicId, "SUCCESS", opinion, + packageView(locked), result); + return result; + } + + @Transactional + public PackageView revisePackage(String publicId, PackageRevisionRequest request) { + PackageRow found = scopedPackage(publicId, "archive:package:submit"); + PackageRow locked = lock(found.id(), request.version()); + requireState(locked.status(), "RETURNED"); + String reason = requiredText(request.reason(), 1000, "生成补件新版本必须填写版本说明"); + Actor actor = actor(); + int packageVersion = mapper.nextPackageVersion(locked.projectId()); + String newPublicId = ulidGenerator.next(); + String businessNo = businessNo(locked.projectBusinessNo(), packageVersion); + try { + Long rootPackageId = mapper.rootPackageId(locked.id()); + mapper.insertPackage(newPublicId, businessNo, locked.companyId(), locked.projectId(), + rootPackageId == null ? locked.id() : rootPackageId, locked.id(), reason, + packageVersion, RULE_VERSION, locked.retentionYears(), + locked.physicalLocation(), actor.userId()); + } catch (DataIntegrityViolationException exception) { + throw duplicate("退回档案包的补件版本已生成,请刷新后继续"); + } + long newPackageId = requirePackageId(newPublicId); + List returnedItems = mapper.returnedItemPublicIds(locked.id()); + if (returnedItems.isEmpty()) { + throw invalidState("退回档案包没有需要补交的清单项"); + } + List sourceSnapshots = mapper.listObjectSnapshots(locked.id()); + for (PackageItemRow item : mapper.listItems(locked.id())) { + String targetItemPublicId = ulidGenerator.next(); + mapper.copyRevisionItem(targetItemPublicId, newPackageId, item.id(), returnedItems, reason); + Long targetItemId = mapper.itemId(targetItemPublicId, newPackageId); + if (targetItemId == null) throw new IllegalStateException("Archive revision item was not created"); + for (ObjectSnapshotRow snapshot : sourceSnapshots) { + if (snapshot.itemId() == item.id()) { + mapper.copyObjectSnapshot(ulidGenerator.next(), newPackageId, targetItemId, snapshot.id()); + } + } + } + initializeManifest(newPackageId, false); + mapper.markProjectArchiveStatus(locked.projectId(), "PREPARING", actor.userId()); + PackageRow created = mapper.findPackage(newPublicId); + insertAction(created, "REVISE", locked.status(), created.status(), reason, null); + PackageView result = packageView(mapper.findPackage(newPublicId)); + auditService.recordScoped(locked.companyPublicId(), locked.projectPublicId(), + "ARCHIVE_PACKAGE_REVISE", "ARCHIVE_PACKAGE", newPublicId, "SUCCESS", reason, + packageView(locked), result); + return result; + } + + @Transactional + public PackageView archivePackage(String publicId, PackageCommandRequest request) { + PackageRow found = scopedPackage(publicId, "archive:package:archive"); + Actor actor = actor(); + PackageRow locked = lock(found.id(), request.version()); + requireState(locked.status(), "REVIEWING"); + Manifest manifest = requireCompleteManifest(locked.id()); + if (locked.submittedBy() != null && locked.submittedBy() == actor.userId()) { + throw sod("档案包提交人与资料审核人不能是同一人"); + } + String opinion = clean(request.opinion(), 1000); + if (mapper.archivePackage(locked.id(), locked.version(), manifest.completeness(), manifest.missingCount(), + manifest.json(), manifest.sha256(), opinion, actor.userId()) != 1) throw conflict(); + mapper.markPackageFiles(locked.id(), "ARCHIVED"); + mapper.markProjectArchived(locked.projectId(), actor.userId()); + PackageRow updated = mapper.findPackage(publicId); + insertAction(updated, "ARCHIVE", locked.status(), updated.status(), opinion, null); + PackageView result = packageView(mapper.findPackage(publicId)); + auditService.recordScoped(locked.companyPublicId(), locked.projectPublicId(), + "ARCHIVE_PACKAGE_ARCHIVE", "ARCHIVE_PACKAGE", publicId, "SUCCESS", opinion, + packageView(locked), result); + return result; + } + + @Transactional + public PackageView freezePackage(String publicId, FreezeRequest request) { + PackageRow found = scopedPackage(publicId, "archive:package:freeze"); + Actor actor = actor(); + PackageRow locked = lock(found.id(), request.version()); + String reason = requiredText(request.reason(), 1000, "冻结档案包必须填写原因"); + if (mapper.freezePackage(locked.id(), locked.version(), reason, actor.userId()) != 1) { + throw invalidState("当前档案包状态不能冻结"); + } + mapper.markPackageFiles(locked.id(), "FROZEN"); + PackageRow updated = mapper.findPackage(publicId); + insertAction(updated, "FREEZE", locked.status(), updated.status(), reason, null); + PackageView result = packageView(mapper.findPackage(publicId)); + auditService.recordScoped(locked.companyPublicId(), locked.projectPublicId(), + "ARCHIVE_PACKAGE_FREEZE", "ARCHIVE_PACKAGE", publicId, "SUCCESS", reason, + packageView(locked), result); + return result; + } + + @Transactional + public PackageView unfreezePackage(String publicId, PackageCommandRequest request) { + PackageRow found = scopedPackage(publicId, "archive:package:unfreeze"); + Actor actor = actor(); + PackageRow locked = lock(found.id(), request.version()); + requireState(locked.status(), "FROZEN"); + if (mapper.unfreezePackage(locked.id(), locked.version(), actor.userId()) != 1) throw conflict(); + PackageRow updated = mapper.findPackage(publicId); + mapper.markPackageFiles(locked.id(), "ARCHIVED".equals(updated.status()) ? "ARCHIVED" : "ACTIVE"); + insertAction(updated, "UNFREEZE", locked.status(), updated.status(), request.opinion(), null); + PackageView result = packageView(mapper.findPackage(publicId)); + auditService.recordScoped(locked.companyPublicId(), locked.projectPublicId(), + "ARCHIVE_PACKAGE_UNFREEZE", "ARCHIVE_PACKAGE", publicId, "SUCCESS", request.opinion(), + packageView(locked), result); + return result; + } + + @Transactional + public PackageView markNotApplicable(String packagePublicId, String itemPublicId, + NotApplicableRequest request) { + PackageRow found = scopedPackage(packagePublicId, "archive:package:submit"); + PackageRow locked = lock(found.id(), request.version()); + requireState(locked.status(), "PREPARING", "CHECKED"); + Long itemId = mapper.itemId(requiredId(itemPublicId, "档案项编号无效"), locked.id()); + if (itemId == null) throw notFound("档案项不存在"); + Actor actor = actor(); + String reason = request.reason().trim(); + if (mapper.markNotApplicable(itemId, locked.id(), reason, actor.userId()) != 1) { + throw invalidState("只有缺失或已退回的清单项可以申请不涉及"); + } + initializeManifest(locked.id(), true); + PackageRow updated = mapper.findPackage(packagePublicId); + insertAction(updated, "NA_REQUEST", locked.status(), updated.status(), reason, null); + PackageView result = packageView(updated); + auditService.recordScoped(found.companyPublicId(), found.projectPublicId(), + "ARCHIVE_ITEM_NOT_APPLICABLE_REQUEST", "ARCHIVE_PACKAGE_ITEM", itemPublicId, "SUCCESS", + reason, null, result); + return result; + } + + @Transactional + public PackageView reviewNotApplicable(String packagePublicId, String itemPublicId, + NotApplicableReviewRequest request) { + PackageRow found = scopedPackage(packagePublicId, "archive:package:na-review"); + PackageRow locked = lock(found.id(), request.version()); + requireState(locked.status(), "PREPARING", "CHECKED"); + Long itemId = mapper.itemId(requiredId(itemPublicId, "档案项编号无效"), locked.id()); + if (itemId == null) throw notFound("档案项不存在"); + Actor actor = actor(); + Long requester = mapper.notApplicableRequester(itemId, locked.id()); + if (requester == null) throw invalidState("该清单项当前没有待复核的不涉及申请"); + if (requester == actor.userId()) throw sod("不涉及申请人与财务复核人不能是同一人"); + String opinion = requiredText(request.opinion(), 1000, "复核不涉及申请必须填写意见"); + if (mapper.reviewNotApplicable(itemId, locked.id(), request.approved(), opinion, actor.userId()) != 1) { + throw conflict(); + } + initializeManifest(locked.id(), true); + PackageRow updated = mapper.findPackage(packagePublicId); + String action = request.approved() ? "NA_APPROVE" : "NA_REJECT"; + insertAction(updated, action, locked.status(), updated.status(), opinion, null); + PackageView result = packageView(updated); + auditService.recordScoped(found.companyPublicId(), found.projectPublicId(), + request.approved() ? "ARCHIVE_ITEM_NOT_APPLICABLE_APPROVE" : "ARCHIVE_ITEM_NOT_APPLICABLE_REJECT", + "ARCHIVE_PACKAGE_ITEM", itemPublicId, "SUCCESS", opinion, packageView(locked), result); + return result; + } + + @Transactional + public ArchiveFileView uploadFile(FileLinkMetadata metadata, MultipartFile multipartFile) { + ProjectRow project = mapper.findProject(requiredId(metadata.projectId(), "项目编号无效")); + if (project == null) throw notFound("项目不存在"); + authorizationService.requireScope("archive:file:upload", project.companyPublicId(), project.publicId()); + PackageRow packageRow = null; + if (blank(metadata.packageId()) != null) { + packageRow = scopedPackage(metadata.packageId(), "archive:file:upload"); + if (!packageRow.projectPublicId().equals(project.publicId())) throw validation("档案包与项目不一致"); + requireState(packageRow.status(), "PREPARING", "CHECKED"); + } + String fileType = requiredText(metadata.fileType(), 64, "文件类型不能为空").toUpperCase(Locale.ROOT); + String contractPublicId = optionalId(metadata.contractId()); + String counterpartyPublicId = optionalId(metadata.counterpartyId()); + String formType = clean(metadata.formType(), 32); + if (formType != null) { + formType = formType.toUpperCase(Locale.ROOT); + if (!FORM_TYPES.contains(formType)) throw validation("表单类型参数无效"); + } + if (contractPublicId != null) { + ArchiveMapper.FileDimensionRow contract = mapper.findFileContract(contractPublicId); + if (contract == null) throw notFound("合同不存在或当前不可用"); + if (!project.publicId().equals(contract.projectPublicId())) throw validation("合同与项目不一致"); + if (counterpartyPublicId != null && !counterpartyPublicId.equals(contract.counterpartyPublicId())) { + throw validation("往来单位与合同不一致"); + } + counterpartyPublicId = contract.counterpartyPublicId(); + } else if (counterpartyPublicId != null && mapper.activeCounterpartyCount(counterpartyPublicId) == 0) { + throw notFound("往来单位不存在或当前不可用"); + } + FileLinkRow previous = null; + String seriesPublicId = ulidGenerator.next(); + int versionNo = 1; + String replacesFileId = optionalId(metadata.replacesFileId()); + if (replacesFileId != null) { + previous = mapper.fileLinkForReplacement(replacesFileId); + if (previous == null) throw notFound("被替换文件不存在或未关联档案"); + authorizationService.requireScope("archive:file:upload", project.companyPublicId(), project.publicId()); + if (!project.publicId().equals(previous.projectPublicId())) throw validation("被替换文件与项目不一致"); + if (!fileType.equals(previous.documentType())) throw validation("新版本的文件类型必须与被替换文件一致"); + if (contractPublicId != null && !contractPublicId.equals(previous.contractPublicId())) { + throw validation("新版本的合同维度必须与被替换文件一致"); + } + if (counterpartyPublicId != null && !counterpartyPublicId.equals(previous.counterpartyPublicId())) { + throw validation("新版本的往来单位维度必须与被替换文件一致"); + } + if (formType != null && !formType.equals(previous.formType())) { + throw validation("新版本的表单类型必须与被替换文件一致"); + } + contractPublicId = previous.contractPublicId(); + counterpartyPublicId = previous.counterpartyPublicId(); + formType = previous.formType(); + requiredText(metadata.replacementReason(), 500, "上传新版本必须填写版本说明"); + if (!"ACTIVE".equals(previous.archiveStatus())) { + throw invalidState("只能为当前有效文件上传新版本"); + } + mapper.lockFileSeries(previous.seriesPublicId()); + previous = mapper.fileLinkForReplacement(replacesFileId); + if (previous == null || !"ACTIVE".equals(previous.archiveStatus())) { + throw invalidState("该文件已产生新版本,请刷新后重试"); + } + if (mapper.activeBorrowCountBySeries(previous.seriesPublicId()) > 0) { + throw invalidState("该文件系列存在未归还借阅,不能上传新版本"); + } + seriesPublicId = previous.seriesPublicId(); + versionNo = mapper.nextFileVersion(seriesPublicId); + } + Long packageItemId = null; + if (blank(metadata.packageItemId()) != null) { + if (packageRow == null) throw validation("关联档案项时必须指定档案包"); + PackageItemTargetRow packageItem = mapper.findPackageItemTarget( + requiredId(metadata.packageItemId(), "档案项编号无效"), packageRow.id()); + if (packageItem == null) throw notFound("档案项不存在"); + if (!fileType.equals(packageItem.itemType())) { + throw validation("上传文件类型必须与档案项类型一致"); + } + packageItemId = packageItem.id(); + } + FileView uploaded; + boolean quarantined = false; + try { + uploaded = fileService.upload(multipartFile); + } catch (FileScannerUnavailableException exception) { + uploaded = exception.persistedFile(); + quarantined = true; + } + FileObjectRecord file = fileService.require(uploaded.publicId()); + String objectPublicId = packageRow == null ? project.publicId() : packageRow.publicId(); + if (previous != null && !quarantined) { + mapper.supersedeFileLink(previous.id()); + } + String linkedSeriesPublicId = quarantined && previous != null ? ulidGenerator.next() : seriesPublicId; + int linkedVersionNo = quarantined && previous != null ? 1 : versionNo; + mapper.insertFileLink(file.getId(), packageRow == null ? "PROJECT" : "ARCHIVE_PACKAGE", objectPublicId, + linkedSeriesPublicId, + project.companyPublicId(), project.publicId(), contractPublicId, counterpartyPublicId, formType, fileType, + clean(metadata.originalType(), 64), linkedVersionNo, + previous == null || quarantined ? null : previous.id(), + clean(metadata.notApplicableReason(), 500), actor().publicId()); + long linkId = mapper.lastInsertId(); + if (packageItemId != null && !quarantined) { + mapper.attachItemFile(packageItemId, packageRow.id(), file.getId(), linkId, file.getSha256()); + // Keep the checked package's persisted summary aligned with its attached evidence. + initializeManifest(packageRow.id(), false); + } + ArchiveFileView result = fileView(mapper.findFileByLinkId(linkId)); + auditService.recordScoped(project.companyPublicId(), project.publicId(), + quarantined ? "ARCHIVE_FILE_QUARANTINE_LINK" : "ARCHIVE_FILE_LINK", + "FILE", uploaded.publicId(), "SUCCESS", + clean(metadata.replacementReason(), 500), null, result); + return result; + } + + @Transactional + public PageResult listFiles(String companyId, String projectId, String contractId, + String counterpartyId, String formType, String fileName, + String uploadedBy, LocalDate uploadedDateFrom, + LocalDate uploadedDateTo, String originalType, + String archiveStatus, String scanStatus, String sort, + int page, int size) { + authorizationService.requirePermission("archive:file:view"); + mapper.markOverdueBorrows(); + Actor actor = actor(); + Page requestedPage = page(page, size); + String effectiveCompanyId = optionalId(companyId); + String effectiveProjectId = optionalId(projectId); + String effectiveContractId = optionalId(contractId); + String effectiveCounterpartyId = optionalId(counterpartyId); + String effectiveFormType = clean(formType, 32); + String effectiveFileName = clean(fileName, 100); + String effectiveUploadedBy = optionalId(uploadedBy); + String effectiveOriginalType = clean(originalType, 64); + String effectiveArchiveStatus = optionalEnum(archiveStatus, FILE_STATUSES, "文件归档状态"); + String effectiveScanStatus = optionalEnum(scanStatus, FILE_SCAN_STATUSES, "文件扫描状态"); + String effectiveSort = sort(sort, FILE_SORT_FIELDS, "文件排序"); + if (effectiveCompanyId == null && effectiveProjectId == null && effectiveContractId == null + && effectiveCounterpartyId == null && effectiveFormType == null && effectiveFileName == null + && effectiveUploadedBy == null && uploadedDateFrom == null && uploadedDateTo == null + && effectiveOriginalType == null && effectiveArchiveStatus == null && effectiveScanStatus == null) { + return new PageResult<>(List.of(), 0, requestedPage.page(), requestedPage.size()); + } + if (uploadedDateFrom != null && uploadedDateTo != null && uploadedDateTo.isBefore(uploadedDateFrom)) { + throw validation("上传日期结束时间不能早于开始时间"); + } + LocalDateTime from = uploadedDateFrom == null ? null : uploadedDateFrom.atStartOfDay(); + LocalDateTime to = uploadedDateTo == null ? null : uploadedDateTo.plusDays(1).atStartOfDay(); + List items = mapper.listFiles(actor.userId(), actor.roleCode(), effectiveCompanyId, + effectiveProjectId, effectiveContractId, effectiveCounterpartyId, effectiveFormType, + effectiveOriginalType, effectiveArchiveStatus, effectiveScanStatus, effectiveUploadedBy, + effectiveFileName, from, to, effectiveSort, requestedPage.size(), requestedPage.offset()) + .stream().map(this::fileView).toList(); + long total = mapper.countFiles(actor.userId(), actor.roleCode(), effectiveCompanyId, effectiveProjectId, + effectiveContractId, effectiveCounterpartyId, effectiveFormType, effectiveOriginalType, + effectiveArchiveStatus, effectiveScanStatus, effectiveUploadedBy, effectiveFileName, from, to); + return new PageResult<>(items, total, requestedPage.page(), requestedPage.size()); + } + + @Transactional + public ArchiveFileDetailView getFile(String publicId) { + mapper.markOverdueBorrows(); + FileRow row = scopedFile(publicId, "archive:file:view"); + Actor actor = actor(); + List versions = mapper.fileVersions(row.seriesPublicId(), actor.userId(), actor.roleCode()) + .stream().map(this::fileView).toList(); + List borrows = mapper.listBorrowsBySeries(row.seriesPublicId(), actor.userId(), actor.roleCode()) + .stream().map(this::borrowView).toList(); + return new ArchiveFileDetailView(fileView(row), versions, borrows); + } + + @Transactional + public ArchiveFileView rescanFile(String publicId) { + FileRow beforeRow = scopedFile(publicId, "archive:file:upload"); + if (!"ACTIVE".equals(beforeRow.archiveStatus())) { + throw invalidState("只有当前有效的隔离文件可以重新扫描"); + } + FileView rescanned = fileService.rescanForBusiness(publicId); + FileRow updatedRow = requireFileScopeByLink(beforeRow.linkId(), "archive:file:upload"); + ArchiveFileView before = fileView(beforeRow); + ArchiveFileView result = fileView(updatedRow); + auditService.recordScoped(beforeRow.companyPublicId(), beforeRow.projectPublicId(), + "ARCHIVE_FILE_RESCAN", "FILE", publicId, + scanResultCode(rescanned.scanStatus()), + rescanned.scanDetail(), before, result); + return result; + } + + private String scanResultCode(String scanStatus) { + return switch (scanStatus) { + case "AVAILABLE" -> "SUCCESS"; + case "QUARANTINED" -> "UNAVAILABLE"; + case "REJECTED" -> "REJECTED"; + default -> scanStatus; + }; + } + + @Transactional + public ControlledDownload downloadFile(String publicId, boolean preview) { + String permission = preview ? "archive:file:preview" : "archive:file:download"; + FileRow row = scopedFile(publicId, "archive:file:view"); + authorizationService.requireScope(permission, row.companyPublicId(), row.projectPublicId()); + Actor actor = actor(); + String watermark = actor.displayName() + " / " + actor.publicId() + " / " + actor.roleCode() + " / " + + LocalDateTime.now(ZoneOffset.UTC) + " UTC"; + WatermarkedContent content = watermarkService.render(fileService.require(row.publicId()), watermark); + auditService.recordScoped(row.companyPublicId(), row.projectPublicId(), + preview ? "ARCHIVE_FILE_PREVIEW" : "ARCHIVE_FILE_DOWNLOAD", "FILE", publicId, + "SUCCESS", watermark, null, Map.of("sha256", row.sha256(), "versionNo", row.versionNo())); + return new ControlledDownload(content, watermark, preview); + } + + @Transactional + public PageResult listBorrows(String status, String keyword, int page, int size) { + authorizationService.requirePermission("archive:file:view"); + mapper.markOverdueBorrows(); + Actor actor = actor(); + Page requestedPage = page(page, size); + String effectiveStatus = optionalEnum(status, BORROW_STATUSES, "借阅状态"); + String effectiveKeyword = clean(keyword, 100); + List items = mapper.listBorrows(actor.userId(), actor.roleCode(), effectiveStatus, + effectiveKeyword, requestedPage.size(), requestedPage.offset()) + .stream().map(this::borrowView).toList(); + long total = mapper.countBorrows(actor.userId(), actor.roleCode(), effectiveStatus, effectiveKeyword); + return new PageResult<>(items, total, requestedPage.page(), requestedPage.size()); + } + + @Transactional + public BorrowView createBorrow(BorrowCreateRequest request) { + FileRow file = scopedFile(request.fileId(), "archive:borrow:apply"); + mapper.lockFileSeries(file.seriesPublicId()); + file = requireFileScopeByLink(file.linkId(), "archive:borrow:apply"); + requireBorrowFileNotFrozen(file); + if (!"AVAILABLE".equals(file.scanStatus()) + || !Set.of("ACTIVE", "ARCHIVED").contains(file.archiveStatus())) { + throw invalidState("文件当前不可借阅"); + } + if (mapper.activeBorrowCountBySeries(file.seriesPublicId()) > 0) { + throw invalidState("该文件系列已有未归还借阅记录"); + } + Actor actor = actor(); + String publicId = ulidGenerator.next(); + String businessNo = "BOR-" + LocalDate.now(ZoneOffset.UTC).toString().replace("-", "") + "-" + + publicId.substring(18); + try { + mapper.insertBorrow(publicId, businessNo, file.id(), file.linkId(), file.seriesPublicId(), + mapper.packageIdForFileLink(file.linkId()), actor.userId(), request.purpose().trim(), request.dueAt()); + } catch (DataIntegrityViolationException exception) { + throw invalidState("该文件系列已有未归还借阅记录"); + } + BorrowView result = borrowView(mapper.findBorrowView(publicId)); + auditService.recordScoped(file.companyPublicId(), file.projectPublicId(), + "ARCHIVE_BORROW_APPLY", "ARCHIVE_BORROW", publicId, "SUCCESS", request.purpose(), null, result); + return result; + } + + @Transactional + public BorrowView approveBorrow(String publicId, BorrowCommandRequest request) { + return reviewBorrow(publicId, request, true); + } + + @Transactional + public BorrowView rejectBorrow(String publicId, BorrowCommandRequest request) { + return reviewBorrow(publicId, request, false); + } + + @Transactional + public BorrowView returnBorrow(String publicId, BorrowCommandRequest request) { + authorizationService.requirePermission("archive:borrow:return"); + Actor actor = actor(); + BorrowLock borrow = requireBorrow(publicId, request.version()); + FileRow file = requireFileScopeByLink(borrow.fileLinkId(), "archive:borrow:return"); + requireBorrowFileNotFrozen(file); + String condition = requiredText(request.returnCondition(), 500, "登记归还时必须填写文件状态"); + if (mapper.returnBorrow(borrow.id(), borrow.version(), condition, actor.userId()) != 1) { + throw invalidState("当前借阅状态不能登记归还"); + } + BorrowView result = borrowView(mapper.findBorrowView(publicId)); + auditService.recordScoped(file.companyPublicId(), file.projectPublicId(), + "ARCHIVE_BORROW_RETURN", "ARCHIVE_BORROW", publicId, "SUCCESS", condition, borrow, result); + return result; + } + + private BorrowView reviewBorrow(String publicId, BorrowCommandRequest request, boolean approve) { + String permission = approve ? "archive:borrow:approve" : "archive:borrow:reject"; + authorizationService.requirePermission(permission); + Actor actor = actor(); + BorrowLock borrow = requireBorrow(publicId, request.version()); + FileRow file = requireFileScopeByLink(borrow.fileLinkId(), permission); + requireBorrowFileNotFrozen(file); + requireState(borrow.status(), "APPLIED"); + if (borrow.applicantId() == actor.userId()) throw sod("借阅申请人与批准人不能是同一人"); + String opinion = requiredText(request.opinion(), 1000, approve ? "批准借阅必须填写意见" : "拒绝借阅必须填写原因"); + int changed = approve + ? mapper.approveBorrow(borrow.id(), borrow.version(), opinion, actor.userId()) + : mapper.rejectBorrow(borrow.id(), borrow.version(), opinion, actor.userId()); + if (changed != 1) throw conflict(); + BorrowView result = borrowView(mapper.findBorrowView(publicId)); + auditService.recordScoped(file.companyPublicId(), file.projectPublicId(), + approve ? "ARCHIVE_BORROW_APPROVE" : "ARCHIVE_BORROW_REJECT", "ARCHIVE_BORROW", + publicId, "SUCCESS", opinion, borrow, result); + return result; + } + + private void requireBorrowFileNotFrozen(FileRow file) { + if ("FROZEN".equals(file.archiveStatus())) { + throw invalidState("冻结的档案文件不能处理借阅"); + } + } + + private void createExpectedItems(long packageId, ProjectRow project) { + insertExpected(packageId, "PROJECT_BASE", "项目基础资料", "PROJECT", project.publicId(), true, true, + null); + for (String formType : FORM_TYPES) { + boolean present = mapper.countSource(project.publicId(), formType) > 0; + insertExpected(packageId, "SOURCE_FORM", formType + " 审批表及审批快照", "SOURCE_DOCUMENT", null, + true, present, present ? null : "缺少 " + formType + " 已批准来源表单"); + } + insertExpected(packageId, "CONTRACT", "合同及变更资料", "CONTRACT", null, true, + mapper.countContracts(project.publicId()) > 0, "缺少生效合同"); + insertExpected(packageId, "RECEIPT", "收款资料", "RECEIPT", null, true, + mapper.countReceipts(project.publicId()) > 0, "缺少收款记录"); + insertExpected(packageId, "INVOICE", "发票资料", "INVOICE", null, true, + mapper.countInvoices(project.publicId()) > 0, "缺少发票记录"); + insertExpected(packageId, "PAYMENT", "付款资料及回单", "PAYMENT", null, true, + mapper.countPayments(project.publicId()) > 0, "缺少付款记录"); + insertExpected(packageId, "ACCOUNTING", "记账准备结果", "ACCOUNTING_VOUCHER", null, true, + mapper.countCompletedVouchers(project.publicId()) > 0, "缺少已完成记账准备结果"); + insertExpected(packageId, "ACCEPTANCE", "项目验收资料", "SOURCE_DOCUMENT", null, true, + mapper.countSource(project.publicId(), "OA-12") > 0, "缺少项目验收资料"); + insertExpected(packageId, "FILE_INDEX", "档案文件清单", "ARCHIVE_PACKAGE", null, false, true, null); + } + + private void refreshObjectSnapshots(long packageId, long projectId, String projectPublicId) { + mapper.deleteObjectSnapshots(packageId); + addSnapshot(packageId, requireItem(packageId, "PROJECT_BASE"), "PROJECT", mapper.projectSnapshot(projectId), + null); + for (Map source : mapper.sourceDocumentSnapshots(projectId)) { + String formType = text(source, "formType"); + Long itemId = mapper.packageSourceItemId(packageId, formType); + if (itemId == null) throw new IllegalStateException("Archive source item is missing for " + formType); + addSnapshot(packageId, itemId, "SOURCE_DOCUMENT", source, text(source, "sourceVersionPublicId")); + } + addSnapshots(packageId, "CONTRACT", "CONTRACT", mapper.contractSnapshots(projectId)); + addSnapshots(packageId, "RECEIPT", "RECEIPT", mapper.receiptSnapshots(projectId)); + addSnapshots(packageId, "INVOICE", "INVOICE", mapper.invoiceSnapshots(projectId)); + addSnapshots(packageId, "PAYMENT", "PAYMENT", mapper.paymentSnapshots(projectId)); + addSnapshots(packageId, "ACCOUNTING", "ACCOUNTING_VOUCHER", mapper.voucherSnapshots(projectId)); + // Unlike file-backed items, accounting completion can happen after the + // archive package was created. Keep the checklist in sync with the + // snapshots so a late completed voucher can satisfy the package. + mapper.syncAccountingItem(packageId, projectId); + Long fileItemId = requireItem(packageId, "FILE_INDEX"); + for (Map file : mapper.projectFileSnapshots(projectPublicId)) { + addSnapshot(packageId, fileItemId, "FILE_LINK", file, null); + } + } + + private void addSnapshots(long packageId, String itemType, String objectType, + Collection> rows) { + Long itemId = requireItem(packageId, itemType); + for (Map row : rows) addSnapshot(packageId, itemId, objectType, row, null); + } + + private void addSnapshot(long packageId, long itemId, String objectType, Map source, + String sourceVersionPublicId) { + if (source == null || source.isEmpty()) return; + Map normalized = normalizedSnapshot(source); + String publicId = text(normalized, "publicId"); + if (publicId == null) throw new IllegalStateException("Archive snapshot object has no public id"); + String snapshotJson = json(normalized); + mapper.insertObjectSnapshot(ulidGenerator.next(), packageId, itemId, objectType, publicId, + text(normalized, "businessNo"), text(normalized, "name"), + text(normalized, "status") == null ? "CAPTURED" : text(normalized, "status"), + number(normalized, "version"), sourceVersionPublicId, snapshotJson, sha256(snapshotJson)); + } + + private Map normalizedSnapshot(Map source) { + Map result = new TreeMap<>(); + source.forEach((key, raw) -> result.put(key, normalizeValue(key, raw))); + return result; + } + + private Object normalizeValue(String key, Object value) { + if (value instanceof byte[] bytes) { + if (key.endsWith("Json")) return readSnapshotJson(new String(bytes, StandardCharsets.UTF_8)); + return java.util.HexFormat.of().formatHex(bytes); + } + if (value instanceof String text && key.endsWith("Json")) return readSnapshotJson(text); + if (value instanceof java.sql.Date date) return date.toLocalDate().toString(); + if (value instanceof java.sql.Timestamp timestamp) return timestamp.toLocalDateTime().toString(); + return value; + } + + private Object readSnapshotJson(String value) { + try { + return objectMapper.readTree(value); + } catch (JsonProcessingException exception) { + throw new IllegalStateException("Archive source snapshot contains invalid JSON", exception); + } + } + + private Long requireItem(long packageId, String itemType) { + Long itemId = mapper.packageItemIdByType(packageId, itemType); + if (itemId == null) throw new IllegalStateException("Archive package item is missing: " + itemType); + return itemId; + } + + private static String text(Map value, String key) { + Object raw = value.get(key); + return raw == null ? null : raw.toString(); + } + + private static Long number(Map value, String key) { + Object raw = value.get(key); + return raw instanceof Number number ? number.longValue() : null; + } + + private void insertExpected(long packageId, String itemType, String itemName, String objectType, + String objectPublicId, boolean required, boolean present, String missingReason) { + mapper.insertItem(ulidGenerator.next(), packageId, itemType, itemName, objectType, objectPublicId, + null, null, null, required, present ? "PRESENT" : "MISSING", + present ? null : missingReason, null); + } + + private void initializeManifest(long packageId, boolean incrementVersion) { + Manifest manifest = manifest(packageId, mapper.listItems(packageId)); + mapper.initializePackageManifest(packageId, manifest.completeness(), manifest.missingCount(), + manifest.json(), manifest.sha256(), actor().userId(), incrementVersion); + } + + private Manifest manifest(long packageId, List items) { + return manifest(packageId, items, Set.of()); + } + + private Manifest manifest(long packageId, List items, Set forcedMissingItemIds) { + int required = 0; + int complete = 0; + int missing = 0; + List> values = new ArrayList<>(); + List> missingValues = new ArrayList<>(); + for (PackageItemRow item : items) { + if (item.requiredFlag()) required++; + boolean forcedMissing = forcedMissingItemIds.contains(item.publicId()); + boolean satisfied = !forcedMissing + && ("PRESENT".equals(item.status()) || "NOT_APPLICABLE".equals(item.status())); + if (item.requiredFlag() && satisfied) complete++; + if (item.requiredFlag() && !satisfied) missing++; + Map value = new LinkedHashMap<>(); + value.put("itemId", item.publicId()); + value.put("itemType", item.itemType()); + value.put("itemName", item.itemName()); + value.put("required", item.requiredFlag()); + value.put("status", forcedMissing ? "MISSING" : item.status()); + value.put("objectType", item.objectType()); + value.put("objectPublicId", item.objectPublicId()); + value.put("fileId", item.filePublicId()); + value.put("sha256", item.fileSha256()); + value.put("missingReason", forcedMissing + ? (item.returnReason() == null ? "资料审核人要求补件" : item.returnReason()) + : item.missingReason()); + value.put("returnReason", item.returnReason()); + value.put("notApplicableReason", item.notApplicableReason()); + value.put("notApplicableRequestedBy", item.notApplicableRequestedByName()); + value.put("notApplicableRequestedAt", item.notApplicableRequestedAt()); + value.put("notApplicableReviewedBy", item.notApplicableReviewedByName()); + value.put("notApplicableReviewedAt", item.notApplicableReviewedAt()); + value.put("notApplicableReviewOpinion", item.notApplicableReviewOpinion()); + values.add(value); + if (item.requiredFlag() && !satisfied) missingValues.add(value); + } + BigDecimal completeness = required == 0 ? new BigDecimal("100.00") + : BigDecimal.valueOf(complete).multiply(BigDecimal.valueOf(100)) + .divide(BigDecimal.valueOf(required), 2, RoundingMode.HALF_UP); + List> snapshotValues = mapper.listObjectSnapshots(packageId).stream().map(snapshot -> { + Map value = new LinkedHashMap<>(); + value.put("itemType", snapshot.itemType()); + value.put("objectType", snapshot.objectType()); + value.put("objectPublicId", snapshot.objectPublicId()); + value.put("objectVersion", snapshot.objectVersion()); + value.put("sourceVersionPublicId", snapshot.sourceVersionPublicId()); + value.put("sha256", snapshot.snapshotSha256()); + return value; + }).toList(); + Map packageManifest = new LinkedHashMap<>(); + packageManifest.put("schemaVersion", 2); + packageManifest.put("items", values); + packageManifest.put("objectSnapshots", snapshotValues); + String manifestJson = json(packageManifest); + return new Manifest(completeness, missing, manifestJson, sha256(manifestJson), json(missingValues)); + } + + private Manifest requireCompleteManifest(long packageId) { + Manifest manifest = manifest(packageId, mapper.listItems(packageId)); + if (manifest.missingCount() > 0) { + throw new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.ATTACHMENT_REQUIRED, + "档案包仍有缺件,请补齐或标记不涉及后重新检查"); + } + return manifest; + } + + private PackageView packageView(PackageRow row) { + LocalDate retentionExpiresOn = row.archivedAt() == null + ? null : row.archivedAt().toLocalDate().plusYears(row.retentionYears()); + String retentionStatus = retentionStatus(row, retentionExpiresOn); + List items = mapper.listItems(row.id()).stream().map(item -> new PackageItemView( + item.publicId(), item.itemType(), item.itemName(), item.objectType(), item.objectPublicId(), + item.filePublicId(), item.fileName(), item.fileSha256(), item.requiredFlag(), item.status(), + item.missingReason(), item.returnReason(), item.notApplicableReason(), item.notApplicableRequestedByName(), + item.notApplicableRequestedAt(), item.notApplicableReviewedByName(), item.notApplicableReviewedAt(), + item.notApplicableReviewOpinion(), itemActions(row, item))).toList(); + List actions = mapper.listActions(row.id()).stream().map(action -> new PackageActionView( + action.sequenceNo(), action.actionCode(), action.fromStatus(), action.toStatus(), action.opinion(), + action.actorName(), action.occurredAt(), action.manifestSha256())).toList(); + List snapshots = mapper.listObjectSnapshots(row.id()).stream().map(snapshot -> + new PackageObjectSnapshotView(snapshot.publicId(), snapshot.itemType(), snapshot.objectType(), + snapshot.objectPublicId(), snapshot.objectBusinessNo(), snapshot.objectName(), + snapshot.objectStatus(), snapshot.objectVersion(), snapshot.objectFormType(), + snapshot.sourceVersionPublicId(), + snapshot.snapshotSha256(), snapshot.createdAt())).toList(); + return new PackageView(row.publicId(), row.businessNo(), + new ReferenceView(row.companyPublicId(), row.companyBusinessNo(), row.companyName()), + new ReferenceView(row.projectPublicId(), row.projectBusinessNo(), row.projectName()), + row.packageVersion(), row.rootPackagePublicId(), row.supersedesPackagePublicId(), row.revisionReason(), + row.ruleVersion(), decimal(row.completeness()), row.missingCount(), + row.retentionYears(), retentionExpiresOn, retentionStatus, row.physicalLocation(), row.frozen(), + row.status(), row.manifestSha256(), + row.submittedByName(), row.reviewedByName(), row.submittedAt(), row.archivedAt(), row.updatedAt(), + row.version(), packageActions(row), items, snapshots, actions); + } + + private static String retentionStatus(PackageRow row, LocalDate retentionExpiresOn) { + if (row.frozen()) return "FROZEN"; + if (retentionExpiresOn == null) return "PENDING"; + LocalDate today = LocalDate.now(ZoneOffset.UTC); + if (retentionExpiresOn.isBefore(today)) return "EXPIRED"; + if (!retentionExpiresOn.isAfter(today.plusDays(90))) return "DUE_SOON"; + return "ACTIVE"; + } + + private ArchiveFileView fileView(FileRow row) { + boolean sensitive = isSensitive(row); + List actions = new ArrayList<>(); + if (authorizationService.hasScope("archive:file:preview", row.companyPublicId(), row.projectPublicId()) + && "AVAILABLE".equals(row.scanStatus())) { + actions.add("PREVIEW"); + } + if (authorizationService.hasScope("archive:file:download", row.companyPublicId(), row.projectPublicId()) + && "AVAILABLE".equals(row.scanStatus())) { + actions.add("DOWNLOAD"); + } + if (authorizationService.hasScope("archive:borrow:apply", row.companyPublicId(), row.projectPublicId()) + && "AVAILABLE".equals(row.scanStatus()) + && row.borrowStatus() == null && Set.of("ACTIVE", "ARCHIVED").contains(row.archiveStatus())) { + actions.add("BORROW"); + } + if (authorizationService.hasScope("archive:file:upload", row.companyPublicId(), row.projectPublicId()) + && "AVAILABLE".equals(row.scanStatus()) && row.borrowStatus() == null + && "ACTIVE".equals(row.archiveStatus())) { + actions.add("UPLOAD_VERSION"); + } + if (authorizationService.hasScope("archive:file:upload", row.companyPublicId(), row.projectPublicId()) + && "QUARANTINED".equals(row.scanStatus()) && "ACTIVE".equals(row.archiveStatus())) { + actions.add("RESCAN"); + } + return new ArchiveFileView(row.publicId(), row.originalName(), row.mediaType(), row.originalType(), + row.sizeBytes(), row.sha256(), row.scanStatus(), row.companyPublicId(), row.companyName(), + row.projectPublicId(), row.projectBusinessNo(), row.projectName(), row.contractPublicId(), + row.counterpartyPublicId(), row.formType(), row.documentType(), row.seriesPublicId(), row.versionNo(), + row.archiveStatus(), row.borrowStatus(), + row.packagePublicId(), row.packageStatus(), row.uploadedBy(), row.createdAt(), sensitive, + List.copyOf(actions)); + } + + private BorrowView borrowView(BorrowRow row) { + List actions = new ArrayList<>(); + boolean reviewSeparated = row.applicantId() != actor().userId(); + if ("APPLIED".equals(row.status()) && reviewSeparated + && authorizationService.hasScope("archive:borrow:approve", row.companyPublicId(), row.projectPublicId())) { + actions.add("APPROVE"); + } + if ("APPLIED".equals(row.status()) && reviewSeparated + && authorizationService.hasScope("archive:borrow:reject", row.companyPublicId(), row.projectPublicId())) { + actions.add("REJECT"); + } + if (Set.of("BORROWED", "OVERDUE").contains(row.status()) + && authorizationService.hasScope("archive:borrow:return", row.companyPublicId(), row.projectPublicId())) { + actions.add("RETURN"); + } + return new BorrowView(row.publicId(), row.businessNo(), row.filePublicId(), row.fileName(), + row.projectPublicId(), row.projectName(), row.purpose(), row.dueAt(), row.status(), row.applicantName(), + row.approvedByName(), row.approvedAt(), row.reviewOpinion(), row.returnCondition(), row.returnedByName(), + row.returnedAt(), row.version(), List.copyOf(actions)); + } + + private List packageActions(PackageRow row) { + List actions = new ArrayList<>(); + boolean reviewSeparated = row.submittedBy() == null || row.submittedBy() != actor().userId(); + if (Set.of("PREPARING", "CHECKED").contains(row.status()) + && authorizationService.hasScope("archive:package:submit", row.companyPublicId(), row.projectPublicId())) { + actions.add("CHECK"); + } + if ("CHECKED".equals(row.status()) && row.missingCount() == 0 && !row.frozen() + && authorizationService.hasScope("archive:package:submit", row.companyPublicId(), row.projectPublicId())) { + actions.add("SUBMIT"); + } + if ("REVIEWING".equals(row.status()) && reviewSeparated + && authorizationService.hasScope("archive:package:review", row.companyPublicId(), row.projectPublicId())) { + actions.add("RETURN"); + } + if ("REVIEWING".equals(row.status()) && row.missingCount() == 0 && reviewSeparated + && authorizationService.hasScope("archive:package:archive", row.companyPublicId(), row.projectPublicId())) { + actions.add("ARCHIVE"); + } + if ("RETURNED".equals(row.status()) && mapper.revisionCount(row.id()) == 0 + && authorizationService.hasScope("archive:package:submit", row.companyPublicId(), row.projectPublicId())) { + actions.add("REVISE"); + } + if (Set.of("ARCHIVED", "CHECKED", "REVIEWING").contains(row.status()) && !row.frozen() + && authorizationService.hasScope("archive:package:freeze", row.companyPublicId(), row.projectPublicId())) { + actions.add("FREEZE"); + } + if ("FROZEN".equals(row.status()) + && authorizationService.hasScope("archive:package:unfreeze", row.companyPublicId(), row.projectPublicId())) { + actions.add("UNFREEZE"); + } + return List.copyOf(actions); + } + + private List itemActions(PackageRow row, PackageItemRow item) { + List actions = new ArrayList<>(); + if (Set.of("PREPARING", "CHECKED").contains(row.status()) + && authorizationService.hasScope("archive:file:upload", row.companyPublicId(), row.projectPublicId())) { + actions.add("UPLOAD"); + } + if (Set.of("MISSING", "NA_REJECTED").contains(item.status()) + && Set.of("PREPARING", "CHECKED").contains(row.status()) + && authorizationService.hasScope("archive:package:submit", row.companyPublicId(), row.projectPublicId())) { + actions.add("REQUEST_NOT_APPLICABLE"); + } + if ("NA_REVIEWING".equals(item.status()) + && authorizationService.hasScope("archive:package:na-review", row.companyPublicId(), row.projectPublicId())) { + actions.add("REVIEW_NOT_APPLICABLE"); + } + return List.copyOf(actions); + } + + private void insertAction(PackageRow row, String actionCode, String fromStatus, String toStatus, + String opinion, String missingJson) { + mapper.insertAction(ulidGenerator.next(), row.id(), mapper.nextActionSequence(row.id()), actionCode, + fromStatus, toStatus, clean(opinion, 1000), missingJson, row.manifestSha256(), actor().userId()); + } + + private PackageRow scopedPackage(String publicId, String permission) { + PackageRow row = mapper.findPackage(requiredId(publicId, "档案包编号无效")); + if (row == null) throw notFound("档案包不存在"); + authorizationService.requireScope(permission, row.companyPublicId(), row.projectPublicId()); + return row; + } + + private FileRow scopedFile(String publicId, String permission) { + FileRow row = mapper.findCurrentFile(requiredId(publicId, "文件编号无效")); + if (row == null) throw notFound("文件不存在或未关联档案"); + authorizationService.requireScope(permission, row.companyPublicId(), row.projectPublicId()); + return row; + } + + private FileRow requireFileScopeByLink(long fileLinkId, String permission) { + FileRow file = mapper.findFileByLinkId(fileLinkId); + if (file == null) throw notFound("文件不存在或未关联档案"); + authorizationService.requireScope(permission, file.companyPublicId(), file.projectPublicId()); + return file; + } + + private PackageRow lock(long id, long version) { + PackageRow row = mapper.lockPackage(id); + if (row == null) throw notFound("档案包不存在"); + requireVersion(row.version(), version); + return row; + } + + private BorrowLock requireBorrow(String publicId, long version) { + BorrowLock row = mapper.findBorrow(requiredId(publicId, "借阅编号无效")); + if (row == null) throw notFound("借阅记录不存在"); + requireVersion(row.version(), version); + return row; + } + + private long requirePackageId(String publicId) { + Long id = mapper.packageId(publicId); + if (id == null) throw notFound("档案包不存在"); + return id; + } + + private Actor actor() { + FinancePrincipal principal = identityContext.requirePrincipal(); + return new Actor(principal.userId(), principal.publicId(), principal.displayName(), + identityContext.requireActiveRole()); + } + + private static String businessNo(String projectBusinessNo, int version) { + String suffix = projectBusinessNo.length() > 42 + ? projectBusinessNo.substring(projectBusinessNo.length() - 42) : projectBusinessNo; + return "ARC-" + suffix + "-V" + String.format(Locale.ROOT, "%02d", version); + } + + private static boolean isSensitive(FileRow row) { + String value = ((row.originalType() == null ? "" : row.originalType()) + " " + + (row.formType() == null ? "" : row.formType()) + " " + row.documentType()).toUpperCase(Locale.ROOT); + return value.contains("SALARY") || value.contains("ID_CARD") || value.contains("BANK_ACCOUNT") + || value.contains("OA-09") || value.contains("PERSON"); + } + + private String json(Object value) { + try { + return objectMapper.writeValueAsString(value); + } catch (JsonProcessingException exception) { + throw new IllegalStateException("Archive manifest cannot be serialized", exception); + } + } + + private static String sha256(String value) { + try { + byte[] digest = MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8)); + return java.util.HexFormat.of().formatHex(digest); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 is not available", exception); + } + } + + private static String decimal(BigDecimal value) { + return value == null ? "0.00" : value.setScale(2, RoundingMode.HALF_UP).toPlainString(); + } + + private static Page page(int page, int size) { + if (page < 1 || !Set.of(20, 50, 100).contains(size)) { + throw validation("分页参数只能使用 page>=1 且 size 为 20、50 或 100"); + } + return new Page(page, size); + } + + private static String requiredId(String value, String message) { + String normalized = blank(value); + if (normalized == null || !PUBLIC_ID.matcher(normalized).matches()) throw validation(message); + return normalized; + } + + private static String optionalId(String value) { + String normalized = blank(value); + if (normalized == null) return null; + if (!PUBLIC_ID.matcher(normalized).matches()) throw validation("公开编号格式无效"); + return normalized; + } + + private static String optionalEnum(String value, Set allowed, String label) { + String normalized = blank(value); + if (normalized == null) return null; + normalized = normalized.toUpperCase(Locale.ROOT); + if (!allowed.contains(normalized)) throw validation(label + "参数无效"); + return normalized; + } + + private static String sort(String value, Set allowedFields, String label) { + String normalized = blank(value); + if (normalized == null) throw validation(label + "参数无效"); + String[] parts = normalized.split(",", -1); + if (parts.length != 2 || !allowedFields.contains(parts[0]) + || !("asc".equalsIgnoreCase(parts[1]) || "desc".equalsIgnoreCase(parts[1]))) { + throw validation(label + "参数无效"); + } + return parts[0] + "," + parts[1].toLowerCase(Locale.ROOT); + } + + private static String requiredText(String value, int max, String message) { + String result = clean(value, max); + if (result == null) throw validation(message); + return result; + } + + private static String clean(String value, int max) { + String result = blank(value); + if (result != null && result.length() > max) throw validation("文本长度不能超过 " + max + " 个字符"); + return result; + } + + private static String blank(String value) { + return value == null || value.isBlank() ? null : value.trim(); + } + + private static void requireVersion(long actual, long expected) { + if (actual != expected) throw conflict(); + } + + private static void requireState(String current, String... allowed) { + if (!Set.of(allowed).contains(current)) throw invalidState("当前档案状态不允许执行该操作"); + } + + private static BusinessException validation(String message) { + return new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.VALIDATION_FAILED, message); + } + + private static BusinessException notFound(String message) { + return new BusinessException(HttpStatus.NOT_FOUND, ErrorCode.RESOURCE_NOT_FOUND, message); + } + + private static BusinessException conflict() { + return new BusinessException(HttpStatus.CONFLICT, ErrorCode.VERSION_CONFLICT, + "数据已被其他人更新,请刷新后重试"); + } + + private static BusinessException invalidState(String message) { + return new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.INVALID_STATE_TRANSITION, message); + } + + private static BusinessException sod(String message) { + return new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.SOD_VIOLATION, message); + } + + private static BusinessException duplicate(String message) { + return new BusinessException(HttpStatus.CONFLICT, ErrorCode.DUPLICATE_RESOURCE, message); + } + + public record ControlledDownload(WatermarkedContent content, String watermark, boolean preview) { + } + + private record Actor(long userId, String publicId, String displayName, String roleCode) { + } + + private record Page(int page, int size) { + int offset() { + long offset = (long) (page - 1) * size; + return offset > Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) offset; + } + } + + private record Manifest(BigDecimal completeness, int missingCount, String json, String sha256, + String missingJson) { + } +} diff --git a/backend/src/main/java/com/kaidi/finance/archive/application/ArchiveWatermarkService.java b/backend/src/main/java/com/kaidi/finance/archive/application/ArchiveWatermarkService.java new file mode 100644 index 0000000..ff25534 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/archive/application/ArchiveWatermarkService.java @@ -0,0 +1,234 @@ +package com.kaidi.finance.archive.application; + +import com.kaidi.finance.shared.api.BusinessException; +import com.kaidi.finance.shared.api.ErrorCode; +import com.kaidi.finance.shared.file.FileApplicationService; +import com.kaidi.finance.shared.file.FileObjectRecord; +import java.awt.AlphaComposite; +import java.awt.Color; +import java.awt.Font; +import java.awt.FontMetrics; +import java.awt.Graphics2D; +import java.awt.RenderingHints; +import java.awt.geom.AffineTransform; +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import java.util.Locale; +import javax.imageio.ImageIO; +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PDPageContentStream; +import org.apache.pdfbox.pdmodel.graphics.blend.BlendMode; +import org.apache.pdfbox.pdmodel.graphics.state.PDExtendedGraphicsState; +import org.apache.pdfbox.pdmodel.font.PDType1Font; +import org.apache.pdfbox.pdmodel.font.Standard14Fonts; +import org.apache.pdfbox.util.Matrix; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; + +@Service +public class ArchiveWatermarkService { + + private static final long MAX_IMAGE_PIXELS = 40_000_000L; + private static final int MAX_PDF_PAGES = 2_000; + private static final String VISIBLE_PREFIX = "KAIDI CONTROLLED COPY"; + + private final FileApplicationService fileService; + + public ArchiveWatermarkService(FileApplicationService fileService) { + this.fileService = fileService; + } + + public WatermarkedContent render(FileObjectRecord source, String watermark) { + byte[] original = fileService.readAvailable(source.getPublicId()); + String extension = source.getExtension().toLowerCase(Locale.ROOT); + String visibleText = VISIBLE_PREFIX + " | " + asciiWatermark(watermark); + try { + return switch (extension) { + case "pdf" -> content(watermarkPdfOrControlledCopy(source, original, visibleText), "application/pdf", + controlledName(source.getOriginalName(), "pdf"), source.getSha256()); + case "jpg", "jpeg" -> content(watermarkImage(original, visibleText, "jpg"), "image/jpeg", + controlledName(source.getOriginalName(), extension), source.getSha256()); + case "png" -> content(watermarkImage(original, visibleText, "png"), "image/png", + controlledName(source.getOriginalName(), "png"), source.getSha256()); + default -> controlledPdf(source, original, visibleText); + }; + } catch (BusinessException exception) { + throw exception; + } catch (IOException | RuntimeException exception) { + throw new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.FILE_WATERMARK_UNSUPPORTED, + "文件内容无法生成受控水印副本"); + } + } + + private byte[] watermarkPdfOrControlledCopy(FileObjectRecord source, byte[] original, String text) throws IOException { + try { + return watermarkPdf(original, text); + } catch (IOException | RuntimeException exception) { + return controlledPdf(source, original, text).bytes(); + } + } + + private byte[] watermarkPdf(byte[] original, String text) throws IOException { + try (PDDocument document = Loader.loadPDF(original)) { + if (document.getNumberOfPages() > MAX_PDF_PAGES) { + throw unsupported("PDF 页数超过受控预览限制"); + } + var font = new PDType1Font(Standard14Fonts.FontName.HELVETICA_BOLD); + for (PDPage page : document.getPages()) { + float width = page.getMediaBox().getWidth(); + float height = page.getMediaBox().getHeight(); + try (PDPageContentStream stream = new PDPageContentStream(document, page, + PDPageContentStream.AppendMode.APPEND, true, true)) { + PDExtendedGraphicsState state = new PDExtendedGraphicsState(); + state.setNonStrokingAlphaConstant(0.16f); + state.setBlendMode(BlendMode.MULTIPLY); + stream.setGraphicsStateParameters(state); + stream.setNonStrokingColor(new Color(176, 31, 45)); + for (float y = 40; y < height + width; y += 150) { + stream.beginText(); + stream.setFont(font, 10); + stream.setTextMatrix(Matrix.getRotateInstance(Math.toRadians(32), -80, y)); + stream.showText(text); + stream.endText(); + } + stream.beginText(); + stream.setFont(font, 7); + stream.newLineAtOffset(18, 12); + stream.showText(text); + stream.endText(); + } + } + ByteArrayOutputStream output = new ByteArrayOutputStream(); + document.save(output); + return output.toByteArray(); + } + } + + private byte[] watermarkImage(byte[] original, String text, String format) throws IOException { + BufferedImage decoded = ImageIO.read(new java.io.ByteArrayInputStream(original)); + if (decoded == null || (long) decoded.getWidth() * decoded.getHeight() > MAX_IMAGE_PIXELS) { + throw unsupported("图片尺寸超过受控预览限制或格式无效"); + } + int type = "jpg".equals(format) ? BufferedImage.TYPE_INT_RGB : BufferedImage.TYPE_INT_ARGB; + BufferedImage outputImage = new BufferedImage(decoded.getWidth(), decoded.getHeight(), type); + Graphics2D graphics = outputImage.createGraphics(); + try { + graphics.setColor(Color.WHITE); + graphics.fillRect(0, 0, decoded.getWidth(), decoded.getHeight()); + graphics.drawImage(decoded, 0, 0, null); + graphics.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + graphics.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, 0.22f)); + graphics.setColor(new Color(176, 31, 45)); + int fontSize = Math.max(12, Math.min(28, decoded.getWidth() / 32)); + graphics.setFont(new Font(Font.SANS_SERIF, Font.BOLD, fontSize)); + FontMetrics metrics = graphics.getFontMetrics(); + int textWidth = metrics.stringWidth(text); + AffineTransform originalTransform = graphics.getTransform(); + graphics.rotate(-Math.PI / 6, decoded.getWidth() / 2.0, decoded.getHeight() / 2.0); + for (int y = -decoded.getHeight(); y < decoded.getHeight() * 2; y += fontSize * 6) { + for (int x = -decoded.getWidth(); x < decoded.getWidth() * 2; x += textWidth + fontSize * 5) { + graphics.drawString(text, x, y); + } + } + graphics.setTransform(originalTransform); + } finally { + graphics.dispose(); + } + ByteArrayOutputStream output = new ByteArrayOutputStream(); + if (!ImageIO.write(outputImage, format, output)) throw unsupported("图片格式不支持写入水印"); + return output.toByteArray(); + } + + private WatermarkedContent controlledPdf(FileObjectRecord source, byte[] original, String text) throws IOException { + try (PDDocument document = new PDDocument()) { + PDPage page = new PDPage(); + document.addPage(page); + var regular = new PDType1Font(Standard14Fonts.FontName.HELVETICA); + var bold = new PDType1Font(Standard14Fonts.FontName.HELVETICA_BOLD); + try (PDPageContentStream stream = new PDPageContentStream(document, page)) { + stream.setNonStrokingColor(new Color(176, 31, 45)); + stream.beginText(); + stream.setFont(bold, 16); + stream.newLineAtOffset(48, 730); + stream.showText("CONTROLLED ARCHIVE COPY"); + stream.endText(); + stream.setNonStrokingColor(Color.DARK_GRAY); + String[] lines = { + "Original file: " + asciiWatermark(source.getOriginalName()), + "Original format: " + source.getExtension().toUpperCase(Locale.ROOT), + "Original bytes: " + original.length, + "Original SHA-256: " + source.getSha256(), + "Access watermark: " + text, + "The original binary is attached to this controlled PDF." + }; + float y = 690; + for (String line : lines) { + stream.beginText(); + stream.setFont(regular, 10); + stream.newLineAtOffset(48, y); + stream.showText(truncate(line, 100)); + stream.endText(); + y -= 24; + } + } + var names = document.getDocumentCatalog().getNames(); + if (names == null) { + names = new org.apache.pdfbox.pdmodel.PDDocumentNameDictionary(document.getDocumentCatalog()); + document.getDocumentCatalog().setNames(names); + } + var embeddedFiles = new org.apache.pdfbox.pdmodel.PDEmbeddedFilesNameTreeNode(); + var embedded = new org.apache.pdfbox.pdmodel.common.filespecification.PDEmbeddedFile(document, + new java.io.ByteArrayInputStream(original)); + embedded.setSize(original.length); + var specification = new org.apache.pdfbox.pdmodel.common.filespecification.PDComplexFileSpecification(); + specification.setFile(source.getOriginalName()); + specification.setEmbeddedFile(embedded); + embeddedFiles.setNames(java.util.Map.of(source.getOriginalName(), specification)); + names.setEmbeddedFiles(embeddedFiles); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + document.save(output); + return content(output.toByteArray(), "application/pdf", + controlledName(source.getOriginalName(), "pdf"), source.getSha256()); + } + } + + private static WatermarkedContent content(byte[] bytes, String mediaType, String fileName, String sourceSha256) { + return new WatermarkedContent(bytes, mediaType, fileName, sourceSha256, sha256(bytes)); + } + + private static String controlledName(String original, String extension) { + int dot = original.lastIndexOf('.'); + String base = dot > 0 ? original.substring(0, dot) : original; + return base + "-受控副本." + extension; + } + + private static String asciiWatermark(String value) { + return value == null ? "" : value.replaceAll("[^\\x20-\\x7E]", "?"); + } + + private static String truncate(String value, int maximum) { + return value.length() <= maximum ? value : value.substring(0, maximum); + } + + private static BusinessException unsupported(String message) { + return new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.FILE_WATERMARK_UNSUPPORTED, message); + } + + private static String sha256(byte[] bytes) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 is unavailable", exception); + } + } + + public record WatermarkedContent(byte[] bytes, String mediaType, String fileName, + String sourceSha256, String derivedSha256) { + } +} diff --git a/backend/src/main/java/com/kaidi/finance/archive/infrastructure/ArchiveMapper.java b/backend/src/main/java/com/kaidi/finance/archive/infrastructure/ArchiveMapper.java new file mode 100644 index 0000000..52a5d63 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/archive/infrastructure/ArchiveMapper.java @@ -0,0 +1,1559 @@ +package com.kaidi.finance.archive.infrastructure; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.Map; +import java.util.List; +import org.apache.ibatis.annotations.Delete; +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +@org.apache.ibatis.annotations.Mapper +public interface ArchiveMapper { + + String PACKAGE_SELECT = """ + SELECT package_record.id, package_record.public_id, package_record.business_no, + package_record.company_id, company.public_id AS company_public_id, + company.business_no AS company_business_no, company.name AS company_name, + package_record.project_id, project.public_id AS project_public_id, + project.business_no AS project_business_no, project.name AS project_name, + package_record.package_version, + root_package.public_id AS root_package_public_id, + superseded_package.public_id AS supersedes_package_public_id, + package_record.revision_reason, package_record.rule_version, + package_record.completeness, package_record.missing_count, + package_record.retention_years, package_record.physical_location, + package_record.frozen, package_record.status, package_record.manifest_sha256, + package_record.submitted_by, submitter.display_name AS submitted_by_name, + package_record.reviewed_by, reviewer.display_name AS reviewed_by_name, + package_record.submitted_at, package_record.archived_at, + package_record.updated_at, package_record.version + FROM arc_package package_record + JOIN md_company company ON company.id = package_record.company_id + JOIN md_project project ON project.id = package_record.project_id + LEFT JOIN arc_package root_package ON root_package.id = package_record.root_package_id + LEFT JOIN arc_package superseded_package ON superseded_package.id = package_record.supersedes_package_id + LEFT JOIN iam_user submitter ON submitter.id = package_record.submitted_by + LEFT JOIN iam_user reviewer ON reviewer.id = package_record.reviewed_by + """; + + String PACKAGE_SCOPE = """ + EXISTS ( + SELECT 1 FROM iam_scope scope + JOIN iam_role role ON role.id = scope.role_id + JOIN iam_permission permission ON permission.id = scope.permission_id + JOIN iam_user_role user_role ON user_role.user_id = scope.user_id + AND user_role.role_id = scope.role_id + WHERE scope.user_id = #{userId} AND role.code = #{roleCode} + AND role.enabled = TRUE + AND permission.code = 'archive:package:view' AND scope.status = 'ACTIVE' + AND scope.valid_from <= UTC_TIMESTAMP(3) + AND (scope.valid_to IS NULL OR scope.valid_to >= UTC_TIMESTAMP(3)) + AND (scope.scope_type = 'GLOBAL' + OR (scope.scope_type = 'COMPANY' AND scope.company_public_id = company.public_id) + OR (scope.scope_type = 'PROJECT' AND scope.project_public_id = project.public_id)) + ) + """; + + @Select(""" + + """) + List listPackages(@Param("userId") long userId, @Param("roleCode") String roleCode, + @Param("view") String view, @Param("companyId") String companyId, + @Param("projectId") String projectId, @Param("status") String status, + @Param("completeness") String completeness, @Param("frozen") Boolean frozen, + @Param("keyword") String keyword, @Param("sort") String sort, + @Param("limit") int limit, @Param("offset") int offset); + + @Select(""" + + """) + long countPackages(@Param("userId") long userId, @Param("roleCode") String roleCode, + @Param("view") String view, @Param("companyId") String companyId, + @Param("projectId") String projectId, @Param("status") String status, + @Param("completeness") String completeness, @Param("frozen") Boolean frozen, + @Param("keyword") String keyword); + + @Select(PACKAGE_SELECT + " WHERE package_record.public_id = #{publicId}") + PackageRow findPackage(String publicId); + + @Select(PACKAGE_SELECT + " WHERE package_record.id = #{id} FOR UPDATE") + PackageRow lockPackage(long id); + + @Select("SELECT COUNT(*) FROM arc_package WHERE supersedes_package_id = #{packageId}") + int revisionCount(long packageId); + + @Select(""" + SELECT project.id, project.public_id, project.business_no, project.name, + project.company_id, company.public_id AS company_public_id, company.name AS company_name, + project.archive_status, project.status, project.finance_status + FROM md_project project JOIN md_company company ON company.id = project.company_id + WHERE project.public_id = #{publicId} + """) + ProjectRow findProject(String publicId); + + @Select("SELECT COALESCE(MAX(package_version), 0) + 1 FROM arc_package WHERE project_id = #{projectId}") + int nextPackageVersion(long projectId); + + @Select("SELECT root_package_id FROM arc_package WHERE id = #{id}") + Long rootPackageId(long id); + + @Select(""" + SELECT COUNT(*) FROM arc_package + WHERE project_id = #{projectId} + AND (status IN ('PREPARING','CHECKED','REVIEWING','RETURNED') + OR (status = 'FROZEN' AND freeze_status_before IN ('PREPARING','CHECKED','REVIEWING','RETURNED'))) + """) + int activePackageCount(long projectId); + + @Insert(""" + INSERT INTO arc_package ( + public_id, business_no, company_id, project_id, root_package_id, supersedes_package_id, + revision_reason, package_version, rule_version, retention_years, physical_location, + status, created_by, updated_by + ) VALUES ( + #{publicId}, #{businessNo}, #{companyId}, #{projectId}, #{rootPackageId}, #{supersedesPackageId}, + #{revisionReason}, #{packageVersion}, #{ruleVersion}, #{retentionYears}, #{physicalLocation}, + 'PREPARING', #{actorId}, #{actorId} + ) + """) + int insertPackage(@Param("publicId") String publicId, @Param("businessNo") String businessNo, + @Param("companyId") long companyId, @Param("projectId") long projectId, + @Param("rootPackageId") Long rootPackageId, + @Param("supersedesPackageId") Long supersedesPackageId, + @Param("revisionReason") String revisionReason, + @Param("packageVersion") int packageVersion, @Param("ruleVersion") String ruleVersion, + @Param("retentionYears") int retentionYears, @Param("physicalLocation") String physicalLocation, + @Param("actorId") long actorId); + + @Update("UPDATE arc_package SET root_package_id = id WHERE id = #{id} AND root_package_id IS NULL") + int initializePackageRoot(long id); + + @Update(""" + UPDATE arc_package + SET completeness = #{completeness}, missing_count = #{missingCount}, + manifest_json = CAST(#{manifestJson} AS JSON), manifest_sha256 = #{manifestSha256}, + updated_by = COALESCE(#{actorId}, updated_by), + version = CASE WHEN #{incrementVersion} THEN version + 1 ELSE version END + WHERE id = #{id} AND status IN ('PREPARING','CHECKED') + """) + int initializePackageManifest(@Param("id") long id, @Param("completeness") BigDecimal completeness, + @Param("missingCount") int missingCount, + @Param("manifestJson") String manifestJson, + @Param("manifestSha256") String manifestSha256, + @Param("actorId") Long actorId, + @Param("incrementVersion") boolean incrementVersion); + + @Insert(""" + + """) + int copyRevisionItem(@Param("publicId") String publicId, + @Param("targetPackageId") long targetPackageId, + @Param("sourceItemId") long sourceItemId, + @Param("returnItemIds") List returnItemIds, + @Param("reason") String reason); + + @Insert(""" + INSERT INTO arc_package_object_snapshot ( + public_id, package_id, item_id, object_type, object_public_id, + object_business_no, object_name, object_status, object_version, + source_version_public_id, snapshot_json, snapshot_sha256 + ) VALUES ( + #{publicId}, #{packageId}, #{itemId}, #{objectType}, #{objectPublicId}, + #{objectBusinessNo}, #{objectName}, #{objectStatus}, #{objectVersion}, + #{sourceVersionPublicId}, CAST(#{snapshotJson} AS JSON), #{snapshotSha256} + ) + """) + int insertObjectSnapshot(@Param("publicId") String publicId, @Param("packageId") long packageId, + @Param("itemId") long itemId, @Param("objectType") String objectType, + @Param("objectPublicId") String objectPublicId, + @Param("objectBusinessNo") String objectBusinessNo, + @Param("objectName") String objectName, @Param("objectStatus") String objectStatus, + @Param("objectVersion") Long objectVersion, + @Param("sourceVersionPublicId") String sourceVersionPublicId, + @Param("snapshotJson") String snapshotJson, + @Param("snapshotSha256") String snapshotSha256); + + @Insert(""" + INSERT INTO arc_package_object_snapshot ( + public_id, package_id, item_id, object_type, object_public_id, + object_business_no, object_name, object_status, object_version, + source_version_public_id, snapshot_json, snapshot_sha256 + ) + SELECT #{publicId}, #{targetPackageId}, #{targetItemId}, object_type, object_public_id, + object_business_no, object_name, object_status, object_version, + source_version_public_id, snapshot_json, snapshot_sha256 + FROM arc_package_object_snapshot WHERE id = #{sourceSnapshotId} + """) + int copyObjectSnapshot(@Param("publicId") String publicId, + @Param("targetPackageId") long targetPackageId, + @Param("targetItemId") long targetItemId, + @Param("sourceSnapshotId") long sourceSnapshotId); + + @Select(""" + SELECT snapshot.id, snapshot.public_id, snapshot.item_id, item.item_type, + snapshot.object_type, snapshot.object_public_id, snapshot.object_business_no, + snapshot.object_name, snapshot.object_status, snapshot.object_version, + JSON_UNQUOTE(JSON_EXTRACT(snapshot.snapshot_json, '$.formType')) AS object_form_type, + snapshot.source_version_public_id, snapshot.snapshot_sha256, snapshot.created_at + FROM arc_package_object_snapshot snapshot + JOIN arc_package_item item ON item.id = snapshot.item_id + WHERE snapshot.package_id = #{packageId} + ORDER BY item.item_type, snapshot.object_type, snapshot.object_public_id + """) + List listObjectSnapshots(long packageId); + + @Select(""" + SELECT id FROM arc_package_item + WHERE package_id = #{packageId} AND item_type = #{itemType} + ORDER BY id LIMIT 1 + """) + Long packageItemIdByType(@Param("packageId") long packageId, @Param("itemType") String itemType); + + @Select(""" + SELECT id FROM arc_package_item + WHERE package_id = #{packageId} AND item_type = 'SOURCE_FORM' + AND item_name LIKE CONCAT(#{formType}, ' %') + ORDER BY id LIMIT 1 + """) + Long packageSourceItemId(@Param("packageId") long packageId, @Param("formType") String formType); + + @Delete("DELETE FROM arc_package_object_snapshot WHERE package_id = #{packageId}") + int deleteObjectSnapshots(long packageId); + + @Select(""" + SELECT project.public_id AS publicId, project.business_no AS businessNo, + project.name AS name, project.status AS status, project.version AS version, + project.description AS description, project.start_date AS startDate, + project.end_date AS endDate, project.currency AS currency, + project.stage AS stage, project.finance_status AS financeStatus, + project.archive_status AS archiveStatus, + company.public_id AS companyPublicId, company.business_no AS companyBusinessNo, + company.name AS companyName, + customer.public_id AS customerPublicId, customer.name AS customerName, + owner.public_id AS ownerPublicId, owner.display_name AS ownerName + FROM md_project project + JOIN md_company company ON company.id = project.company_id + JOIN iam_user owner ON owner.id = project.owner_user_id + LEFT JOIN md_counterparty customer ON customer.id = project.customer_id + WHERE project.id = #{projectId} + """) + Map projectSnapshot(long projectId); + + @Select(""" + SELECT document.public_id AS publicId, document.business_no AS businessNo, + document.form_type AS formType, document.source_system AS sourceSystem, + document.source_no AS sourceNo, document.source_version AS sourceVersion, + document.applicant_name AS applicantName, document.business_date AS businessDate, + document.status AS status, document.version AS version, + version.public_id AS sourceVersionPublicId, version.version_no AS versionNo, + version.snapshot_sha256 AS sourceSnapshotSha256, + version.payload_json AS payloadJson, version.submitted_at AS submittedAt, + template.public_id AS templatePublicId, template.template_version AS templateVersion, + (SELECT JSON_ARRAYAGG(JSON_OBJECT( + 'publicId', approval.public_id, 'nodeCode', approval.node_code, + 'actorName', approval.actor_name, 'action', approval.action, + 'opinion', approval.opinion, 'actedAt', approval.acted_at, + 'sourceSnapshot', approval.source_snapshot_json + )) FROM src_approval_snapshot approval + WHERE approval.document_version_id = version.id) AS approvalTimelineJson + FROM src_source_document document + JOIN src_document_version version ON version.id = document.current_version_id + JOIN src_template template ON template.id = version.template_id + WHERE document.project_id = #{projectId} + AND document.status IN ('APPROVED','RESULT_PENDING','ARCHIVE_PENDING','ARCHIVED') + ORDER BY document.form_type, document.public_id + """) + List> sourceDocumentSnapshots(long projectId); + + @Select(""" + SELECT contract.public_id AS publicId, contract.business_no AS businessNo, + contract.name AS name, contract.original_amount AS originalAmount, + contract.approved_change_amount AS approvedChangeAmount, + contract.settlement_amount AS settlementAmount, contract.currency AS currency, + contract.status AS status, contract.version AS version, + contract.submitted_at AS submittedAt, contract.reviewed_at AS reviewedAt, + contract.review_opinion AS reviewOpinion, + counterparty.public_id AS counterpartyPublicId, + counterparty.name AS counterpartyName, + (SELECT JSON_ARRAYAGG(JSON_OBJECT( + 'publicId', change_record.public_id, 'changeNo', change_record.change_no, + 'amount', change_record.change_amount, 'effectiveDate', change_record.effective_date, + 'reason', change_record.reason, 'status', change_record.status, + 'version', change_record.version + )) FROM fin_contract_change change_record + WHERE change_record.contract_id = contract.id) AS changesJson, + (SELECT JSON_ARRAYAGG(JSON_OBJECT( + 'publicId', settlement.public_id, 'settlementNo', settlement.settlement_no, + 'amount', settlement.settlement_amount, 'settlementDate', settlement.settlement_date, + 'status', settlement.status, 'version', settlement.version + )) FROM fin_contract_settlement settlement + WHERE settlement.contract_id = contract.id) AS settlementsJson + FROM md_contract contract + JOIN md_counterparty counterparty ON counterparty.id = contract.counterparty_id + WHERE contract.project_id = #{projectId} AND contract.status IN ('ACTIVE','SETTLED') + ORDER BY contract.public_id + """) + List> contractSnapshots(long projectId); + + @Select(""" + SELECT receipt.public_id AS publicId, receipt.business_no AS businessNo, + receipt.receipt_date AS receiptDate, receipt.amount AS amount, + receipt.allocated_amount AS allocatedAmount, receipt.currency AS currency, + receipt.payer_name AS payerName, receipt.reference_no AS referenceNo, + receipt.status AS status, receipt.version AS version, + receipt.confirmed_at AS confirmedAt, receipt.void_reason AS voidReason, + source.public_id AS sourceDocumentPublicId, + (SELECT JSON_ARRAYAGG(JSON_OBJECT( + 'publicId', allocation.public_id, 'sequenceNo', allocation.sequence_no, + 'amount', allocation.amount, 'note', allocation.note, + 'contractPublicId', contract.public_id, + 'receivablePublicId', receivable.public_id + )) FROM fin_receipt_allocation allocation + LEFT JOIN md_contract contract ON contract.id = allocation.contract_id + LEFT JOIN fin_receivable receivable ON receivable.id = allocation.receivable_id + WHERE allocation.receipt_id = receipt.id) AS allocationsJson + FROM fin_receipt receipt + LEFT JOIN src_source_document source ON source.id = receipt.source_document_id + WHERE receipt.project_id = #{projectId} AND receipt.status <> 'DRAFT' + ORDER BY receipt.public_id + """) + List> receiptSnapshots(long projectId); + + @Select(""" + SELECT invoice.public_id AS publicId, invoice.business_no AS businessNo, + invoice.invoice_type AS invoiceType, invoice.amount AS amount, + invoice.tax_rate AS taxRate, invoice.requested_date AS requestedDate, + invoice.invoice_no AS invoiceNo, invoice.issued_date AS issuedDate, + invoice.status AS status, invoice.version AS version, + invoice.review_opinion AS reviewOpinion, + invoice.termination_reason AS terminationReason, + contract.public_id AS contractPublicId, + counterparty.public_id AS counterpartyPublicId, + source.public_id AS sourceDocumentPublicId, + original.public_id AS originalInvoicePublicId, + file.public_id AS resultFilePublicId, file.original_name AS resultFileName, + file.sha256 AS resultFileSha256 + FROM fin_invoice invoice + LEFT JOIN md_contract contract ON contract.id = invoice.contract_id + JOIN md_counterparty counterparty ON counterparty.id = invoice.counterparty_id + LEFT JOIN src_source_document source ON source.id = invoice.source_document_id + LEFT JOIN fin_invoice original ON original.id = invoice.original_invoice_id + LEFT JOIN file_object file ON file.id = invoice.result_file_id + WHERE invoice.project_id = #{projectId} AND invoice.status <> 'DRAFT' + ORDER BY invoice.public_id + """) + List> invoiceSnapshots(long projectId); + + @Select(""" + SELECT payment.public_id AS publicId, payment.business_no AS businessNo, + payment.requested_amount AS requestedAmount, + payment.approved_amount AS approvedAmount, payment.frozen_amount AS frozenAmount, + payment.paid_amount AS paidAmount, payment.currency AS currency, + payment.requested_date AS requestedDate, payment.purpose AS purpose, + payment.status AS status, payment.version AS version, + payment.check_snapshot_json AS checkSnapshotJson, + payment.check_rule_version AS checkRuleVersion, + payment.return_reason AS returnReason, + contract.public_id AS contractPublicId, supplier.public_id AS supplierPublicId, + supplier.name AS supplierName, source.public_id AS sourceDocumentPublicId, + original.public_id AS originalPaymentPublicId, + payment.repayment_sequence AS repaymentSequence, + (SELECT JSON_ARRAYAGG(JSON_OBJECT( + 'publicId', check_record.public_id, 'code', check_record.check_code, + 'result', check_record.result, 'message', check_record.message, + 'checkedAt', check_record.checked_at + )) FROM fin_payment_check check_record + WHERE check_record.payment_id = payment.id) AS checksJson, + (SELECT JSON_ARRAYAGG(JSON_OBJECT( + 'publicId', result.public_id, 'resultNo', result.result_no, + 'type', result.result_type, 'paidDate', result.paid_date, + 'amount', result.amount, 'transactionNo', result.transaction_no, + 'status', result.status, 'version', result.version, + 'receiptFilePublicId', receipt_file.public_id, + 'receiptFileSha256', receipt_file.sha256 + )) FROM fin_payment_result result + LEFT JOIN file_object receipt_file ON receipt_file.id = result.receipt_file_id + WHERE result.payment_id = payment.id) AS resultsJson, + (SELECT JSON_ARRAYAGG(JSON_OBJECT( + 'publicId', attachment.public_id, 'documentType', attachment.document_type, + 'active', attachment.active, 'filePublicId', file.public_id, + 'fileName', file.original_name, 'fileSha256', file.sha256 + )) FROM fin_payment_attachment attachment + JOIN file_object file ON file.id = attachment.file_id + WHERE attachment.payment_id = payment.id) AS attachmentsJson + FROM fin_payment_request payment + JOIN md_contract contract ON contract.id = payment.contract_id + JOIN md_counterparty supplier ON supplier.id = payment.supplier_id + LEFT JOIN src_source_document source ON source.id = payment.source_document_id + LEFT JOIN fin_payment_request original ON original.id = payment.original_payment_id + WHERE payment.project_id = #{projectId} AND payment.status <> 'DRAFT' + ORDER BY payment.public_id + """) + List> paymentSnapshots(long projectId); + + @Select(""" + SELECT voucher.public_id AS publicId, voucher.business_no AS businessNo, + voucher.summary AS name, voucher.period AS period, + voucher.business_date AS businessDate, voucher.summary AS summary, + voucher.debit_total AS debitTotal, voucher.credit_total AS creditTotal, + voucher.status AS status, voucher.version AS version, + creator.public_id AS createdByPublicId, + creator.display_name AS createdByName, + voucher.created_at AS createdAt, + submitter.public_id AS submittedByPublicId, + submitter.display_name AS submittedByName, + (SELECT MAX(submit_log.created_at) FROM audit_log submit_log + WHERE submit_log.object_type = 'ACCOUNTING_VOUCHER' + AND submit_log.object_public_id = voucher.public_id + AND submit_log.action_code = 'ACCOUNTING_VOUCHER_SUBMIT' + AND submit_log.result_code = 'SUCCESS') AS submittedAt, + reviewer.public_id AS reviewedByPublicId, + reviewer.display_name AS reviewedByName, + (SELECT MAX(review_log.created_at) FROM audit_log review_log + WHERE review_log.object_type = 'ACCOUNTING_VOUCHER' + AND review_log.object_public_id = voucher.public_id + AND review_log.action_code = 'ACCOUNTING_VOUCHER_APPROVE' + AND review_log.result_code = 'SUCCESS') AS reviewedAt, + voucher.rule_version AS ruleVersion, + CAST(voucher.rule_snapshot_json AS CHAR) AS ruleSnapshotJson, + voucher.export_batch_no AS exportBatchNo, + voucher.export_sha256 AS exportSha256, + export_actor.public_id AS exportedByPublicId, + export_actor.display_name AS exportedByName, + export_batch.exported_at AS exportedAt, + export_file.public_id AS exportFilePublicId, + export_file.original_name AS exportFileName, + export_file.media_type AS exportFileMediaType, + export_file.size_bytes AS exportFileSizeBytes, + export_file.sha256 AS exportFileSha256, + voucher.external_voucher_no AS externalVoucherNo, + voucher.result_at AS resultAt, + recorder.public_id AS resultRecordedByPublicId, + recorder.display_name AS resultRecordedByName, + voucher.result_recorded_at AS resultRecordedAt, + verifier.public_id AS resultVerifiedByPublicId, + verifier.display_name AS resultVerifiedByName, + voucher.result_verified_at AS resultVerifiedAt, + voucher.result_record_remark AS resultRecordRemark, + voucher.result_verify_opinion AS resultVerifyOpinion, + event.public_id AS eventPublicId, event.event_type AS eventType, + event.source_type AS sourceType, event.source_public_id AS sourcePublicId, + event.source_version AS sourceVersion, event.event_sequence AS eventSequence, + event.source_event_key AS sourceEventKey, + event.amount AS eventAmount, event.currency AS eventCurrency, + (SELECT JSON_ARRAYAGG(JSON_OBJECT( + 'publicId', entry.public_id, 'lineNo', entry.line_no, + 'direction', entry.direction, 'amount', entry.amount, + 'summary', entry.summary, 'auxiliary', entry.auxiliary_json + )) FROM acc_voucher_entry entry + WHERE entry.voucher_id = voucher.id) AS entriesJson, + (SELECT JSON_ARRAYAGG(JSON_OBJECT( + 'publicId', change_record.public_id, 'reason', change_record.reason, + 'before', change_record.before_json, 'after', change_record.after_json, + 'changedAt', change_record.changed_at + )) FROM acc_voucher_change change_record + WHERE change_record.voucher_id = voucher.id) AS changesJson, + (SELECT JSON_ARRAYAGG(JSON_OBJECT( + 'publicId', result_file.public_id, 'filePublicId', file.public_id, + 'fileName', file.original_name, 'fileSha256', file.sha256, + 'fileMediaType', file.media_type, 'fileExtension', file.extension, + 'fileSizeBytes', file.size_bytes, 'fileScanStatus', file.scan_status, + 'createdByPublicId', result_actor.public_id, + 'createdByName', result_actor.display_name, + 'createdAt', result_file.created_at + )) FROM acc_voucher_result_file result_file + JOIN file_object file ON file.id = result_file.file_id + JOIN iam_user result_actor ON result_actor.id = result_file.created_by + WHERE result_file.voucher_id = voucher.id) AS resultFilesJson + FROM acc_voucher voucher + JOIN acc_event event ON event.id = voucher.event_id + JOIN iam_user creator ON creator.id = voucher.created_by + LEFT JOIN iam_user submitter ON submitter.id = voucher.submitted_by + LEFT JOIN iam_user reviewer ON reviewer.id = voucher.reviewed_by + LEFT JOIN acc_export_batch export_batch ON export_batch.voucher_id = voucher.id + LEFT JOIN file_object export_file ON export_file.id = export_batch.file_id + LEFT JOIN iam_user export_actor ON export_actor.id = export_batch.exported_by + LEFT JOIN iam_user recorder ON recorder.id = voucher.result_recorded_by + LEFT JOIN iam_user verifier ON verifier.id = voucher.result_verified_by + WHERE (voucher.project_id = #{projectId} OR event.project_id = #{projectId}) + AND voucher.status = 'COMPLETED' + ORDER BY voucher.public_id + """) + List> voucherSnapshots(long projectId); + + @Select(""" + SELECT file.public_id AS publicId, file.original_name AS name, + file.scan_status AS status, link.version_no AS version, + file.sha256 AS fileSha256, file.media_type AS mediaType, + file.extension AS extension, file.size_bytes AS sizeBytes, + link.object_type AS ownerType, link.object_public_id AS ownerPublicId, + link.document_type AS documentType, link.original_type AS originalType, + link.archive_status AS archiveStatus, link.form_type AS formType, + link.contract_public_id AS contractPublicId, + link.counterparty_public_id AS counterpartyPublicId + FROM file_link link + JOIN file_object file ON file.id = link.file_id + WHERE link.project_public_id = #{projectPublicId} + AND link.archive_status IN ('ACTIVE','ARCHIVED','FROZEN') + ORDER BY link.version_no, file.public_id + """) + List> projectFileSnapshots(String projectPublicId); + + @Insert(""" + INSERT INTO arc_package_return_item (package_id, item_id, reason, created_by) + VALUES (#{packageId}, #{itemId}, #{reason}, #{actorId}) + """) + int insertReturnedItem(@Param("packageId") long packageId, @Param("itemId") long itemId, + @Param("reason") String reason, @Param("actorId") long actorId); + + @Select(""" + SELECT item.public_id + FROM arc_package_return_item returned + JOIN arc_package_item item ON item.id = returned.item_id + WHERE returned.package_id = #{packageId} + ORDER BY item.id + """) + List returnedItemPublicIds(long packageId); + + @Insert(""" + INSERT INTO arc_package_item ( + public_id, package_id, item_type, item_name, object_type, object_public_id, + file_id, file_link_id, file_sha256, required_flag, status, missing_reason, not_applicable_reason + ) VALUES ( + #{publicId}, #{packageId}, #{itemType}, #{itemName}, #{objectType}, #{objectPublicId}, + #{fileId}, #{fileLinkId}, #{fileSha256}, #{required}, #{status}, #{missingReason}, #{notApplicableReason} + ) + """) + int insertItem(@Param("publicId") String publicId, @Param("packageId") long packageId, + @Param("itemType") String itemType, @Param("itemName") String itemName, + @Param("objectType") String objectType, @Param("objectPublicId") String objectPublicId, + @Param("fileId") Long fileId, @Param("fileLinkId") Long fileLinkId, + @Param("fileSha256") String fileSha256, @Param("required") boolean required, + @Param("status") String status, @Param("missingReason") String missingReason, + @Param("notApplicableReason") String notApplicableReason); + + @Delete("DELETE FROM arc_package_item WHERE package_id = #{packageId}") + int deleteItems(long packageId); + + @Select("SELECT id FROM arc_package WHERE public_id = #{publicId}") + Long packageId(String publicId); + + @Select("SELECT id FROM arc_package_item WHERE public_id = #{publicId} AND package_id = #{packageId}") + Long itemId(@Param("publicId") String publicId, @Param("packageId") long packageId); + + @Select(""" + SELECT id, item_type + FROM arc_package_item + WHERE public_id = #{publicId} AND package_id = #{packageId} + """) + PackageItemTargetRow findPackageItemTarget(@Param("publicId") String publicId, + @Param("packageId") long packageId); + + @Select(""" + SELECT item.id, item.public_id, item.item_type, item.item_name, item.object_type, + item.object_public_id, item.file_id, item.file_link_id, item.file_sha256, + item.required_flag, item.status, item.missing_reason, item.not_applicable_reason, + returned.reason AS return_reason, + requester.display_name AS not_applicable_requested_by_name, + item.not_applicable_requested_at, + reviewer.display_name AS not_applicable_reviewed_by_name, + item.not_applicable_reviewed_at, item.not_applicable_review_opinion, + file.public_id AS file_public_id, file.original_name AS file_name + FROM arc_package_item item + LEFT JOIN arc_package_return_item returned + ON returned.package_id = item.package_id AND returned.item_id = item.id + LEFT JOIN file_object file ON file.id = item.file_id + LEFT JOIN iam_user requester ON requester.id = item.not_applicable_requested_by + LEFT JOIN iam_user reviewer ON reviewer.id = item.not_applicable_reviewed_by + WHERE item.package_id = #{packageId} + ORDER BY item.required_flag DESC, item.item_type, item.id + """) + List listItems(long packageId); + + @Select(""" + SELECT action.sequence_no, action.action_code, action.from_status, action.to_status, + action.opinion, user_account.display_name AS actor_name, action.occurred_at, + action.manifest_sha256 + FROM arc_package_action action JOIN iam_user user_account ON user_account.id = action.actor_id + WHERE action.package_id = #{packageId} + ORDER BY action.sequence_no + """) + List listActions(long packageId); + + @Select("SELECT COALESCE(MAX(sequence_no), 0) + 1 FROM arc_package_action WHERE package_id = #{packageId}") + int nextActionSequence(long packageId); + + @Insert(""" + INSERT INTO arc_package_action ( + public_id, package_id, sequence_no, action_code, from_status, to_status, + opinion, missing_json, manifest_sha256, actor_id + ) VALUES ( + #{publicId}, #{packageId}, #{sequenceNo}, #{actionCode}, #{fromStatus}, #{toStatus}, + CASE WHEN #{opinion} IS NULL THEN NULL ELSE #{opinion} END, + CASE WHEN #{missingJson} IS NULL THEN NULL ELSE CAST(#{missingJson} AS JSON) END, + #{manifestSha256}, #{actorId} + ) + """) + int insertAction(@Param("publicId") String publicId, @Param("packageId") long packageId, + @Param("sequenceNo") int sequenceNo, @Param("actionCode") String actionCode, + @Param("fromStatus") String fromStatus, @Param("toStatus") String toStatus, + @Param("opinion") String opinion, @Param("missingJson") String missingJson, + @Param("manifestSha256") String manifestSha256, @Param("actorId") long actorId); + + @Update(""" + UPDATE arc_package + SET completeness = #{completeness}, missing_count = #{missingCount}, + manifest_json = CASE WHEN #{manifestJson} IS NULL THEN NULL ELSE CAST(#{manifestJson} AS JSON) END, + manifest_sha256 = #{manifestSha256}, status = 'CHECKED', updated_by = #{actorId}, version = version + 1 + WHERE id = #{id} AND version = #{version} AND status IN ('PREPARING','CHECKED') + """) + int markChecked(@Param("id") long id, @Param("version") long version, + @Param("completeness") BigDecimal completeness, @Param("missingCount") int missingCount, + @Param("manifestJson") String manifestJson, @Param("manifestSha256") String manifestSha256, + @Param("actorId") long actorId); + + @Update(""" + UPDATE arc_package + SET completeness = #{completeness}, missing_count = #{missingCount}, + manifest_json = CASE WHEN #{manifestJson} IS NULL THEN NULL ELSE CAST(#{manifestJson} AS JSON) END, + manifest_sha256 = #{manifestSha256}, + status = 'REVIEWING', submitted_by = #{actorId}, submitted_at = UTC_TIMESTAMP(3), + review_opinion = NULL, updated_by = #{actorId}, version = version + 1 + WHERE id = #{id} AND version = #{version} AND status = 'CHECKED' AND missing_count = 0 AND frozen = FALSE + """) + int submitPackage(@Param("id") long id, @Param("version") long version, + @Param("completeness") BigDecimal completeness, @Param("missingCount") int missingCount, + @Param("manifestJson") String manifestJson, @Param("manifestSha256") String manifestSha256, + @Param("actorId") long actorId); + + @Update(""" + UPDATE arc_package + SET completeness = #{completeness}, missing_count = #{missingCount}, + manifest_json = CASE WHEN #{manifestJson} IS NULL THEN NULL ELSE CAST(#{manifestJson} AS JSON) END, + manifest_sha256 = #{manifestSha256}, status = 'RETURNED', + reviewed_by = #{actorId}, review_opinion = #{opinion}, + updated_by = #{actorId}, version = version + 1 + WHERE id = #{id} AND version = #{version} AND status = 'REVIEWING' + """) + int returnPackage(@Param("id") long id, @Param("version") long version, + @Param("completeness") BigDecimal completeness, @Param("missingCount") int missingCount, + @Param("manifestJson") String manifestJson, @Param("manifestSha256") String manifestSha256, + @Param("opinion") String opinion, @Param("actorId") long actorId); + + @Update(""" + UPDATE md_project SET archive_status = #{archiveStatus}, updated_by = #{actorId}, version = version + 1 + WHERE id = #{projectId} AND archive_status <> 'FROZEN' + """) + int markProjectArchiveStatus(@Param("projectId") long projectId, + @Param("archiveStatus") String archiveStatus, + @Param("actorId") long actorId); + + @Update(""" + UPDATE arc_package + SET completeness = #{completeness}, missing_count = #{missingCount}, + manifest_json = CASE WHEN #{manifestJson} IS NULL THEN NULL ELSE CAST(#{manifestJson} AS JSON) END, + manifest_sha256 = #{manifestSha256}, status = 'ARCHIVED', + reviewed_by = #{actorId}, review_opinion = #{opinion}, + archived_at = UTC_TIMESTAMP(3), updated_by = #{actorId}, version = version + 1 + WHERE id = #{id} AND version = #{version} AND status = 'REVIEWING' + AND missing_count = 0 AND frozen = FALSE + """) + int archivePackage(@Param("id") long id, @Param("version") long version, + @Param("completeness") BigDecimal completeness, @Param("missingCount") int missingCount, + @Param("manifestJson") String manifestJson, @Param("manifestSha256") String manifestSha256, + @Param("opinion") String opinion, @Param("actorId") long actorId); + + @Update(""" + UPDATE arc_package + SET frozen = TRUE, freeze_status_before = status, freeze_reason = #{reason}, status = 'FROZEN', + updated_by = #{actorId}, version = version + 1 + WHERE id = #{id} AND version = #{version} AND status IN ('ARCHIVED','CHECKED','REVIEWING') AND frozen = FALSE + """) + int freezePackage(@Param("id") long id, @Param("version") long version, + @Param("reason") String reason, @Param("actorId") long actorId); + + @Update(""" + UPDATE arc_package + SET frozen = FALSE, status = COALESCE(freeze_status_before, 'ARCHIVED'), freeze_reason = NULL, + freeze_status_before = NULL, updated_by = #{actorId}, version = version + 1 + WHERE id = #{id} AND version = #{version} AND status = 'FROZEN' AND frozen = TRUE + """) + int unfreezePackage(@Param("id") long id, @Param("version") long version, @Param("actorId") long actorId); + + @Update(""" + UPDATE file_link link + JOIN arc_package_item item ON item.file_link_id = link.id + SET link.archive_status = #{archiveStatus} + WHERE item.package_id = #{packageId} AND link.archive_status <> 'SUPERSEDED' + """) + int markPackageFiles(@Param("packageId") long packageId, @Param("archiveStatus") String archiveStatus); + + @Update(""" + UPDATE md_project + SET archive_status = 'ARCHIVED', status = 'ARCHIVED', stage = 'CLOSED', + updated_by = #{actorId}, version = version + 1 + WHERE id = #{projectId} + """) + int markProjectArchived(@Param("projectId") long projectId, @Param("actorId") long actorId); + + @Update(""" + UPDATE arc_package_item + SET status = 'NA_REVIEWING', not_applicable_reason = #{reason}, missing_reason = NULL, + not_applicable_requested_by = #{actorId}, not_applicable_requested_at = UTC_TIMESTAMP(3), + not_applicable_reviewed_by = NULL, not_applicable_reviewed_at = NULL, + not_applicable_review_opinion = NULL + WHERE id = #{id} AND package_id = #{packageId} AND status IN ('MISSING','NA_REJECTED') + """) + int markNotApplicable(@Param("id") long id, @Param("packageId") long packageId, + @Param("reason") String reason, @Param("actorId") long actorId); + + @Select("SELECT not_applicable_requested_by FROM arc_package_item WHERE id = #{id} AND package_id = #{packageId}") + Long notApplicableRequester(@Param("id") long id, @Param("packageId") long packageId); + + @Update(""" + UPDATE arc_package_item + SET status = CASE WHEN #{approved} THEN 'NOT_APPLICABLE' ELSE 'NA_REJECTED' END, + missing_reason = CASE WHEN #{approved} THEN NULL ELSE '不涉及申请已被财务退回' END, + not_applicable_reviewed_by = #{actorId}, not_applicable_reviewed_at = UTC_TIMESTAMP(3), + not_applicable_review_opinion = #{opinion} + WHERE id = #{id} AND package_id = #{packageId} AND status = 'NA_REVIEWING' + """) + int reviewNotApplicable(@Param("id") long id, @Param("packageId") long packageId, + @Param("approved") boolean approved, @Param("opinion") String opinion, + @Param("actorId") long actorId); + + @Update(""" + UPDATE arc_package_item + SET status = 'MISSING', missing_reason = #{reason}, not_applicable_reason = NULL, + not_applicable_requested_by = NULL, not_applicable_requested_at = NULL, + not_applicable_reviewed_by = NULL, not_applicable_reviewed_at = NULL, + not_applicable_review_opinion = NULL + WHERE id = #{id} AND package_id = #{packageId} AND status <> 'SUPERSEDED' + """) + int markItemMissing(@Param("id") long id, @Param("packageId") long packageId, + @Param("reason") String reason); + + @Update(""" + UPDATE arc_package_item + SET file_id = #{fileId}, file_link_id = #{fileLinkId}, file_sha256 = #{sha256}, + status = 'PRESENT', missing_reason = NULL, not_applicable_reason = NULL + WHERE id = #{itemId} AND package_id = #{packageId} + """) + int attachItemFile(@Param("itemId") long itemId, @Param("packageId") long packageId, + @Param("fileId") long fileId, @Param("fileLinkId") long fileLinkId, + @Param("sha256") String sha256); + + /** + * Accounting is an object-backed archive item rather than a file upload. + * Reconcile its persisted checklist state whenever object snapshots are + * refreshed, while leaving an explicit NA/revision decision untouched. + */ + @Update(""" + UPDATE arc_package_item item + SET status = CASE WHEN EXISTS ( + SELECT 1 FROM acc_voucher voucher + JOIN acc_event event ON event.id = voucher.event_id + WHERE (voucher.project_id = #{projectId} OR event.project_id = #{projectId}) + AND voucher.status = 'COMPLETED' + ) THEN 'PRESENT' ELSE 'MISSING' END, + missing_reason = CASE WHEN EXISTS ( + SELECT 1 FROM acc_voucher voucher + JOIN acc_event event ON event.id = voucher.event_id + WHERE (voucher.project_id = #{projectId} OR event.project_id = #{projectId}) + AND voucher.status = 'COMPLETED' + ) THEN NULL ELSE '缺少已完成记账准备结果' END + WHERE item.package_id = #{packageId} + AND item.item_type = 'ACCOUNTING' + AND item.status IN ('PRESENT', 'MISSING', 'NA_REJECTED') + """) + int syncAccountingItem(@Param("packageId") long packageId, @Param("projectId") long projectId); + + @Select(""" + SELECT COUNT(*) FROM src_source_document document + WHERE document.project_id = (SELECT id FROM md_project WHERE public_id = #{projectPublicId}) + AND document.form_type = #{formType} + AND document.status IN ('APPROVED','RESULT_PENDING','ARCHIVE_PENDING','ARCHIVED') + """) + int countSource(@Param("projectPublicId") String projectPublicId, @Param("formType") String formType); + + @Select(""" + SELECT COUNT(*) FROM md_contract contract + JOIN md_project project ON project.id = contract.project_id + WHERE project.public_id = #{projectPublicId} AND contract.status = 'ACTIVE' + """) + int countContracts(String projectPublicId); + + @Select(""" + SELECT COUNT(*) FROM fin_receipt receipt JOIN md_project project ON project.id = receipt.project_id + WHERE project.public_id = #{projectPublicId} AND receipt.status NOT IN ('DRAFT','VOID') + """) + int countReceipts(String projectPublicId); + + @Select(""" + SELECT COUNT(*) FROM fin_invoice invoice JOIN md_project project ON project.id = invoice.project_id + WHERE project.public_id = #{projectPublicId} AND invoice.status NOT IN ('DRAFT','VOID') + """) + int countInvoices(String projectPublicId); + + @Select(""" + SELECT COUNT(*) FROM fin_payment_request payment JOIN md_project project ON project.id = payment.project_id + WHERE project.public_id = #{projectPublicId} AND payment.status NOT IN ('DRAFT','VOID') + """) + int countPayments(String projectPublicId); + + @Select(""" + SELECT COUNT(*) + FROM acc_voucher voucher + JOIN acc_event event ON event.id = voucher.event_id + JOIN md_project project ON project.public_id = #{projectPublicId} + WHERE (voucher.project_id = project.id OR event.project_id = project.id) + AND voucher.status = 'COMPLETED' + """) + int countCompletedVouchers(String projectPublicId); + + @Select(""" + SELECT contract.public_id AS contract_public_id, project.public_id AS project_public_id, + counterparty.public_id AS counterparty_public_id + FROM md_contract contract + JOIN md_project project ON project.id = contract.project_id + JOIN md_counterparty counterparty ON counterparty.id = contract.counterparty_id + WHERE contract.public_id = #{publicId} AND contract.status IN ('ACTIVE','SETTLED') + """) + FileDimensionRow findFileContract(String publicId); + + @Select("SELECT COUNT(*) FROM md_counterparty WHERE public_id = #{publicId} AND status = 'ACTIVE'") + int activeCounterpartyCount(String publicId); + + @Select(""" + SELECT COUNT(*) FROM file_link link + WHERE link.project_public_id = #{projectPublicId} + AND link.archive_status IN ('ACTIVE','ARCHIVED','FROZEN') + AND link.document_type = #{fileType} + """) + int countProjectFiles(@Param("projectPublicId") String projectPublicId, @Param("fileType") String fileType); + + @Select(""" + SELECT link.id, link.file_id, link.series_public_id, link.version_no, link.document_type, + link.original_type, link.project_public_id, link.contract_public_id, + link.counterparty_public_id, link.form_type, link.archive_status, link.supersedes_link_id + FROM file_link link JOIN file_object file ON file.id = link.file_id + WHERE file.public_id = #{filePublicId} + ORDER BY link.version_no DESC, link.id DESC LIMIT 1 + """) + FileLinkRow fileLinkForReplacement(String filePublicId); + + @Select(""" + SELECT COALESCE(MAX(version_no), 0) + 1 + FROM file_link + WHERE series_public_id = #{seriesPublicId} + """) + int nextFileVersion(String seriesPublicId); + + @Update(""" + UPDATE file_link + SET active = FALSE, archive_status = 'SUPERSEDED' + WHERE id = #{id} AND archive_status = 'ACTIVE' + """) + int supersedeFileLink(long id); + + @Select(""" + SELECT file.id, file.public_id, file.original_name, file.media_type, file.size_bytes, + file.sha256, file.scan_status, file.uploaded_by, file.created_at, + (SELECT borrow.status FROM arc_borrow borrow + JOIN file_link borrow_link ON borrow_link.id = borrow.file_link_id + WHERE borrow.series_public_id = link.series_public_id + AND COALESCE(borrow_link.company_public_id, '') = COALESCE(link.company_public_id, '') + AND COALESCE(borrow_link.project_public_id, '') = COALESCE(link.project_public_id, '') + AND borrow.status IN ('APPLIED','APPROVED','BORROWED','OVERDUE') + ORDER BY borrow.id DESC LIMIT 1) AS borrow_status, + link.id AS link_id, link.series_public_id, link.version_no, link.document_type, link.original_type, link.archive_status, + link.company_public_id, company.name AS company_name, + link.project_public_id, project.business_no AS project_business_no, project.name AS project_name, + link.contract_public_id, link.counterparty_public_id, link.form_type, + package_record.public_id AS package_public_id, package_record.status AS package_status + FROM file_link link JOIN file_object file ON file.id = link.file_id + LEFT JOIN md_company company ON company.public_id = link.company_public_id + LEFT JOIN md_project project ON project.public_id = link.project_public_id + LEFT JOIN arc_package package_record ON package_record.id = ( + SELECT candidate_item.package_id + FROM arc_package_item candidate_item + JOIN arc_package candidate_package ON candidate_package.id = candidate_item.package_id + WHERE candidate_item.file_link_id = link.id + ORDER BY candidate_package.package_version DESC, candidate_item.id DESC + LIMIT 1 + ) + WHERE link.id = #{linkId} + """) + FileRow findFileByLinkId(long linkId); + + @Select(""" + + """) + List listFiles(@Param("userId") long userId, @Param("roleCode") String roleCode, + @Param("companyId") String companyId, @Param("projectId") String projectId, + @Param("contractId") String contractId, @Param("counterpartyId") String counterpartyId, + @Param("formType") String formType, @Param("originalType") String originalType, + @Param("archiveStatus") String archiveStatus, + @Param("scanStatus") String scanStatus, + @Param("uploadedBy") String uploadedBy, + @Param("fileName") String fileName, @Param("uploadedFrom") LocalDateTime uploadedFrom, + @Param("uploadedTo") LocalDateTime uploadedTo, @Param("sort") String sort, + @Param("limit") int limit, + @Param("offset") int offset); + + @Select(""" + + """) + long countFiles(@Param("userId") long userId, @Param("roleCode") String roleCode, + @Param("companyId") String companyId, @Param("projectId") String projectId, + @Param("contractId") String contractId, @Param("counterpartyId") String counterpartyId, + @Param("formType") String formType, @Param("originalType") String originalType, + @Param("archiveStatus") String archiveStatus, + @Param("scanStatus") String scanStatus, + @Param("uploadedBy") String uploadedBy, + @Param("fileName") String fileName, @Param("uploadedFrom") LocalDateTime uploadedFrom, + @Param("uploadedTo") LocalDateTime uploadedTo); + + @Select(""" + SELECT file.id, file.public_id, file.original_name, file.media_type, file.size_bytes, + file.sha256, file.scan_status, file.uploaded_by, file.created_at, + (SELECT borrow.status FROM arc_borrow borrow + JOIN file_link borrow_link ON borrow_link.id = borrow.file_link_id + WHERE borrow.series_public_id = link.series_public_id + AND COALESCE(borrow_link.company_public_id, '') = COALESCE(link.company_public_id, '') + AND COALESCE(borrow_link.project_public_id, '') = COALESCE(link.project_public_id, '') + AND borrow.status IN ('APPLIED','APPROVED','BORROWED','OVERDUE') + ORDER BY borrow.id DESC LIMIT 1) AS borrow_status, + link.id AS link_id, link.series_public_id, link.version_no, link.document_type, link.original_type, link.archive_status, + link.company_public_id, company.name AS company_name, + link.project_public_id, project.business_no AS project_business_no, project.name AS project_name, + link.contract_public_id, link.counterparty_public_id, link.form_type, + package_record.public_id AS package_public_id, package_record.status AS package_status + FROM file_link link JOIN file_object file ON file.id = link.file_id + LEFT JOIN md_company company ON company.public_id = link.company_public_id + LEFT JOIN md_project project ON project.public_id = link.project_public_id + LEFT JOIN arc_package package_record ON package_record.id = ( + SELECT candidate_item.package_id + FROM arc_package_item candidate_item + JOIN arc_package candidate_package ON candidate_package.id = candidate_item.package_id + WHERE candidate_item.file_link_id = link.id + ORDER BY candidate_package.package_version DESC, candidate_item.id DESC + LIMIT 1 + ) + WHERE file.public_id = #{filePublicId} + ORDER BY link.version_no DESC, link.id DESC LIMIT 1 + """) + FileRow findFile(String filePublicId); + + @Select(""" + SELECT file.id, file.public_id, file.original_name, file.media_type, file.size_bytes, + file.sha256, file.scan_status, file.uploaded_by, file.created_at, + (SELECT borrow.status FROM arc_borrow borrow + JOIN file_link borrow_link ON borrow_link.id = borrow.file_link_id + WHERE borrow.series_public_id = link.series_public_id + AND COALESCE(borrow_link.company_public_id, '') = COALESCE(link.company_public_id, '') + AND COALESCE(borrow_link.project_public_id, '') = COALESCE(link.project_public_id, '') + AND borrow.status IN ('APPLIED','APPROVED','BORROWED','OVERDUE') + ORDER BY borrow.id DESC LIMIT 1) AS borrow_status, + link.id AS link_id, link.series_public_id, link.version_no, link.document_type, link.original_type, link.archive_status, + link.company_public_id, company.name AS company_name, + link.project_public_id, project.business_no AS project_business_no, project.name AS project_name, + link.contract_public_id, link.counterparty_public_id, link.form_type, + package_record.public_id AS package_public_id, package_record.status AS package_status + FROM file_link link JOIN file_object file ON file.id = link.file_id + LEFT JOIN md_company company ON company.public_id = link.company_public_id + LEFT JOIN md_project project ON project.public_id = link.project_public_id + LEFT JOIN arc_package package_record ON package_record.id = ( + SELECT candidate_item.package_id + FROM arc_package_item candidate_item + JOIN arc_package candidate_package ON candidate_package.id = candidate_item.package_id + WHERE candidate_item.file_link_id = link.id + ORDER BY candidate_package.package_version DESC, candidate_item.id DESC + LIMIT 1 + ) + WHERE link.id = (SELECT link2.id FROM file_link link2 JOIN file_object file2 ON file2.id = link2.file_id + WHERE file2.public_id = #{filePublicId} ORDER BY link2.version_no DESC, link2.id DESC LIMIT 1) + """) + FileRow findCurrentFile(String filePublicId); + + @Select(""" + SELECT file.id, file.public_id, file.original_name, file.media_type, file.size_bytes, + file.sha256, file.scan_status, file.uploaded_by, file.created_at, + (SELECT borrow.status FROM arc_borrow borrow + JOIN file_link borrow_link ON borrow_link.id = borrow.file_link_id + WHERE borrow.series_public_id = link.series_public_id + AND COALESCE(borrow_link.company_public_id, '') = COALESCE(link.company_public_id, '') + AND COALESCE(borrow_link.project_public_id, '') = COALESCE(link.project_public_id, '') + AND borrow.status IN ('APPLIED','APPROVED','BORROWED','OVERDUE') + ORDER BY borrow.id DESC LIMIT 1) AS borrow_status, + link.id AS link_id, link.series_public_id, link.version_no, link.document_type, link.original_type, link.archive_status, + link.company_public_id, company.name AS company_name, + link.project_public_id, project.business_no AS project_business_no, project.name AS project_name, + link.contract_public_id, link.counterparty_public_id, link.form_type, + package_record.public_id AS package_public_id, package_record.status AS package_status + FROM file_link link JOIN file_object file ON file.id = link.file_id + LEFT JOIN md_company company ON company.public_id = link.company_public_id + LEFT JOIN md_project project ON project.public_id = link.project_public_id + LEFT JOIN arc_package package_record ON package_record.id = ( + SELECT candidate_item.package_id + FROM arc_package_item candidate_item + JOIN arc_package candidate_package ON candidate_package.id = candidate_item.package_id + WHERE candidate_item.file_link_id = link.id + ORDER BY candidate_package.package_version DESC, candidate_item.id DESC + LIMIT 1 + ) + WHERE link.series_public_id = #{seriesPublicId} + AND EXISTS ( + SELECT 1 FROM iam_scope scope + JOIN iam_role role ON role.id = scope.role_id + JOIN iam_permission permission ON permission.id = scope.permission_id + JOIN iam_user_role user_role ON user_role.user_id = scope.user_id + AND user_role.role_id = scope.role_id + WHERE scope.user_id = #{userId} AND role.code = #{roleCode} + AND role.enabled = TRUE + AND permission.code = 'archive:file:view' AND scope.status = 'ACTIVE' + AND scope.valid_from <= UTC_TIMESTAMP(3) + AND (scope.valid_to IS NULL OR scope.valid_to >= UTC_TIMESTAMP(3)) + AND (scope.scope_type = 'GLOBAL' + OR (scope.scope_type = 'COMPANY' AND scope.company_public_id = link.company_public_id) + OR (scope.scope_type = 'PROJECT' AND scope.project_public_id = link.project_public_id)) + ) + ORDER BY link.version_no DESC, link.id DESC + """) + List fileVersions(@Param("seriesPublicId") String seriesPublicId, + @Param("userId") long userId, @Param("roleCode") String roleCode); + + @Insert(""" + INSERT INTO file_link ( + file_id, object_type, object_public_id, series_public_id, company_public_id, project_public_id, + contract_public_id, counterparty_public_id, form_type, document_type, original_type, + version_no, active, archive_status, supersedes_link_id, not_applicable_reason, linked_by + ) VALUES ( + #{fileId}, #{objectType}, #{objectPublicId}, #{seriesPublicId}, #{companyPublicId}, #{projectPublicId}, + #{contractPublicId}, #{counterpartyPublicId}, #{formType}, #{documentType}, #{originalType}, + #{versionNo}, TRUE, 'ACTIVE', #{supersedesLinkId}, #{notApplicableReason}, #{linkedBy} + ) + """) + int insertFileLink(@Param("fileId") long fileId, @Param("objectType") String objectType, + @Param("objectPublicId") String objectPublicId, @Param("seriesPublicId") String seriesPublicId, + @Param("companyPublicId") String companyPublicId, + @Param("projectPublicId") String projectPublicId, @Param("contractPublicId") String contractPublicId, + @Param("counterpartyPublicId") String counterpartyPublicId, @Param("formType") String formType, + @Param("documentType") String documentType, @Param("originalType") String originalType, + @Param("versionNo") int versionNo, @Param("supersedesLinkId") Long supersedesLinkId, + @Param("notApplicableReason") String notApplicableReason, @Param("linkedBy") String linkedBy); + + @Select("SELECT LAST_INSERT_ID()") + long lastInsertId(); + + @Select("SELECT id, file_id, series_public_id, version_no, document_type, original_type, project_public_id, contract_public_id, counterparty_public_id, form_type, archive_status, supersedes_link_id FROM file_link WHERE id = #{id}") + FileLinkRow findFileLink(long id); + + @Select("SELECT id FROM file_object WHERE public_id = #{publicId}") + Long fileId(String publicId); + + @Select("SELECT id FROM file_link WHERE series_public_id = #{seriesPublicId} ORDER BY id LIMIT 1 FOR UPDATE") + Long lockFileSeries(String seriesPublicId); + + @Select(""" + SELECT file.id, file.public_id, file.original_name, file.media_type, file.size_bytes, + file.sha256, file.scan_status, file.uploaded_by, file.created_at, + (SELECT borrow.status FROM arc_borrow borrow + JOIN file_link borrow_link ON borrow_link.id = borrow.file_link_id + WHERE borrow.series_public_id = link.series_public_id + AND COALESCE(borrow_link.company_public_id, '') = COALESCE(link.company_public_id, '') + AND COALESCE(borrow_link.project_public_id, '') = COALESCE(link.project_public_id, '') + AND borrow.status IN ('APPLIED','APPROVED','BORROWED','OVERDUE') + ORDER BY borrow.id DESC LIMIT 1) AS borrow_status, + link.id AS link_id, link.series_public_id, link.version_no, link.document_type, link.original_type, link.archive_status, + link.company_public_id, company.name AS company_name, + link.project_public_id, project.business_no AS project_business_no, project.name AS project_name, + link.contract_public_id, link.counterparty_public_id, link.form_type, + package_record.public_id AS package_public_id, package_record.status AS package_status + FROM file_object file + JOIN file_link link ON link.file_id = file.id + LEFT JOIN md_company company ON company.public_id = link.company_public_id + LEFT JOIN md_project project ON project.public_id = link.project_public_id + LEFT JOIN arc_package package_record ON package_record.id = ( + SELECT candidate_item.package_id + FROM arc_package_item candidate_item + JOIN arc_package candidate_package ON candidate_package.id = candidate_item.package_id + WHERE candidate_item.file_link_id = link.id + ORDER BY candidate_package.package_version DESC, candidate_item.id DESC + LIMIT 1 + ) + WHERE file.id = #{fileId} + ORDER BY link.version_no DESC, link.id DESC LIMIT 1 + """) + FileRow findFileById(long fileId); + + @Select(""" + SELECT item.package_id FROM arc_package_item item + JOIN arc_package package_record ON package_record.id = item.package_id + WHERE item.file_link_id = #{fileLinkId} + ORDER BY package_record.package_version DESC, item.id DESC LIMIT 1 + """) + Long packageIdForFileLink(long fileLinkId); + + @Select("SELECT file_id FROM file_link WHERE id = #{id}") + Long linkedFileId(long id); + + @Update(""" + UPDATE arc_borrow borrow + SET borrow.status = 'OVERDUE', borrow.version = borrow.version + 1, + borrow.updated_at = UTC_TIMESTAMP(3) + WHERE borrow.status = 'BORROWED' AND borrow.due_at < UTC_TIMESTAMP(3) + AND NOT EXISTS ( + SELECT 1 FROM arc_package package_record + WHERE package_record.id = borrow.package_id + AND package_record.status = 'FROZEN' AND package_record.frozen = TRUE + ) + """) + int markOverdueBorrows(); + + @Select("SELECT id, public_id, package_id, file_id, file_link_id, series_public_id, applicant_id, purpose, due_at, status, approved_by, approved_at, review_opinion, returned_by, returned_at, return_condition, version FROM arc_borrow WHERE public_id = #{publicId} FOR UPDATE") + BorrowLock findBorrow(String publicId); + + @Select(""" + SELECT borrow.id, borrow.public_id, borrow.business_no, borrow.file_id, file.public_id AS file_public_id, + file.original_name AS file_name, borrowed_link.company_public_id AS company_public_id, + project.public_id AS project_public_id, project.name AS project_name, + borrow.purpose, borrow.due_at, borrow.status, borrow.applicant_id, + applicant.display_name AS applicant_name, + approver.display_name AS approved_by_name, borrow.approved_at, borrow.review_opinion, + borrow.return_condition, returner.display_name AS returned_by_name, borrow.returned_at, + borrow.version, borrow.updated_at + FROM arc_borrow borrow JOIN file_object file ON file.id = borrow.file_id + JOIN file_link borrowed_link ON borrowed_link.id = borrow.file_link_id + LEFT JOIN arc_package package_record ON package_record.id = borrow.package_id + LEFT JOIN md_project project ON project.public_id = borrowed_link.project_public_id + JOIN iam_user applicant ON applicant.id = borrow.applicant_id + LEFT JOIN iam_user approver ON approver.id = borrow.approved_by + LEFT JOIN iam_user returner ON returner.id = borrow.returned_by + WHERE borrow.public_id = #{publicId} + """) + BorrowRow findBorrowView(String publicId); + + @Select(""" + SELECT borrow.id, borrow.public_id, borrow.business_no, borrow.file_id, file.public_id AS file_public_id, + file.original_name AS file_name, borrowed_link.company_public_id AS company_public_id, + project.public_id AS project_public_id, project.name AS project_name, + borrow.purpose, borrow.due_at, borrow.status, borrow.applicant_id, + applicant.display_name AS applicant_name, + approver.display_name AS approved_by_name, borrow.approved_at, borrow.review_opinion, + borrow.return_condition, returner.display_name AS returned_by_name, borrow.returned_at, + borrow.version, borrow.updated_at + FROM arc_borrow borrow JOIN file_object file ON file.id = borrow.file_id + JOIN file_link borrowed_link ON borrowed_link.id = borrow.file_link_id + LEFT JOIN arc_package package_record ON package_record.id = borrow.package_id + LEFT JOIN md_project project ON project.public_id = borrowed_link.project_public_id + JOIN iam_user applicant ON applicant.id = borrow.applicant_id + LEFT JOIN iam_user approver ON approver.id = borrow.approved_by + LEFT JOIN iam_user returner ON returner.id = borrow.returned_by + WHERE borrow.status IN ('APPLIED','APPROVED','BORROWED','OVERDUE','RETURNED','REJECTED') + AND EXISTS ( + SELECT 1 FROM file_link scoped_link + JOIN iam_scope scope ON scope.user_id = #{userId} + JOIN iam_role role ON role.id = scope.role_id + JOIN iam_permission permission ON permission.id = scope.permission_id + JOIN iam_user_role user_role ON user_role.user_id = scope.user_id + AND user_role.role_id = scope.role_id + WHERE scoped_link.id = borrow.file_link_id AND role.code = #{roleCode} + AND role.enabled = TRUE + AND permission.code = 'archive:file:view' AND scope.status = 'ACTIVE' + AND scope.valid_from <= UTC_TIMESTAMP(3) + AND (scope.valid_to IS NULL OR scope.valid_to >= UTC_TIMESTAMP(3)) + AND (scope.scope_type = 'GLOBAL' + OR (scope.scope_type = 'COMPANY' AND scope.company_public_id = scoped_link.company_public_id) + OR (scope.scope_type = 'PROJECT' AND scope.project_public_id = scoped_link.project_public_id)) + ) + AND (#{status} IS NULL OR borrow.status = #{status}) + AND (#{keyword} IS NULL OR borrow.business_no LIKE CONCAT('%',#{keyword},'%') + OR file.original_name LIKE CONCAT('%',#{keyword},'%')) + ORDER BY borrow.updated_at DESC, borrow.public_id ASC + LIMIT #{limit} OFFSET #{offset} + """) + List listBorrows(@Param("userId") long userId, @Param("roleCode") String roleCode, + @Param("status") String status, @Param("keyword") String keyword, + @Param("limit") int limit, @Param("offset") int offset); + + @Select(""" + SELECT borrow.id, borrow.public_id, borrow.business_no, borrow.file_id, file.public_id AS file_public_id, + file.original_name AS file_name, borrowed_link.company_public_id AS company_public_id, + project.public_id AS project_public_id, project.name AS project_name, + borrow.purpose, borrow.due_at, borrow.status, borrow.applicant_id, + applicant.display_name AS applicant_name, + approver.display_name AS approved_by_name, borrow.approved_at, borrow.review_opinion, + borrow.return_condition, returner.display_name AS returned_by_name, borrow.returned_at, + borrow.version, borrow.updated_at + FROM arc_borrow borrow + JOIN file_object file ON file.id = borrow.file_id + JOIN file_link borrowed_link ON borrowed_link.id = borrow.file_link_id + LEFT JOIN md_project project ON project.public_id = borrowed_link.project_public_id + JOIN iam_user applicant ON applicant.id = borrow.applicant_id + LEFT JOIN iam_user approver ON approver.id = borrow.approved_by + LEFT JOIN iam_user returner ON returner.id = borrow.returned_by + WHERE borrow.series_public_id = #{seriesPublicId} + AND EXISTS ( + SELECT 1 FROM iam_scope scope + JOIN iam_role role ON role.id = scope.role_id + JOIN iam_permission permission ON permission.id = scope.permission_id + JOIN iam_user_role user_role ON user_role.user_id = scope.user_id + AND user_role.role_id = scope.role_id + WHERE scope.user_id = #{userId} AND role.code = #{roleCode} + AND role.enabled = TRUE + AND permission.code = 'archive:file:view' AND scope.status = 'ACTIVE' + AND scope.valid_from <= UTC_TIMESTAMP(3) + AND (scope.valid_to IS NULL OR scope.valid_to >= UTC_TIMESTAMP(3)) + AND (scope.scope_type = 'GLOBAL' + OR (scope.scope_type = 'COMPANY' AND scope.company_public_id = borrowed_link.company_public_id) + OR (scope.scope_type = 'PROJECT' AND scope.project_public_id = borrowed_link.project_public_id)) + ) + ORDER BY borrow.created_at DESC, borrow.public_id ASC + """) + List listBorrowsBySeries(@Param("seriesPublicId") String seriesPublicId, + @Param("userId") long userId, @Param("roleCode") String roleCode); + + @Select(""" + SELECT COUNT(*) FROM arc_borrow borrow JOIN file_object file ON file.id = borrow.file_id + WHERE EXISTS ( + SELECT 1 FROM file_link scoped_link + JOIN iam_scope scope ON scope.user_id = #{userId} + JOIN iam_role role ON role.id = scope.role_id + JOIN iam_permission permission ON permission.id = scope.permission_id + JOIN iam_user_role user_role ON user_role.user_id = scope.user_id + AND user_role.role_id = scope.role_id + WHERE scoped_link.id = borrow.file_link_id AND role.code = #{roleCode} + AND role.enabled = TRUE + AND permission.code = 'archive:file:view' AND scope.status = 'ACTIVE' + AND scope.valid_from <= UTC_TIMESTAMP(3) + AND (scope.valid_to IS NULL OR scope.valid_to >= UTC_TIMESTAMP(3)) + AND (scope.scope_type = 'GLOBAL' + OR (scope.scope_type = 'COMPANY' AND scope.company_public_id = scoped_link.company_public_id) + OR (scope.scope_type = 'PROJECT' AND scope.project_public_id = scoped_link.project_public_id)) + ) + AND (#{status} IS NULL OR borrow.status = #{status}) + AND (#{keyword} IS NULL OR borrow.business_no LIKE CONCAT('%',#{keyword},'%') + OR file.original_name LIKE CONCAT('%',#{keyword},'%')) + """) + long countBorrows(@Param("userId") long userId, @Param("roleCode") String roleCode, + @Param("status") String status, @Param("keyword") String keyword); + + @Insert(""" + INSERT INTO arc_borrow ( + public_id, business_no, file_id, file_link_id, series_public_id, package_id, + applicant_id, purpose, due_at, status + ) VALUES ( + #{publicId}, #{businessNo}, #{fileId}, #{fileLinkId}, #{seriesPublicId}, #{packageId}, + #{applicantId}, #{purpose}, #{dueAt}, 'APPLIED' + ) + """) + int insertBorrow(@Param("publicId") String publicId, @Param("businessNo") String businessNo, + @Param("fileId") long fileId, @Param("fileLinkId") long fileLinkId, + @Param("seriesPublicId") String seriesPublicId, @Param("packageId") Long packageId, + @Param("applicantId") long applicantId, @Param("purpose") String purpose, + @Param("dueAt") LocalDateTime dueAt); + + @Update(""" + UPDATE arc_borrow SET status = 'BORROWED', approved_by = #{actorId}, approved_at = UTC_TIMESTAMP(3), + review_opinion = #{opinion}, version = version + 1, updated_at = UTC_TIMESTAMP(3) + WHERE id = #{id} AND version = #{version} AND status = 'APPLIED' + """) + int approveBorrow(@Param("id") long id, @Param("version") long version, + @Param("opinion") String opinion, @Param("actorId") long actorId); + + @Update(""" + UPDATE arc_borrow SET status = 'REJECTED', approved_by = #{actorId}, approved_at = UTC_TIMESTAMP(3), + review_opinion = #{opinion}, version = version + 1, updated_at = UTC_TIMESTAMP(3) + WHERE id = #{id} AND version = #{version} AND status = 'APPLIED' + """) + int rejectBorrow(@Param("id") long id, @Param("version") long version, + @Param("opinion") String opinion, @Param("actorId") long actorId); + + @Update(""" + UPDATE arc_borrow SET status = 'RETURNED', returned_by = #{actorId}, returned_at = UTC_TIMESTAMP(3), + return_condition = #{returnCondition}, version = version + 1, updated_at = UTC_TIMESTAMP(3) + WHERE id = #{id} AND version = #{version} AND status IN ('BORROWED','OVERDUE') + """) + int returnBorrow(@Param("id") long id, @Param("version") long version, + @Param("returnCondition") String returnCondition, @Param("actorId") long actorId); + + @Select("SELECT COUNT(*) FROM arc_borrow WHERE series_public_id = #{seriesPublicId} AND status IN ('APPLIED','APPROVED','BORROWED','OVERDUE')") + int activeBorrowCountBySeries(String seriesPublicId); + + record ProjectRow(long id, String publicId, String businessNo, String name, long companyId, + String companyPublicId, String companyName, String archiveStatus, String status, + String financeStatus) { + } + + record PackageRow(long id, String publicId, String businessNo, long companyId, String companyPublicId, + String companyBusinessNo, String companyName, long projectId, String projectPublicId, + String projectBusinessNo, String projectName, int packageVersion, + String rootPackagePublicId, String supersedesPackagePublicId, String revisionReason, + String ruleVersion, + BigDecimal completeness, int missingCount, int retentionYears, String physicalLocation, + boolean frozen, String status, String manifestSha256, Long submittedBy, + String submittedByName, Long reviewedBy, String reviewedByName, LocalDateTime submittedAt, + LocalDateTime archivedAt, LocalDateTime updatedAt, long version) { + } + + record PackageItemRow(long id, String publicId, String itemType, String itemName, String objectType, + String objectPublicId, Long fileId, Long fileLinkId, String fileSha256, + boolean requiredFlag, String status, String missingReason, String notApplicableReason, + String returnReason, + String notApplicableRequestedByName, LocalDateTime notApplicableRequestedAt, + String notApplicableReviewedByName, LocalDateTime notApplicableReviewedAt, + String notApplicableReviewOpinion, + String filePublicId, String fileName) { + } + + record PackageItemTargetRow(long id, String itemType) { + } + + record PackageActionRow(int sequenceNo, String actionCode, String fromStatus, String toStatus, + String opinion, String actorName, LocalDateTime occurredAt, String manifestSha256) { + } + + record ObjectSnapshotRow(long id, String publicId, long itemId, String itemType, String objectType, + String objectPublicId, String objectBusinessNo, String objectName, + String objectStatus, Long objectVersion, String objectFormType, + String sourceVersionPublicId, + String snapshotSha256, LocalDateTime createdAt) { + } + + record FileLinkRow(long id, long fileId, String seriesPublicId, int versionNo, String documentType, + String originalType, String projectPublicId, String contractPublicId, + String counterpartyPublicId, String formType, String archiveStatus, Long supersedesLinkId) { + } + + record FileDimensionRow(String contractPublicId, String projectPublicId, String counterpartyPublicId) { + } + + record FileRow(long id, String publicId, String originalName, String mediaType, long sizeBytes, + String sha256, String scanStatus, String uploadedBy, LocalDateTime createdAt, String borrowStatus, + long linkId, String seriesPublicId, int versionNo, String documentType, String originalType, + String archiveStatus, String companyPublicId, + String companyName, String projectPublicId, String projectBusinessNo, String projectName, + String contractPublicId, String counterpartyPublicId, String formType, String packagePublicId, + String packageStatus) { + } + + record BorrowLock(long id, String publicId, Long packageId, long fileId, long fileLinkId, + String seriesPublicId, long applicantId, + String purpose, LocalDateTime dueAt, String status, Long approvedBy, + LocalDateTime approvedAt, String reviewOpinion, Long returnedBy, + LocalDateTime returnedAt, String returnCondition, long version) { + } + + record BorrowRow(long id, String publicId, String businessNo, long fileId, String filePublicId, + String fileName, String companyPublicId, String projectPublicId, String projectName, String purpose, + LocalDateTime dueAt, String status, long applicantId, String applicantName, String approvedByName, + LocalDateTime approvedAt, String reviewOpinion, String returnCondition, + String returnedByName, LocalDateTime returnedAt, long version, LocalDateTime updatedAt) { + } +} diff --git a/backend/src/main/java/com/kaidi/finance/audit/api/AuditContracts.java b/backend/src/main/java/com/kaidi/finance/audit/api/AuditContracts.java new file mode 100644 index 0000000..0d267db --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/audit/api/AuditContracts.java @@ -0,0 +1,28 @@ +package com.kaidi.finance.audit.api; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import java.time.Instant; + +public final class AuditContracts { + + private AuditContracts() { + } + + public record AuditExportRequest( + Instant occurredFrom, + Instant occurredTo, + @Size(max = 26) String actorId, + @Size(max = 40) String identityCode, + @Size(max = 80) String objectType, + @Size(max = 26) String objectId, + @Size(max = 100) String action, + @Size(max = 26) String requestId, + @Size(max = 40) String result, + @Size(max = 100) String keyword, + @Schema(allowableValues = {"occurredAt,asc", "occurredAt,desc", "eventSequence,asc", "eventSequence,desc"}) + @Pattern(regexp = "(occurredAt|eventSequence),(asc|desc)") String sort + ) { + } +} diff --git a/backend/src/main/java/com/kaidi/finance/audit/api/AuditController.java b/backend/src/main/java/com/kaidi/finance/audit/api/AuditController.java new file mode 100644 index 0000000..571b408 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/audit/api/AuditController.java @@ -0,0 +1,100 @@ +package com.kaidi.finance.audit.api; + +import com.kaidi.finance.audit.api.AuditContracts.AuditExportRequest; +import com.kaidi.finance.audit.api.AuditViews.AuditExportView; +import com.kaidi.finance.audit.api.AuditViews.AuditLogView; +import com.kaidi.finance.audit.application.AuditApplicationService; +import com.kaidi.finance.shared.api.ApiResponse; +import com.kaidi.finance.shared.api.BusinessException; +import com.kaidi.finance.shared.api.ErrorCode; +import com.kaidi.finance.shared.api.PageResult; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.util.MultiValueMap; +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; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping(value = "/api/v1/audit", produces = MediaType.APPLICATION_JSON_VALUE) +public class AuditController { + + private static final Set LIST_QUERY_PARAMS = Set.of( + "occurredFrom", "occurredTo", "actorId", "identityCode", "objectType", "objectId", + "action", "requestId", "result", "keyword", "sort", "page", "size" + ); + + private final AuditApplicationService service; + + public AuditController(AuditApplicationService service) { + this.service = service; + } + + @GetMapping("/logs") + @Operation(operationId = "listAuditLogs", summary = "查询审计日志") + @PreAuthorize("@authorizationService.hasPermission('audit:log:view')") + public ApiResponse> logs( + @RequestParam(required = false) Instant occurredFrom, + @RequestParam(required = false) Instant occurredTo, + @RequestParam(required = false) @Size(max = 26) String actorId, + @RequestParam(required = false) @Size(max = 40) String identityCode, + @RequestParam(required = false) @Size(max = 80) String objectType, + @RequestParam(required = false) @Size(max = 26) String objectId, + @RequestParam(required = false) @Size(max = 100) String action, + @RequestParam(required = false) @Size(max = 26) String requestId, + @Parameter(schema = @Schema(allowableValues = {"SUCCESS", "FAILED", "DENIED", "BLOCKED"})) + @RequestParam(required = false) @Size(max = 40) String result, + @RequestParam(required = false) @Size(max = 100) String keyword, + @Parameter(schema = @Schema(allowableValues = { + "occurredAt,asc", "occurredAt,desc", "eventSequence,asc", "eventSequence,desc" + })) + @RequestParam(defaultValue = "occurredAt,desc") + @Pattern(regexp = "(occurredAt|eventSequence),(asc|desc)") String sort, + @RequestParam(defaultValue = "1") int page, + @Parameter(schema = @Schema(type = "integer", format = "int32", allowableValues = {"20", "50", "100"})) + @RequestParam(defaultValue = "20") int size, + @Parameter(hidden = true) @RequestParam MultiValueMap query) { + validateQuery(query); + PageResult resultPage = service.list(occurredFrom, occurredTo, actorId, identityCode, + objectType, objectId, action, requestId, result, keyword, sort, page, size); + return ApiResponse.ok(resultPage.items(), resultPage.meta()); + } + + @GetMapping("/logs/{publicId}") + @Operation(operationId = "getAuditLog", summary = "读取审计日志详情") + @PreAuthorize("@authorizationService.hasPermission('audit:log:view')") + public ApiResponse detail(@PathVariable String publicId) { + return ApiResponse.ok(service.detail(publicId)); + } + + @PostMapping("/exports") + @Operation(operationId = "exportAuditLogs", summary = "导出审计日志") + @PreAuthorize("@authorizationService.hasPermission('audit:log:export')") + public ApiResponse export(@Valid @RequestBody(required = false) AuditExportRequest request) { + return ApiResponse.ok(service.export(request)); + } + + private static void validateQuery(Map query) { + for (String name : query.keySet()) { + if (!LIST_QUERY_PARAMS.contains(name)) { + throw new BusinessException(HttpStatus.BAD_REQUEST, ErrorCode.QUERY_PARAMETER_INVALID, + "不支持的查询参数: " + name); + } + } + } +} diff --git a/backend/src/main/java/com/kaidi/finance/audit/api/AuditViews.java b/backend/src/main/java/com/kaidi/finance/audit/api/AuditViews.java new file mode 100644 index 0000000..37af24c --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/audit/api/AuditViews.java @@ -0,0 +1,34 @@ +package com.kaidi.finance.audit.api; + +import java.time.Instant; +import java.util.List; + +public final class AuditViews { + + private AuditViews() { + } + + public record AuditLogView( + String publicId, + long eventSequence, + String requestId, + String userPublicId, + String username, + String activeRole, + String companyPublicId, + String projectPublicId, + String actionCode, + String objectType, + String objectPublicId, + String resultCode, + String reason, + String beforeJson, + String afterJson, + Instant occurredAt, + List allowedActions + ) { + } + + public record AuditExportView(String exportId, int rowCount, String sha256, String fileName, String content) { + } +} diff --git a/backend/src/main/java/com/kaidi/finance/audit/application/AuditApplicationService.java b/backend/src/main/java/com/kaidi/finance/audit/application/AuditApplicationService.java new file mode 100644 index 0000000..cf889a3 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/audit/application/AuditApplicationService.java @@ -0,0 +1,302 @@ +package com.kaidi.finance.audit.application; + +import com.kaidi.finance.audit.api.AuditContracts.AuditExportRequest; +import com.kaidi.finance.audit.api.AuditViews.AuditExportView; +import com.kaidi.finance.audit.api.AuditViews.AuditLogView; +import com.kaidi.finance.audit.infrastructure.AuditQueryMapper; +import com.kaidi.finance.audit.infrastructure.AuditQueryMapper.AuditRow; +import com.kaidi.finance.iam.domain.FinancePrincipal; +import com.kaidi.finance.shared.api.BusinessException; +import com.kaidi.finance.shared.api.ErrorCode; +import com.kaidi.finance.shared.api.PageResult; +import com.kaidi.finance.shared.audit.AuditService; +import com.kaidi.finance.shared.id.UlidGenerator; +import com.kaidi.finance.shared.infrastructure.RequestContext; +import com.kaidi.finance.shared.security.AuthorizationService; +import com.kaidi.finance.shared.security.IdentityContext; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.time.temporal.ChronoUnit; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Isolation; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class AuditApplicationService { + + private static final Set RESULT_CODES = Set.of("SUCCESS", "FAILED", "DENIED", "BLOCKED"); + private static final Set SORTS = Set.of( + "occurredAt,asc", "occurredAt,desc", "eventSequence,asc", "eventSequence,desc" + ); + private static final String VIEW_PERMISSION = "audit:log:view"; + private static final String EXPORT_PERMISSION = "audit:log:export"; + private static final int EXPORT_ROW_LIMIT = 50_000; + + private final AuditQueryMapper mapper; + private final AuthorizationService authorizationService; + private final IdentityContext identityContext; + private final AuditService auditService; + private final UlidGenerator ulidGenerator; + private final ObjectMapper objectMapper; + + public AuditApplicationService(AuditQueryMapper mapper, AuthorizationService authorizationService, + IdentityContext identityContext, AuditService auditService, + UlidGenerator ulidGenerator, ObjectMapper objectMapper) { + this.mapper = mapper; + this.authorizationService = authorizationService; + this.identityContext = identityContext; + this.auditService = auditService; + this.ulidGenerator = ulidGenerator; + this.objectMapper = objectMapper; + } + + @Transactional(readOnly = true) + public PageResult list(Instant occurredFrom, Instant occurredTo, String actorId, + String identityCode, String objectType, String objectId, String action, + String requestId, String result, String keyword, String sort, + int page, int size) { + authorizationService.requirePermission(VIEW_PERMISSION); + Page requested = page(page, size); + String safeRequestId = clean(requestId); + TimeRange range = timeRange(occurredFrom, occurredTo, safeRequestId != null); + Filter filter = new Filter(range.from(), range.to(), clean(actorId), clean(identityCode), clean(objectType), + clean(objectId), clean(action), safeRequestId, cleanResult(result), clean(keyword), cleanSort(sort)); + Actor actor = actor(); + LocalDateTime scopeAsOf = LocalDateTime.now(ZoneOffset.UTC).truncatedTo(ChronoUnit.MILLIS); + List rows = mapper.list(actor.userId(), actor.roleCode(), VIEW_PERMISSION, scopeAsOf, + filter.from(), filter.to(), + filter.actorId(), filter.identityCode(), filter.objectType(), filter.objectId(), filter.action(), + filter.requestId(), filter.result(), filter.keyword(), filter.sort(), requested.size(), requested.offset()).stream() + .map(this::view).toList(); + long total = mapper.count(actor.userId(), actor.roleCode(), VIEW_PERMISSION, scopeAsOf, filter.from(), filter.to(), filter.actorId(), + filter.identityCode(), filter.objectType(), filter.objectId(), filter.action(), filter.requestId(), + filter.result(), filter.keyword()); + return new PageResult<>(rows, total, requested.page(), requested.size()); + } + + @Transactional(readOnly = true) + public AuditLogView detail(String publicId) { + authorizationService.requirePermission(VIEW_PERMISSION); + AuditRow row = mapper.findByPublicId(publicId); + if (row == null) throw new BusinessException(HttpStatus.NOT_FOUND, ErrorCode.RESOURCE_NOT_FOUND, + "审计记录不存在"); + // A detail lookup must enforce the same company/project data scope as the + // paged query. The public id is user supplied and is not a scope grant; + // checking only the permission here would allow a caller to read an + // out-of-scope audit row by guessing/obtaining its id from another flow. + authorizationService.requireScope(VIEW_PERMISSION, row.companyPublicId(), row.projectPublicId()); + return view(row); + } + + @Transactional(isolation = Isolation.REPEATABLE_READ) + public AuditExportView export(AuditExportRequest request) { + authorizationService.requirePermission(EXPORT_PERMISSION); + AuditExportRequest safe = request == null + ? new AuditExportRequest(null, null, null, null, null, null, null, null, null, null, null) + : request; + String safeRequestId = clean(safe.requestId()); + TimeRange range = timeRange(safe.occurredFrom(), safe.occurredTo(), safeRequestId != null); + Filter filter = new Filter(range.from(), range.to(), clean(safe.actorId()), clean(safe.identityCode()), + clean(safe.objectType()), clean(safe.objectId()), clean(safe.action()), safeRequestId, + cleanResult(safe.result()), clean(safe.keyword()), cleanSort(safe.sort())); + Actor actor = actor(); + LocalDateTime scopeAsOf = LocalDateTime.now(ZoneOffset.UTC).truncatedTo(ChronoUnit.MILLIS); + long total = mapper.count(actor.userId(), actor.roleCode(), EXPORT_PERMISSION, scopeAsOf, filter.from(), + filter.to(), filter.actorId(), filter.identityCode(), filter.objectType(), filter.objectId(), filter.action(), + filter.requestId(), filter.result(), filter.keyword()); + if (total > EXPORT_ROW_LIMIT) { + throw validation("审计导出结果超过 50000 行上限,请缩小筛选范围"); + } + List rows = mapper.list(actor.userId(), actor.roleCode(), EXPORT_PERMISSION, scopeAsOf, + filter.from(), filter.to(), filter.actorId(), filter.identityCode(), filter.objectType(), + filter.objectId(), filter.action(), filter.requestId(), filter.result(), filter.keyword(), + filter.sort(), Math.toIntExact(total), 0) + .stream().map(this::view).toList(); + if (rows.size() != total) { + throw new BusinessException(HttpStatus.CONFLICT, ErrorCode.VERSION_CONFLICT, + "审计导出快照已变化,请重新执行导出"); + } + String content = "\uFEFF" + csv(rows); + String sha256 = sha256(content); + String exportId = ulidGenerator.next(); + mapper.insertExportLog(exportId, RequestContext.requestId(), actor.publicId(), actor.roleCode(), + json(filter.values()), json(List.of("eventSequence", "occurredAt", "username", "activeRole", "actionCode", "objectType", + "objectPublicId", "resultCode", "requestId")), rows.size(), sha256); + auditService.record("AUDIT_LOG_EXPORT", "AUDIT_LOG", null, "SUCCESS", null, + null, Map.of("exportId", exportId, "rowCount", rows.size(), "sha256", sha256)); + return new AuditExportView(exportId, rows.size(), sha256, "audit-logs.csv", content); + } + + private AuditLogView view(AuditRow row) { + return new AuditLogView(row.publicId(), row.eventSequence(), row.requestId(), row.userPublicId(), row.username(), row.activeRole(), + row.companyPublicId(), row.projectPublicId(), row.actionCode(), row.objectType(), row.objectPublicId(), + row.resultCode(), maskReason(row.reason()), mask(row.beforeJson()), mask(row.afterJson()), + row.occurredAt().toInstant(ZoneOffset.UTC), + List.of("VIEW")); + } + + private String mask(String value) { + if (value == null || value.isBlank()) return value; + try { + JsonNode node = objectMapper.readTree(value); + maskNode(node); + return objectMapper.writeValueAsString(node); + } catch (Exception ignored) { + return value.replaceAll("(?i)(password|token|accountNo|idCard|phone|salary)\\\"?\\s*:\\s*\\\"[^\\\"]*\\\"", + "$1:\"***\""); + } + } + + private String maskReason(String value) { + if (value == null || value.isBlank()) return value; + return value.replaceAll("(? { + JsonNode child = node.get(name); + if (name.toLowerCase().matches(".*(password|token|account|idcard|phone|salary|secret).*")) { + ((com.fasterxml.jackson.databind.node.ObjectNode) node).put(name, "***"); + } else { + maskNode(child); + } + }); + } else if (node.isArray()) { + node.forEach(this::maskNode); + } + } + + private static String csv(List rows) { + StringBuilder output = new StringBuilder( + "eventSequence,occurredAt,username,activeRole,actionCode,objectType,objectPublicId,resultCode,requestId\n" + ); + for (AuditLogView row : rows) { + List values = java.util.Arrays.asList(String.valueOf(row.eventSequence()), + String.valueOf(row.occurredAt()), row.username(), + row.activeRole(), row.actionCode(), row.objectType(), row.objectPublicId(), row.resultCode(), + row.requestId()); + output.append(values.stream().map(AuditApplicationService::csvCell).reduce((a, b) -> a + "," + b).orElse("")) + .append('\n'); + } + return output.toString(); + } + + private static String csvCell(String value) { + String safe = value == null ? "" : value; + String leading = safe.stripLeading(); + if (!leading.isEmpty() && "=+-@".indexOf(leading.charAt(0)) >= 0) safe = "'" + safe; + return "\"" + safe.replace("\"", "\"\"") + "\""; + } + + private static String sha256(String value) { + try { + return java.util.HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(value.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 is not available", exception); + } + } + + private String json(Object value) { + try { + return objectMapper.writeValueAsString(value); + } catch (JsonProcessingException exception) { + throw new IllegalStateException("Audit export filter cannot be serialized", exception); + } + } + + private Actor actor() { + FinancePrincipal principal = identityContext.requirePrincipal(); + return new Actor(principal.userId(), principal.publicId(), identityContext.requireActiveRole()); + } + + private static String clean(String value) { + return value == null || value.isBlank() ? null : value.trim(); + } + + private static String cleanResult(String value) { + String result = clean(value); + if (result != null && !RESULT_CODES.contains(result)) throw validation("审计结果参数无效"); + return result; + } + + private static String cleanSort(String value) { + String sort = clean(value); + if (sort == null) return "occurredAt,desc"; + if (!SORTS.contains(sort)) throw validation("审计排序参数无效"); + return sort; + } + + /** + * The API accepts UTC instants and the database stores UTC timestamps. Keeping + * the conversion at this boundary prevents an offset-less local value from + * silently changing the requested half-open interval. + */ + private static TimeRange timeRange(Instant from, Instant to, boolean fullRequestHistory) { + Instant end = to == null ? Instant.now(Clock.systemUTC()) : to; + // A request-id lookup reconstructs the whole chain unless the caller explicitly narrows its start time. + Instant start = from == null + ? (fullRequestHistory ? Instant.EPOCH : end.minusSeconds(24 * 60 * 60)) + : from; + if (!end.isAfter(start)) throw validation("审计时间范围无效"); + return new TimeRange(LocalDateTime.ofInstant(start, ZoneOffset.UTC), + LocalDateTime.ofInstant(end, ZoneOffset.UTC)); + } + + private static Page page(int page, int size) { + if (page < 1 || !Set.of(20, 50, 100).contains(size)) throw validation("分页参数无效"); + return new Page(page, size); + } + + private static BusinessException validation(String message) { + return new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.VALIDATION_FAILED, message); + } + + private record Actor(long userId, String publicId, String roleCode) { + } + + private record TimeRange(LocalDateTime from, LocalDateTime to) { + } + + private record Filter(LocalDateTime from, LocalDateTime to, String actorId, String identityCode, + String objectType, String objectId, String action, String requestId, + String result, String keyword, String sort) { + Map values() { + Map values = new LinkedHashMap<>(); + values.put("occurredFrom", from.toInstant(ZoneOffset.UTC)); + values.put("occurredTo", to.toInstant(ZoneOffset.UTC)); + values.put("actorId", actorId); + values.put("identityCode", identityCode); + values.put("objectType", objectType); + values.put("objectId", objectId); + values.put("action", action); + values.put("requestId", requestId); + values.put("result", result); + values.put("keyword", keyword); + values.put("sort", sort); + return values; + } + } + + private record Page(int page, int size) { + int offset() { + long value = (long) (page - 1) * size; + return value > Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) value; + } + } +} diff --git a/backend/src/main/java/com/kaidi/finance/audit/infrastructure/AuditQueryMapper.java b/backend/src/main/java/com/kaidi/finance/audit/infrastructure/AuditQueryMapper.java new file mode 100644 index 0000000..d7c7a9a --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/audit/infrastructure/AuditQueryMapper.java @@ -0,0 +1,104 @@ +package com.kaidi.finance.audit.infrastructure; + +import java.time.LocalDateTime; +import java.util.List; +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@org.apache.ibatis.annotations.Mapper +public interface AuditQueryMapper { + + String SCOPE = """ + EXISTS ( + SELECT 1 FROM iam_scope scope + JOIN iam_role role ON role.id = scope.role_id + JOIN iam_permission permission ON permission.id = scope.permission_id + WHERE scope.user_id = #{userId} AND role.code = #{roleCode} + AND permission.code = #{scopePermission} AND scope.status = 'ACTIVE' + AND scope.valid_from <= #{scopeAsOf} + AND (scope.valid_to IS NULL OR scope.valid_to >= #{scopeAsOf}) + AND (scope.scope_type = 'GLOBAL' + OR (scope.scope_type = 'COMPANY' AND scope.company_public_id = audit.company_public_id) + OR (scope.scope_type = 'PROJECT' AND scope.project_public_id = audit.project_public_id)) + ) + """; + + String FILTERS = """ + AND audit.created_at >= #{occurredFrom} + AND audit.created_at < #{occurredTo} + AND audit.user_public_id = #{actorId} + AND audit.active_role = #{identityCode} + AND audit.object_type = #{objectType} + AND audit.object_public_id = #{objectId} + AND audit.action_code = #{action} + AND audit.request_id = #{requestId} + AND audit.result_code = #{result} + AND (audit.username LIKE CONCAT('%', #{keyword}, '%') + OR audit.action_code LIKE CONCAT('%', #{keyword}, '%') + OR audit.object_public_id LIKE CONCAT('%', #{keyword}, '%') + OR audit.request_id LIKE CONCAT('%', #{keyword}, '%')) + """; + + String ORDERING = """ + + ORDER BY audit.created_at ASC, audit.id ASC + ORDER BY audit.id ASC + ORDER BY audit.id DESC + ORDER BY audit.created_at DESC, audit.id DESC + + """; + + @Select("") + List list(@Param("userId") long userId, @Param("roleCode") String roleCode, + @Param("scopePermission") String scopePermission, + @Param("scopeAsOf") LocalDateTime scopeAsOf, + @Param("occurredFrom") LocalDateTime occurredFrom, + @Param("occurredTo") LocalDateTime occurredTo, @Param("actorId") String actorId, + @Param("identityCode") String identityCode, @Param("objectType") String objectType, + @Param("objectId") String objectId, @Param("action") String action, + @Param("requestId") String requestId, @Param("result") String result, + @Param("keyword") String keyword, @Param("sort") String sort, @Param("limit") int limit, + @Param("offset") int offset); + + @Select("") + long count(@Param("userId") long userId, @Param("roleCode") String roleCode, + @Param("scopePermission") String scopePermission, + @Param("scopeAsOf") LocalDateTime scopeAsOf, + @Param("occurredFrom") LocalDateTime occurredFrom, @Param("occurredTo") LocalDateTime occurredTo, + @Param("actorId") String actorId, @Param("identityCode") String identityCode, + @Param("objectType") String objectType, @Param("objectId") String objectId, + @Param("action") String action, @Param("requestId") String requestId, + @Param("result") String result, @Param("keyword") String keyword); + + @Select(""" + SELECT audit.id AS event_sequence, audit.public_id, audit.request_id, audit.user_public_id, audit.username, audit.active_role, + audit.company_public_id, audit.project_public_id, audit.action_code, audit.object_type, + audit.object_public_id, audit.result_code, audit.reason, CAST(audit.before_json AS CHAR) AS before_json, + CAST(audit.after_json AS CHAR) AS after_json, audit.created_at + FROM audit_log audit WHERE audit.public_id = #{publicId} + """) + AuditRow findByPublicId(String publicId); + + @Insert(""" + INSERT INTO export_log (public_id, request_id, actor_public_id, identity_code, resource_type, + filter_json, columns_json, row_count, sha256) + VALUES (#{publicId}, #{requestId}, #{actorPublicId}, #{identityCode}, 'AUDIT_LOG', + CAST(#{filterJson} AS JSON), CAST(#{columnsJson} AS JSON), #{rowCount}, #{sha256}) + """) + int insertExportLog(@Param("publicId") String publicId, @Param("requestId") String requestId, + @Param("actorPublicId") String actorPublicId, @Param("identityCode") String identityCode, + @Param("filterJson") String filterJson, @Param("columnsJson") String columnsJson, + @Param("rowCount") int rowCount, @Param("sha256") String sha256); + + record AuditRow(long eventSequence, String publicId, String requestId, String userPublicId, String username, String activeRole, + String companyPublicId, String projectPublicId, String actionCode, String objectType, + String objectPublicId, String resultCode, String reason, String beforeJson, String afterJson, + LocalDateTime occurredAt) { + } +} diff --git a/backend/src/main/java/com/kaidi/finance/fund/api/FundLedgerController.java b/backend/src/main/java/com/kaidi/finance/fund/api/FundLedgerController.java new file mode 100644 index 0000000..2bbc9a3 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/fund/api/FundLedgerController.java @@ -0,0 +1,41 @@ +package com.kaidi.finance.fund.api; + +import com.kaidi.finance.fund.api.FundLedgerViews.FundLedgerPageView; +import com.kaidi.finance.fund.application.FundLedgerApplicationService; +import com.kaidi.finance.shared.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import java.time.LocalDate; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/funds") +public class FundLedgerController { + + private final FundLedgerApplicationService service; + + public FundLedgerController(FundLedgerApplicationService service) { + this.service = service; + } + + @GetMapping("/ledger") + @Operation(operationId = "listFundLedger", summary = "查询项目逐笔资金流水") + @PreAuthorize("@authorizationService.hasPermission('payment:request:view')") + public ApiResponse ledger( + @RequestParam String projectId, + @RequestParam(defaultValue = "CNY") String currency, + @RequestParam(required = false) String entryType, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate dateFrom, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate dateTo, + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int size, + @RequestParam(defaultValue = "occurredAt") String sortBy, + @RequestParam(defaultValue = "desc") String sortDirection) { + return ApiResponse.ok(service.ledger(projectId, currency, entryType, dateFrom, dateTo, page, size, + sortBy, sortDirection)); + } +} diff --git a/backend/src/main/java/com/kaidi/finance/fund/api/FundLedgerViews.java b/backend/src/main/java/com/kaidi/finance/fund/api/FundLedgerViews.java new file mode 100644 index 0000000..d8f85f2 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/fund/api/FundLedgerViews.java @@ -0,0 +1,63 @@ +package com.kaidi.finance.fund.api; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +public final class FundLedgerViews { + + private FundLedgerViews() { + } + + public record FundControlView( + String projectId, + String projectCode, + String projectName, + String currency, + String confirmedReceipt, + String confirmedDeduction, + String approvedAdjustment, + String frozenAmount, + String approvedUnpaid, + String paidAmount, + String availableBalance, + long version + ) { + } + + public record FundLedgerEntryView( + String publicId, + String entryType, + String sourceType, + String sourcePublicId, + String sourceBusinessNo, + String description, + String currency, + String eventAmount, + String deltaAmount, + String confirmedReceiptDelta, + String confirmedDeductionDelta, + String approvedAdjustmentDelta, + String frozenAmountDelta, + String approvedUnpaidDelta, + String paidAmountDelta, + String confirmedReceiptAfter, + String confirmedDeductionAfter, + String approvedAdjustmentAfter, + String frozenAmountAfter, + String approvedUnpaidAfter, + String paidAmountAfter, + String balanceAfter, + long controlVersionAfter, + String createdByName, + LocalDateTime occurredAt + ) { + } + + public record FundLedgerPageView( + FundControlView control, + List items, + Map meta + ) { + } +} diff --git a/backend/src/main/java/com/kaidi/finance/fund/application/FundLedgerApplicationService.java b/backend/src/main/java/com/kaidi/finance/fund/application/FundLedgerApplicationService.java new file mode 100644 index 0000000..0e87579 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/fund/application/FundLedgerApplicationService.java @@ -0,0 +1,311 @@ +package com.kaidi.finance.fund.application; + +import com.kaidi.finance.fund.api.FundLedgerViews.FundControlView; +import com.kaidi.finance.fund.api.FundLedgerViews.FundLedgerEntryView; +import com.kaidi.finance.fund.api.FundLedgerViews.FundLedgerPageView; +import com.kaidi.finance.fund.infrastructure.FundLedgerMapper; +import com.kaidi.finance.fund.infrastructure.FundLedgerMapper.FundControlRow; +import com.kaidi.finance.fund.infrastructure.FundLedgerMapper.FundLedgerRow; +import com.kaidi.finance.fund.infrastructure.FundLedgerMapper.ExistingEntry; +import com.kaidi.finance.fund.infrastructure.FundLedgerMapper.ProjectScope; +import com.kaidi.finance.shared.api.BusinessException; +import com.kaidi.finance.shared.api.ErrorCode; +import com.kaidi.finance.shared.api.PageResult; +import com.kaidi.finance.shared.audit.AuditService; +import com.kaidi.finance.shared.id.UlidGenerator; +import com.kaidi.finance.shared.security.AuthorizationService; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.regex.Pattern; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class FundLedgerApplicationService { + + private static final Pattern PUBLIC_ID = Pattern.compile("[0-9A-HJKMNP-TV-Z]{26}"); + private static final Set ENTRY_TYPES = Set.of( + "OPENING_BALANCE", "RECEIPT_CONFIRMED", "RECEIPT_ALLOCATED_TO_PROJECT", "RECEIPT_VOIDED", + "PAYMENT_FUNDS_RESERVED", "PAYMENT_PAID", "PAYMENT_FUNDS_RELEASED_FAILURE", + "PAYMENT_FUNDS_RELEASED_REFUND", "PAYMENT_REFUNDED" + ); + private static final Set SORT_FIELDS = Set.of("occurredAt", "eventAmount", "entryType", "sourceBusinessNo"); + private static final BigDecimal ZERO = BigDecimal.ZERO.setScale(2); + + private final FundLedgerMapper mapper; + private final AuthorizationService authorizationService; + private final UlidGenerator ulidGenerator; + private final AuditService auditService; + + public FundLedgerApplicationService(FundLedgerMapper mapper, AuthorizationService authorizationService, + UlidGenerator ulidGenerator, AuditService auditService) { + this.mapper = mapper; + this.authorizationService = authorizationService; + this.ulidGenerator = ulidGenerator; + this.auditService = auditService; + } + + @Transactional(readOnly = true) + public FundLedgerPageView ledger(String projectPublicId, String currency, String entryType, + LocalDate dateFrom, LocalDate dateTo, int page, int size, + String sortBy, String sortDirection) { + if (projectPublicId == null || !PUBLIC_ID.matcher(projectPublicId).matches()) { + throw validation("请选择有效项目"); + } + String safeCurrency = currency == null || currency.isBlank() + ? "CNY" : currency.trim().toUpperCase(Locale.ROOT); + if (!safeCurrency.matches("[A-Z]{3}")) throw validation("资金币种无效"); + String safeEntryType = entryType == null || entryType.isBlank() + ? null : entryType.trim().toUpperCase(Locale.ROOT); + if (safeEntryType != null && !ENTRY_TYPES.contains(safeEntryType)) throw validation("资金流水类型无效"); + if (dateFrom != null && dateTo != null && dateTo.isBefore(dateFrom)) throw validation("资金流水日期范围无效"); + String safeSortBy = sortBy == null || sortBy.isBlank() ? "occurredAt" : sortBy.trim(); + String safeSortDirection = sortDirection == null || sortDirection.isBlank() + ? "desc" : sortDirection.trim().toLowerCase(Locale.ROOT); + if (!SORT_FIELDS.contains(safeSortBy) || !Set.of("asc", "desc").contains(safeSortDirection)) { + throw validation("资金流水排序无效"); + } + int safePage = Math.max(1, page); + int safeSize = Math.max(1, Math.min(size, 100)); + ProjectScope project = mapper.findProject(projectPublicId); + if (project == null) throw notFound("项目不存在或已失效"); + authorizationService.requireScope("payment:request:view", project.companyPublicId(), project.publicId()); + FundControlRow control = mapper.findControl(project.id(), safeCurrency); + if (control == null) throw notFound("项目尚无该币种的资金控制记录"); + LocalDateTime from = dateFrom == null ? null : dateFrom.atStartOfDay(); + LocalDateTime toExclusive = dateTo == null ? null : dateTo.plusDays(1).atStartOfDay(); + int offset = Math.toIntExact(Math.min((long) (safePage - 1) * safeSize, Integer.MAX_VALUE)); + List items = mapper.listEntries(project.id(), safeCurrency, safeEntryType, + from, toExclusive, safeSize, offset, safeSortBy, safeSortDirection).stream().map(this::entryView).toList(); + long total = mapper.countEntries(project.id(), safeCurrency, safeEntryType, from, toExclusive); + PageResult result = new PageResult<>(items, total, safePage, safeSize); + return new FundLedgerPageView(controlView(control), items, result.meta()); + } + + @Transactional(propagation = Propagation.MANDATORY) + public void append(FundLedgerCommand command) { + validateCommand(command); + BigDecimal confirmedReceiptDelta = money(command.confirmedReceiptDelta()); + BigDecimal confirmedDeductionDelta = money(command.confirmedDeductionDelta()); + BigDecimal approvedAdjustmentDelta = money(command.approvedAdjustmentDelta()); + BigDecimal frozenAmountDelta = money(command.frozenAmountDelta()); + BigDecimal approvedUnpaidDelta = money(command.approvedUnpaidDelta()); + BigDecimal paidAmountDelta = money(command.paidAmountDelta()); + BigDecimal availableDelta = confirmedReceiptDelta.subtract(confirmedDeductionDelta) + .add(approvedAdjustmentDelta).subtract(frozenAmountDelta) + .subtract(approvedUnpaidDelta).subtract(paidAmountDelta); + FundControlRow control = mapper.lockControl(command.projectId(), command.currency()); + if (control == null) throw new IllegalStateException("Fund control row was not found"); + // Use a locking read here: the owning domain transaction may already have + // established a repeatable-read snapshot before it updated the control row. + ExistingEntry existing = mapper.lockBySourceEventKey(command.sourceEventKey()); + if (existing != null) { + if (sameEntry(existing, command)) return; + throw duplicateSource(); + } + String ledgerPublicId = ulidGenerator.next(); + Map beforeSnapshot = controlSnapshot(control, confirmedReceiptDelta, + confirmedDeductionDelta, approvedAdjustmentDelta, frozenAmountDelta, approvedUnpaidDelta, + paidAmountDelta, availableDelta, true); + try { + if (mapper.insertEntry(ledgerPublicId, command.projectId(), command.currency(), + command.sourceEventKey(), command.entryType(), command.sourceType(), command.sourcePublicId(), + command.sourceBusinessNo(), command.description(), money(command.eventAmount()), availableDelta, + confirmedReceiptDelta, confirmedDeductionDelta, approvedAdjustmentDelta, frozenAmountDelta, + approvedUnpaidDelta, paidAmountDelta, control.confirmedReceipt(), control.confirmedDeduction(), + control.approvedAdjustment(), control.frozenAmount(), control.approvedUnpaid(), control.paidAmount(), + control.availableBalance(), control.version(), command.actorId()) != 1) { + throw new IllegalStateException("Fund ledger entry was not created"); + } + } catch (DuplicateKeyException exception) { + ExistingEntry concurrent = mapper.lockBySourceEventKey(command.sourceEventKey()); + if (concurrent != null && sameEntry(concurrent, command)) return; + throw duplicateSource(); + } + Map afterSnapshot = controlSnapshot(control, confirmedReceiptDelta, + confirmedDeductionDelta, approvedAdjustmentDelta, frozenAmountDelta, approvedUnpaidDelta, + paidAmountDelta, availableDelta, false); + afterSnapshot.put("ledgerPublicId", ledgerPublicId); + afterSnapshot.put("entryType", command.entryType()); + afterSnapshot.put("sourceEventKey", command.sourceEventKey()); + afterSnapshot.put("sourceType", command.sourceType()); + afterSnapshot.put("sourcePublicId", command.sourcePublicId()); + afterSnapshot.put("sourceBusinessNo", command.sourceBusinessNo()); + afterSnapshot.put("eventAmount", money(command.eventAmount())); + afterSnapshot.put("deltaAmount", availableDelta); + auditService.recordScoped(control.companyPublicId(), control.projectPublicId(), + "FUND_LEDGER_APPEND", "FUND_CONTROL", control.projectPublicId(), "SUCCESS", + command.description(), beforeSnapshot, afterSnapshot); + } + + /** + * The fund-control row is updated by the owning domain service immediately + * before this append call. The locked row therefore represents the + * post-transition state; subtracting the command deltas reconstructs the + * exact before snapshot without introducing a second, weaker read. + */ + private Map controlSnapshot(FundControlRow control, + BigDecimal confirmedReceiptDelta, + BigDecimal confirmedDeductionDelta, + BigDecimal approvedAdjustmentDelta, + BigDecimal frozenAmountDelta, + BigDecimal approvedUnpaidDelta, + BigDecimal paidAmountDelta, + BigDecimal availableDelta, + boolean before) { + BigDecimal confirmedReceipt = control.confirmedReceipt(); + BigDecimal confirmedDeduction = control.confirmedDeduction(); + BigDecimal approvedAdjustment = control.approvedAdjustment(); + BigDecimal frozenAmount = control.frozenAmount(); + BigDecimal approvedUnpaid = control.approvedUnpaid(); + BigDecimal paidAmount = control.paidAmount(); + BigDecimal availableBalance = control.availableBalance(); + long version = control.version(); + if (before) { + confirmedReceipt = confirmedReceipt.subtract(confirmedReceiptDelta); + confirmedDeduction = confirmedDeduction.subtract(confirmedDeductionDelta); + approvedAdjustment = approvedAdjustment.subtract(approvedAdjustmentDelta); + frozenAmount = frozenAmount.subtract(frozenAmountDelta); + approvedUnpaid = approvedUnpaid.subtract(approvedUnpaidDelta); + paidAmount = paidAmount.subtract(paidAmountDelta); + availableBalance = availableBalance.subtract(availableDelta); + version = Math.max(0, version - 1); + } + Map snapshot = new LinkedHashMap<>(); + snapshot.put("companyPublicId", control.companyPublicId()); + snapshot.put("projectPublicId", control.projectPublicId()); + snapshot.put("projectCode", control.projectCode()); + snapshot.put("currency", control.currency()); + snapshot.put("confirmedReceipt", money(confirmedReceipt)); + snapshot.put("confirmedDeduction", money(confirmedDeduction)); + snapshot.put("approvedAdjustment", money(approvedAdjustment)); + snapshot.put("frozenAmount", money(frozenAmount)); + snapshot.put("approvedUnpaid", money(approvedUnpaid)); + snapshot.put("paidAmount", money(paidAmount)); + snapshot.put("availableBalance", money(availableBalance)); + snapshot.put("version", version); + return snapshot; + } + + private FundControlView controlView(FundControlRow row) { + return new FundControlView(row.projectPublicId(), row.projectCode(), row.projectName(), row.currency(), + text(row.confirmedReceipt()), text(row.confirmedDeduction()), text(row.approvedAdjustment()), + text(row.frozenAmount()), text(row.approvedUnpaid()), text(row.paidAmount()), + text(row.availableBalance()), row.version()); + } + + private FundLedgerEntryView entryView(FundLedgerRow row) { + return new FundLedgerEntryView(row.publicId(), row.entryType(), row.sourceType(), row.sourcePublicId(), + row.sourceBusinessNo(), row.description(), row.currency(), text(row.eventAmount()), + text(row.deltaAmount()), text(row.confirmedReceiptDelta()), text(row.confirmedDeductionDelta()), + text(row.approvedAdjustmentDelta()), text(row.frozenAmountDelta()), text(row.approvedUnpaidDelta()), + text(row.paidAmountDelta()), text(row.confirmedReceiptAfter()), text(row.confirmedDeductionAfter()), + text(row.approvedAdjustmentAfter()), text(row.frozenAmountAfter()), text(row.approvedUnpaidAfter()), + text(row.paidAmountAfter()), text(row.balanceAfter()), row.controlVersionAfter(), + row.createdByName(), row.occurredAt()); + } + + private void validateCommand(FundLedgerCommand command) { + if (command == null || command.projectId() <= 0 || command.actorId() <= 0 + || command.currency() == null || !command.currency().matches("[A-Z]{3}") + || command.sourceEventKey() == null || command.sourceEventKey().isBlank() + || command.sourceEventKey().length() > 160 + || command.entryType() == null || !ENTRY_TYPES.contains(command.entryType()) + || command.sourceType() == null || command.sourceType().isBlank() || command.sourceType().length() > 32 + || (command.sourcePublicId() != null && command.sourcePublicId().length() > 26) + || (command.sourceBusinessNo() != null && command.sourceBusinessNo().length() > 80) + || command.description() == null || command.description().isBlank() + || command.description().length() > 500 + || (command.eventAmount() != null && invalidScale(command.eventAmount())) + || invalidScale(command.confirmedReceiptDelta()) || invalidScale(command.confirmedDeductionDelta()) + || invalidScale(command.approvedAdjustmentDelta()) || invalidScale(command.frozenAmountDelta()) + || invalidScale(command.approvedUnpaidDelta()) || invalidScale(command.paidAmountDelta()) + || (command.eventAmount() != null && command.eventAmount().signum() < 0)) { + throw validation("资金流水命令无效"); + } + } + + private boolean sameEntry(ExistingEntry existing, FundLedgerCommand command) { + return existing.projectId() == command.projectId() + && existing.currency().equals(command.currency()) + && existing.entryType().equals(command.entryType()) + && existing.sourceType().equals(command.sourceType()) + && java.util.Objects.equals(existing.sourcePublicId(), command.sourcePublicId()) + && java.util.Objects.equals(existing.sourceBusinessNo(), command.sourceBusinessNo()) + && existing.description().equals(command.description()) + && money(existing.eventAmount()).compareTo(money(command.eventAmount())) == 0 + && money(existing.deltaAmount()).compareTo(availableDelta(command)) == 0 + && money(existing.confirmedReceiptDelta()).compareTo(money(command.confirmedReceiptDelta())) == 0 + && money(existing.confirmedDeductionDelta()).compareTo(money(command.confirmedDeductionDelta())) == 0 + && money(existing.approvedAdjustmentDelta()).compareTo(money(command.approvedAdjustmentDelta())) == 0 + && money(existing.frozenAmountDelta()).compareTo(money(command.frozenAmountDelta())) == 0 + && money(existing.approvedUnpaidDelta()).compareTo(money(command.approvedUnpaidDelta())) == 0 + && money(existing.paidAmountDelta()).compareTo(money(command.paidAmountDelta())) == 0; + } + + private BigDecimal availableDelta(FundLedgerCommand command) { + return money(command.confirmedReceiptDelta()).subtract(money(command.confirmedDeductionDelta())) + .add(money(command.approvedAdjustmentDelta())).subtract(money(command.frozenAmountDelta())) + .subtract(money(command.approvedUnpaidDelta())).subtract(money(command.paidAmountDelta())); + } + + private BigDecimal money(BigDecimal value) { + return value == null ? ZERO : value.setScale(2, RoundingMode.UNNECESSARY); + } + + private String text(BigDecimal value) { + return money(value).toPlainString(); + } + + private BusinessException validation(String message) { + return new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.VALIDATION_FAILED, message); + } + + private BusinessException notFound(String message) { + return new BusinessException(HttpStatus.NOT_FOUND, ErrorCode.RESOURCE_NOT_FOUND, message); + } + + private BusinessException duplicateSource() { + return new BusinessException(HttpStatus.CONFLICT, ErrorCode.DUPLICATE_SOURCE, "资金流水来源事件已处理"); + } + + private boolean invalidScale(BigDecimal value) { + if (value == null) return false; + try { + money(value); + return false; + } catch (ArithmeticException exception) { + return true; + } + } + + public record FundLedgerCommand( + long projectId, + String currency, + String sourceEventKey, + String entryType, + String sourceType, + String sourcePublicId, + String sourceBusinessNo, + String description, + BigDecimal eventAmount, + BigDecimal confirmedReceiptDelta, + BigDecimal confirmedDeductionDelta, + BigDecimal approvedAdjustmentDelta, + BigDecimal frozenAmountDelta, + BigDecimal approvedUnpaidDelta, + BigDecimal paidAmountDelta, + long actorId + ) { + } +} diff --git a/backend/src/main/java/com/kaidi/finance/fund/infrastructure/FundLedgerMapper.java b/backend/src/main/java/com/kaidi/finance/fund/infrastructure/FundLedgerMapper.java new file mode 100644 index 0000000..7c0abc5 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/fund/infrastructure/FundLedgerMapper.java @@ -0,0 +1,213 @@ +package com.kaidi.finance.fund.infrastructure; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface FundLedgerMapper { + + @Select(""" + SELECT project.id, project.public_id, project.business_no, project.name, + company.public_id AS company_public_id + FROM md_project project + JOIN md_company company ON company.id = project.company_id + WHERE project.public_id = #{publicId} AND project.status = 'ACTIVE' + """) + ProjectScope findProject(String publicId); + + @Select(""" + SELECT control.id, company.public_id AS company_public_id, + project.public_id AS project_public_id, + project.business_no AS project_code, project.name AS project_name, + control.currency, control.confirmed_receipt, control.confirmed_deduction, + control.approved_adjustment, control.frozen_amount, control.approved_unpaid, + control.paid_amount, + control.confirmed_receipt - control.confirmed_deduction + control.approved_adjustment + - control.frozen_amount - control.approved_unpaid - control.paid_amount AS available_balance, + control.version + FROM project_fund_control control + JOIN md_project project ON project.id = control.project_id + JOIN md_company company ON company.id = project.company_id + WHERE control.project_id = #{projectId} AND control.currency = #{currency} + """) + FundControlRow findControl(@Param("projectId") long projectId, @Param("currency") String currency); + + @Select(""" + SELECT control.id, company.public_id AS company_public_id, + project.public_id AS project_public_id, + project.business_no AS project_code, project.name AS project_name, + control.currency, control.confirmed_receipt, control.confirmed_deduction, + control.approved_adjustment, control.frozen_amount, control.approved_unpaid, + control.paid_amount, + control.confirmed_receipt - control.confirmed_deduction + control.approved_adjustment + - control.frozen_amount - control.approved_unpaid - control.paid_amount AS available_balance, + control.version + FROM project_fund_control control + JOIN md_project project ON project.id = control.project_id + JOIN md_company company ON company.id = project.company_id + WHERE control.project_id = #{projectId} AND control.currency = #{currency} + FOR UPDATE + """) + FundControlRow lockControl(@Param("projectId") long projectId, @Param("currency") String currency); + + @Select(""" + SELECT project_id, currency, entry_type, source_type, source_public_id, + source_business_no, description, + event_amount, delta_amount, confirmed_receipt_delta, confirmed_deduction_delta, + approved_adjustment_delta, frozen_amount_delta, approved_unpaid_delta, paid_amount_delta + FROM fund_ledger + WHERE source_event_key = #{sourceEventKey} + """) + ExistingEntry findBySourceEventKey(@Param("sourceEventKey") String sourceEventKey); + + @Select(""" + SELECT project_id, currency, entry_type, source_type, source_public_id, + source_business_no, description, + event_amount, delta_amount, confirmed_receipt_delta, confirmed_deduction_delta, + approved_adjustment_delta, frozen_amount_delta, approved_unpaid_delta, paid_amount_delta + FROM fund_ledger + WHERE source_event_key = #{sourceEventKey} + FOR UPDATE + """) + ExistingEntry lockBySourceEventKey(@Param("sourceEventKey") String sourceEventKey); + + @Insert(""" + INSERT INTO fund_ledger ( + public_id, company_id, project_id, currency, source_event_key, entry_type, + source_type, source_public_id, source_business_no, description, event_amount, + delta_amount, confirmed_receipt_delta, confirmed_deduction_delta, + approved_adjustment_delta, frozen_amount_delta, approved_unpaid_delta, paid_amount_delta, + confirmed_receipt_after, confirmed_deduction_after, approved_adjustment_after, + frozen_amount_after, approved_unpaid_after, paid_amount_after, balance_after, + control_version_after, occurred_at, created_by + ) + SELECT + #{publicId}, project.company_id, control.project_id, control.currency, + #{sourceEventKey}, #{entryType}, #{sourceType}, #{sourcePublicId}, + #{sourceBusinessNo}, #{description}, #{eventAmount}, #{deltaAmount}, + #{confirmedReceiptDelta}, #{confirmedDeductionDelta}, #{approvedAdjustmentDelta}, + #{frozenAmountDelta}, #{approvedUnpaidDelta}, #{paidAmountDelta}, + #{confirmedReceiptAfter}, #{confirmedDeductionAfter}, #{approvedAdjustmentAfter}, + #{frozenAmountAfter}, #{approvedUnpaidAfter}, #{paidAmountAfter}, #{balanceAfter}, + #{controlVersionAfter}, UTC_TIMESTAMP(3), #{actorId} + FROM project_fund_control control + JOIN md_project project ON project.id = control.project_id + WHERE control.project_id = #{projectId} AND control.currency = #{currency} + """) + int insertEntry(@Param("publicId") String publicId, + @Param("projectId") long projectId, + @Param("currency") String currency, + @Param("sourceEventKey") String sourceEventKey, + @Param("entryType") String entryType, + @Param("sourceType") String sourceType, + @Param("sourcePublicId") String sourcePublicId, + @Param("sourceBusinessNo") String sourceBusinessNo, + @Param("description") String description, + @Param("eventAmount") BigDecimal eventAmount, + @Param("deltaAmount") BigDecimal deltaAmount, + @Param("confirmedReceiptDelta") BigDecimal confirmedReceiptDelta, + @Param("confirmedDeductionDelta") BigDecimal confirmedDeductionDelta, + @Param("approvedAdjustmentDelta") BigDecimal approvedAdjustmentDelta, + @Param("frozenAmountDelta") BigDecimal frozenAmountDelta, + @Param("approvedUnpaidDelta") BigDecimal approvedUnpaidDelta, + @Param("paidAmountDelta") BigDecimal paidAmountDelta, + @Param("confirmedReceiptAfter") BigDecimal confirmedReceiptAfter, + @Param("confirmedDeductionAfter") BigDecimal confirmedDeductionAfter, + @Param("approvedAdjustmentAfter") BigDecimal approvedAdjustmentAfter, + @Param("frozenAmountAfter") BigDecimal frozenAmountAfter, + @Param("approvedUnpaidAfter") BigDecimal approvedUnpaidAfter, + @Param("paidAmountAfter") BigDecimal paidAmountAfter, + @Param("balanceAfter") BigDecimal balanceAfter, + @Param("controlVersionAfter") long controlVersionAfter, + @Param("actorId") long actorId); + + @Select(""" + + """) + List listEntries(@Param("projectId") long projectId, + @Param("currency") String currency, + @Param("entryType") String entryType, + @Param("dateFrom") LocalDateTime dateFrom, + @Param("dateToExclusive") LocalDateTime dateToExclusive, + @Param("limit") int limit, + @Param("offset") int offset, + @Param("sortBy") String sortBy, + @Param("sortDirection") String sortDirection); + + @Select(""" + + """) + long countEntries(@Param("projectId") long projectId, + @Param("currency") String currency, + @Param("entryType") String entryType, + @Param("dateFrom") LocalDateTime dateFrom, + @Param("dateToExclusive") LocalDateTime dateToExclusive); + + record ProjectScope(long id, String publicId, String businessNo, String name, String companyPublicId) { + } + + record FundControlRow(long id, String companyPublicId, String projectPublicId, + String projectCode, String projectName, + String currency, BigDecimal confirmedReceipt, BigDecimal confirmedDeduction, + BigDecimal approvedAdjustment, BigDecimal frozenAmount, BigDecimal approvedUnpaid, + BigDecimal paidAmount, BigDecimal availableBalance, long version) { + } + + record ExistingEntry(long projectId, String currency, String entryType, String sourceType, + String sourcePublicId, String sourceBusinessNo, String description, + BigDecimal eventAmount, BigDecimal deltaAmount, + BigDecimal confirmedReceiptDelta, BigDecimal confirmedDeductionDelta, + BigDecimal approvedAdjustmentDelta, BigDecimal frozenAmountDelta, + BigDecimal approvedUnpaidDelta, BigDecimal paidAmountDelta) { + } + + record FundLedgerRow(String publicId, String entryType, String sourceType, String sourcePublicId, + String sourceBusinessNo, String description, String currency, + BigDecimal eventAmount, BigDecimal deltaAmount, BigDecimal confirmedReceiptDelta, + BigDecimal confirmedDeductionDelta, BigDecimal approvedAdjustmentDelta, + BigDecimal frozenAmountDelta, BigDecimal approvedUnpaidDelta, BigDecimal paidAmountDelta, + BigDecimal confirmedReceiptAfter, BigDecimal confirmedDeductionAfter, + BigDecimal approvedAdjustmentAfter, BigDecimal frozenAmountAfter, + BigDecimal approvedUnpaidAfter, BigDecimal paidAmountAfter, BigDecimal balanceAfter, + long controlVersionAfter, String createdByName, + LocalDateTime occurredAt) { + } +} diff --git a/backend/src/main/java/com/kaidi/finance/governance/api/GovernanceContracts.java b/backend/src/main/java/com/kaidi/finance/governance/api/GovernanceContracts.java new file mode 100644 index 0000000..2105279 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/governance/api/GovernanceContracts.java @@ -0,0 +1,63 @@ +package com.kaidi.finance.governance.api; + +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.math.BigDecimal; +import java.util.List; + +public final class GovernanceContracts { + + private GovernanceContracts() { + } + + public record ResourceCreateRequest( + @Size(max = 64) String code, + @Size(max = 128) String name, + @Size(max = 255) String description, + @Size(max = 64) String username, + @Size(max = 128) String displayName, + @Size(max = 128) String departmentName, + @Size(max = 64) String password, + @Size(max = 40) String roleCode, + @Size(max = 100) String permissionCode, + @Size(max = 26) String userId, + @Size(max = 16) String scopeType, + @Size(max = 26) String companyId, + @Size(max = 26) String projectId, + @DecimalMin("0.00") BigDecimal amountLimit, + @Size(max = 32) String formType, + Integer templateVersion, + Integer schemaVersion, + @Size(max = 80) String parameterGroup, + Integer versionNo, + @Size(max = 10000) String valueJson, + List<@Size(max = 40) String> roleCodes, + List<@Size(max = 100) String> permissionCodes + ) { + } + + public record ResourceUpdateRequest( + @NotNull Long version, + @Size(max = 128) String name, + @Size(max = 255) String description, + @Size(max = 128) String displayName, + @Size(max = 128) String departmentName, + Boolean enabled, + @DecimalMin("0.00") BigDecimal amountLimit, + @Size(max = 16) String scopeType, + @Size(max = 26) String companyId, + @Size(max = 26) String projectId, + @Size(max = 10000) String valueJson, + List<@Size(max = 40) String> roleCodes, + List<@Size(max = 100) String> permissionCodes + ) { + } + + public record ResourceCommandRequest( + @NotNull Long version, + @Size(max = 1000) String reason + ) { + } +} diff --git a/backend/src/main/java/com/kaidi/finance/governance/api/GovernanceController.java b/backend/src/main/java/com/kaidi/finance/governance/api/GovernanceController.java new file mode 100644 index 0000000..15e31b3 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/governance/api/GovernanceController.java @@ -0,0 +1,94 @@ +package com.kaidi.finance.governance.api; + +import com.kaidi.finance.governance.api.GovernanceContracts.ResourceCommandRequest; +import com.kaidi.finance.governance.api.GovernanceContracts.ResourceCreateRequest; +import com.kaidi.finance.governance.api.GovernanceContracts.ResourceUpdateRequest; +import com.kaidi.finance.governance.api.GovernanceViews.ResourceListView; +import com.kaidi.finance.governance.api.GovernanceViews.ResourceView; +import com.kaidi.finance.governance.application.GovernanceApplicationService; +import com.kaidi.finance.shared.api.ApiResponse; +import com.kaidi.finance.shared.api.BusinessException; +import com.kaidi.finance.shared.api.ErrorCode; +import com.kaidi.finance.shared.api.PageResult; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.util.MultiValueMap; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping(value = "/api/v1/admin", produces = MediaType.APPLICATION_JSON_VALUE) +public class GovernanceController { + + private static final Set LIST_QUERY_PARAMS = Set.of("keyword", "page", "size"); + + private final GovernanceApplicationService service; + + public GovernanceController(GovernanceApplicationService service) { + this.service = service; + } + + @GetMapping("/{resource}") + @Operation(operationId = "listGovernanceResources", summary = "查询系统治理资源") + public ApiResponse> list( + @Parameter(schema = @Schema(allowableValues = {"users", "roles", "scopes", "templates", "parameters"})) + @PathVariable String resource, + @RequestParam(required = false) String keyword, + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int size, + @Parameter(hidden = true) @RequestParam MultiValueMap query) { + validateQuery(query); + PageResult result = service.list(resource, keyword, page, size); + return ApiResponse.ok(result.items(), result.meta()); + } + + @GetMapping("/{resource}/{publicId}") + @Operation(operationId = "getGovernanceResource", summary = "读取系统治理资源详情") + public ApiResponse detail(@PathVariable String resource, @PathVariable String publicId) { + return ApiResponse.ok(service.detail(resource, publicId)); + } + + @PostMapping("/{resource}") + @Operation(operationId = "createGovernanceResource", summary = "创建系统治理资源") + public ApiResponse create(@PathVariable String resource, + @Valid @RequestBody ResourceCreateRequest request) { + return ApiResponse.ok(service.create(resource, request)); + } + + @PatchMapping("/{resource}/{publicId}") + @Operation(operationId = "updateGovernanceResource", summary = "更新系统治理资源") + public ApiResponse update(@PathVariable String resource, @PathVariable String publicId, + @Valid @RequestBody ResourceUpdateRequest request) { + return ApiResponse.ok(service.update(resource, publicId, request)); + } + + @PostMapping("/{resource}/{publicId}/{command:enable|disable|publish|restore}") + @Operation(operationId = "commandGovernanceResource", summary = "执行系统治理资源命令") + public ApiResponse command(@PathVariable String resource, @PathVariable String publicId, + @PathVariable String command, + @Valid @RequestBody ResourceCommandRequest request) { + return ApiResponse.ok(service.command(resource, publicId, command, request)); + } + + private static void validateQuery(Map query) { + for (String name : query.keySet()) { + if (!LIST_QUERY_PARAMS.contains(name)) { + throw new BusinessException(HttpStatus.BAD_REQUEST, ErrorCode.QUERY_PARAMETER_INVALID, + "不支持的查询参数: " + name); + } + } + } +} diff --git a/backend/src/main/java/com/kaidi/finance/governance/api/GovernanceViews.java b/backend/src/main/java/com/kaidi/finance/governance/api/GovernanceViews.java new file mode 100644 index 0000000..634d06d --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/governance/api/GovernanceViews.java @@ -0,0 +1,51 @@ +package com.kaidi.finance.governance.api; + +import java.time.Instant; +import java.util.List; +import java.util.Map; + +public final class GovernanceViews { + + private GovernanceViews() { + } + + public record ResourceView(String resource, String publicId, String businessCode, String status, + long version, Map values, List allowedActions) { + } + + /** Stable superset used by the five governance list tabs. Null fields are omitted from JSON. */ + public record ResourceListView( + String resource, + String publicId, + String username, + String displayName, + String departmentName, + Boolean enabled, + Boolean mustChangePassword, + List roleCodes, + String code, + String name, + String description, + Integer sortOrder, + Long memberCount, + String userPublicId, + String roleCode, + String permissionCode, + String scopeType, + String companyPublicId, + String projectPublicId, + String amountLimit, + String status, + String formType, + Integer templateVersion, + Integer schemaVersion, + String parameterGroup, + Integer versionNo, + Long version, + Instant publishedAt, + Instant effectiveAt, + Instant createdAt, + Instant updatedAt + ) { + } +} diff --git a/backend/src/main/java/com/kaidi/finance/governance/application/GovernanceApplicationService.java b/backend/src/main/java/com/kaidi/finance/governance/application/GovernanceApplicationService.java new file mode 100644 index 0000000..775f7bd --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/governance/application/GovernanceApplicationService.java @@ -0,0 +1,936 @@ +package com.kaidi.finance.governance.application; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.kaidi.finance.governance.api.GovernanceContracts.ResourceCommandRequest; +import com.kaidi.finance.governance.api.GovernanceContracts.ResourceCreateRequest; +import com.kaidi.finance.governance.api.GovernanceContracts.ResourceUpdateRequest; +import com.kaidi.finance.governance.api.GovernanceViews.ResourceListView; +import com.kaidi.finance.governance.api.GovernanceViews.ResourceView; +import com.kaidi.finance.governance.infrastructure.GovernanceMapper; +import com.kaidi.finance.governance.infrastructure.GovernanceMapper.ParameterRow; +import com.kaidi.finance.governance.infrastructure.GovernanceMapper.RoleRow; +import com.kaidi.finance.governance.infrastructure.GovernanceMapper.ScopeRow; +import com.kaidi.finance.governance.infrastructure.GovernanceMapper.TemplateRow; +import com.kaidi.finance.governance.infrastructure.GovernanceMapper.UserRow; +import com.kaidi.finance.iam.domain.FinancePrincipal; +import com.kaidi.finance.operations.application.FinanceOperationsApplicationService; +import com.kaidi.finance.payment.domain.PaymentAttachmentMatrix; +import com.kaidi.finance.shared.api.BusinessException; +import com.kaidi.finance.shared.api.ErrorCode; +import com.kaidi.finance.shared.api.PageResult; +import com.kaidi.finance.shared.audit.AuditService; +import com.kaidi.finance.shared.id.UlidGenerator; +import com.kaidi.finance.shared.security.AuthorizationService; +import com.kaidi.finance.shared.security.IdentityContext; +import java.math.BigDecimal; +import java.sql.Timestamp; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.regex.Pattern; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.http.HttpStatus; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class GovernanceApplicationService { + + private static final Set RESOURCES = Set.of("users", "roles", "scopes", "templates", "parameters"); + private static final Set IDENTITY_ROLES = Set.of( + "PROJECT_MANAGER", "FINANCE_MANAGER", "ARCHIVE_MANAGER", "SYSTEM_ADMIN"); + private static final Pattern USERNAME = Pattern.compile("[a-z][a-z0-9._-]{2,63}"); + private static final Pattern CODE = Pattern.compile("[A-Z][A-Z0-9_-]{2,39}"); + private static final Pattern PUBLIC_ID = Pattern.compile("[0-9A-HJKMNP-TV-Z]{26}"); + private static final String ACCOUNTING_RULE_TEMPLATE_GROUP = "ACCOUNTING_RULE_TEMPLATE"; + private static final Set ACCOUNTING_EVENT_TYPES = Set.of( + "RECEIPT", "INVOICE", "PAYABLE", "PAYMENT", "PAYMENT_REFUND"); + + private final GovernanceMapper mapper; + private final FinanceOperationsApplicationService operationsService; + private final AuthorizationService authorizationService; + private final IdentityContext identityContext; + private final AuditService auditService; + private final UlidGenerator ulidGenerator; + private final PasswordEncoder passwordEncoder; + private final ObjectMapper objectMapper; + + public GovernanceApplicationService(GovernanceMapper mapper, + FinanceOperationsApplicationService operationsService, + AuthorizationService authorizationService, + IdentityContext identityContext, + AuditService auditService, + UlidGenerator ulidGenerator, + PasswordEncoder passwordEncoder, + ObjectMapper objectMapper) { + this.mapper = mapper; + this.operationsService = operationsService; + this.authorizationService = authorizationService; + this.identityContext = identityContext; + this.auditService = auditService; + this.ulidGenerator = ulidGenerator; + this.passwordEncoder = passwordEncoder; + this.objectMapper = objectMapper; + } + + @Transactional(readOnly = true) + public PageResult list(String resource, String keyword, int page, int size) { + requireSystemAdministrator(); + String normalized = resource(resource); + PageResult> rows = operationsService.listAdmin(normalized, keyword, page, size); + return new PageResult<>(rows.items().stream().map(row -> listView(normalized, row)).toList(), + rows.totalElements(), rows.page(), rows.size()); + } + + @Transactional(readOnly = true) + public ResourceView detail(String resource, String publicId) { + requireSystemAdministrator(); + String normalized = resource(resource); + authorizationService.requirePermission(permission(normalized, "view")); + return read(normalized, publicId); + } + + @Transactional + public ResourceView create(String resource, ResourceCreateRequest request) { + FinancePrincipal actor = requireSystemAdministrator(); + String normalized = resource(resource); + authorizationService.requirePermission(permission(normalized, "create")); + String publicId; + try { + publicId = switch (normalized) { + case "users" -> createUser(request); + case "roles" -> createRole(request); + case "scopes" -> createScope(request); + case "templates" -> createTemplate(request, actor); + case "parameters" -> createParameter(request, actor); + default -> throw invalidResource(); + }; + } catch (DuplicateKeyException exception) { + throw duplicate(normalized); + } + ResourceView result = read(normalized, publicId); + auditService.record(action(normalized, "CREATE"), objectType(normalized), publicId, + "SUCCESS", null, null, auditValue(result)); + return result; + } + + @Transactional + public ResourceView update(String resource, String publicId, ResourceUpdateRequest request) { + FinancePrincipal actor = requireSystemAdministrator(); + String normalized = resource(resource); + authorizationService.requirePermission(permission(normalized, "edit")); + ResourceView before = read(normalized, publicId); + try { + switch (normalized) { + case "users" -> updateUser(publicId, request); + case "roles" -> updateRole(publicId, request, actor); + case "scopes" -> updateScope(publicId, request); + case "templates" -> updateTemplate(publicId, request, actor); + case "parameters" -> updateParameter(publicId, request, actor); + default -> throw invalidResource(); + } + } catch (DuplicateKeyException exception) { + throw duplicate(normalized); + } + ResourceView result = read(normalized, publicId); + auditService.record(action(normalized, "UPDATE"), objectType(normalized), publicId, + "SUCCESS", null, auditValue(before), auditValue(result)); + return result; + } + + @Transactional + public ResourceView command(String resource, String publicId, String command, + ResourceCommandRequest request) { + FinancePrincipal actor = requireSystemAdministrator(); + String normalized = resource(resource); + String action = command == null ? "" : command.trim().toLowerCase(Locale.ROOT); + if (!Set.of("enable", "disable", "publish", "restore").contains(action)) { + throw validation("不支持的配置操作"); + } + if (Set.of("publish", "restore").contains(action) + && !Set.of("templates", "parameters").contains(normalized)) { + throw validation("当前配置不支持该版本操作"); + } + if (Set.of("templates", "parameters").contains(normalized) && "enable".equals(action)) { + throw validation("版本配置必须通过发布或恢复命令生效"); + } + authorizationService.requirePermission(permission(normalized, action)); + ResourceView before = read(normalized, publicId); + long version = request.version(); + switch (normalized) { + case "users" -> commandUser(publicId, version, action); + case "roles" -> commandRole(publicId, version, action, actor); + case "scopes" -> commandScope(publicId, version, action); + case "templates" -> commandTemplate(publicId, version, action, actor); + case "parameters" -> commandParameter(publicId, version, action, actor); + default -> throw invalidResource(); + } + ResourceView result = read(normalized, publicId); + auditService.record(action(normalized, action.toUpperCase(Locale.ROOT)), objectType(normalized), publicId, + "SUCCESS", clean(request.reason(), 1000), auditValue(before), auditValue(result)); + return result; + } + + private String createUser(ResourceCreateRequest request) { + String username = required(request.username(), "username", 64).toLowerCase(Locale.ROOT); + if (!USERNAME.matcher(username).matches()) { + throw validation("账号只能使用小写字母、数字、点、下划线和连字符,且至少 3 位"); + } + if ("admin".equals(username)) throw validation("系统内置管理员账号不可重复创建"); + String displayName = required(request.displayName(), "displayName", 128); + String password = required(request.password(), "password", 64); + validatePassword(password); + List roles = businessRoles(request.roleCode(), request.roleCodes()); + String publicId = ulidGenerator.next(); + mapper.insertUser(publicId, username, displayName, clean(request.departmentName(), 128), + passwordEncoder.encode(password)); + UserRow user = requireUser(publicId); + for (RoleRow role : roles) mapper.insertUserRole(user.id(), role.id()); + return publicId; + } + + private String createRole(ResourceCreateRequest request) { + String code = required(request.code(), "code", 40).toUpperCase(Locale.ROOT); + validateRoleCode(code); + mapper.insertRole(code, required(request.name(), "name", 128), clean(request.description(), 255)); + RoleRow role = requireRole(code); + replaceRolePermissions(role, request.permissionCodes()); + return code; + } + + private String createScope(ResourceCreateRequest request) { + String userId = publicId(required(request.userId(), "userId", 26)); + UserRow user = requireUser(userId); + RoleRow role = requireBusinessRole(required(request.roleCode(), "roleCode", 40)); + if (mapper.userHasRole(user.id(), role.id()) == 0) { + throw validation("该用户尚未被授予所选业务身份"); + } + String permissionCode = required(request.permissionCode(), "permissionCode", 100); + Long permissionId = mapper.permissionId(permissionCode); + if (permissionId == null || mapper.roleHasPermission(role.id(), permissionId) == 0) { + throw validation("所选权限不属于该业务身份"); + } + ScopeValues scope = scopeValues(request.scopeType(), request.companyId(), request.projectId(), + request.amountLimit()); + mapper.insertScope(user.id(), role.id(), permissionId, scope.type(), scope.companyId(), scope.projectId(), + scope.amountLimit()); + Long scopeId = mapper.scopeIdForGrant(user.id(), role.id(), permissionId, scope.type(), scope.companyId(), + scope.projectId()); + if (scopeId == null) throw new IllegalStateException("Created governance scope cannot be reloaded"); + mapper.invalidateSessions(user.username()); + return Long.toString(scopeId); + } + + private String createTemplate(ResourceCreateRequest request, FinancePrincipal actor) { + String formType = required(request.formType(), "formType", 32).toUpperCase(Locale.ROOT); + int templateVersion = positive(request.templateVersion(), "templateVersion"); + int schemaVersion = positive(request.schemaVersion(), "schemaVersion"); + mapper.lockTemplateVersions(formType); + TemplateRow source = mapper.findActiveTemplateByFormType(formType); + if (source == null) throw validation("新模板版本必须基于当前生效模板"); + String publicId = ulidGenerator.next(); + ensureCreated(mapper.insertTemplate(publicId, formType, templateVersion, required(request.name(), "name", 128), + schemaVersion, actor.userId())); + mapper.copyTemplateFields(source.id(), requireTemplate(publicId).id()); + return publicId; + } + + private String createParameter(ResourceCreateRequest request, FinancePrincipal actor) { + String group = required(request.parameterGroup(), "parameterGroup", 80).toUpperCase(Locale.ROOT); + String valueJson = validJson(required(request.valueJson(), "valueJson", 10_000)); + validateParameter(group, valueJson); + Integer locked = mapper.lockParameterVersionGroup(group); + if (locked == null || locked != 1) throw conflict(); + try { + mapper.lockParameterVersions(group); + int nextVersion = mapper.nextParameterVersion(group); + if (request.versionNo() != null && request.versionNo() != nextVersion) { + throw validation("参数版本号必须连续,下一版本应为 " + nextVersion); + } + String publicId = ulidGenerator.next(); + mapper.insertParameter(publicId, group, nextVersion, valueJson, actor.publicId()); + return publicId; + } finally { + mapper.unlockParameterVersionGroup(group); + } + } + + private void updateUser(String publicId, ResourceUpdateRequest request) { + UserRow row = requireUser(publicId); + checkVersion(row.version(), request.version()); + List currentRoles = mapper.listRoleCodes(row.id()); + boolean systemAdmin = currentRoles.contains("SYSTEM_ADMIN"); + if (systemAdmin && request.roleCodes() != null) { + throw validation("内置系统管理员身份不可变更"); + } + ensureUpdated(mapper.updateUser(row.id(), row.version(), clean(request.displayName(), 128), + clean(request.departmentName(), 128), null)); + if (request.roleCodes() != null) { + List roles = businessRoles(null, request.roleCodes()); + mapper.deleteUserScopes(row.id()); + mapper.deleteUserRoles(row.id()); + for (RoleRow role : roles) mapper.insertUserRole(row.id(), role.id()); + } + mapper.invalidateSessions(row.username()); + } + + private void updateRole(String code, ResourceUpdateRequest request, FinancePrincipal actor) { + RoleRow row = requireRole(code); + protectSystemRole(row); + checkVersion(row.version(), request.version()); + ensureUpdated(mapper.updateRole(row.id(), row.version(), clean(request.name(), 128), + clean(request.description(), 255), null, actor.userId())); + if (request.permissionCodes() != null) replaceRolePermissions(row, request.permissionCodes()); + invalidateRoleSessions(row); + } + + private void updateScope(String publicId, ResourceUpdateRequest request) { + ScopeRow row = requireScope(publicId); + checkVersion(row.version(), request.version()); + String type = request.scopeType() == null ? row.scopeType() : request.scopeType(); + String companyId = request.scopeType() == null && request.companyId() == null + ? row.companyPublicId() : request.companyId(); + String projectId = request.scopeType() == null && request.projectId() == null + ? row.projectPublicId() : request.projectId(); + BigDecimal amount = request.amountLimit() == null ? row.amountLimit() : request.amountLimit(); + ScopeValues values = scopeValues(type, companyId, projectId, amount); + ensureUpdated(mapper.updateScope(row.id(), row.version(), values.type(), values.companyId(), + values.projectId(), values.amountLimit(), null)); + mapper.invalidateSessions(row.username()); + } + + private void updateTemplate(String publicId, ResourceUpdateRequest request, FinancePrincipal actor) { + TemplateRow row = requireTemplate(publicId); + checkVersion(row.version(), request.version()); + if (!"DRAFT".equals(row.status())) throw invalidState("只有草稿模板可以编辑"); + ensureUpdated(mapper.updateTemplate(row.id(), row.version(), clean(request.name(), 128), null, + actor.userId())); + } + + private void updateParameter(String publicId, ResourceUpdateRequest request, FinancePrincipal actor) { + ParameterRow row = requireParameter(publicId); + checkVersion(row.version(), request.version()); + if (!"DRAFT".equals(row.status())) throw invalidState("只有草稿参数版本可以编辑"); + String valueJson = request.valueJson() == null ? null : validJson(request.valueJson()); + if (valueJson != null) validateParameter(row.parameterGroup(), valueJson); + ensureUpdated(mapper.updateParameter(row.id(), row.version(), valueJson, null, actor.publicId())); + } + + private void commandUser(String publicId, long version, String action) { + if ("publish".equals(action)) throw validation("用户配置不支持发布"); + UserRow row = requireUser(publicId); + if ("admin".equals(row.username()) && "disable".equals(action)) { + throw invalidState("内置系统管理员账号不可停用"); + } + checkVersion(row.version(), version); + requireToggleTransition(row.enabled(), action, "用户"); + ensureUpdated(mapper.setUserEnabled(row.id(), row.version(), "enable".equals(action))); + mapper.invalidateSessions(row.username()); + } + + private void commandRole(String code, long version, String action, FinancePrincipal actor) { + if ("publish".equals(action)) throw validation("角色配置不支持发布"); + RoleRow row = requireRole(code); + protectSystemRole(row); + checkVersion(row.version(), version); + requireToggleTransition(row.enabled(), action, "角色"); + ensureUpdated(mapper.setRoleEnabled(row.id(), row.version(), "enable".equals(action), actor.userId())); + invalidateRoleSessions(row); + } + + private void commandScope(String publicId, long version, String action) { + if ("publish".equals(action)) throw validation("数据范围不支持发布"); + ScopeRow row = requireScope(publicId); + checkVersion(row.version(), version); + requireToggleTransition("ACTIVE".equals(row.status()), action, "数据范围"); + ensureUpdated(mapper.setScopeStatus(row.id(), row.version(), "enable".equals(action) ? "ACTIVE" : "DISABLED")); + mapper.invalidateSessions(row.username()); + } + + private void commandTemplate(String publicId, long version, String action, FinancePrincipal actor) { + TemplateRow row = requireTemplate(publicId); + mapper.lockTemplateVersions(row.formType()); + row = requireTemplate(publicId); + checkVersion(row.version(), version); + if ("disable".equals(action)) { + if (!"ACTIVE".equals(row.status())) throw invalidState("只有生效模板可以停用"); + ensureUpdated(mapper.setTemplateStatus(row.id(), row.version(), "RETIRED", actor.userId())); + return; + } + if ("publish".equals(action) && !"DRAFT".equals(row.status())) { + throw invalidState("只有草稿模板可以发布"); + } + if ("restore".equals(action) && !"RETIRED".equals(row.status())) { + throw invalidState("只有已停用模板可以恢复"); + } + if (mapper.countTemplateFields(row.id()) == 0) { + throw invalidState("模板必须包含至少一个字段才可以生效"); + } + mapper.retireOtherTemplates(row.formType(), row.id(), actor.userId()); + ensureUpdated(mapper.setTemplateStatus(row.id(), row.version(), "ACTIVE", actor.userId())); + } + + private void commandParameter(String publicId, long version, String action, FinancePrincipal actor) { + ParameterRow row = requireParameter(publicId); + mapper.lockParameterVersions(row.parameterGroup()); + row = requireParameter(publicId); + checkVersion(row.version(), version); + if ("disable".equals(action)) { + if (!"ACTIVE".equals(row.status())) throw invalidState("只有生效参数版本可以停用"); + ensureUpdated(mapper.setParameterStatus(row.id(), row.version(), "RETIRED", actor.publicId())); + return; + } + if ("publish".equals(action) && !"DRAFT".equals(row.status())) { + throw invalidState("只有草稿参数版本可以发布"); + } + if ("restore".equals(action) && !"RETIRED".equals(row.status())) { + throw invalidState("只有已停用参数版本可以恢复"); + } + validateParameter(row.parameterGroup(), row.valueJson()); + validateParameterForPublish(row.parameterGroup(), row.valueJson()); + mapper.retireOtherParameters(row.parameterGroup(), row.id(), actor.publicId()); + ensureUpdated(mapper.setParameterStatus(row.id(), row.version(), "ACTIVE", actor.publicId())); + } + + private ResourceListView listView(String resource, Map row) { + Boolean enabled = bool(row, "enabled"); + String status = text(row, "status"); + if (status == null && enabled != null) status = enabled ? "ACTIVE" : "DISABLED"; + return new ResourceListView( + resource, + text(row, "publicId"), + text(row, "username"), + text(row, "displayName"), + text(row, "departmentName"), + enabled, + bool(row, "mustChangePassword"), + "users".equals(resource) ? strings(row, "roleCodes") : null, + text(row, "code"), + text(row, "name"), + text(row, "description"), + integer(row, "sortOrder"), + longValue(row, "memberCount"), + text(row, "userPublicId"), + text(row, "roleCode"), + text(row, "permissionCode"), + text(row, "scopeType"), + text(row, "companyPublicId"), + text(row, "projectPublicId"), + decimal(row, "amountLimit"), + status, + text(row, "formType"), + integer(row, "templateVersion"), + integer(row, "schemaVersion"), + text(row, "parameterGroup"), + integer(row, "versionNo"), + longValue(row, "version"), + instant(row, "publishedAt"), + instant(row, "effectiveAt"), + instant(row, "createdAt"), + instant(row, "updatedAt") + ); + } + + private static Object rowValue(Map row, String camelName) { + if (row.containsKey(camelName)) return row.get(camelName); + String snakeName = camelName.replaceAll("([a-z0-9])([A-Z])", "$1_$2").toLowerCase(Locale.ROOT); + if (row.containsKey(snakeName)) return row.get(snakeName); + for (Map.Entry entry : row.entrySet()) { + if (camelName.equalsIgnoreCase(entry.getKey()) || snakeName.equalsIgnoreCase(entry.getKey())) { + return entry.getValue(); + } + } + return null; + } + + private static String text(Map row, String name) { + Object value = rowValue(row, name); + return value == null ? null : value.toString(); + } + + private static Boolean bool(Map row, String name) { + Object value = rowValue(row, name); + if (value == null) return null; + if (value instanceof Boolean bool) return bool; + if (value instanceof Number number) return number.intValue() != 0; + return Boolean.parseBoolean(value.toString()); + } + + private static Integer integer(Map row, String name) { + Object value = rowValue(row, name); + if (value == null) return null; + if (value instanceof Number number) return number.intValue(); + return Integer.valueOf(value.toString()); + } + + private static Long longValue(Map row, String name) { + Object value = rowValue(row, name); + if (value == null) return null; + if (value instanceof Number number) return number.longValue(); + return Long.valueOf(value.toString()); + } + + private static String decimal(Map row, String name) { + Object value = rowValue(row, name); + if (value == null) return null; + BigDecimal number = value instanceof BigDecimal decimal ? decimal : new BigDecimal(value.toString()); + return number.setScale(2, java.math.RoundingMode.HALF_UP).toPlainString(); + } + + private static Instant instant(Map row, String name) { + Object value = rowValue(row, name); + if (value == null) return null; + if (value instanceof Instant instant) return instant; + if (value instanceof Timestamp timestamp) return timestamp.toInstant(); + if (value instanceof OffsetDateTime offsetDateTime) return offsetDateTime.toInstant(); + if (value instanceof LocalDateTime localDateTime) return localDateTime.toInstant(ZoneOffset.UTC); + return Instant.parse(value.toString()); + } + + private static List strings(Map row, String name) { + Object value = rowValue(row, name); + if (value == null) return List.of(); + if (value instanceof Collection collection) { + return collection.stream().filter(java.util.Objects::nonNull).map(Object::toString).toList(); + } + return java.util.Arrays.stream(value.toString().split(",")) + .map(String::trim).filter(item -> !item.isEmpty()).toList(); + } + + private ResourceView read(String resource, String publicId) { + return switch (resource) { + case "users" -> userView(requireUser(publicId)); + case "roles" -> roleView(requireRole(publicId)); + case "scopes" -> scopeView(requireScope(publicId)); + case "templates" -> templateView(requireTemplate(publicId)); + case "parameters" -> parameterView(requireParameter(publicId)); + default -> throw invalidResource(); + }; + } + + private ResourceView userView(UserRow row) { + Map values = values(); + values.put("publicId", row.publicId()); + values.put("username", row.username()); + values.put("displayName", row.displayName()); + values.put("departmentName", row.departmentName()); + values.put("enabled", row.enabled()); + values.put("roleCodes", mapper.listRoleCodes(row.id())); + List actions = new ArrayList<>(); + if (authorizationService.hasPermission("admin:user:edit")) actions.add("EDIT"); + if (!"admin".equals(row.username())) { + addToggle(actions, "admin:user", row.enabled()); + } + return new ResourceView("users", row.publicId(), row.username(), row.enabled() ? "ACTIVE" : "DISABLED", + row.version(), values, actions); + } + + private ResourceView roleView(RoleRow row) { + Map values = values(); + values.put("code", row.code()); + values.put("name", row.name()); + values.put("description", row.description()); + values.put("enabled", row.enabled()); + values.put("permissionCodes", mapper.listPermissionCodes(row.id())); + List actions = new ArrayList<>(); + if (!"SYSTEM_ADMIN".equals(row.code())) { + if (authorizationService.hasPermission("admin:role:edit")) actions.add("EDIT"); + addToggle(actions, "admin:role", row.enabled()); + } + return new ResourceView("roles", row.code(), row.code(), row.enabled() ? "ACTIVE" : "DISABLED", + row.version(), values, actions); + } + + private ResourceView scopeView(ScopeRow row) { + Map values = values(); + values.put("id", row.id()); + values.put("userId", row.userPublicId()); + values.put("username", row.username()); + values.put("roleCode", row.roleCode()); + values.put("permissionCode", row.permissionCode()); + values.put("scopeType", row.scopeType()); + values.put("companyId", row.companyPublicId()); + values.put("projectId", row.projectPublicId()); + values.put("amountLimit", row.amountLimit()); + List actions = new ArrayList<>(); + if (authorizationService.hasPermission("admin:scope:edit")) actions.add("EDIT"); + addToggle(actions, "admin:scope", "ACTIVE".equals(row.status())); + return new ResourceView("scopes", Long.toString(row.id()), row.username(), row.status(), row.version(), + values, actions); + } + + private ResourceView templateView(TemplateRow row) { + Map values = values(); + values.put("publicId", row.publicId()); + values.put("formType", row.formType()); + values.put("templateVersion", row.templateVersion()); + values.put("name", row.name()); + values.put("schemaVersion", row.schemaVersion()); + values.put("publishedAt", row.publishedAt()); + List actions = new ArrayList<>(); + if ("DRAFT".equals(row.status()) && authorizationService.hasPermission("admin:template:edit")) { + actions.add("EDIT"); + } + if ("DRAFT".equals(row.status()) && authorizationService.hasPermission("admin:template:publish")) { + actions.add("PUBLISH"); + } + if ("RETIRED".equals(row.status()) && authorizationService.hasPermission("admin:template:restore")) { + actions.add("RESTORE"); + } + if ("ACTIVE".equals(row.status()) && authorizationService.hasPermission("admin:template:disable")) { + actions.add("DISABLE"); + } + return new ResourceView("templates", row.publicId(), row.formType(), row.status(), row.version(), values, + actions); + } + + private ResourceView parameterView(ParameterRow row) { + Map values = values(); + values.put("publicId", row.publicId()); + values.put("parameterGroup", row.parameterGroup()); + values.put("versionNo", row.versionNo()); + values.put("value", jsonNode(row.valueJson())); + values.put("effectiveAt", row.effectiveAt()); + List actions = new ArrayList<>(); + if ("DRAFT".equals(row.status()) && authorizationService.hasPermission("admin:parameter:edit")) { + actions.add("EDIT"); + } + if ("DRAFT".equals(row.status()) && authorizationService.hasPermission("admin:parameter:publish")) { + actions.add("PUBLISH"); + } + if ("RETIRED".equals(row.status()) && authorizationService.hasPermission("admin:parameter:restore")) { + actions.add("RESTORE"); + } + if ("ACTIVE".equals(row.status()) && authorizationService.hasPermission("admin:parameter:disable")) { + actions.add("DISABLE"); + } + return new ResourceView("parameters", row.publicId(), row.parameterGroup(), row.status(), row.version(), + values, actions); + } + + private void replaceRolePermissions(RoleRow role, List requested) { + if (requested == null) return; + List permissionIds = new ArrayList<>(); + for (String raw : requested) { + String code = required(raw, "permissionCodes", 100); + if (code.startsWith("admin:") || code.startsWith("audit:log:")) { + throw validation("业务角色不能获得系统治理或审计权限"); + } + Long permissionId = mapper.permissionId(code); + if (permissionId == null) throw validation("权限码不存在:" + code); + if (!permissionIds.contains(permissionId)) permissionIds.add(permissionId); + } + mapper.deleteRolePermissions(role.id()); + for (Long permissionId : permissionIds) mapper.insertRolePermission(role.id(), permissionId); + } + + private List businessRoles(String single, List requested) { + LinkedHashSet codes = new LinkedHashSet<>(); + if (single != null && !single.isBlank()) codes.add(single.trim().toUpperCase(Locale.ROOT)); + if (requested != null) { + requested.stream().filter(value -> value != null && !value.isBlank()) + .map(value -> value.trim().toUpperCase(Locale.ROOT)).forEach(codes::add); + } + if (codes.isEmpty()) throw validation("至少选择一个业务身份"); + if (codes.contains("SYSTEM_ADMIN")) throw validation("系统管理员身份只允许内置隔离账号使用"); + List roles = new ArrayList<>(); + for (String code : codes) roles.add(requireBusinessRole(code)); + return roles; + } + + private RoleRow requireBusinessRole(String code) { + RoleRow role = requireRole(code.trim().toUpperCase(Locale.ROOT)); + if (!role.enabled()) throw validation("所选业务身份已停用"); + if ("SYSTEM_ADMIN".equals(role.code())) throw validation("系统管理员身份必须与业务账号隔离"); + return role; + } + + private ScopeValues scopeValues(String rawType, String rawCompanyId, String rawProjectId, + BigDecimal amountLimit) { + String type = required(rawType, "scopeType", 16).toUpperCase(Locale.ROOT); + if (!Set.of("GLOBAL", "COMPANY", "PROJECT").contains(type)) throw validation("数据范围类型无效"); + String companyId = optionalPublicId(rawCompanyId); + String projectId = optionalPublicId(rawProjectId); + if ("GLOBAL".equals(type) && (companyId != null || projectId != null)) { + throw validation("全局范围不能指定公司或项目"); + } + if ("COMPANY".equals(type) && (companyId == null || projectId != null)) { + throw validation("公司范围必须且只能指定公司"); + } + if ("PROJECT".equals(type) && projectId == null) throw validation("项目范围必须指定项目"); + if (amountLimit != null && amountLimit.signum() < 0) throw validation("金额阈值不能为负数"); + return new ScopeValues(type, companyId, projectId, amountLimit); + } + + private UserRow requireUser(String publicId) { + UserRow row = mapper.findUser(publicId(publicId)); + if (row == null) throw notFound("用户不存在"); + return row; + } + + private RoleRow requireRole(String code) { + RoleRow row = mapper.findRole(required(code, "roleCode", 40).toUpperCase(Locale.ROOT)); + if (row == null) throw notFound("角色不存在"); + return row; + } + + private ScopeRow requireScope(String publicId) { + long id; + try { + id = Long.parseLong(publicId); + } catch (NumberFormatException exception) { + throw validation("数据范围编号格式无效"); + } + ScopeRow row = mapper.findScope(id); + if (row == null) throw notFound("数据范围不存在"); + return row; + } + + private TemplateRow requireTemplate(String publicId) { + TemplateRow row = mapper.findTemplate(publicId(publicId)); + if (row == null) throw notFound("表单模板不存在"); + return row; + } + + private ParameterRow requireParameter(String publicId) { + ParameterRow row = mapper.findParameter(publicId(publicId)); + if (row == null) throw notFound("参数版本不存在"); + return row; + } + + private FinancePrincipal requireSystemAdministrator() { + FinancePrincipal principal = identityContext.requirePrincipal(); + String activeRole = identityContext.requireActiveRole(); + boolean isolated = principal.roles().size() == 1 + && "SYSTEM_ADMIN".equals(principal.roles().get(0).code()); + if (!"SYSTEM_ADMIN".equals(activeRole) || !isolated) { + throw new BusinessException(HttpStatus.FORBIDDEN, ErrorCode.PERMISSION_DENIED, + "系统配置仅允许隔离的系统管理员账号访问"); + } + return principal; + } + + private void addToggle(List actions, String prefix, boolean enabled) { + String action = enabled ? "disable" : "enable"; + if (authorizationService.hasPermission(prefix + ":" + action)) { + actions.add(action.toUpperCase(Locale.ROOT)); + } + } + + private static void requireToggleTransition(boolean enabled, String action, String resource) { + if ((enabled && "enable".equals(action)) || (!enabled && "disable".equals(action))) { + throw invalidState(enabled ? resource + "当前已启用" : resource + "当前已停用"); + } + } + + private void invalidateRoleSessions(RoleRow role) { + for (String username : mapper.listUsernamesByRole(role.id())) mapper.invalidateSessions(username); + } + + private static void protectSystemRole(RoleRow role) { + if ("SYSTEM_ADMIN".equals(role.code())) throw invalidState("内置系统管理员角色不可变更"); + } + + private void validateRoleCode(String code) { + if (!CODE.matcher(code).matches()) throw validation("角色代码格式无效"); + if (IDENTITY_ROLES.contains(code)) throw validation("内置身份角色不可重复创建"); + } + + private static void validatePassword(String password) { + boolean valid = password.length() >= 12 + && password.chars().anyMatch(Character::isUpperCase) + && password.chars().anyMatch(Character::isLowerCase) + && password.chars().anyMatch(Character::isDigit) + && password.chars().anyMatch(value -> !Character.isLetterOrDigit(value)); + if (!valid) throw validation("初始密码至少 12 位,并包含大小写字母、数字和特殊字符"); + } + + private String validJson(String value) { + try { + JsonNode node = objectMapper.readTree(value); + if (node == null || (!node.isObject() && !node.isArray())) { + throw validation("参数值必须是 JSON 对象或数组"); + } + return objectMapper.writeValueAsString(node); + } catch (JsonProcessingException exception) { + throw validation("参数值不是合法 JSON"); + } + } + + private void validateParameter(String group, String valueJson) { + if (ACCOUNTING_RULE_TEMPLATE_GROUP.equals(group)) { + validateAccountingRuleTemplate(valueJson); + return; + } + if (!PaymentAttachmentMatrix.PARAMETER_GROUP.equals(group)) return; + try { + PaymentAttachmentMatrix.parse(objectMapper, valueJson); + } catch (IllegalArgumentException exception) { + throw validation(exception.getMessage()); + } + } + + private void validateParameterForPublish(String group, String valueJson) { + if (ACCOUNTING_RULE_TEMPLATE_GROUP.equals(group)) { + validateAccountingRuleTemplate(valueJson); + return; + } + if (!PaymentAttachmentMatrix.PARAMETER_GROUP.equals(group)) return; + PaymentAttachmentMatrix.parse(objectMapper, valueJson); + } + + private void validateAccountingRuleTemplate(String valueJson) { + try { + JsonNode root = objectMapper.readTree(valueJson); + if (root == null || !root.isObject() + || root.path("schemaVersion").asInt(0) < 1 + || root.path("ruleVersion").asText("").isBlank() + || !root.path("eventTypes").isObject()) { + throw validation("凭证规则模板必须包含 schemaVersion、ruleVersion 和 eventTypes"); + } + for (String eventType : ACCOUNTING_EVENT_TYPES) { + JsonNode rule = root.path("eventTypes").path(eventType); + if (rule.path("debitAccount").asText("").isBlank() + || rule.path("creditAccount").asText("").isBlank()) { + throw validation("凭证规则模板缺少 " + eventType + " 借贷科目"); + } + } + } catch (JsonProcessingException exception) { + throw validation("凭证规则模板不是有效 JSON"); + } + } + + private Object jsonNode(String value) { + if (value == null) return null; + try { + return objectMapper.readTree(value); + } catch (JsonProcessingException exception) { + return value; + } + } + + private static String resource(String resource) { + String normalized = resource == null ? "" : resource.trim().toLowerCase(Locale.ROOT); + if (!RESOURCES.contains(normalized)) throw invalidResource(); + return normalized; + } + + private static String permission(String resource, String action) { + String segment = switch (resource) { + case "users" -> "user"; + case "roles" -> "role"; + case "scopes" -> "scope"; + case "templates" -> "template"; + case "parameters" -> "parameter"; + default -> throw invalidResource(); + }; + return "admin:" + segment + ":" + action; + } + + private static String objectType(String resource) { + return "ADMIN_" + resource.substring(0, resource.length() - 1).toUpperCase(Locale.ROOT); + } + + private static String action(String resource, String action) { + return objectType(resource) + "_" + action; + } + + private static Map auditValue(ResourceView view) { + Map value = values(); + value.put("resource", view.resource()); + value.put("businessCode", view.businessCode()); + value.put("status", view.status()); + value.put("version", view.version()); + value.put("values", view.values()); + return value; + } + + private static Map values() { + return new LinkedHashMap<>(); + } + + private static String required(String value, String field, int max) { + String result = clean(value, max); + if (result == null) throw new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, + ErrorCode.VALIDATION_FAILED, "请填写必填字段", Map.of(field, "此项不能为空")); + return result; + } + + private static String clean(String value, int max) { + if (value == null || value.isBlank()) return null; + String result = value.trim(); + if (result.length() > max) throw validation("字段长度不能超过 " + max + " 个字符"); + return result; + } + + private static String publicId(String value) { + String normalized = required(value, "publicId", 26).toUpperCase(Locale.ROOT); + if (!PUBLIC_ID.matcher(normalized).matches()) throw validation("公开编号格式无效"); + return normalized; + } + + private static String optionalPublicId(String value) { + String normalized = clean(value, 26); + return normalized == null ? null : publicId(normalized); + } + + private static int positive(Integer value, String field) { + if (value == null || value < 1) throw new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, + ErrorCode.VALIDATION_FAILED, "版本号必须大于零", Map.of(field, "必须大于零")); + return value; + } + + private static void checkVersion(long actual, Long expected) { + if (expected == null || actual != expected) throw conflict(); + } + + private static void ensureUpdated(int changed) { + if (changed != 1) throw conflict(); + } + + private static void ensureCreated(int changed) { + if (changed != 1) throw validation("新模板版本必须基于当前生效模板"); + } + + private static BusinessException invalidResource() { + return new BusinessException(HttpStatus.BAD_REQUEST, ErrorCode.QUERY_PARAMETER_INVALID, + "不支持的系统配置资源"); + } + + private static BusinessException validation(String message) { + return new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.VALIDATION_FAILED, message); + } + + private static BusinessException notFound(String message) { + return new BusinessException(HttpStatus.NOT_FOUND, ErrorCode.RESOURCE_NOT_FOUND, message); + } + + private static BusinessException duplicate(String resource) { + return new BusinessException(HttpStatus.CONFLICT, ErrorCode.DUPLICATE_RESOURCE, + resource + " 配置已存在"); + } + + private static BusinessException conflict() { + return new BusinessException(HttpStatus.CONFLICT, ErrorCode.VERSION_CONFLICT, + "配置已被其他用户更新,请刷新后重试"); + } + + private static BusinessException invalidState(String message) { + return new BusinessException(HttpStatus.CONFLICT, ErrorCode.INVALID_STATE_TRANSITION, message); + } + + private record ScopeValues(String type, String companyId, String projectId, BigDecimal amountLimit) { + } +} diff --git a/backend/src/main/java/com/kaidi/finance/governance/infrastructure/GovernanceMapper.java b/backend/src/main/java/com/kaidi/finance/governance/infrastructure/GovernanceMapper.java new file mode 100644 index 0000000..ea1b4fd --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/governance/infrastructure/GovernanceMapper.java @@ -0,0 +1,300 @@ +package com.kaidi.finance.governance.infrastructure; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +/** Write and detail queries for the isolated system-governance area. */ +@org.apache.ibatis.annotations.Mapper +public interface GovernanceMapper { + + @Select(""" + SELECT id, public_id, username, display_name, department_name, enabled, version + FROM iam_user WHERE public_id = #{publicId} + """) + UserRow findUser(String publicId); + + @Select(""" + SELECT id, code, name, description, version, enabled + FROM iam_role WHERE code = #{code} + """) + RoleRow findRole(String code); + + @Select("SELECT id FROM iam_permission WHERE code = #{code}") + Long permissionId(String code); + + @Select("SELECT id FROM iam_scope WHERE id = #{id}") + Long scopeId(long id); + + @Select(""" + SELECT id FROM iam_scope + WHERE user_id = #{userId} AND role_id = #{roleId} AND permission_id = #{permissionId} + AND scope_type = #{scopeType} + AND (company_public_id <=> #{companyId}) AND (project_public_id <=> #{projectId}) + """) + Long scopeIdForGrant(@Param("userId") long userId, @Param("roleId") long roleId, + @Param("permissionId") long permissionId, @Param("scopeType") String scopeType, + @Param("companyId") String companyId, @Param("projectId") String projectId); + + @Select(""" + SELECT scope.id, user_account.public_id AS user_public_id, user_account.username, + role.code AS role_code, permission.code AS permission_code, scope.scope_type, + scope.company_public_id, scope.project_public_id, scope.amount_limit, + scope.status, scope.version + FROM iam_scope scope + JOIN iam_user user_account ON user_account.id = scope.user_id + JOIN iam_role role ON role.id = scope.role_id + JOIN iam_permission permission ON permission.id = scope.permission_id + WHERE scope.id = #{id} + """) + ScopeRow findScope(long id); + + @Select(""" + SELECT id, public_id, form_type, template_version, name, schema_version, status, + published_at, version + FROM src_template WHERE public_id = #{publicId} + """) + TemplateRow findTemplate(String publicId); + + @Select(""" + SELECT id, public_id, parameter_group, version_no, CAST(value_json AS CHAR) AS value_json, + status, effective_at, version + FROM sys_parameter_version WHERE public_id = #{publicId} + """) + ParameterRow findParameter(String publicId); + + @Select("SELECT COALESCE(MAX(version_no), 0) + 1 FROM sys_parameter_version WHERE parameter_group = #{parameterGroup}") + int nextParameterVersion(String parameterGroup); + + @Select("SELECT GET_LOCK(CONCAT('kaidi:governance:parameter:', #{parameterGroup}), 10)") + Integer lockParameterVersionGroup(String parameterGroup); + + @Select("SELECT RELEASE_LOCK(CONCAT('kaidi:governance:parameter:', #{parameterGroup}))") + Integer unlockParameterVersionGroup(String parameterGroup); + + @Select(""" + SELECT role.code FROM iam_user_role assignment + JOIN iam_role role ON role.id = assignment.role_id + WHERE assignment.user_id = #{userId} + ORDER BY role.sort_order, role.code + """) + List listRoleCodes(long userId); + + @Select(""" + SELECT permission.code FROM iam_role_permission assignment + JOIN iam_permission permission ON permission.id = assignment.permission_id + WHERE assignment.role_id = #{roleId} + ORDER BY permission.code + """) + List listPermissionCodes(long roleId); + + @Select(""" + SELECT DISTINCT user_account.username + FROM iam_user user_account + JOIN iam_user_role assignment ON assignment.user_id = user_account.id + WHERE assignment.role_id = #{roleId} + """) + List listUsernamesByRole(long roleId); + + @Select("SELECT COUNT(*) FROM iam_user_role WHERE user_id = #{userId} AND role_id = #{roleId}") + int userHasRole(@Param("userId") long userId, @Param("roleId") long roleId); + + @Select("SELECT COUNT(*) FROM iam_role_permission WHERE role_id = #{roleId} AND permission_id = #{permissionId}") + int roleHasPermission(@Param("roleId") long roleId, @Param("permissionId") long permissionId); + + @Insert(""" + INSERT INTO iam_user (public_id, username, display_name, department_name, password_hash, + enabled, must_change_password) + VALUES (#{publicId}, #{username}, #{displayName}, #{departmentName}, #{passwordHash}, TRUE, TRUE) + """) + int insertUser(@Param("publicId") String publicId, @Param("username") String username, + @Param("displayName") String displayName, @Param("departmentName") String departmentName, + @Param("passwordHash") String passwordHash); + + @Insert("INSERT INTO iam_user_role (user_id, role_id) VALUES (#{userId}, #{roleId})") + int insertUserRole(@Param("userId") long userId, @Param("roleId") long roleId); + + @Insert("INSERT INTO iam_role_permission (role_id, permission_id) VALUES (#{roleId}, #{permissionId})") + int insertRolePermission(@Param("roleId") long roleId, @Param("permissionId") long permissionId); + + @Insert("INSERT INTO iam_role (code, name, description, enabled) VALUES (#{code}, #{name}, #{description}, TRUE)") + int insertRole(@Param("code") String code, @Param("name") String name, @Param("description") String description); + + @Insert(""" + INSERT INTO iam_scope (user_id, role_id, permission_id, scope_type, company_public_id, + project_public_id, amount_limit, status) + VALUES (#{userId}, #{roleId}, #{permissionId}, #{scopeType}, #{companyId}, #{projectId}, #{amountLimit}, 'ACTIVE') + """) + int insertScope(@Param("userId") long userId, @Param("roleId") long roleId, + @Param("permissionId") long permissionId, @Param("scopeType") String scopeType, + @Param("companyId") String companyId, @Param("projectId") String projectId, + @Param("amountLimit") BigDecimal amountLimit); + + @Insert(""" + INSERT INTO src_template (public_id, form_type, template_version, name, group_code, purpose, + initiator_roles, schema_version, status, created_by, updated_by) + SELECT #{publicId}, #{formType}, #{templateVersion}, #{name}, source.group_code, source.purpose, + source.initiator_roles, #{schemaVersion}, 'DRAFT', #{actorId}, #{actorId} + FROM src_template source + WHERE source.form_type = #{formType} AND source.status = 'ACTIVE' + ORDER BY source.template_version DESC + LIMIT 1 + """) + int insertTemplate(@Param("publicId") String publicId, @Param("formType") String formType, + @Param("templateVersion") int templateVersion, @Param("name") String name, + @Param("schemaVersion") int schemaVersion, @Param("actorId") long actorId); + + @Insert(""" + INSERT INTO src_template_field ( + template_id, field_code, label, section_code, section_name, data_type, required_flag, repeatable, + max_length, numeric_precision, numeric_scale, required_when_json, enum_code, options_json, unit, + sensitivity, masking_rule, mapping_code, import_column, help_text, sort_order + ) + SELECT #{targetTemplateId}, field_code, label, section_code, section_name, data_type, required_flag, + repeatable, max_length, numeric_precision, numeric_scale, required_when_json, enum_code, + options_json, unit, sensitivity, masking_rule, mapping_code, import_column, help_text, sort_order + FROM src_template_field + WHERE template_id = #{sourceTemplateId} + """) + int copyTemplateFields(@Param("sourceTemplateId") long sourceTemplateId, + @Param("targetTemplateId") long targetTemplateId); + + @Select(""" + SELECT id, public_id, form_type, template_version, name, schema_version, status, published_at, version + FROM src_template + WHERE form_type = #{formType} AND status = 'ACTIVE' + ORDER BY template_version DESC + LIMIT 1 + """) + TemplateRow findActiveTemplateByFormType(String formType); + + @Select("SELECT COUNT(*) FROM src_template_field WHERE template_id = #{templateId}") + int countTemplateFields(long templateId); + + @Select("SELECT id FROM src_template WHERE form_type = #{formType} ORDER BY id FOR UPDATE") + List lockTemplateVersions(String formType); + + @Insert(""" + INSERT INTO sys_parameter_version (public_id, parameter_group, version_no, value_json, status, created_by) + VALUES (#{publicId}, #{parameterGroup}, #{versionNo}, CAST(#{valueJson} AS JSON), 'DRAFT', #{actorPublicId}) + """) + int insertParameter(@Param("publicId") String publicId, @Param("parameterGroup") String parameterGroup, + @Param("versionNo") int versionNo, @Param("valueJson") String valueJson, + @Param("actorPublicId") String actorPublicId); + + @Update("DELETE FROM iam_user_role WHERE user_id = #{userId}") + int deleteUserRoles(long userId); + + @Update("DELETE FROM iam_scope WHERE user_id = #{userId}") + int deleteUserScopes(long userId); + + @Update("DELETE FROM iam_role_permission WHERE role_id = #{roleId}") + int deleteRolePermissions(long roleId); + + @Update(""" + UPDATE iam_user SET display_name = COALESCE(#{displayName}, display_name), + department_name = COALESCE(#{departmentName}, department_name), + enabled = COALESCE(#{enabled}, enabled), version = version + 1 + WHERE id = #{id} AND version = #{version} + """) + int updateUser(@Param("id") long id, @Param("version") long version, @Param("displayName") String displayName, + @Param("departmentName") String departmentName, @Param("enabled") Boolean enabled); + + @Update(""" + UPDATE iam_role SET name = COALESCE(#{name}, name), description = COALESCE(#{description}, description), + enabled = COALESCE(#{enabled}, enabled), version = version + 1, updated_by = #{actorId} + WHERE id = #{id} AND version = #{version} + """) + int updateRole(@Param("id") long id, @Param("version") long version, @Param("name") String name, + @Param("description") String description, @Param("enabled") Boolean enabled, + @Param("actorId") long actorId); + + @Update(""" + UPDATE iam_scope SET scope_type = COALESCE(#{scopeType}, scope_type), company_public_id = #{companyId}, + project_public_id = #{projectId}, amount_limit = #{amountLimit}, status = COALESCE(#{status}, status), + version = version + 1, updated_at = UTC_TIMESTAMP(3) + WHERE id = #{id} AND version = #{version} + """) + int updateScope(@Param("id") long id, @Param("version") long version, @Param("scopeType") String scopeType, + @Param("companyId") String companyId, @Param("projectId") String projectId, + @Param("amountLimit") BigDecimal amountLimit, @Param("status") String status); + + @Update(""" + UPDATE src_template SET name = COALESCE(#{name}, name), status = COALESCE(#{status}, status), + updated_by = #{actorId}, version = version + 1 + WHERE id = #{id} AND version = #{version} + """) + int updateTemplate(@Param("id") long id, @Param("version") long version, @Param("name") String name, + @Param("status") String status, @Param("actorId") long actorId); + + @Update(""" + UPDATE sys_parameter_version + SET value_json = COALESCE(CAST(#{valueJson} AS JSON), value_json), + status = COALESCE(#{status}, status), updated_by = #{actorPublicId}, + version = version + 1, updated_at = UTC_TIMESTAMP(3) + WHERE id = #{id} AND version = #{version} + """) + int updateParameter(@Param("id") long id, @Param("version") long version, + @Param("valueJson") String valueJson, @Param("status") String status, + @Param("actorPublicId") String actorPublicId); + + @Update("UPDATE iam_user SET enabled = #{enabled}, version = version + 1 WHERE id = #{id} AND version = #{version}") + int setUserEnabled(@Param("id") long id, @Param("version") long version, @Param("enabled") boolean enabled); + + @Update("UPDATE iam_role SET enabled = #{enabled}, version = version + 1, updated_by = #{actorId} WHERE id = #{id} AND version = #{version}") + int setRoleEnabled(@Param("id") long id, @Param("version") long version, @Param("enabled") boolean enabled, + @Param("actorId") long actorId); + + @Update("UPDATE iam_scope SET status = #{status}, version = version + 1, updated_at = UTC_TIMESTAMP(3) WHERE id = #{id} AND version = #{version}") + int setScopeStatus(@Param("id") long id, @Param("version") long version, @Param("status") String status); + + @Update("UPDATE src_template SET status = #{status}, published_at = CASE WHEN #{status} = 'ACTIVE' THEN UTC_TIMESTAMP(3) ELSE published_at END, version = version + 1, updated_by = #{actorId} WHERE id = #{id} AND version = #{version}") + int setTemplateStatus(@Param("id") long id, @Param("version") long version, @Param("status") String status, + @Param("actorId") long actorId); + + @Update("UPDATE src_template SET status = 'RETIRED', updated_by = #{actorId}, version = version + 1 WHERE form_type = #{formType} AND status = 'ACTIVE' AND id <> #{id}") + int retireOtherTemplates(@Param("formType") String formType, @Param("id") long id, @Param("actorId") long actorId); + + @Update("UPDATE sys_parameter_version SET status = 'RETIRED', version = version + 1, updated_by = #{actorPublicId}, updated_at = UTC_TIMESTAMP(3) WHERE parameter_group = #{parameterGroup} AND status = 'ACTIVE' AND id <> #{id}") + int retireOtherParameters(@Param("parameterGroup") String parameterGroup, @Param("id") long id, + @Param("actorPublicId") String actorPublicId); + + @Select("SELECT id FROM sys_parameter_version WHERE parameter_group = #{parameterGroup} ORDER BY id FOR UPDATE") + List lockParameterVersions(String parameterGroup); + + @Update(""" + UPDATE sys_parameter_version SET status = #{status}, + effective_at = CASE WHEN #{status} = 'ACTIVE' THEN UTC_TIMESTAMP(3) ELSE effective_at END, + updated_by = #{actorPublicId}, version = version + 1, updated_at = UTC_TIMESTAMP(3) + WHERE id = #{id} AND version = #{version} + """) + int setParameterStatus(@Param("id") long id, @Param("version") long version, + @Param("status") String status, @Param("actorPublicId") String actorPublicId); + + @Update("DELETE FROM SPRING_SESSION WHERE PRINCIPAL_NAME = #{username}") + int invalidateSessions(String username); + + record UserRow(long id, String publicId, String username, String displayName, String departmentName, + boolean enabled, long version) { + } + + record RoleRow(long id, String code, String name, String description, long version, boolean enabled) { + } + + record ScopeRow(long id, String userPublicId, String username, String roleCode, String permissionCode, + String scopeType, String companyPublicId, String projectPublicId, + BigDecimal amountLimit, String status, long version) { + } + + record TemplateRow(long id, String publicId, String formType, int templateVersion, String name, + int schemaVersion, String status, LocalDateTime publishedAt, long version) { + } + + record ParameterRow(long id, String publicId, String parameterGroup, int versionNo, String valueJson, + String status, LocalDateTime effectiveAt, long version) { + } +} diff --git a/backend/src/main/java/com/kaidi/finance/iam/api/AuthController.java b/backend/src/main/java/com/kaidi/finance/iam/api/AuthController.java new file mode 100644 index 0000000..5603f63 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/iam/api/AuthController.java @@ -0,0 +1,89 @@ +package com.kaidi.finance.iam.api; + +import com.kaidi.finance.iam.application.AuthApplicationService; +import com.kaidi.finance.shared.api.ApiResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpSession; +import jakarta.validation.Valid; +import org.springframework.security.web.csrf.CsrfToken; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import java.util.List; + +@RestController +@RequestMapping("/api/v1/auth") +public class AuthController { + + private final AuthApplicationService authService; + + public AuthController(AuthApplicationService authService) { + this.authService = authService; + } + + @GetMapping("/csrf") + public ApiResponse csrf(HttpServletRequest request) { + CsrfToken token = (CsrfToken) request.getAttribute(CsrfToken.class.getName()); + return ApiResponse.ok(new CsrfView(token.getHeaderName(), token.getParameterName(), token.getToken())); + } + + @PostMapping("/login") + public ApiResponse login(@Valid @RequestBody LoginRequest login, HttpServletRequest request) { + return ApiResponse.ok(authService.login(login, request)); + } + + @GetMapping("/session") + public ApiResponse session(HttpSession session) { + return ApiResponse.ok(authService.current(session)); + } + + @GetMapping("/profile") + public ApiResponse profile(HttpSession session) { + return ApiResponse.ok(authService.profile(session)); + } + + @GetMapping("/roles") + public ApiResponse> roles(HttpSession session) { + return ApiResponse.ok(authService.current(session).roles()); + } + + @GetMapping("/sessions") + public ApiResponse> sessions(HttpServletRequest request) { + return ApiResponse.ok(authService.sessions(request)); + } + + @DeleteMapping("/sessions/{sessionId}") + public ApiResponse deleteSession(@org.springframework.web.bind.annotation.PathVariable String sessionId, + HttpServletRequest request) { + authService.deleteSession(sessionId, request); + return ApiResponse.ok(null); + } + + @DeleteMapping("/sessions") + public ApiResponse deleteAllSessions(HttpServletRequest request) { + authService.deleteAllSessions(request); + return ApiResponse.ok(null); + } + + @PostMapping("/select-role") + public ApiResponse selectRole(@Valid @RequestBody SelectRoleRequest selection, + HttpServletRequest request) { + return ApiResponse.ok(authService.selectRole(selection.roleCode(), request)); + } + + @PatchMapping("/password") + public ApiResponse changePassword(@Valid @RequestBody PasswordChangeRequest change, + HttpServletRequest request) { + return ApiResponse.ok(authService.changePassword(change, request)); + } + + @PostMapping("/logout") + public ApiResponse logout(HttpServletRequest request) { + authService.logout(request); + return ApiResponse.ok(null); + } +} diff --git a/backend/src/main/java/com/kaidi/finance/iam/api/AuthorizationScopeView.java b/backend/src/main/java/com/kaidi/finance/iam/api/AuthorizationScopeView.java new file mode 100644 index 0000000..a146083 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/iam/api/AuthorizationScopeView.java @@ -0,0 +1,19 @@ +package com.kaidi.finance.iam.api; + +import java.math.BigDecimal; + +/** A grouped, read-only summary of the current user's effective data scope. */ +public record AuthorizationScopeView( + String roleCode, + String roleName, + String scopeType, + String companyPublicId, + String companyCode, + String companyName, + String projectPublicId, + String projectCode, + String projectName, + long permissionCount, + BigDecimal amountLimit +) { +} diff --git a/backend/src/main/java/com/kaidi/finance/iam/api/CsrfView.java b/backend/src/main/java/com/kaidi/finance/iam/api/CsrfView.java new file mode 100644 index 0000000..e5ce614 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/iam/api/CsrfView.java @@ -0,0 +1,4 @@ +package com.kaidi.finance.iam.api; + +public record CsrfView(String headerName, String parameterName, String token) { +} diff --git a/backend/src/main/java/com/kaidi/finance/iam/api/LoginRequest.java b/backend/src/main/java/com/kaidi/finance/iam/api/LoginRequest.java new file mode 100644 index 0000000..6f6d751 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/iam/api/LoginRequest.java @@ -0,0 +1,13 @@ +package com.kaidi.finance.iam.api; + +import com.fasterxml.jackson.annotation.JsonAlias; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record LoginRequest( + @JsonAlias("account") @NotBlank(message = "请输入账号") @Size(max = 64, message = "账号长度不能超过 64 位") + String username, + @NotBlank(message = "请输入密码") @Size(min = 12, max = 64, message = "密码长度必须为 12 到 64 位") + String password +) { +} diff --git a/backend/src/main/java/com/kaidi/finance/iam/api/PasswordChangeRequest.java b/backend/src/main/java/com/kaidi/finance/iam/api/PasswordChangeRequest.java new file mode 100644 index 0000000..8577b9b --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/iam/api/PasswordChangeRequest.java @@ -0,0 +1,12 @@ +package com.kaidi.finance.iam.api; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record PasswordChangeRequest( + @NotBlank(message = "请输入当前密码") String currentPassword, + @NotBlank(message = "请输入新密码") @Size(min = 12, max = 100, message = "新密码长度必须为 12 到 100 位") + String newPassword, + @NotBlank(message = "请再次输入新密码") String confirmPassword +) { +} diff --git a/backend/src/main/java/com/kaidi/finance/iam/api/RecentLoginView.java b/backend/src/main/java/com/kaidi/finance/iam/api/RecentLoginView.java new file mode 100644 index 0000000..e33a253 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/iam/api/RecentLoginView.java @@ -0,0 +1,7 @@ +package com.kaidi.finance.iam.api; + +import java.time.Instant; + +/** A deliberately reduced login history view; raw user-agent and IP are never exposed. */ +public record RecentLoginView(Instant occurredAt, String deviceSummary, String maskedIp) { +} diff --git a/backend/src/main/java/com/kaidi/finance/iam/api/RoleView.java b/backend/src/main/java/com/kaidi/finance/iam/api/RoleView.java new file mode 100644 index 0000000..f9a8a0a --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/iam/api/RoleView.java @@ -0,0 +1,4 @@ +package com.kaidi.finance.iam.api; + +public record RoleView(String code, String name, String description, String workbenchRoute) { +} diff --git a/backend/src/main/java/com/kaidi/finance/iam/api/SelectRoleRequest.java b/backend/src/main/java/com/kaidi/finance/iam/api/SelectRoleRequest.java new file mode 100644 index 0000000..b700b1c --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/iam/api/SelectRoleRequest.java @@ -0,0 +1,6 @@ +package com.kaidi.finance.iam.api; + +import jakarta.validation.constraints.NotBlank; + +public record SelectRoleRequest(@NotBlank(message = "请选择工作身份") String roleCode) { +} diff --git a/backend/src/main/java/com/kaidi/finance/iam/api/SessionSummaryView.java b/backend/src/main/java/com/kaidi/finance/iam/api/SessionSummaryView.java new file mode 100644 index 0000000..7bbb559 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/iam/api/SessionSummaryView.java @@ -0,0 +1,8 @@ +package com.kaidi.finance.iam.api; + +import java.time.Instant; + +public record SessionSummaryView(String sessionId, Instant createdAt, Instant lastAccessedAt, + Instant expiresAt, boolean current, String deviceSummary, + String maskedIp) { +} diff --git a/backend/src/main/java/com/kaidi/finance/iam/api/SessionView.java b/backend/src/main/java/com/kaidi/finance/iam/api/SessionView.java new file mode 100644 index 0000000..7424d7b --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/iam/api/SessionView.java @@ -0,0 +1,10 @@ +package com.kaidi.finance.iam.api; + +import java.util.List; +import java.util.Set; +import java.time.Instant; + +public record SessionView(boolean authenticated, UserView user, List roles, String activeRole, + Set permissions, String permissionVersion, Instant sessionExpiresAt, + Instant absoluteSessionExpiresAt) { +} diff --git a/backend/src/main/java/com/kaidi/finance/iam/api/UserView.java b/backend/src/main/java/com/kaidi/finance/iam/api/UserView.java new file mode 100644 index 0000000..5e01202 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/iam/api/UserView.java @@ -0,0 +1,13 @@ +package com.kaidi.finance.iam.api; + +import java.util.List; + +public record UserView(String publicId, String username, String displayName, String departmentName, + boolean mustChangePassword, List authorizationScopes, + List recentLogins) { + + public UserView(String publicId, String username, String displayName, String departmentName, + boolean mustChangePassword) { + this(publicId, username, displayName, departmentName, mustChangePassword, null, null); + } +} diff --git a/backend/src/main/java/com/kaidi/finance/iam/application/AuthApplicationService.java b/backend/src/main/java/com/kaidi/finance/iam/application/AuthApplicationService.java new file mode 100644 index 0000000..cc87dc9 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/iam/application/AuthApplicationService.java @@ -0,0 +1,417 @@ +package com.kaidi.finance.iam.application; + +import com.kaidi.finance.iam.api.LoginRequest; +import com.kaidi.finance.iam.api.AuthorizationScopeView; +import com.kaidi.finance.iam.api.PasswordChangeRequest; +import com.kaidi.finance.iam.api.RecentLoginView; +import com.kaidi.finance.iam.api.RoleView; +import com.kaidi.finance.iam.api.SessionView; +import com.kaidi.finance.iam.api.SessionSummaryView; +import com.kaidi.finance.iam.api.UserView; +import com.kaidi.finance.iam.domain.FinancePrincipal; +import com.kaidi.finance.iam.domain.RoleAssignment; +import com.kaidi.finance.iam.domain.UserAccount; +import com.kaidi.finance.iam.infrastructure.UserAccountMapper; +import com.kaidi.finance.shared.api.BusinessException; +import com.kaidi.finance.shared.api.ErrorCode; +import com.kaidi.finance.shared.audit.AuditService; +import com.kaidi.finance.shared.audit.AuditMapper; +import com.kaidi.finance.shared.security.AuthorizationMapper; +import com.kaidi.finance.shared.security.IdentityContext; +import com.kaidi.finance.shared.security.SessionLifecycle; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpSession; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.time.Instant; +import java.time.Duration; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.ArrayList; +import java.util.Comparator; +import org.springframework.http.HttpStatus; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.context.HttpSessionSecurityContextRepository; +import org.springframework.session.FindByIndexNameSessionRepository; +import org.springframework.session.Session; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class AuthApplicationService { + + private final UserAccountMapper userAccountMapper; + private final PasswordEncoder passwordEncoder; + private final AuditService auditService; + private final AuditMapper auditMapper; + private final AuthorizationMapper authorizationMapper; + private final IdentityContext identityContext; + private final FindByIndexNameSessionRepository sessionRepository; + private final Duration absoluteTimeout; + + public AuthApplicationService(UserAccountMapper userAccountMapper, PasswordEncoder passwordEncoder, + AuditService auditService, AuditMapper auditMapper, + AuthorizationMapper authorizationMapper, IdentityContext identityContext, + FindByIndexNameSessionRepository sessionRepository, + @org.springframework.beans.factory.annotation.Value("${finance.session.absolute-timeout:8h}") Duration absoluteTimeout) { + this.userAccountMapper = userAccountMapper; + this.passwordEncoder = passwordEncoder; + this.auditService = auditService; + this.auditMapper = auditMapper; + this.authorizationMapper = authorizationMapper; + this.identityContext = identityContext; + this.sessionRepository = sessionRepository; + this.absoluteTimeout = absoluteTimeout; + } + + @Transactional(noRollbackFor = BusinessException.class) + public SessionView login(LoginRequest login, HttpServletRequest request) { + String username = login.username().trim().toLowerCase(Locale.ROOT); + UserAccount account = userAccountMapper.findByUsername(username); + if (account == null) { + auditService.recordForUser(null, username, "AUTH_LOGIN", "FAILED", "BAD_CREDENTIALS"); + throw badCredentials(); + } + if (!account.isEnabled()) { + auditService.recordForUser(account.getPublicId(), username, "AUTH_LOGIN", "FAILED", "ACCOUNT_DISABLED"); + throw new BusinessException(HttpStatus.FORBIDDEN, ErrorCode.AUTH_ACCOUNT_DISABLED, "账号已停用,请联系管理员"); + } + if (account.getLockedUntil() != null + && account.getLockedUntil().isAfter(LocalDateTime.now(ZoneOffset.UTC))) { + auditService.recordForUser(account.getPublicId(), username, "AUTH_LOGIN", "FAILED", "ACCOUNT_LOCKED"); + throw new BusinessException(HttpStatus.LOCKED, ErrorCode.AUTH_ACCOUNT_LOCKED, + "账号因连续登录失败已临时锁定,请稍后再试"); + } + if (!passwordEncoder.matches(login.password(), account.getPasswordHash())) { + userAccountMapper.recordFailedLogin(account.getId()); + auditService.recordForUser(account.getPublicId(), username, "AUTH_LOGIN", "FAILED", "BAD_CREDENTIALS"); + throw badCredentials(); + } + + userAccountMapper.clearFailedLogins(account.getId()); + List roles = userAccountMapper.findRoles(account.getId()); + if (roles.isEmpty()) { + auditService.recordForUser(account.getPublicId(), username, "AUTH_LOGIN", "FAILED", "NO_ROLE"); + throw new BusinessException(HttpStatus.FORBIDDEN, ErrorCode.ACCESS_DENIED, "账号尚未分配工作身份"); + } + if (hasAdministratorBusinessRoleConflict(roles)) { + auditService.recordForUser(account.getPublicId(), username, "AUTH_LOGIN", "FAILED", + "ROLE_CONFIGURATION_CONFLICT"); + throw new BusinessException(HttpStatus.FORBIDDEN, ErrorCode.ACCESS_DENIED, + "系统管理员身份不能与业务身份同时授予同一账号"); + } + + FinancePrincipal principal = new FinancePrincipal(account.getId(), account.getPublicId(), account.getUsername(), + account.getDisplayName(), account.getDepartmentName(), account.isMustChangePassword(), List.copyOf(roles)); + UsernamePasswordAuthenticationToken authentication = UsernamePasswordAuthenticationToken.authenticated( + principal, null, principal.getAuthorities()); + SecurityContext context = SecurityContextHolder.createEmptyContext(); + context.setAuthentication(authentication); + SecurityContextHolder.setContext(context); + + HttpSession session = request.getSession(true); + request.changeSessionId(); + session.setAttribute(SessionLifecycle.ABSOLUTE_EXPIRES_AT, + Instant.ofEpochMilli(session.getCreationTime()).plus(absoluteTimeout)); + setSessionDeviceMetadata(session, request); + session.setAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, context); + session.removeAttribute(AuditService.ACTIVE_ROLE_SESSION_KEY); + auditService.record("AUTH_LOGIN", "SESSION", account.getPublicId(), "SUCCESS", null, null, + Map.of("username", account.getUsername())); + evictExcessSessions(principal.username(), session.getId(), account.getPublicId()); + return view(principal, session); + } + + @Transactional(readOnly = true) + public SessionView current(HttpSession session) { + return view(identityContext.requirePrincipal(), session); + } + + @Transactional(readOnly = true) + public List sessions(HttpServletRequest request) { + FinancePrincipal principal = identityContext.requirePrincipal(); + String currentId = request.getSession(false) == null ? null : request.getSession(false).getId(); + List result = new ArrayList<>(); + sessionRepository.findByPrincipalName(principal.username()).values().stream() + .sorted(Comparator.comparing(Session::getCreationTime).thenComparing(Session::getId)) + .forEach(session -> { + Instant idleExpiresAt = session.getLastAccessedTime().plus(session.getMaxInactiveInterval()); + Instant absoluteExpiresAt = absoluteExpiresAt(session); + Instant expiresAt = idleExpiresAt.isBefore(absoluteExpiresAt) ? idleExpiresAt : absoluteExpiresAt; + result.add(new SessionSummaryView(session.getId(), session.getCreationTime(), + session.getLastAccessedTime(), expiresAt, session.getId().equals(currentId), + stringAttribute(session, SessionLifecycle.DEVICE_SUMMARY, "未知设备"), + stringAttribute(session, SessionLifecycle.MASKED_IP, "未知"))); + }); + return result; + } + + /** Returns the richer,本人范围内 profile payload used by PAGE-03. */ + @Transactional(readOnly = true) + public UserView profile(HttpSession session) { + FinancePrincipal principal = identityContext.requirePrincipal(); + String activeRole = session == null ? null + : (String) session.getAttribute(AuditService.ACTIVE_ROLE_SESSION_KEY); + List scopes = authorizationMapper.findProfileScopes(principal.userId(), activeRole) + .stream() + .map(scope -> new AuthorizationScopeView(scope.roleCode(), scope.roleName(), scope.scopeType(), + scope.companyPublicId(), scope.companyCode(), scope.companyName(), scope.projectPublicId(), + scope.projectCode(), scope.projectName(), scope.permissionCount(), scope.amountLimit())) + .toList(); + List recentLogins = auditMapper.listRecentSuccessfulLogins(principal.publicId(), 10).stream() + .map(login -> new RecentLoginView(login.occurredAt().toInstant(ZoneOffset.UTC), + deviceSummary(login.userAgent()), maskIp(login.ipAddress()))) + .toList(); + return new UserView(principal.publicId(), principal.username(), principal.displayName(), + principal.departmentName(), principal.mustChangePassword(), scopes, recentLogins); + } + + @Transactional + public void deleteSession(String sessionId, HttpServletRequest request) { + FinancePrincipal principal = identityContext.requirePrincipal(); + Session target = sessionRepository.findByPrincipalName(principal.username()).get(sessionId); + if (target == null) { + throw new BusinessException(HttpStatus.NOT_FOUND, ErrorCode.RESOURCE_NOT_FOUND, "会话不存在"); + } + sessionRepository.deleteById(sessionId); + HttpSession current = request.getSession(false); + if (current != null && current.getId().equals(sessionId)) { + current.invalidate(); + SecurityContextHolder.clearContext(); + } + } + + @Transactional + public void deleteAllSessions(HttpServletRequest request) { + FinancePrincipal principal = identityContext.requirePrincipal(); + sessionRepository.findByPrincipalName(principal.username()).keySet() + .forEach(sessionRepository::deleteById); + HttpSession current = request.getSession(false); + if (current != null) current.invalidate(); + SecurityContextHolder.clearContext(); + } + + @Transactional(noRollbackFor = BusinessException.class) + public SessionView selectRole(String requestedRole, HttpServletRequest request) { + FinancePrincipal principal = identityContext.requirePrincipal(); + String roleCode = requestedRole.trim().toUpperCase(Locale.ROOT); + List currentRoles = currentRoles(principal); + RoleAssignment role = currentRoles.stream().filter(candidate -> candidate.code().equals(roleCode)) + .findFirst() + .orElseThrow(() -> new BusinessException(HttpStatus.FORBIDDEN, ErrorCode.AUTH_ROLE_NOT_GRANTED, + "当前账号未被授予该工作身份")); + String before = identityContext.activeRole(); + HttpSession session = request.getSession(true); + request.changeSessionId(); + setSessionDeviceMetadata(session, request); + session.setAttribute(AuditService.ACTIVE_ROLE_SESSION_KEY, role.code()); + auditService.record("AUTH_ROLE_SELECT", "SESSION", principal.publicId(), "SUCCESS", null, + Map.of("activeRole", before == null ? "" : before), Map.of("activeRole", role.code())); + return view(principal, session); + } + + @Transactional + public SessionView changePassword(PasswordChangeRequest change, HttpServletRequest request) { + FinancePrincipal principal = identityContext.requirePrincipal(); + UserAccount account = userAccountMapper.findByUsername(principal.username()); + if (!passwordEncoder.matches(change.currentPassword(), account.getPasswordHash())) { + throw new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.VALIDATION_FAILED, + "当前密码不正确", Map.of("currentPassword", "当前密码不正确")); + } + if (!change.newPassword().equals(change.confirmPassword())) { + throw new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.VALIDATION_FAILED, + "两次输入的新密码不一致", Map.of("confirmPassword", "两次输入的新密码不一致")); + } + validatePassword(change.newPassword()); + if (passwordEncoder.matches(change.newPassword(), account.getPasswordHash())) { + throw new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.VALIDATION_FAILED, + "新密码不能与当前密码相同", Map.of("newPassword", "新密码不能与当前密码相同")); + } + int updated = userAccountMapper.updatePassword(account.getId(), account.getVersion(), + passwordEncoder.encode(change.newPassword())); + if (updated != 1) { + throw new BusinessException(HttpStatus.CONFLICT, ErrorCode.VERSION_CONFLICT, + "账号资料已被更新,请重新登录后再修改密码"); + } + auditService.record("AUTH_PASSWORD_CHANGE", "USER", principal.publicId(), "SUCCESS", null, null, null); + SecurityContext context = SecurityContextHolder.getContext(); + FinancePrincipal refreshed = new FinancePrincipal(principal.userId(), principal.publicId(), principal.username(), + principal.displayName(), principal.departmentName(), false, principal.roles()); + context.setAuthentication(UsernamePasswordAuthenticationToken.authenticated( + refreshed, null, refreshed.getAuthorities())); + HttpSession current = request.getSession(false); + if (sessionRepository != null) { + sessionRepository.findByPrincipalName(principal.username()).keySet().stream() + .filter(id -> current == null || !id.equals(current.getId())) + .forEach(sessionRepository::deleteById); + } + if (current != null) { + current.setAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, context); + } + return view(refreshed, current); + } + + @Transactional + public void logout(HttpServletRequest request) { + FinancePrincipal principal = identityContext.requirePrincipal(); + auditService.record("AUTH_LOGOUT", "SESSION", principal.publicId(), "SUCCESS", null, null, null); + HttpSession session = request.getSession(false); + if (session != null) { + session.invalidate(); + } + SecurityContextHolder.clearContext(); + } + + private SessionView view(FinancePrincipal principal, HttpSession session) { + List currentRoles = currentRoles(principal); + String activeRole = session == null ? null : (String) session.getAttribute(AuditService.ACTIVE_ROLE_SESSION_KEY); + String activeRoleSnapshot = activeRole; + boolean activeRoleGranted = activeRoleSnapshot != null + && currentRoles.stream().anyMatch(role -> role.code().equals(activeRoleSnapshot)); + if (activeRole != null && !activeRoleGranted) { + session.removeAttribute(AuditService.ACTIVE_ROLE_SESSION_KEY); + activeRole = null; + } + Set permissions = activeRole == null ? Set.of() + : new LinkedHashSet<>(userAccountMapper.findPermissions(principal.userId(), activeRole)); + UserView user = new UserView(principal.publicId(), principal.username(), principal.displayName(), + principal.departmentName(), principal.mustChangePassword()); + List roles = currentRoles.stream() + .map(role -> new RoleView(role.code(), role.name(), role.description(), workbenchRoute(role.code()))) + .toList(); + String permissionVersion = Integer.toUnsignedString(permissions.hashCode(), 16); + Instant sessionExpiresAt = session == null ? null + : Instant.ofEpochMilli(session.getLastAccessedTime()).plusSeconds(session.getMaxInactiveInterval()); + Instant absoluteSessionExpiresAt = session == null ? null : absoluteExpiresAt(session); + if (sessionExpiresAt != null && absoluteSessionExpiresAt != null && absoluteSessionExpiresAt.isBefore(sessionExpiresAt)) { + sessionExpiresAt = absoluteSessionExpiresAt; + } + return new SessionView(true, user, roles, activeRole, permissions, permissionVersion, sessionExpiresAt, + absoluteSessionExpiresAt); + } + + private Instant absoluteExpiresAt(jakarta.servlet.http.HttpSession session) { + Object value = session.getAttribute(SessionLifecycle.ABSOLUTE_EXPIRES_AT); + if (value instanceof Instant instant) return instant; + Instant fallback = Instant.ofEpochMilli(session.getCreationTime()).plus(absoluteTimeout); + session.setAttribute(SessionLifecycle.ABSOLUTE_EXPIRES_AT, fallback); + return fallback; + } + + private Instant absoluteExpiresAt(Session session) { + Object value = session.getAttribute(SessionLifecycle.ABSOLUTE_EXPIRES_AT); + if (value instanceof Instant instant) return instant; + return session.getCreationTime().plus(absoluteTimeout); + } + + private void setSessionDeviceMetadata(HttpSession session, HttpServletRequest request) { + if (session == null || request == null) return; + session.setAttribute(SessionLifecycle.DEVICE_SUMMARY, deviceSummary(request.getHeader("User-Agent"))); + session.setAttribute(SessionLifecycle.MASKED_IP, maskIp(clientIp(request))); + } + + private String stringAttribute(Session session, String key, String fallback) { + Object value = session.getAttribute(key); + return value instanceof String text && !text.isBlank() ? text : fallback; + } + + private String clientIp(HttpServletRequest request) { + String forwarded = request.getHeader("X-Forwarded-For"); + if (forwarded != null && !forwarded.isBlank()) return forwarded.split(",")[0].trim(); + return request.getRemoteAddr(); + } + + private String maskIp(String value) { + if (value == null || value.isBlank()) return "未知"; + String ip = value.trim(); + if (ip.contains(".")) { + String[] parts = ip.split("\\."); + if (parts.length == 4) return parts[0] + "." + parts[1] + ".*.*"; + } + if (ip.contains(":")) { + String[] parts = ip.split(":", -1); + int visible = Math.min(3, parts.length); + return String.join(":", java.util.Arrays.copyOf(parts, visible)) + ":*"; + } + return "已脱敏"; + } + + private String deviceSummary(String userAgent) { + if (userAgent == null || userAgent.isBlank()) return "未知设备"; + String ua = userAgent.toLowerCase(Locale.ROOT); + String browser = ua.contains("edg/") ? "Edge" : ua.contains("chrome/") ? "Chrome" + : ua.contains("firefox/") ? "Firefox" : ua.contains("safari/") ? "Safari" : "浏览器"; + String platform = ua.contains("iphone") || ua.contains("ipad") || ua.contains("android") ? "移动端" + : ua.contains("mac os") || ua.contains("macintosh") ? "macOS" : ua.contains("windows") ? "Windows" + : ua.contains("linux") ? "Linux" : "桌面端"; + return browser + " · " + platform; + } + + private void evictExcessSessions(String username, String currentId, String userPublicId) { + synchronized (("kaidi-session-limit:" + username).intern()) { + List sessions = sessionRepository.findByPrincipalName(username).values().stream() + .sorted(Comparator.comparing(Session::getCreationTime).thenComparing(Session::getId)) + .toList(); + boolean currentAlreadyIndexed = sessions.stream().anyMatch(session -> session.getId().equals(currentId)); + int excess = sessions.size() + (currentAlreadyIndexed ? 0 : 1) - SessionLifecycle.MAX_SESSIONS_PER_USER; + for (Session candidate : sessions) { + if (excess <= 0) break; + if (candidate.getId().equals(currentId)) continue; + sessionRepository.deleteById(candidate.getId()); + auditService.recordForUser(userPublicId, username, "AUTH_SESSION_EVICTED", "SUCCESS", + candidate.getId()); + excess--; + } + } + } + + private String workbenchRoute(String roleCode) { + return switch (roleCode) { + case "PROJECT_MANAGER" -> "/workbench/project"; + case "FINANCE_MANAGER" -> "/workbench/finance"; + case "ARCHIVE_MANAGER" -> "/workbench/archive"; + case "SYSTEM_ADMIN" -> "/governance/settings"; + default -> "/role-select"; + }; + } + + private boolean hasAdministratorBusinessRoleConflict(List roles) { + boolean hasSystemAdministrator = roles.stream().anyMatch(role -> "SYSTEM_ADMIN".equals(role.code())); + return hasSystemAdministrator && roles.stream().anyMatch(role -> !"SYSTEM_ADMIN".equals(role.code())); + } + + private List currentRoles(FinancePrincipal principal) { + List roles = userAccountMapper.findRoles(principal.userId()); + if (hasAdministratorBusinessRoleConflict(roles)) { + auditService.record("AUTH_ROLE_CONFIGURATION_CONFLICT", "SESSION", principal.publicId(), "FAILED", + "ROLE_CONFIGURATION_CONFLICT", null, null); + throw new BusinessException(HttpStatus.CONFLICT, ErrorCode.ACCESS_DENIED, + "系统管理员身份不能与业务身份同时授予同一账号"); + } + return roles; + } + + private BusinessException badCredentials() { + return new BusinessException(HttpStatus.UNAUTHORIZED, ErrorCode.AUTH_BAD_CREDENTIALS, "账号或密码不正确"); + } + + private void validatePassword(String password) { + boolean valid = password.length() >= 12 + && password.chars().anyMatch(Character::isUpperCase) + && password.chars().anyMatch(Character::isLowerCase) + && password.chars().anyMatch(Character::isDigit) + && password.chars().anyMatch(character -> !Character.isLetterOrDigit(character)); + if (!valid) { + throw new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.VALIDATION_FAILED, + "新密码必须至少 12 位,并包含大小写字母、数字和特殊字符", + Map.of("newPassword", "必须包含大小写字母、数字和特殊字符")); + } + } + +} diff --git a/backend/src/main/java/com/kaidi/finance/iam/application/BootstrapProperties.java b/backend/src/main/java/com/kaidi/finance/iam/application/BootstrapProperties.java new file mode 100644 index 0000000..e764daf --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/iam/application/BootstrapProperties.java @@ -0,0 +1,7 @@ +package com.kaidi.finance.iam.application; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "finance.bootstrap") +public record BootstrapProperties(boolean enabled, String password, boolean demoData) { +} diff --git a/backend/src/main/java/com/kaidi/finance/iam/application/BootstrapUserInitializer.java b/backend/src/main/java/com/kaidi/finance/iam/application/BootstrapUserInitializer.java new file mode 100644 index 0000000..31944b2 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/iam/application/BootstrapUserInitializer.java @@ -0,0 +1,217 @@ +package com.kaidi.finance.iam.application; + +import com.kaidi.finance.shared.id.UlidGenerator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Component +public class BootstrapUserInitializer implements ApplicationRunner { + + private final BootstrapProperties properties; + private final JdbcTemplate jdbcTemplate; + private final PasswordEncoder passwordEncoder; + private final UlidGenerator ulidGenerator; + + public BootstrapUserInitializer(BootstrapProperties properties, JdbcTemplate jdbcTemplate, + PasswordEncoder passwordEncoder, UlidGenerator ulidGenerator) { + this.properties = properties; + this.jdbcTemplate = jdbcTemplate; + this.passwordEncoder = passwordEncoder; + this.ulidGenerator = ulidGenerator; + } + + @Override + @Transactional + public void run(ApplicationArguments args) { + ensureSystemAdministratorGrants(); + if (!properties.enabled()) { + return; + } + if (properties.password() == null || properties.password().length() < 12) { + throw new IllegalStateException("Bootstrap password must contain at least 12 characters"); + } + Map users = new LinkedHashMap<>(); + users.put("admin", new SeedUser("系统管理员", "信息中心", List.of("SYSTEM_ADMIN"))); + if (properties.demoData()) { + users.put("project", new SeedUser("项目经办员", "项目管理部", List.of("PROJECT_MANAGER"))); + users.put("finance", new SeedUser("财务审核员", "财务部", List.of("FINANCE_MANAGER"))); + users.put("archive", new SeedUser("资料管理员", "资料管理部", List.of("ARCHIVE_MANAGER"))); + users.put("demo", new SeedUser("多岗位演示用户", "项目财务中心", + List.of("PROJECT_MANAGER", "FINANCE_MANAGER", "ARCHIVE_MANAGER"))); + } + + String encodedPassword = passwordEncoder.encode(properties.password()); + users.forEach((username, seed) -> ensureUser(username, seed, encodedPassword, !properties.demoData())); + if (properties.demoData()) { + ensureFinanceReferenceData(); + } + } + + @Transactional + public void ensureSystemAdministratorGrants() { + jdbcTemplate.update(""" + INSERT IGNORE INTO iam_role_permission (role_id, permission_id) + SELECT role.id, permission.id + FROM iam_role role + CROSS JOIN iam_permission permission + WHERE role.code = 'SYSTEM_ADMIN' AND role.enabled = TRUE + """); + jdbcTemplate.update(""" + UPDATE iam_scope scope_grant + JOIN iam_user_role user_role + ON user_role.user_id = scope_grant.user_id + AND user_role.role_id = scope_grant.role_id + JOIN iam_role role ON role.id = user_role.role_id + SET scope_grant.status = 'ACTIVE', + scope_grant.amount_limit = NULL, + scope_grant.valid_from = LEAST(scope_grant.valid_from, UTC_TIMESTAMP(3)), + scope_grant.valid_to = NULL, + scope_grant.version = scope_grant.version + 1 + WHERE role.code = 'SYSTEM_ADMIN' AND role.enabled = TRUE + AND scope_grant.scope_type = 'GLOBAL' + AND ( + scope_grant.status <> 'ACTIVE' + OR scope_grant.amount_limit IS NOT NULL + OR scope_grant.valid_from > UTC_TIMESTAMP(3) + OR scope_grant.valid_to IS NOT NULL + ) + """); + jdbcTemplate.update(""" + INSERT IGNORE INTO iam_scope ( + user_id, role_id, permission_id, scope_type, company_public_id, project_public_id, + amount_limit, status + ) + SELECT user_role.user_id, user_role.role_id, role_permission.permission_id, + 'GLOBAL', NULL, NULL, NULL, 'ACTIVE' + FROM iam_user_role user_role + JOIN iam_role role ON role.id = user_role.role_id + JOIN iam_role_permission role_permission ON role_permission.role_id = role.id + WHERE role.code = 'SYSTEM_ADMIN' AND role.enabled = TRUE + """); + } + + private void ensureUser(String username, SeedUser seed, String encodedPassword, boolean mustChangePassword) { + Integer count = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM iam_user WHERE username = ?", Integer.class, + username); + if (count == null || count == 0) { + jdbcTemplate.update(""" + INSERT INTO iam_user (public_id, username, display_name, department_name, password_hash, + enabled, must_change_password) + VALUES (?, ?, ?, ?, ?, TRUE, ?) + """, ulidGenerator.next(), username, seed.displayName(), seed.departmentName(), encodedPassword, + mustChangePassword); + } + Long userId = jdbcTemplate.queryForObject("SELECT id FROM iam_user WHERE username = ?", Long.class, username); + for (String roleCode : seed.roles()) { + jdbcTemplate.update(""" + INSERT IGNORE INTO iam_user_role (user_id, role_id) + SELECT ?, id FROM iam_role WHERE code = ? AND enabled = TRUE + """, userId, roleCode); + ensureGlobalScopes(userId, roleCode); + } + } + + /** + * Keep local fixture accounts aligned with permissions introduced by later Flyway migrations. + * The method is also useful to tests and maintenance commands that need to refresh a built-in + * user's grants after the application has already started. + */ + public void ensureGlobalScopes(long userId, String roleCode) { + jdbcTemplate.update(""" + INSERT IGNORE INTO iam_scope ( + user_id, role_id, permission_id, scope_type, company_public_id, project_public_id, + amount_limit, status + ) + SELECT ?, role.id, role_permission.permission_id, 'GLOBAL', NULL, NULL, NULL, 'ACTIVE' + FROM iam_role role + JOIN iam_role_permission role_permission ON role_permission.role_id = role.id + WHERE role.code = ? AND role.enabled = TRUE + """, userId, roleCode); + } + + /** + * Flyway creates the finance reference tables before this runner creates the + * local bootstrap users. Seed the built-in accounts and tax rates here so a + * fresh local database has the same usable baseline as an upgraded database. + * Existing rows are left untouched because they may already be in review or + * disabled states. + */ + private void ensureFinanceReferenceData() { + Long ownerId = jdbcTemplate.queryForObject( + "SELECT id FROM iam_user WHERE username = 'finance' LIMIT 1", Long.class); + if (ownerId == null) { + return; + } + jdbcTemplate.update(""" + INSERT INTO md_account (public_id, code, name, account_type, auxiliary_required, status, created_by, updated_by) + SELECT ?, ?, ?, ?, ?, 'ACTIVE', ?, ? + WHERE NOT EXISTS (SELECT 1 FROM md_account WHERE code = ?) + """, "00000000000000000000000311", "1002", "银行存款", "ASSET", false, + ownerId, ownerId, "1002"); + jdbcTemplate.update(""" + INSERT INTO md_account (public_id, code, name, account_type, auxiliary_required, status, created_by, updated_by) + SELECT ?, ?, ?, ?, ?, 'ACTIVE', ?, ? + WHERE NOT EXISTS (SELECT 1 FROM md_account WHERE code = ?) + """, "00000000000000000000000312", "1122", "应收账款", "ASSET", true, + ownerId, ownerId, "1122"); + jdbcTemplate.update(""" + INSERT INTO md_account (public_id, code, name, account_type, auxiliary_required, status, created_by, updated_by) + SELECT ?, ?, ?, ?, ?, 'ACTIVE', ?, ? + WHERE NOT EXISTS (SELECT 1 FROM md_account WHERE code = ?) + """, "00000000000000000000000313", "2202", "应付账款", "LIABILITY", true, + ownerId, ownerId, "2202"); + jdbcTemplate.update(""" + INSERT INTO md_account (public_id, code, name, account_type, auxiliary_required, status, created_by, updated_by) + SELECT ?, ?, ?, ?, ?, 'ACTIVE', ?, ? + WHERE NOT EXISTS (SELECT 1 FROM md_account WHERE code = ?) + """, "00000000000000000000000314", "5401", "工程施工成本", "COST", true, + ownerId, ownerId, "5401"); + jdbcTemplate.update(""" + INSERT INTO md_account (public_id, code, name, account_type, auxiliary_required, status, created_by, updated_by) + SELECT ?, ?, ?, ?, ?, 'ACTIVE', ?, ? + WHERE NOT EXISTS (SELECT 1 FROM md_account WHERE code = ?) + """, "00000000000000000000000315", "6001", "主营业务收入", "INCOME", true, + ownerId, ownerId, "6001"); + + jdbcTemplate.update(""" + INSERT INTO md_tax_rate (public_id, code, name, rate, status, created_by, updated_by) + SELECT ?, ?, ?, ?, 'ACTIVE', ?, ? + WHERE NOT EXISTS (SELECT 1 FROM md_tax_rate WHERE code = ?) + """, "00000000000000000000000321", "VAT-0", "免税/零税率", 0.0, + ownerId, ownerId, "VAT-0"); + jdbcTemplate.update(""" + INSERT INTO md_tax_rate (public_id, code, name, rate, status, created_by, updated_by) + SELECT ?, ?, ?, ?, 'ACTIVE', ?, ? + WHERE NOT EXISTS (SELECT 1 FROM md_tax_rate WHERE code = ?) + """, "00000000000000000000000322", "VAT-3", "增值税 3%", 0.03, + ownerId, ownerId, "VAT-3"); + jdbcTemplate.update(""" + INSERT INTO md_tax_rate (public_id, code, name, rate, status, created_by, updated_by) + SELECT ?, ?, ?, ?, 'ACTIVE', ?, ? + WHERE NOT EXISTS (SELECT 1 FROM md_tax_rate WHERE code = ?) + """, "00000000000000000000000323", "VAT-6", "增值税 6%", 0.06, + ownerId, ownerId, "VAT-6"); + jdbcTemplate.update(""" + INSERT INTO md_tax_rate (public_id, code, name, rate, status, created_by, updated_by) + SELECT ?, ?, ?, ?, 'ACTIVE', ?, ? + WHERE NOT EXISTS (SELECT 1 FROM md_tax_rate WHERE code = ?) + """, "00000000000000000000000324", "VAT-9", "增值税 9%", 0.09, + ownerId, ownerId, "VAT-9"); + jdbcTemplate.update(""" + INSERT INTO md_tax_rate (public_id, code, name, rate, status, created_by, updated_by) + SELECT ?, ?, ?, ?, 'ACTIVE', ?, ? + WHERE NOT EXISTS (SELECT 1 FROM md_tax_rate WHERE code = ?) + """, "00000000000000000000000325", "VAT-13", "增值税 13%", 0.13, + ownerId, ownerId, "VAT-13"); + } + + private record SeedUser(String displayName, String departmentName, List roles) { + } +} diff --git a/backend/src/main/java/com/kaidi/finance/iam/domain/FinancePrincipal.java b/backend/src/main/java/com/kaidi/finance/iam/domain/FinancePrincipal.java new file mode 100644 index 0000000..a09c032 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/iam/domain/FinancePrincipal.java @@ -0,0 +1,32 @@ +package com.kaidi.finance.iam.domain; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Collection; +import java.util.List; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +public record FinancePrincipal(long userId, String publicId, String username, String displayName, + String departmentName, boolean mustChangePassword, + List roles) implements UserDetails, Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Override + public Collection getAuthorities() { + return roles.stream().map(role -> new SimpleGrantedAuthority("ROLE_AVAILABLE_" + role.code())).toList(); + } + + @Override + public String getPassword() { + return null; + } + + @Override + public String getUsername() { + return username; + } +} diff --git a/backend/src/main/java/com/kaidi/finance/iam/domain/RoleAssignment.java b/backend/src/main/java/com/kaidi/finance/iam/domain/RoleAssignment.java new file mode 100644 index 0000000..f517c69 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/iam/domain/RoleAssignment.java @@ -0,0 +1,6 @@ +package com.kaidi.finance.iam.domain; + +import java.io.Serializable; + +public record RoleAssignment(long id, String code, String name, String description) implements Serializable { +} diff --git a/backend/src/main/java/com/kaidi/finance/iam/domain/UserAccount.java b/backend/src/main/java/com/kaidi/finance/iam/domain/UserAccount.java new file mode 100644 index 0000000..04cbae8 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/iam/domain/UserAccount.java @@ -0,0 +1,41 @@ +package com.kaidi.finance.iam.domain; + +import java.time.LocalDateTime; + +public class UserAccount { + + private long id; + private String publicId; + private String username; + private String displayName; + private String departmentName; + private String passwordHash; + private boolean enabled; + private boolean mustChangePassword; + private int failedLoginCount; + private LocalDateTime lockedUntil; + private long version; + + public long getId() { return id; } + public void setId(long id) { this.id = id; } + public String getPublicId() { return publicId; } + public void setPublicId(String publicId) { this.publicId = publicId; } + public String getUsername() { return username; } + public void setUsername(String username) { this.username = username; } + public String getDisplayName() { return displayName; } + public void setDisplayName(String displayName) { this.displayName = displayName; } + public String getDepartmentName() { return departmentName; } + public void setDepartmentName(String departmentName) { this.departmentName = departmentName; } + public String getPasswordHash() { return passwordHash; } + public void setPasswordHash(String passwordHash) { this.passwordHash = passwordHash; } + public boolean isEnabled() { return enabled; } + public void setEnabled(boolean enabled) { this.enabled = enabled; } + public boolean isMustChangePassword() { return mustChangePassword; } + public void setMustChangePassword(boolean mustChangePassword) { this.mustChangePassword = mustChangePassword; } + public int getFailedLoginCount() { return failedLoginCount; } + public void setFailedLoginCount(int failedLoginCount) { this.failedLoginCount = failedLoginCount; } + public LocalDateTime getLockedUntil() { return lockedUntil; } + public void setLockedUntil(LocalDateTime lockedUntil) { this.lockedUntil = lockedUntil; } + public long getVersion() { return version; } + public void setVersion(long version) { this.version = version; } +} diff --git a/backend/src/main/java/com/kaidi/finance/iam/infrastructure/UserAccountMapper.java b/backend/src/main/java/com/kaidi/finance/iam/infrastructure/UserAccountMapper.java new file mode 100644 index 0000000..77f9e87 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/iam/infrastructure/UserAccountMapper.java @@ -0,0 +1,86 @@ +package com.kaidi.finance.iam.infrastructure; + +import com.kaidi.finance.iam.domain.RoleAssignment; +import com.kaidi.finance.iam.domain.UserAccount; +import java.util.List; +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +@org.apache.ibatis.annotations.Mapper +public interface UserAccountMapper { + + @Select(""" + SELECT id, public_id, username, display_name, department_name, password_hash, enabled, + must_change_password, failed_login_count, locked_until, version + FROM iam_user WHERE username = #{username} + """) + UserAccount findByUsername(String username); + + @Select(""" + SELECT r.id, r.code, r.name, r.description + FROM iam_role r + JOIN iam_user_role ur ON ur.role_id = r.id + WHERE ur.user_id = #{userId} AND r.enabled = TRUE + ORDER BY r.sort_order, r.id + """) + List findRoles(long userId); + + @Select(""" + SELECT p.code + FROM iam_permission p + JOIN iam_role_permission rp ON rp.permission_id = p.id + JOIN iam_role r ON r.id = rp.role_id + JOIN iam_user_role ur ON ur.role_id = r.id + WHERE ur.user_id = #{userId} AND r.code = #{roleCode} AND r.enabled = TRUE + ORDER BY p.code + """) + List findPermissions(@Param("userId") long userId, @Param("roleCode") String roleCode); + + @Select(""" + SELECT COUNT(*) + FROM iam_permission p + JOIN iam_role_permission rp ON rp.permission_id = p.id + JOIN iam_role r ON r.id = rp.role_id + JOIN iam_user_role ur ON ur.role_id = r.id + WHERE ur.user_id = #{userId} AND r.code = #{roleCode} AND p.code = #{permissionCode} + AND r.enabled = TRUE + """) + int countPermission(@Param("userId") long userId, @Param("roleCode") String roleCode, + @Param("permissionCode") String permissionCode); + + @Update(""" + UPDATE iam_user + SET failed_login_count = failed_login_count + 1, + locked_until = CASE WHEN failed_login_count >= 5 + THEN DATE_ADD(UTC_TIMESTAMP(3), INTERVAL 15 MINUTE) ELSE locked_until END, + version = version + 1 + WHERE id = #{userId} + """) + int recordFailedLogin(long userId); + + @Update(""" + UPDATE iam_user + SET failed_login_count = 0, locked_until = NULL, version = version + 1 + WHERE id = #{userId} + """) + int clearFailedLogins(long userId); + + @Update(""" + UPDATE iam_user + SET password_hash = #{passwordHash}, must_change_password = FALSE, version = version + 1 + WHERE id = #{userId} AND version = #{version} + """) + int updatePassword(@Param("userId") long userId, @Param("version") long version, + @Param("passwordHash") String passwordHash); + + @Insert(""" + INSERT INTO iam_user (public_id, username, display_name, department_name, password_hash, + enabled, must_change_password) + VALUES (#{publicId}, #{username}, #{displayName}, #{departmentName}, #{passwordHash}, TRUE, TRUE) + """) + int insertUser(@Param("publicId") String publicId, @Param("username") String username, + @Param("displayName") String displayName, @Param("departmentName") String departmentName, + @Param("passwordHash") String passwordHash); +} diff --git a/backend/src/main/java/com/kaidi/finance/masterdata/api/BankAccountCreateRequest.java b/backend/src/main/java/com/kaidi/finance/masterdata/api/BankAccountCreateRequest.java new file mode 100644 index 0000000..70668c3 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/masterdata/api/BankAccountCreateRequest.java @@ -0,0 +1,18 @@ +package com.kaidi.finance.masterdata.api; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.time.LocalDate; + +public record BankAccountCreateRequest( + @NotBlank String ownerType, + @NotBlank String ownerId, + @NotBlank String accountCategory, + @NotBlank @Size(max = 200) String accountName, + @NotBlank @Size(max = 200) String bankName, + @NotBlank @Size(min = 6, max = 64) String accountNo, + @NotNull LocalDate validFrom, + LocalDate validTo +) { +} diff --git a/backend/src/main/java/com/kaidi/finance/masterdata/api/BankAccountUpdateRequest.java b/backend/src/main/java/com/kaidi/finance/masterdata/api/BankAccountUpdateRequest.java new file mode 100644 index 0000000..07fa42b --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/masterdata/api/BankAccountUpdateRequest.java @@ -0,0 +1,18 @@ +package com.kaidi.finance.masterdata.api; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.PositiveOrZero; +import jakarta.validation.constraints.Size; +import java.time.LocalDate; + +public record BankAccountUpdateRequest( + @NotBlank String accountCategory, + @NotBlank @Size(max = 200) String accountName, + @NotBlank @Size(max = 200) String bankName, + @Size(min = 6, max = 64) String accountNo, + @NotNull LocalDate validFrom, + LocalDate validTo, + @NotNull @PositiveOrZero Long version +) { +} diff --git a/backend/src/main/java/com/kaidi/finance/masterdata/api/BankAccountView.java b/backend/src/main/java/com/kaidi/finance/masterdata/api/BankAccountView.java new file mode 100644 index 0000000..f7c3b8f --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/masterdata/api/BankAccountView.java @@ -0,0 +1,24 @@ +package com.kaidi.finance.masterdata.api; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.Set; + +public record BankAccountView( + String publicId, + String ownerType, + MasterDataReferenceView owner, + String accountCategory, + String accountName, + String bankName, + String maskedAccountNo, + int versionNo, + LocalDate validFrom, + LocalDate validTo, + String status, + long version, + LocalDateTime createdAt, + LocalDateTime updatedAt, + Set allowedActions +) { +} diff --git a/backend/src/main/java/com/kaidi/finance/masterdata/api/CompanyCreateRequest.java b/backend/src/main/java/com/kaidi/finance/masterdata/api/CompanyCreateRequest.java new file mode 100644 index 0000000..94dcc2a --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/masterdata/api/CompanyCreateRequest.java @@ -0,0 +1,11 @@ +package com.kaidi.finance.masterdata.api; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record CompanyCreateRequest( + @NotBlank @Size(max = 64) String businessNo, + @NotBlank @Size(max = 200) String name, + @Size(max = 64) String taxNo +) { +} diff --git a/backend/src/main/java/com/kaidi/finance/masterdata/api/CompanyUpdateRequest.java b/backend/src/main/java/com/kaidi/finance/masterdata/api/CompanyUpdateRequest.java new file mode 100644 index 0000000..21db551 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/masterdata/api/CompanyUpdateRequest.java @@ -0,0 +1,13 @@ +package com.kaidi.finance.masterdata.api; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.PositiveOrZero; +import jakarta.validation.constraints.Size; + +public record CompanyUpdateRequest( + @NotBlank @Size(max = 200) String name, + @Size(max = 64) String taxNo, + @NotNull @PositiveOrZero Long version +) { +} diff --git a/backend/src/main/java/com/kaidi/finance/masterdata/api/CompanyView.java b/backend/src/main/java/com/kaidi/finance/masterdata/api/CompanyView.java new file mode 100644 index 0000000..a36d3e5 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/masterdata/api/CompanyView.java @@ -0,0 +1,9 @@ +package com.kaidi.finance.masterdata.api; + +import java.time.LocalDateTime; +import java.util.Set; + +public record CompanyView(String publicId, String businessNo, String name, String taxNo, String status, + long version, LocalDateTime createdAt, LocalDateTime updatedAt, + Set allowedActions) { +} diff --git a/backend/src/main/java/com/kaidi/finance/masterdata/api/ContractCreateRequest.java b/backend/src/main/java/com/kaidi/finance/masterdata/api/ContractCreateRequest.java new file mode 100644 index 0000000..8fea560 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/masterdata/api/ContractCreateRequest.java @@ -0,0 +1,16 @@ +package com.kaidi.finance.masterdata.api; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +public record ContractCreateRequest( + @NotBlank String companyId, + @NotBlank String projectId, + @NotBlank String counterpartyId, + @NotBlank @Size(max = 64) String businessNo, + @NotBlank @Size(max = 200) String name, + @NotBlank @Pattern(regexp = "\\d{1,18}(\\.\\d{1,2})?") String originalAmount, + @NotBlank @Pattern(regexp = "[A-Za-z]{3}") String currency +) { +} diff --git a/backend/src/main/java/com/kaidi/finance/masterdata/api/ContractUpdateRequest.java b/backend/src/main/java/com/kaidi/finance/masterdata/api/ContractUpdateRequest.java new file mode 100644 index 0000000..3553fd2 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/masterdata/api/ContractUpdateRequest.java @@ -0,0 +1,18 @@ +package com.kaidi.finance.masterdata.api; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.PositiveOrZero; +import jakarta.validation.constraints.Size; + +public record ContractUpdateRequest( + @NotBlank String companyId, + @NotBlank String projectId, + @NotBlank String counterpartyId, + @NotBlank @Size(max = 200) String name, + @NotBlank @Pattern(regexp = "\\d{1,18}(\\.\\d{1,2})?") String originalAmount, + @NotBlank @Pattern(regexp = "[A-Za-z]{3}") String currency, + @NotNull @PositiveOrZero Long version +) { +} diff --git a/backend/src/main/java/com/kaidi/finance/masterdata/api/ContractView.java b/backend/src/main/java/com/kaidi/finance/masterdata/api/ContractView.java new file mode 100644 index 0000000..8a3bca8 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/masterdata/api/ContractView.java @@ -0,0 +1,23 @@ +package com.kaidi.finance.masterdata.api; + +import java.time.LocalDateTime; +import java.util.Set; + +public record ContractView( + String publicId, + MasterDataReferenceView company, + MasterDataReferenceView project, + MasterDataReferenceView counterparty, + String businessNo, + String name, + String originalAmount, + String approvedChangeAmount, + String settlementAmount, + String currency, + String status, + long version, + LocalDateTime createdAt, + LocalDateTime updatedAt, + Set allowedActions +) { +} diff --git a/backend/src/main/java/com/kaidi/finance/masterdata/api/CounterpartyCreateRequest.java b/backend/src/main/java/com/kaidi/finance/masterdata/api/CounterpartyCreateRequest.java new file mode 100644 index 0000000..6a147cb --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/masterdata/api/CounterpartyCreateRequest.java @@ -0,0 +1,14 @@ +package com.kaidi.finance.masterdata.api; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record CounterpartyCreateRequest( + @NotBlank @Size(max = 64) String businessNo, + @NotBlank String type, + @NotBlank @Size(max = 200) String name, + @Size(max = 64) String taxNo, + @Size(max = 100) String contactName, + @Size(max = 32) String contactPhone +) { +} diff --git a/backend/src/main/java/com/kaidi/finance/masterdata/api/CounterpartyUpdateRequest.java b/backend/src/main/java/com/kaidi/finance/masterdata/api/CounterpartyUpdateRequest.java new file mode 100644 index 0000000..e4179e0 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/masterdata/api/CounterpartyUpdateRequest.java @@ -0,0 +1,16 @@ +package com.kaidi.finance.masterdata.api; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.PositiveOrZero; +import jakarta.validation.constraints.Size; + +public record CounterpartyUpdateRequest( + @NotBlank String type, + @NotBlank @Size(max = 200) String name, + @Size(max = 64) String taxNo, + @Size(max = 100) String contactName, + @Size(max = 32) String contactPhone, + @NotNull @PositiveOrZero Long version +) { +} diff --git a/backend/src/main/java/com/kaidi/finance/masterdata/api/CounterpartyView.java b/backend/src/main/java/com/kaidi/finance/masterdata/api/CounterpartyView.java new file mode 100644 index 0000000..a3ef026 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/masterdata/api/CounterpartyView.java @@ -0,0 +1,9 @@ +package com.kaidi.finance.masterdata.api; + +import java.time.LocalDateTime; +import java.util.Set; + +public record CounterpartyView(String publicId, String businessNo, String type, String name, String taxNo, + String contactName, String contactPhone, String status, long version, + LocalDateTime createdAt, LocalDateTime updatedAt, Set allowedActions) { +} diff --git a/backend/src/main/java/com/kaidi/finance/masterdata/api/DictionaryCreateRequest.java b/backend/src/main/java/com/kaidi/finance/masterdata/api/DictionaryCreateRequest.java new file mode 100644 index 0000000..98867c3 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/masterdata/api/DictionaryCreateRequest.java @@ -0,0 +1,14 @@ +package com.kaidi.finance.masterdata.api; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record DictionaryCreateRequest( + @NotBlank @Size(max = 64) String code, + @NotBlank @Size(max = 128) String name, + String parentId, + String accountType, + Boolean auxiliaryRequired, + @Size(max = 16) String rate +) { +} diff --git a/backend/src/main/java/com/kaidi/finance/masterdata/api/DictionaryUpdateRequest.java b/backend/src/main/java/com/kaidi/finance/masterdata/api/DictionaryUpdateRequest.java new file mode 100644 index 0000000..d997e5e --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/masterdata/api/DictionaryUpdateRequest.java @@ -0,0 +1,16 @@ +package com.kaidi.finance.masterdata.api; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.PositiveOrZero; +import jakarta.validation.constraints.Size; + +public record DictionaryUpdateRequest( + @NotBlank @Size(max = 128) String name, + String parentId, + String accountType, + Boolean auxiliaryRequired, + @Size(max = 16) String rate, + @NotNull @PositiveOrZero Long version +) { +} diff --git a/backend/src/main/java/com/kaidi/finance/masterdata/api/DictionaryView.java b/backend/src/main/java/com/kaidi/finance/masterdata/api/DictionaryView.java new file mode 100644 index 0000000..73b633f --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/masterdata/api/DictionaryView.java @@ -0,0 +1,21 @@ +package com.kaidi.finance.masterdata.api; + +import java.time.LocalDateTime; +import java.util.Set; + +public record DictionaryView( + String publicId, + String resource, + String code, + String name, + MasterDataReferenceView parent, + String accountType, + Boolean auxiliaryRequired, + String rate, + String status, + long version, + LocalDateTime createdAt, + LocalDateTime updatedAt, + Set allowedActions +) { +} diff --git a/backend/src/main/java/com/kaidi/finance/masterdata/api/LegacyIdentifierCreateRequest.java b/backend/src/main/java/com/kaidi/finance/masterdata/api/LegacyIdentifierCreateRequest.java new file mode 100644 index 0000000..aaae6f4 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/masterdata/api/LegacyIdentifierCreateRequest.java @@ -0,0 +1,10 @@ +package com.kaidi.finance.masterdata.api; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record LegacyIdentifierCreateRequest( + @NotBlank @Size(max = 64) String sourceSystem, + @NotBlank @Size(max = 128) String legacyCode +) { +} diff --git a/backend/src/main/java/com/kaidi/finance/masterdata/api/LegacyIdentifierResolutionView.java b/backend/src/main/java/com/kaidi/finance/masterdata/api/LegacyIdentifierResolutionView.java new file mode 100644 index 0000000..0fd9481 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/masterdata/api/LegacyIdentifierResolutionView.java @@ -0,0 +1,10 @@ +package com.kaidi.finance.masterdata.api; + +public record LegacyIdentifierResolutionView( + String mappingPublicId, + String resource, + String sourceSystem, + String legacyCode, + String targetPublicId +) { +} diff --git a/backend/src/main/java/com/kaidi/finance/masterdata/api/LegacyIdentifierView.java b/backend/src/main/java/com/kaidi/finance/masterdata/api/LegacyIdentifierView.java new file mode 100644 index 0000000..87fd73f --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/masterdata/api/LegacyIdentifierView.java @@ -0,0 +1,18 @@ +package com.kaidi.finance.masterdata.api; + +import java.time.LocalDateTime; +import java.util.Set; + +public record LegacyIdentifierView( + String publicId, + String resource, + String sourceSystem, + String legacyCode, + String targetPublicId, + String status, + long version, + LocalDateTime createdAt, + LocalDateTime updatedAt, + Set allowedActions +) { +} diff --git a/backend/src/main/java/com/kaidi/finance/masterdata/api/MasterDataController.java b/backend/src/main/java/com/kaidi/finance/masterdata/api/MasterDataController.java new file mode 100644 index 0000000..dc63ec0 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/masterdata/api/MasterDataController.java @@ -0,0 +1,385 @@ +package com.kaidi.finance.masterdata.api; + +import com.kaidi.finance.masterdata.application.CatalogMasterDataApplicationService; +import com.kaidi.finance.masterdata.application.LegacyIdentifierApplicationService; +import com.kaidi.finance.masterdata.application.MasterDataApplicationService; +import com.kaidi.finance.masterdata.application.MasterDataVersionApplicationService; +import com.kaidi.finance.shared.api.ApiResponse; +import com.kaidi.finance.shared.api.PageResult; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import java.util.LinkedHashMap; +import java.util.Map; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/masterdata") +public class MasterDataController { + + private final MasterDataApplicationService service; + private final CatalogMasterDataApplicationService catalogService; + private final MasterDataVersionApplicationService versionService; + private final LegacyIdentifierApplicationService legacyIdentifierService; + + public MasterDataController(MasterDataApplicationService service, + CatalogMasterDataApplicationService catalogService, + MasterDataVersionApplicationService versionService, + LegacyIdentifierApplicationService legacyIdentifierService) { + this.service = service; + this.catalogService = catalogService; + this.versionService = versionService; + this.legacyIdentifierService = legacyIdentifierService; + } + + @PostMapping("/companies") + @PreAuthorize("@authorizationService.hasPermission('masterdata:company:create')") + public ApiResponse createCompany(@Valid @RequestBody CompanyCreateRequest request) { + return ApiResponse.ok(service.createCompany(request)); + } + + @GetMapping("/companies") + @PreAuthorize("@authorizationService.hasPermission('masterdata:company:view')") + public ApiResponse> listCompanies( + @RequestParam(required = false) String keyword, + @RequestParam(required = false) String status, + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int size) { + PageResult result = service.listCompanies(keyword, status, page, size); + return ApiResponse.ok(result.items(), result.meta()); + } + + @GetMapping("/companies/{publicId}") + @PreAuthorize("@authorizationService.hasPermission('masterdata:company:view')") + public ApiResponse getCompany(@PathVariable String publicId) { + return ApiResponse.ok(service.getCompany(publicId)); + } + + @PatchMapping("/companies/{publicId}") + @PreAuthorize("@authorizationService.hasPermission('masterdata:company:edit')") + public ApiResponse updateCompany(@PathVariable String publicId, + @Valid @RequestBody CompanyUpdateRequest request) { + return ApiResponse.ok(service.updateCompany(publicId, request)); + } + + @PostMapping("/companies/{publicId}/submit") + @PreAuthorize("@authorizationService.hasPermission('masterdata:company:submit')") + public ApiResponse submitCompany(@PathVariable String publicId, + @Valid @RequestBody VersionCommandRequest request) { + return ApiResponse.ok(service.submitCompany(publicId, request.version())); + } + + @PostMapping("/companies/{publicId}/review") + @PreAuthorize("@authorizationService.hasPermission('masterdata:company:review')") + public ApiResponse reviewCompany(@PathVariable String publicId, + @Valid @RequestBody ReviewRequest request) { + return ApiResponse.ok(service.reviewCompany(publicId, request)); + } + + @PostMapping("/companies/{publicId}/disable") + @PreAuthorize("@authorizationService.hasPermission('masterdata:company:disable')") + public ApiResponse disableCompany(@PathVariable String publicId, + @Valid @RequestBody VersionCommandRequest request) { + return ApiResponse.ok(service.disableCompany(publicId, request.version())); + } + + @PostMapping("/counterparties") + @PreAuthorize("@authorizationService.hasPermission('masterdata:counterparty:create')") + public ApiResponse createCounterparty(@Valid @RequestBody CounterpartyCreateRequest request) { + return ApiResponse.ok(service.createCounterparty(request)); + } + + @GetMapping("/counterparties") + @PreAuthorize("@authorizationService.hasPermission('masterdata:counterparty:view')") + public ApiResponse> listCounterparties( + @RequestParam(required = false) String keyword, + @RequestParam(required = false) String status, + @RequestParam(required = false) String type, + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int size) { + PageResult result = service.listCounterparties(keyword, status, type, page, size); + return ApiResponse.ok(result.items(), result.meta()); + } + + @GetMapping("/counterparties/{publicId}") + @PreAuthorize("@authorizationService.hasPermission('masterdata:counterparty:view')") + public ApiResponse getCounterparty(@PathVariable String publicId) { + return ApiResponse.ok(service.getCounterparty(publicId)); + } + + @PatchMapping("/counterparties/{publicId}") + @PreAuthorize("@authorizationService.hasPermission('masterdata:counterparty:edit')") + public ApiResponse updateCounterparty(@PathVariable String publicId, + @Valid @RequestBody CounterpartyUpdateRequest request) { + return ApiResponse.ok(service.updateCounterparty(publicId, request)); + } + + @PostMapping("/counterparties/{publicId}/submit") + @PreAuthorize("@authorizationService.hasPermission('masterdata:counterparty:submit')") + public ApiResponse submitCounterparty(@PathVariable String publicId, + @Valid @RequestBody VersionCommandRequest request) { + return ApiResponse.ok(service.submitCounterparty(publicId, request.version())); + } + + @PostMapping("/counterparties/{publicId}/review") + @PreAuthorize("@authorizationService.hasPermission('masterdata:counterparty:review')") + public ApiResponse reviewCounterparty(@PathVariable String publicId, + @Valid @RequestBody ReviewRequest request) { + return ApiResponse.ok(service.reviewCounterparty(publicId, request)); + } + + @PostMapping("/counterparties/{publicId}/disable") + @PreAuthorize("@authorizationService.hasPermission('masterdata:counterparty:disable')") + public ApiResponse disableCounterparty(@PathVariable String publicId, + @Valid @RequestBody VersionCommandRequest request) { + return ApiResponse.ok(service.disableCounterparty(publicId, request.version())); + } + + @GetMapping("/references") + @PreAuthorize("@authorizationService.hasPermission('masterdata:company:view')") + public ApiResponse references() { + return ApiResponse.ok(catalogService.references()); + } + + @PostMapping("/bank-accounts") + @PreAuthorize("@authorizationService.hasPermission('masterdata:bank-account:create')") + public ApiResponse createBankAccount( + @Valid @RequestBody BankAccountCreateRequest request) { + return ApiResponse.ok(catalogService.createBankAccount(request)); + } + + @GetMapping("/bank-accounts") + @PreAuthorize("@authorizationService.hasPermission('masterdata:bank-account:view')") + public ApiResponse> listBankAccounts( + @RequestParam(required = false) String keyword, + @RequestParam(required = false) String status, + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int size) { + PageResult result = catalogService.listBankAccounts(keyword, status, page, size); + return ApiResponse.ok(result.items(), result.meta()); + } + + @GetMapping("/bank-accounts/{publicId}") + @PreAuthorize("@authorizationService.hasPermission('masterdata:bank-account:view')") + public ApiResponse getBankAccount(@PathVariable String publicId) { + return ApiResponse.ok(catalogService.getBankAccount(publicId)); + } + + @PatchMapping("/bank-accounts/{publicId}") + @PreAuthorize("@authorizationService.hasPermission('masterdata:bank-account:edit')") + public ApiResponse updateBankAccount( + @PathVariable String publicId, + @Valid @RequestBody BankAccountUpdateRequest request) { + return ApiResponse.ok(catalogService.updateBankAccount(publicId, request)); + } + + @PostMapping("/bank-accounts/{publicId}/submit") + @PreAuthorize("@authorizationService.hasPermission('masterdata:bank-account:submit')") + public ApiResponse submitBankAccount( + @PathVariable String publicId, + @Valid @RequestBody VersionCommandRequest request) { + return ApiResponse.ok(catalogService.submitBankAccount(publicId, request.version())); + } + + @PostMapping("/bank-accounts/{publicId}/review") + @PreAuthorize("@authorizationService.hasPermission('masterdata:bank-account:review')") + public ApiResponse reviewBankAccount( + @PathVariable String publicId, + @Valid @RequestBody ReviewRequest request) { + return ApiResponse.ok(catalogService.reviewBankAccount(publicId, request)); + } + + @PostMapping("/bank-accounts/{publicId}/disable") + @PreAuthorize("@authorizationService.hasPermission('masterdata:bank-account:disable')") + public ApiResponse disableBankAccount( + @PathVariable String publicId, + @Valid @RequestBody VersionCommandRequest request) { + return ApiResponse.ok(catalogService.disableBankAccount(publicId, request.version())); + } + + @PostMapping("/contracts") + @PreAuthorize("@authorizationService.hasPermission('masterdata:contract:create')") + public ApiResponse createContract(@Valid @RequestBody ContractCreateRequest request) { + return ApiResponse.ok(catalogService.createContract(request)); + } + + @GetMapping("/contracts") + @PreAuthorize("@authorizationService.hasPermission('masterdata:contract:view')") + public ApiResponse> listContracts( + @RequestParam(required = false) String keyword, + @RequestParam(required = false) String status, + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int size) { + PageResult result = catalogService.listContracts(keyword, status, page, size); + return ApiResponse.ok(result.items(), result.meta()); + } + + @GetMapping("/contracts/{publicId}") + @PreAuthorize("@authorizationService.hasPermission('masterdata:contract:view')") + public ApiResponse getContract(@PathVariable String publicId) { + return ApiResponse.ok(catalogService.getContract(publicId)); + } + + @PatchMapping("/contracts/{publicId}") + @PreAuthorize("@authorizationService.hasPermission('masterdata:contract:edit')") + public ApiResponse updateContract( + @PathVariable String publicId, + @Valid @RequestBody ContractUpdateRequest request) { + return ApiResponse.ok(catalogService.updateContract(publicId, request)); + } + + @PostMapping("/contracts/{publicId}/submit") + @PreAuthorize("@authorizationService.hasPermission('masterdata:contract:submit')") + public ApiResponse submitContract( + @PathVariable String publicId, + @Valid @RequestBody VersionCommandRequest request) { + return ApiResponse.ok(catalogService.submitContract(publicId, request.version())); + } + + @PostMapping("/contracts/{publicId}/review") + @PreAuthorize("@authorizationService.hasPermission('masterdata:contract:review')") + public ApiResponse reviewContract( + @PathVariable String publicId, + @Valid @RequestBody ReviewRequest request) { + return ApiResponse.ok(catalogService.reviewContract(publicId, request)); + } + + @PostMapping("/contracts/{publicId}/disable") + @PreAuthorize("@authorizationService.hasPermission('masterdata:contract:disable')") + public ApiResponse disableContract( + @PathVariable String publicId, + @Valid @RequestBody VersionCommandRequest request) { + return ApiResponse.ok(catalogService.disableContract(publicId, request.version())); + } + + @PostMapping("/{resource:departments|cost-categories|accounts|tax-rates}") + @PreAuthorize("@authorizationService.hasPermission('masterdata:dictionary:create')") + public ApiResponse createDictionary( + @PathVariable String resource, + @Valid @RequestBody DictionaryCreateRequest request) { + return ApiResponse.ok(catalogService.createDictionary(resource, request)); + } + + @GetMapping("/{resource:departments|cost-categories|accounts|tax-rates}") + @PreAuthorize("@authorizationService.hasPermission('masterdata:dictionary:view')") + public ApiResponse> listDictionaries( + @PathVariable String resource, + @RequestParam(required = false) String keyword, + @RequestParam(required = false) String status, + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int size) { + PageResult result = catalogService.listDictionaries(resource, keyword, status, page, size); + return ApiResponse.ok(result.items(), result.meta()); + } + + @GetMapping("/{resource:departments|cost-categories|accounts|tax-rates}/{publicId}") + @PreAuthorize("@authorizationService.hasPermission('masterdata:dictionary:view')") + public ApiResponse getDictionary( + @PathVariable String resource, + @PathVariable String publicId) { + return ApiResponse.ok(catalogService.getDictionary(resource, publicId)); + } + + @PatchMapping("/{resource:departments|cost-categories|accounts|tax-rates}/{publicId}") + @PreAuthorize("@authorizationService.hasPermission('masterdata:dictionary:edit')") + public ApiResponse updateDictionary( + @PathVariable String resource, + @PathVariable String publicId, + @Valid @RequestBody DictionaryUpdateRequest request) { + return ApiResponse.ok(catalogService.updateDictionary(resource, publicId, request)); + } + + @PostMapping("/{resource:departments|cost-categories|accounts|tax-rates}/{publicId}/submit") + @PreAuthorize("@authorizationService.hasPermission('masterdata:dictionary:submit')") + public ApiResponse submitDictionary( + @PathVariable String resource, + @PathVariable String publicId, + @Valid @RequestBody VersionCommandRequest request) { + return ApiResponse.ok(catalogService.submitDictionary(resource, publicId, request.version())); + } + + @PostMapping("/{resource:departments|cost-categories|accounts|tax-rates}/{publicId}/review") + @PreAuthorize("@authorizationService.hasPermission('masterdata:dictionary:review')") + public ApiResponse reviewDictionary( + @PathVariable String resource, + @PathVariable String publicId, + @Valid @RequestBody ReviewRequest request) { + return ApiResponse.ok(catalogService.reviewDictionary(resource, publicId, request)); + } + + @PostMapping("/{resource:departments|cost-categories|accounts|tax-rates}/{publicId}/disable") + @PreAuthorize("@authorizationService.hasPermission('masterdata:dictionary:disable')") + public ApiResponse disableDictionary( + @PathVariable String resource, + @PathVariable String publicId, + @Valid @RequestBody VersionCommandRequest request) { + return ApiResponse.ok(catalogService.disableDictionary(resource, publicId, request.version())); + } + + @GetMapping("/{resource:companies|departments|counterparties|bank-accounts|contracts|cost-categories|accounts|tax-rates}/{publicId}/versions") + @Operation(operationId = "listMasterDataVersions", summary = "查询主数据版本记录") + @PreAuthorize("@masterDataVersionApplicationService.canView(#resource)") + public ApiResponse> listMasterDataVersions( + @PathVariable String resource, + @PathVariable String publicId, + @RequestParam(defaultValue = "1") int page, + @Parameter(schema = @Schema(type = "integer", format = "int32", allowableValues = {"20", "50", "100"})) + @RequestParam(defaultValue = "20") int size) { + PageResult result = versionService.listVersions(resource, publicId, page, size); + return ApiResponse.ok(result.items(), result.meta()); + } + + @GetMapping("/{resource:companies|departments|projects|counterparties|bank-accounts|contracts|cost-categories|accounts|tax-rates}/{publicId}/legacy-identifiers") + @PreAuthorize("@legacyIdentifierApplicationService.canView(#resource)") + public ApiResponse> listLegacyIdentifiers( + @PathVariable String resource, + @PathVariable String publicId, + @RequestParam(required = false) String status, + @RequestParam(required = false) String keyword, + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int size) { + LegacyIdentifierApplicationService.MappingPage result = legacyIdentifierService.list( + resource, publicId, status, keyword, page, size); + Map meta = new LinkedHashMap<>(result.page().meta()); + meta.put("allowedActions", result.allowedActions()); + return ApiResponse.ok(result.page().items(), meta); + } + + @PostMapping("/{resource:companies|departments|projects|counterparties|bank-accounts|contracts|cost-categories|accounts|tax-rates}/{publicId}/legacy-identifiers") + @PreAuthorize("@legacyIdentifierApplicationService.canEdit(#resource)") + public ApiResponse createLegacyIdentifier( + @PathVariable String resource, + @PathVariable String publicId, + @Valid @RequestBody LegacyIdentifierCreateRequest request) { + return ApiResponse.ok(legacyIdentifierService.create(resource, publicId, request)); + } + + @GetMapping("/{resource:companies|departments|projects|counterparties|bank-accounts|contracts|cost-categories|accounts|tax-rates}/legacy-identifiers/resolve") + @PreAuthorize("@legacyIdentifierApplicationService.canView(#resource)") + public ApiResponse resolveLegacyIdentifier( + @PathVariable String resource, + @RequestParam String sourceSystem, + @RequestParam String legacyCode) { + return ApiResponse.ok(legacyIdentifierService.resolve(resource, sourceSystem, legacyCode)); + } + + @PostMapping("/{resource:companies|departments|projects|counterparties|bank-accounts|contracts|cost-categories|accounts|tax-rates}/{publicId}/legacy-identifiers/{mappingPublicId}/disable") + @PreAuthorize("@legacyIdentifierApplicationService.canEdit(#resource)") + public ApiResponse disableLegacyIdentifier( + @PathVariable String resource, + @PathVariable String publicId, + @PathVariable String mappingPublicId, + @Valid @RequestBody VersionCommandRequest request) { + return ApiResponse.ok(legacyIdentifierService.disable(resource, publicId, mappingPublicId, + request.version())); + } +} diff --git a/backend/src/main/java/com/kaidi/finance/masterdata/api/MasterDataReferenceOptionsView.java b/backend/src/main/java/com/kaidi/finance/masterdata/api/MasterDataReferenceOptionsView.java new file mode 100644 index 0000000..a8b31f6 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/masterdata/api/MasterDataReferenceOptionsView.java @@ -0,0 +1,11 @@ +package com.kaidi.finance.masterdata.api; + +import java.util.List; + +public record MasterDataReferenceOptionsView( + List companies, + List projects, + List counterparties, + List costCategories +) { +} diff --git a/backend/src/main/java/com/kaidi/finance/masterdata/api/MasterDataReferenceView.java b/backend/src/main/java/com/kaidi/finance/masterdata/api/MasterDataReferenceView.java new file mode 100644 index 0000000..5ae8040 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/masterdata/api/MasterDataReferenceView.java @@ -0,0 +1,4 @@ +package com.kaidi.finance.masterdata.api; + +public record MasterDataReferenceView(String publicId, String code, String name) { +} diff --git a/backend/src/main/java/com/kaidi/finance/masterdata/api/MasterDataVersionView.java b/backend/src/main/java/com/kaidi/finance/masterdata/api/MasterDataVersionView.java new file mode 100644 index 0000000..a9b4ce8 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/masterdata/api/MasterDataVersionView.java @@ -0,0 +1,16 @@ +package com.kaidi.finance.masterdata.api; + +import com.fasterxml.jackson.databind.JsonNode; +import java.time.LocalDateTime; + +public record MasterDataVersionView( + String auditId, + long version, + String status, + String actionCode, + String actorName, + LocalDateTime changedAt, + JsonNode before, + JsonNode after +) { +} diff --git a/backend/src/main/java/com/kaidi/finance/masterdata/api/ReviewRequest.java b/backend/src/main/java/com/kaidi/finance/masterdata/api/ReviewRequest.java new file mode 100644 index 0000000..be03225 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/masterdata/api/ReviewRequest.java @@ -0,0 +1,13 @@ +package com.kaidi.finance.masterdata.api; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.PositiveOrZero; +import jakarta.validation.constraints.Size; + +public record ReviewRequest( + @NotNull @PositiveOrZero Long version, + @NotBlank String decision, + @NotBlank @Size(min = 2, max = 1000) String opinion +) { +} diff --git a/backend/src/main/java/com/kaidi/finance/masterdata/api/VersionCommandRequest.java b/backend/src/main/java/com/kaidi/finance/masterdata/api/VersionCommandRequest.java new file mode 100644 index 0000000..a5612fc --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/masterdata/api/VersionCommandRequest.java @@ -0,0 +1,7 @@ +package com.kaidi.finance.masterdata.api; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.PositiveOrZero; + +public record VersionCommandRequest(@NotNull @PositiveOrZero Long version) { +} diff --git a/backend/src/main/java/com/kaidi/finance/masterdata/application/BankAccountProtector.java b/backend/src/main/java/com/kaidi/finance/masterdata/application/BankAccountProtector.java new file mode 100644 index 0000000..dc2f290 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/masterdata/application/BankAccountProtector.java @@ -0,0 +1,61 @@ +package com.kaidi.finance.masterdata.application; + +import com.kaidi.finance.shared.security.FieldEncryptionKey; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.util.HexFormat; +import java.util.Locale; +import javax.crypto.Cipher; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Component +public class BankAccountProtector { + + private static final int IV_BYTES = 12; + private static final int TAG_BITS = 128; + private final SecretKeySpec key; + private final SecureRandom secureRandom = new SecureRandom(); + + public BankAccountProtector( + @Value("${finance.crypto.field-key}") String encodedKey, + @Value("${finance.crypto.reject-default:false}") boolean rejectKnownDefault + ) { + byte[] decoded = FieldEncryptionKey.decode(encodedKey, rejectKnownDefault); + this.key = new SecretKeySpec(decoded, "AES"); + } + + public ProtectedAccount protect(String value) { + String normalized = normalize(value); + try { + byte[] iv = new byte[IV_BYTES]; + secureRandom.nextBytes(iv); + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(TAG_BITS, iv)); + byte[] encrypted = cipher.doFinal(normalized.getBytes(StandardCharsets.UTF_8)); + byte[] packed = ByteBuffer.allocate(iv.length + encrypted.length).put(iv).put(encrypted).array(); + String hash = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(normalized.getBytes(StandardCharsets.UTF_8))); + return new ProtectedAccount(packed, hash, "**** **** " + normalized.substring(normalized.length() - 4)); + } catch (GeneralSecurityException exception) { + throw new IllegalStateException("Bank account protection failed", exception); + } + } + + private String normalize(String value) { + String normalized = value == null ? "" : value.replaceAll("[\\s-]", "").toUpperCase(Locale.ROOT); + if (normalized.length() < 6 || normalized.length() > 64 || !normalized.matches("[0-9A-Z]+")) { + throw new IllegalArgumentException("银行账号只能包含数字或字母,去除空格后长度为 6 至 64 位"); + } + return normalized; + } + + public record ProtectedAccount(byte[] ciphertext, String hash, String masked) { + } +} diff --git a/backend/src/main/java/com/kaidi/finance/masterdata/application/CatalogMasterDataApplicationService.java b/backend/src/main/java/com/kaidi/finance/masterdata/application/CatalogMasterDataApplicationService.java new file mode 100644 index 0000000..6bb6e8d --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/masterdata/application/CatalogMasterDataApplicationService.java @@ -0,0 +1,852 @@ +package com.kaidi.finance.masterdata.application; + +import com.kaidi.finance.iam.domain.FinancePrincipal; +import com.kaidi.finance.masterdata.api.BankAccountCreateRequest; +import com.kaidi.finance.masterdata.api.BankAccountUpdateRequest; +import com.kaidi.finance.masterdata.api.BankAccountView; +import com.kaidi.finance.masterdata.api.ContractCreateRequest; +import com.kaidi.finance.masterdata.api.ContractUpdateRequest; +import com.kaidi.finance.masterdata.api.ContractView; +import com.kaidi.finance.masterdata.api.DictionaryCreateRequest; +import com.kaidi.finance.masterdata.api.DictionaryUpdateRequest; +import com.kaidi.finance.masterdata.api.DictionaryView; +import com.kaidi.finance.masterdata.api.MasterDataReferenceOptionsView; +import com.kaidi.finance.masterdata.api.MasterDataReferenceView; +import com.kaidi.finance.masterdata.api.ReviewRequest; +import com.kaidi.finance.masterdata.domain.BankAccountRecord; +import com.kaidi.finance.masterdata.domain.ContractMasterRecord; +import com.kaidi.finance.masterdata.domain.DictionaryRecord; +import com.kaidi.finance.masterdata.domain.MasterDataReferenceRecord; +import com.kaidi.finance.masterdata.infrastructure.CatalogMasterDataMapper; +import com.kaidi.finance.shared.api.BusinessException; +import com.kaidi.finance.shared.api.ErrorCode; +import com.kaidi.finance.shared.api.PageResult; +import com.kaidi.finance.shared.audit.AuditService; +import com.kaidi.finance.shared.id.UlidGenerator; +import com.kaidi.finance.shared.security.AuthorizationService; +import com.kaidi.finance.shared.security.IdentityContext; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.text.Normalizer; +import java.time.LocalDate; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class CatalogMasterDataApplicationService { + + private static final Set DICTIONARY_RESOURCES = Set.of( + "departments", "cost-categories", "accounts", "tax-rates"); + private static final Set STATUSES = Set.of( + "DRAFT", "REVIEWING", "RETURNED", "ACTIVE", "SETTLED", "DISABLED", "VOID"); + private static final Set ACCOUNT_CATEGORIES = Set.of("BASIC", "GENERAL", "SPECIAL", "PROJECT", "OTHER"); + private static final Set ACCOUNT_TYPES = Set.of("ASSET", "LIABILITY", "EQUITY", "INCOME", "COST", "EXPENSE"); + private static final String ULID_PATTERN = "[0-9A-HJKMNP-TV-Z]{26}"; + + private final CatalogMasterDataMapper mapper; + private final IdentityContext identityContext; + private final AuthorizationService authorizationService; + private final AuditService auditService; + private final UlidGenerator ulidGenerator; + private final BankAccountProtector accountProtector; + + public CatalogMasterDataApplicationService( + CatalogMasterDataMapper mapper, + IdentityContext identityContext, + AuthorizationService authorizationService, + AuditService auditService, + UlidGenerator ulidGenerator, + BankAccountProtector accountProtector + ) { + this.mapper = mapper; + this.identityContext = identityContext; + this.authorizationService = authorizationService; + this.auditService = auditService; + this.ulidGenerator = ulidGenerator; + this.accountProtector = accountProtector; + } + + @Transactional(readOnly = true) + public MasterDataReferenceOptionsView references() { + authorizationService.requirePermission("masterdata:company:view"); + authorizationService.requirePermission("project:project:view"); + authorizationService.requireGlobalScope("masterdata:counterparty:view"); + authorizationService.requireGlobalScope("masterdata:dictionary:view"); + FinancePrincipal actor = identityContext.requirePrincipal(); + String role = identityContext.requireActiveRole(); + return new MasterDataReferenceOptionsView( + referenceViews(mapper.listCompanyReferences(actor.userId(), role)), + referenceViews(mapper.listProjectReferences(actor.userId(), role)), + referenceViews(mapper.listCounterpartyReferences()), + referenceViews(mapper.listCostCategoryReferences()) + ); + } + + @Transactional(readOnly = true) + public PageResult listBankAccounts(String keyword, String status, int page, int size) { + authorizationService.requirePermission("masterdata:bank-account:view"); + Page request = page(page, size); + FinancePrincipal actor = identityContext.requirePrincipal(); + String role = identityContext.requireActiveRole(); + String safeKeyword = nullableTrim(keyword, 100); + String safeStatus = status(status); + long total = mapper.countBankAccounts(actor.userId(), role, safeKeyword, safeStatus); + List items = mapper.listBankAccounts(actor.userId(), role, safeKeyword, safeStatus, + request.size(), request.offset()).stream().map(this::bankAccountView).toList(); + return new PageResult<>(items, total, request.page(), request.size()); + } + + @Transactional(readOnly = true) + public BankAccountView getBankAccount(String publicId) { + BankAccountRecord record = requireBankAccount(publicId); + requireBankScope("masterdata:bank-account:view", record); + return bankAccountView(record); + } + + @Transactional + public BankAccountView createBankAccount(BankAccountCreateRequest request) { + Owner owner = owner(request.ownerType(), request.ownerId()); + requireOwnerScope("masterdata:bank-account:create", owner); + validateValidity(request.validFrom(), request.validTo()); + BankAccountProtector.ProtectedAccount protectedAccount = protect(request.accountNo()); + if (mapper.countDuplicateBankAccount(owner.companyId(), owner.counterpartyId(), + protectedAccount.hash(), null) > 0) { + throw duplicate("该主体已存在相同的有效银行账号"); + } + FinancePrincipal actor = identityContext.requirePrincipal(); + BankAccountRecord record = new BankAccountRecord(); + record.setPublicId(ulidGenerator.next()); + record.setOwnerType(owner.type()); + record.setCompanyId(owner.companyId()); + record.setCounterpartyId(owner.counterpartyId()); + record.setAccountCategory(accountCategory(request.accountCategory())); + record.setAccountName(request.accountName().trim()); + record.setBankName(request.bankName().trim()); + applyProtectedAccount(record, protectedAccount); + record.setVersionNo(mapper.nextBankAccountVersion(owner.companyId(), owner.counterpartyId())); + record.setValidFrom(request.validFrom().atStartOfDay()); + record.setValidTo(request.validTo() == null ? null : request.validTo().atStartOfDay()); + record.setCreatedBy(actor.userId()); + record.setUpdatedBy(actor.userId()); + try { + mapper.insertBankAccount(record); + } catch (DuplicateKeyException exception) { + throw duplicate("银行账户版本已存在"); + } + BankAccountView view = bankAccountView(mapper.findBankAccount(record.getPublicId())); + auditService.record("MASTERDATA_BANK_ACCOUNT_CREATE", "BANK_ACCOUNT", record.getPublicId(), + "SUCCESS", null, null, view); + return view; + } + + @Transactional + public BankAccountView updateBankAccount(String publicId, BankAccountUpdateRequest request) { + BankAccountRecord current = requireBankAccount(publicId); + requireBankScope("masterdata:bank-account:edit", current); + requireVersion(current.getVersion(), request.version()); + requireState(current.getStatus(), Set.of("DRAFT", "RETURNED")); + validateValidity(request.validFrom(), request.validTo()); + BankAccountView before = bankAccountView(current); + current.setAccountCategory(accountCategory(request.accountCategory())); + current.setAccountName(request.accountName().trim()); + current.setBankName(request.bankName().trim()); + if (request.accountNo() != null && !request.accountNo().isBlank()) { + BankAccountProtector.ProtectedAccount protectedAccount = protect(request.accountNo()); + if (mapper.countDuplicateBankAccount(current.getCompanyId(), current.getCounterpartyId(), + protectedAccount.hash(), current.getId()) > 0) { + throw duplicate("该主体已存在相同的有效银行账号"); + } + applyProtectedAccount(current, protectedAccount); + } + current.setValidFrom(request.validFrom().atStartOfDay()); + current.setValidTo(request.validTo() == null ? null : request.validTo().atStartOfDay()); + current.setUpdatedBy(identityContext.requirePrincipal().userId()); + if (mapper.updateBankAccount(current) != 1) { + throw conflict(); + } + BankAccountView after = bankAccountView(mapper.findBankAccount(publicId)); + auditService.record("MASTERDATA_BANK_ACCOUNT_UPDATE", "BANK_ACCOUNT", publicId, + "SUCCESS", null, before, after); + return after; + } + + @Transactional + public BankAccountView submitBankAccount(String publicId, long version) { + BankAccountRecord current = requireBankAccount(publicId); + requireBankScope("masterdata:bank-account:submit", current); + requireVersion(current.getVersion(), version); + requireState(current.getStatus(), Set.of("DRAFT", "RETURNED")); + BankAccountView before = bankAccountView(current); + long actorId = identityContext.requirePrincipal().userId(); + if (mapper.submitBankAccount(current.getId(), version, actorId) != 1) { + throw conflict(); + } + BankAccountView after = bankAccountView(mapper.findBankAccount(publicId)); + auditService.record("MASTERDATA_BANK_ACCOUNT_SUBMIT", "BANK_ACCOUNT", publicId, + "SUCCESS", null, before, after); + return after; + } + + @Transactional + public BankAccountView reviewBankAccount(String publicId, ReviewRequest request) { + BankAccountRecord current = requireBankAccount(publicId); + requireBankScope("masterdata:bank-account:review", current); + requireVersion(current.getVersion(), request.version()); + requireState(current.getStatus(), Set.of("REVIEWING")); + long actorId = identityContext.requirePrincipal().userId(); + requireDifferentReviewer(current.getCreatedBy(), current.getSubmittedBy(), actorId); + String targetStatus = reviewTarget(request.decision()); + if ("ACTIVE".equals(targetStatus) && !"ACTIVE".equals(mapper.findBankAccountOwnerStatus(current.getId()))) { + throw new BusinessException( + HttpStatus.UNPROCESSABLE_ENTITY, + ErrorCode.INVALID_STATE_TRANSITION, + "账户所属公司或供应商尚未生效" + ); + } + BankAccountView before = bankAccountView(current); + if (mapper.reviewBankAccount(current.getId(), request.version(), actorId, + targetStatus, request.opinion().trim()) != 1) { + throw conflict(); + } + BankAccountView after = bankAccountView(mapper.findBankAccount(publicId)); + auditService.record("MASTERDATA_BANK_ACCOUNT_REVIEW", "BANK_ACCOUNT", publicId, + "SUCCESS", request.opinion().trim(), before, after); + return after; + } + + @Transactional + public BankAccountView disableBankAccount(String publicId, long version) { + BankAccountRecord current = requireBankAccount(publicId); + requireBankScope("masterdata:bank-account:disable", current); + requireVersion(current.getVersion(), version); + requireState(current.getStatus(), Set.of("ACTIVE")); + BankAccountView before = bankAccountView(current); + if (mapper.disableBankAccount(current.getId(), version, identityContext.requirePrincipal().userId()) != 1) { + throw conflict(); + } + BankAccountView after = bankAccountView(mapper.findBankAccount(publicId)); + auditService.record("MASTERDATA_BANK_ACCOUNT_DISABLE", "BANK_ACCOUNT", publicId, + "SUCCESS", null, before, after); + return after; + } + + @Transactional(readOnly = true) + public PageResult listContracts(String keyword, String status, int page, int size) { + authorizationService.requirePermission("masterdata:contract:view"); + Page request = page(page, size); + FinancePrincipal actor = identityContext.requirePrincipal(); + String role = identityContext.requireActiveRole(); + String safeKeyword = nullableTrim(keyword, 100); + String safeStatus = status(status); + long total = mapper.countContracts(actor.userId(), role, safeKeyword, safeStatus); + List items = mapper.listContracts(actor.userId(), role, safeKeyword, safeStatus, + request.size(), request.offset()).stream().map(this::contractView).toList(); + return new PageResult<>(items, total, request.page(), request.size()); + } + + @Transactional(readOnly = true) + public ContractView getContract(String publicId) { + ContractMasterRecord record = requireContract(publicId); + authorizationService.requireScope("masterdata:contract:view", record.getCompanyPublicId(), + record.getProjectPublicId()); + return contractView(record); + } + + @Transactional + public ContractView createContract(ContractCreateRequest request) { + ContractReferences references = contractReferences(request.companyId(), request.projectId(), + request.counterpartyId()); + BigDecimal amount = amount(request.originalAmount()); + authorizationService.requireScope("masterdata:contract:create", references.company().publicId(), + references.project().publicId(), amount); + FinancePrincipal actor = identityContext.requirePrincipal(); + ContractMasterRecord record = new ContractMasterRecord(); + record.setPublicId(ulidGenerator.next()); + applyContractReferences(record, references); + record.setBusinessNo(normalizeCode(request.businessNo())); + record.setName(request.name().trim()); + record.setOriginalAmount(amount); + record.setCurrency(currency(request.currency())); + record.setCreatedBy(actor.userId()); + record.setUpdatedBy(actor.userId()); + try { + mapper.insertContract(record); + } catch (DuplicateKeyException exception) { + throw duplicate("同一公司下合同编号已存在"); + } + ContractView view = contractView(mapper.findContract(record.getPublicId())); + auditService.recordScoped(record.getCompanyPublicId(), record.getProjectPublicId(), + "MASTERDATA_CONTRACT_CREATE", "CONTRACT", record.getPublicId(), + "SUCCESS", null, null, view); + return view; + } + + @Transactional + public ContractView updateContract(String publicId, ContractUpdateRequest request) { + ContractMasterRecord current = requireContract(publicId); + authorizationService.requireScope("masterdata:contract:edit", current.getCompanyPublicId(), + current.getProjectPublicId(), current.getOriginalAmount()); + requireVersion(current.getVersion(), request.version()); + requireState(current.getStatus(), Set.of("DRAFT", "RETURNED")); + ContractReferences references = contractReferences(request.companyId(), request.projectId(), + request.counterpartyId()); + BigDecimal amount = amount(request.originalAmount()); + authorizationService.requireScope("masterdata:contract:edit", references.company().publicId(), + references.project().publicId(), amount); + ContractView before = contractView(current); + applyContractReferences(current, references); + current.setName(request.name().trim()); + current.setOriginalAmount(amount); + current.setCurrency(currency(request.currency())); + current.setUpdatedBy(identityContext.requirePrincipal().userId()); + if (mapper.updateContract(current) != 1) { + throw conflict(); + } + ContractView after = contractView(mapper.findContract(publicId)); + ContractMasterRecord stored = mapper.findContract(publicId); + auditService.recordScoped(stored.getCompanyPublicId(), stored.getProjectPublicId(), + "MASTERDATA_CONTRACT_UPDATE", "CONTRACT", publicId, + "SUCCESS", null, before, after); + return after; + } + + @Transactional + public ContractView submitContract(String publicId, long version) { + ContractMasterRecord current = requireContract(publicId); + authorizationService.requireScope("masterdata:contract:submit", current.getCompanyPublicId(), + current.getProjectPublicId(), current.getOriginalAmount()); + requireVersion(current.getVersion(), version); + requireState(current.getStatus(), Set.of("DRAFT", "RETURNED")); + ContractView before = contractView(current); + long actorId = identityContext.requirePrincipal().userId(); + if (mapper.submitContract(current.getId(), version, actorId) != 1) { + throw conflict(); + } + ContractView after = contractView(mapper.findContract(publicId)); + ContractMasterRecord stored = mapper.findContract(publicId); + auditService.recordScoped(stored.getCompanyPublicId(), stored.getProjectPublicId(), + "MASTERDATA_CONTRACT_SUBMIT", "CONTRACT", publicId, + "SUCCESS", null, before, after); + return after; + } + + @Transactional + public ContractView reviewContract(String publicId, ReviewRequest request) { + ContractMasterRecord current = requireContract(publicId); + authorizationService.requireScope("masterdata:contract:review", current.getCompanyPublicId(), + current.getProjectPublicId(), current.getOriginalAmount()); + requireVersion(current.getVersion(), request.version()); + requireState(current.getStatus(), Set.of("REVIEWING")); + long actorId = identityContext.requirePrincipal().userId(); + requireDifferentReviewer(current.getSubmittedBy(), actorId); + ContractView before = contractView(current); + if (mapper.reviewContract(current.getId(), request.version(), actorId, + reviewTarget(request.decision()), request.opinion().trim()) != 1) { + throw conflict(); + } + ContractView after = contractView(mapper.findContract(publicId)); + ContractMasterRecord stored = mapper.findContract(publicId); + auditService.recordScoped(stored.getCompanyPublicId(), stored.getProjectPublicId(), + "MASTERDATA_CONTRACT_REVIEW", "CONTRACT", publicId, + "SUCCESS", request.opinion().trim(), before, after); + return after; + } + + @Transactional + public ContractView disableContract(String publicId, long version) { + ContractMasterRecord current = requireContract(publicId); + authorizationService.requireScope("masterdata:contract:disable", current.getCompanyPublicId(), + current.getProjectPublicId(), current.getOriginalAmount()); + requireVersion(current.getVersion(), version); + requireState(current.getStatus(), Set.of("ACTIVE")); + ContractView before = contractView(current); + if (mapper.disableContract(current.getId(), version, identityContext.requirePrincipal().userId()) != 1) { + throw conflict(); + } + ContractView after = contractView(mapper.findContract(publicId)); + ContractMasterRecord stored = mapper.findContract(publicId); + auditService.recordScoped(stored.getCompanyPublicId(), stored.getProjectPublicId(), + "MASTERDATA_CONTRACT_DISABLE", "CONTRACT", publicId, + "SUCCESS", null, before, after); + return after; + } + + @Transactional(readOnly = true) + public PageResult listDictionaries(String resource, String keyword, String status, + int page, int size) { + String safeResource = dictionaryResource(resource); + authorizationService.requireGlobalScope("masterdata:dictionary:view"); + Page request = page(page, size); + String safeKeyword = nullableTrim(keyword, 100); + String safeStatus = status(status); + long total = mapper.countDictionaries(safeResource, safeKeyword, safeStatus); + List items = mapper.listDictionaries(safeResource, safeKeyword, safeStatus, + request.size(), request.offset()).stream().map(item -> dictionaryView(safeResource, item)).toList(); + return new PageResult<>(items, total, request.page(), request.size()); + } + + @Transactional(readOnly = true) + public DictionaryView getDictionary(String resource, String publicId) { + String safeResource = dictionaryResource(resource); + authorizationService.requireGlobalScope("masterdata:dictionary:view"); + return dictionaryView(safeResource, requireDictionary(safeResource, publicId)); + } + + @Transactional + public DictionaryView createDictionary(String resource, DictionaryCreateRequest request) { + String safeResource = dictionaryResource(resource); + authorizationService.requireGlobalScope("masterdata:dictionary:create"); + FinancePrincipal actor = identityContext.requirePrincipal(); + DictionaryRecord record = new DictionaryRecord(); + record.setPublicId(ulidGenerator.next()); + record.setCode(normalizeCode(request.code())); + record.setName(request.name().trim()); + applyDictionaryFields(safeResource, record, request.parentId(), request.accountType(), + request.auxiliaryRequired(), request.rate(), null); + record.setCreatedBy(actor.userId()); + record.setUpdatedBy(actor.userId()); + try { + mapper.insertDictionary(safeResource, record); + } catch (DuplicateKeyException exception) { + throw duplicate("字典编码已存在"); + } + DictionaryView view = dictionaryView(safeResource, + mapper.findDictionary(safeResource, record.getPublicId())); + auditService.record("MASTERDATA_DICTIONARY_CREATE", dictionaryObjectType(safeResource), + record.getPublicId(), "SUCCESS", null, null, view); + return view; + } + + @Transactional + public DictionaryView updateDictionary(String resource, String publicId, DictionaryUpdateRequest request) { + String safeResource = dictionaryResource(resource); + authorizationService.requireGlobalScope("masterdata:dictionary:edit"); + DictionaryRecord current = requireDictionary(safeResource, publicId); + requireVersion(current.getVersion(), request.version()); + requireState(current.getStatus(), Set.of("DRAFT", "RETURNED")); + DictionaryView before = dictionaryView(safeResource, current); + current.setName(request.name().trim()); + applyDictionaryFields(safeResource, current, request.parentId(), request.accountType(), + request.auxiliaryRequired(), request.rate(), publicId); + current.setUpdatedBy(identityContext.requirePrincipal().userId()); + if (mapper.updateDictionary(safeResource, current) != 1) { + throw conflict(); + } + DictionaryView after = dictionaryView(safeResource, mapper.findDictionary(safeResource, publicId)); + auditService.record("MASTERDATA_DICTIONARY_UPDATE", dictionaryObjectType(safeResource), publicId, + "SUCCESS", null, before, after); + return after; + } + + @Transactional + public DictionaryView submitDictionary(String resource, String publicId, long version) { + String safeResource = dictionaryResource(resource); + authorizationService.requireGlobalScope("masterdata:dictionary:submit"); + DictionaryRecord current = requireDictionary(safeResource, publicId); + requireVersion(current.getVersion(), version); + requireState(current.getStatus(), Set.of("DRAFT", "RETURNED")); + DictionaryView before = dictionaryView(safeResource, current); + long actorId = identityContext.requirePrincipal().userId(); + if (mapper.submitDictionary(safeResource, current.getId(), version, actorId) != 1) { + throw conflict(); + } + DictionaryView after = dictionaryView(safeResource, mapper.findDictionary(safeResource, publicId)); + auditService.record("MASTERDATA_DICTIONARY_SUBMIT", dictionaryObjectType(safeResource), publicId, + "SUCCESS", null, before, after); + return after; + } + + @Transactional + public DictionaryView reviewDictionary(String resource, String publicId, ReviewRequest request) { + String safeResource = dictionaryResource(resource); + authorizationService.requireGlobalScope("masterdata:dictionary:review"); + DictionaryRecord current = requireDictionary(safeResource, publicId); + requireVersion(current.getVersion(), request.version()); + requireState(current.getStatus(), Set.of("REVIEWING")); + long actorId = identityContext.requirePrincipal().userId(); + requireDifferentReviewer(current.getSubmittedBy(), actorId); + DictionaryView before = dictionaryView(safeResource, current); + if (mapper.reviewDictionary(safeResource, current.getId(), request.version(), actorId, + reviewTarget(request.decision()), request.opinion().trim()) != 1) { + throw conflict(); + } + DictionaryView after = dictionaryView(safeResource, mapper.findDictionary(safeResource, publicId)); + auditService.record("MASTERDATA_DICTIONARY_REVIEW", dictionaryObjectType(safeResource), publicId, + "SUCCESS", request.opinion().trim(), before, after); + return after; + } + + @Transactional + public DictionaryView disableDictionary(String resource, String publicId, long version) { + String safeResource = dictionaryResource(resource); + authorizationService.requireGlobalScope("masterdata:dictionary:disable"); + DictionaryRecord current = requireDictionary(safeResource, publicId); + requireVersion(current.getVersion(), version); + requireState(current.getStatus(), Set.of("ACTIVE")); + DictionaryView before = dictionaryView(safeResource, current); + if (mapper.disableDictionary(safeResource, current.getId(), version, + identityContext.requirePrincipal().userId()) != 1) { + throw conflict(); + } + DictionaryView after = dictionaryView(safeResource, mapper.findDictionary(safeResource, publicId)); + auditService.record("MASTERDATA_DICTIONARY_DISABLE", dictionaryObjectType(safeResource), publicId, + "SUCCESS", null, before, after); + return after; + } + + private Owner owner(String requestedType, String ownerPublicId) { + validatePublicId(ownerPublicId); + String type = requestedType == null ? "" : requestedType.trim().toUpperCase(Locale.ROOT); + if ("COMPANY".equals(type)) { + MasterDataReferenceRecord company = mapper.findCompanyReference(ownerPublicId); + if (company == null) throw notFound("所属公司不存在或未生效"); + return new Owner(type, company.id(), null, company.publicId(), company); + } + if ("COUNTERPARTY".equals(type)) { + MasterDataReferenceRecord counterparty = mapper.findCounterpartyReference(ownerPublicId); + if (counterparty == null) throw notFound("所属往来单位不存在或未生效"); + return new Owner(type, null, counterparty.id(), null, counterparty); + } + throw validation("账户主体类型必须是 COMPANY 或 COUNTERPARTY"); + } + + private ContractReferences contractReferences(String companyId, String projectId, String counterpartyId) { + validatePublicId(companyId); + validatePublicId(projectId); + validatePublicId(counterpartyId); + MasterDataReferenceRecord company = mapper.findCompanyReference(companyId); + MasterDataReferenceRecord project = mapper.findProjectReference(projectId); + MasterDataReferenceRecord counterparty = mapper.findCounterpartyReference(counterpartyId); + if (company == null) throw notFound("合同所属公司不存在或未生效"); + if (project == null) throw notFound("合同所属项目不存在或未生效"); + if (counterparty == null) throw notFound("合同相对方不存在或未生效"); + if (!company.publicId().equals(project.companyPublicId())) { + throw validation("所选项目不属于合同公司"); + } + if (!Set.of("CUSTOMER", "SUPPLIER", "BOTH").contains(counterparty.type())) { + throw validation("合同相对方类型无效"); + } + return new ContractReferences(company, project, counterparty); + } + + private void applyContractReferences(ContractMasterRecord target, ContractReferences references) { + target.setCompanyId(references.company().id()); + target.setCompanyPublicId(references.company().publicId()); + target.setCompanyName(references.company().name()); + target.setProjectId(references.project().id()); + target.setProjectPublicId(references.project().publicId()); + target.setProjectName(references.project().name()); + target.setCounterpartyId(references.counterparty().id()); + target.setCounterpartyPublicId(references.counterparty().publicId()); + target.setCounterpartyName(references.counterparty().name()); + } + + private void applyDictionaryFields(String resource, DictionaryRecord target, String parentId, + String accountType, Boolean auxiliaryRequired, String rate, + String currentPublicId) { + target.setParentId(null); + target.setAccountType(null); + target.setAuxiliaryRequired(null); + target.setRate(null); + switch (resource) { + case "departments" -> { + // Departments currently have no resource-specific fields. + } + case "cost-categories" -> { + if (parentId != null && !parentId.isBlank()) { + validatePublicId(parentId); + if (parentId.equals(currentPublicId)) throw validation("成本类别不能把自己设为上级"); + MasterDataReferenceRecord parent = mapper.findCostCategoryReference(parentId); + if (parent == null) throw notFound("上级成本类别不存在或未生效"); + target.setParentId(parent.id()); + } + } + case "accounts" -> { + String normalizedType = accountType == null ? "" : accountType.trim().toUpperCase(Locale.ROOT); + if (!ACCOUNT_TYPES.contains(normalizedType)) { + throw validation("会计科目类型不符合要求"); + } + target.setAccountType(normalizedType); + target.setAuxiliaryRequired(Boolean.TRUE.equals(auxiliaryRequired)); + } + case "tax-rates" -> target.setRate(rate(rate)); + default -> throw queryInvalid("不支持的财务字典资源"); + } + } + + private BankAccountRecord requireBankAccount(String publicId) { + validatePublicId(publicId); + BankAccountRecord record = mapper.findBankAccount(publicId); + if (record == null) throw notFound("银行账户不存在"); + return record; + } + + private ContractMasterRecord requireContract(String publicId) { + validatePublicId(publicId); + ContractMasterRecord record = mapper.findContract(publicId); + if (record == null) throw notFound("合同不存在"); + return record; + } + + private DictionaryRecord requireDictionary(String resource, String publicId) { + validatePublicId(publicId); + DictionaryRecord record = mapper.findDictionary(resource, publicId); + if (record == null) throw notFound("财务字典记录不存在"); + return record; + } + + private BankAccountView bankAccountView(BankAccountRecord record) { + Set actions = lifecycleActions("masterdata:bank-account", record.getStatus(), + record.getCompanyPublicId(), null, record.getSubmittedBy()); + String ownerPublicId = record.getCompanyId() == null + ? record.getCounterpartyPublicId() : record.getCompanyPublicId(); + String ownerName = record.getCompanyId() == null ? record.getCounterpartyName() : record.getCompanyName(); + return new BankAccountView(record.getPublicId(), record.getOwnerType(), + new MasterDataReferenceView(ownerPublicId, null, ownerName), record.getAccountCategory(), + record.getAccountName(), record.getBankName(), record.getMaskedAccountNo(), record.getVersionNo(), + record.getValidFrom().toLocalDate(), record.getValidTo() == null ? null : record.getValidTo().toLocalDate(), + record.getStatus(), record.getVersion(), record.getCreatedAt(), record.getUpdatedAt(), actions); + } + + private ContractView contractView(ContractMasterRecord record) { + Set actions = lifecycleActions("masterdata:contract", record.getStatus(), + record.getCompanyPublicId(), record.getProjectPublicId(), record.getSubmittedBy()); + return new ContractView(record.getPublicId(), + new MasterDataReferenceView(record.getCompanyPublicId(), null, record.getCompanyName()), + new MasterDataReferenceView(record.getProjectPublicId(), null, record.getProjectName()), + new MasterDataReferenceView(record.getCounterpartyPublicId(), null, record.getCounterpartyName()), + record.getBusinessNo(), record.getName(), decimal(record.getOriginalAmount()), + decimal(record.getApprovedChangeAmount()), decimal(record.getSettlementAmount()), record.getCurrency(), + record.getStatus(), record.getVersion(), record.getCreatedAt(), record.getUpdatedAt(), actions); + } + + private DictionaryView dictionaryView(String resource, DictionaryRecord record) { + Set actions = lifecycleActions("masterdata:dictionary", record.getStatus(), null, null, + record.getSubmittedBy()); + MasterDataReferenceView parent = record.getParentPublicId() == null ? null + : new MasterDataReferenceView(record.getParentPublicId(), null, record.getParentName()); + return new DictionaryView(record.getPublicId(), resource, record.getCode(), record.getName(), parent, + record.getAccountType(), record.getAuxiliaryRequired(), decimal(record.getRate()), record.getStatus(), + record.getVersion(), record.getCreatedAt(), record.getUpdatedAt(), actions); + } + + private Set lifecycleActions(String permissionPrefix, String status, String companyId, + String projectId, Long submittedBy) { + Set actions = new LinkedHashSet<>(); + if (Set.of("DRAFT", "RETURNED").contains(status) + && authorizationService.hasScope(permissionPrefix + ":edit", companyId, projectId)) { + actions.add("EDIT"); + } + if (Set.of("DRAFT", "RETURNED").contains(status) + && authorizationService.hasScope(permissionPrefix + ":submit", companyId, projectId)) { + actions.add("SUBMIT"); + } + if ("REVIEWING".equals(status) + && (submittedBy == null || submittedBy != identityContext.requirePrincipal().userId()) + && authorizationService.hasScope(permissionPrefix + ":review", companyId, projectId)) { + actions.add("REVIEW"); + } + if ("ACTIVE".equals(status) + && authorizationService.hasScope(permissionPrefix + ":disable", companyId, projectId)) { + actions.add("DISABLE"); + } + return Set.copyOf(actions); + } + + private List referenceViews(List records) { + return records.stream().map(item -> new MasterDataReferenceView(item.publicId(), item.code(), item.name())) + .toList(); + } + + private void requireBankScope(String permission, BankAccountRecord record) { + authorizationService.requireScope(permission, record.getCompanyPublicId(), null); + } + + private void requireOwnerScope(String permission, Owner owner) { + authorizationService.requireScope(permission, owner.companyPublicId(), null); + } + + private void applyProtectedAccount(BankAccountRecord record, + BankAccountProtector.ProtectedAccount protectedAccount) { + record.setAccountNoCiphertext(protectedAccount.ciphertext()); + record.setAccountNoHash(protectedAccount.hash()); + record.setMaskedAccountNo(protectedAccount.masked()); + } + + private BankAccountProtector.ProtectedAccount protect(String accountNo) { + try { + return accountProtector.protect(accountNo); + } catch (IllegalArgumentException exception) { + throw validation(exception.getMessage()); + } + } + + private void validateValidity(LocalDate validFrom, LocalDate validTo) { + if (validTo != null && validTo.isBefore(validFrom)) { + throw validation("失效日期不能早于生效日期"); + } + } + + private String accountCategory(String value) { + String normalized = value.trim().toUpperCase(Locale.ROOT); + if (!ACCOUNT_CATEGORIES.contains(normalized)) { + throw validation("账户类别必须是 BASIC、GENERAL、SPECIAL、PROJECT 或 OTHER"); + } + return normalized; + } + + private BigDecimal amount(String value) { + try { + BigDecimal result = new BigDecimal(value).setScale(2, RoundingMode.UNNECESSARY); + if (result.signum() < 0 || result.precision() > 20) throw new ArithmeticException(); + return result; + } catch (NumberFormatException | ArithmeticException exception) { + throw validation("合同金额必须是非负数且最多保留两位小数"); + } + } + + private BigDecimal rate(String value) { + if (value == null || value.isBlank()) throw validation("税率不能为空"); + try { + BigDecimal result = new BigDecimal(value).setScale(6, RoundingMode.UNNECESSARY); + if (result.compareTo(BigDecimal.ZERO) < 0 || result.compareTo(BigDecimal.ONE) > 0) { + throw new ArithmeticException(); + } + return result; + } catch (NumberFormatException | ArithmeticException exception) { + throw validation("税率必须在 0 到 1 之间且最多保留六位小数"); + } + } + + private String currency(String value) { + String normalized = value.trim().toUpperCase(Locale.ROOT); + if (!normalized.matches("[A-Z]{3}")) throw validation("币种必须使用三位 ISO 代码"); + return normalized; + } + + private String dictionaryResource(String resource) { + if (resource == null || !DICTIONARY_RESOURCES.contains(resource)) { + throw queryInvalid("不支持的财务字典资源"); + } + return resource; + } + + private String dictionaryObjectType(String resource) { + return switch (resource) { + case "departments" -> "DEPARTMENT"; + case "cost-categories" -> "COST_CATEGORY"; + case "accounts" -> "ACCOUNT"; + case "tax-rates" -> "TAX_RATE"; + default -> throw queryInvalid("不支持的财务字典资源"); + }; + } + + private String reviewTarget(String decision) { + return switch (decision.trim().toUpperCase(Locale.ROOT)) { + case "APPROVE" -> "ACTIVE"; + case "RETURN" -> "RETURNED"; + default -> throw validation("复核结论必须是 APPROVE 或 RETURN"); + }; + } + + private String status(String value) { + if (value == null || value.isBlank()) return null; + String normalized = value.trim().toUpperCase(Locale.ROOT); + if (!STATUSES.contains(normalized)) throw queryInvalid("状态筛选值不存在"); + return normalized; + } + + private Page page(int page, int size) { + if (page < 1 || !Set.of(20, 50, 100).contains(size)) { + throw queryInvalid("分页参数不符合要求"); + } + return new Page(page, size, Math.multiplyExact(page - 1, size)); + } + + private String normalizeCode(String value) { + String normalized = Normalizer.normalize(value, Normalizer.Form.NFKC).trim().toUpperCase(Locale.ROOT); + if (normalized.isBlank() || normalized.length() > 64) throw validation("业务编码格式不符合要求"); + return normalized; + } + + private String nullableTrim(String value, int maxLength) { + if (value == null || value.isBlank()) return null; + String result = value.trim(); + if (result.length() > maxLength) throw queryInvalid("查询条件过长"); + return result; + } + + private void validatePublicId(String publicId) { + if (publicId == null || !publicId.matches(ULID_PATTERN)) { + throw new BusinessException(HttpStatus.BAD_REQUEST, ErrorCode.QUERY_PARAMETER_INVALID, + "公开编号格式不正确"); + } + } + + private void requireVersion(long actual, long requested) { + if (actual != requested) throw conflict(); + } + + private void requireState(String actual, Set allowed) { + if (!allowed.contains(actual)) { + throw new BusinessException(HttpStatus.CONFLICT, ErrorCode.INVALID_STATE_TRANSITION, + "当前状态不允许执行该操作"); + } + } + + private void requireDifferentReviewer(Long submittedBy, long actorId) { + if (submittedBy != null && submittedBy == actorId) { + throw new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.SOD_VIOLATION, + "提交人与复核人不能是同一人"); + } + } + + private void requireDifferentReviewer(long createdBy, Long submittedBy, long actorId) { + if (createdBy == actorId || (submittedBy != null && submittedBy == actorId)) { + throw new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.SOD_VIOLATION, + "经办人与复核人不能是同一人"); + } + } + + private String decimal(BigDecimal value) { + return value == null ? null : value.toPlainString(); + } + + private BusinessException duplicate(String message) { + return new BusinessException(HttpStatus.CONFLICT, ErrorCode.DUPLICATE_RESOURCE, message); + } + + private BusinessException conflict() { + return new BusinessException(HttpStatus.CONFLICT, ErrorCode.VERSION_CONFLICT, + "数据已被其他用户更新,请刷新后重试"); + } + + private BusinessException validation(String message) { + return new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.VALIDATION_FAILED, message); + } + + private BusinessException notFound(String message) { + return new BusinessException(HttpStatus.NOT_FOUND, ErrorCode.RESOURCE_NOT_FOUND, message); + } + + private BusinessException queryInvalid(String message) { + return new BusinessException(HttpStatus.BAD_REQUEST, ErrorCode.QUERY_PARAMETER_INVALID, message); + } + + private record Page(int page, int size, int offset) { + } + + private record Owner(String type, Long companyId, Long counterpartyId, String companyPublicId, + MasterDataReferenceRecord reference) { + } + + private record ContractReferences(MasterDataReferenceRecord company, MasterDataReferenceRecord project, + MasterDataReferenceRecord counterparty) { + } +} diff --git a/backend/src/main/java/com/kaidi/finance/masterdata/application/LegacyIdentifierApplicationService.java b/backend/src/main/java/com/kaidi/finance/masterdata/application/LegacyIdentifierApplicationService.java new file mode 100644 index 0000000..de8eced --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/masterdata/application/LegacyIdentifierApplicationService.java @@ -0,0 +1,317 @@ +package com.kaidi.finance.masterdata.application; + +import com.kaidi.finance.masterdata.api.BankAccountView; +import com.kaidi.finance.masterdata.api.ContractView; +import com.kaidi.finance.masterdata.api.LegacyIdentifierCreateRequest; +import com.kaidi.finance.masterdata.api.LegacyIdentifierResolutionView; +import com.kaidi.finance.masterdata.api.LegacyIdentifierView; +import com.kaidi.finance.masterdata.infrastructure.LegacyIdentifierMapper; +import com.kaidi.finance.project.api.ProjectDetailView; +import com.kaidi.finance.project.application.ProjectApplicationService; +import com.kaidi.finance.shared.api.BusinessException; +import com.kaidi.finance.shared.api.ErrorCode; +import com.kaidi.finance.shared.api.PageResult; +import com.kaidi.finance.shared.audit.AuditService; +import com.kaidi.finance.shared.id.UlidGenerator; +import com.kaidi.finance.shared.security.AuthorizationService; +import com.kaidi.finance.shared.security.IdentityContext; +import java.math.BigDecimal; +import java.text.Normalizer; +import java.util.LinkedHashSet; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class LegacyIdentifierApplicationService { + + private static final String ULID_PATTERN = "[0-9A-HJKMNP-TV-Z]{26}"; + private static final Set STATUSES = Set.of("ACTIVE", "DISABLED"); + private static final Map RESOURCES = Map.of( + "companies", new ResourceDefinition("COMPANY", "masterdata:company:view", "masterdata:company:edit"), + "departments", new ResourceDefinition("DEPARTMENT", "masterdata:dictionary:view", "masterdata:dictionary:edit"), + "projects", new ResourceDefinition("PROJECT", "project:project:view", "project:project:edit"), + "counterparties", new ResourceDefinition("COUNTERPARTY", "masterdata:counterparty:view", + "masterdata:counterparty:edit"), + "bank-accounts", new ResourceDefinition("BANK_ACCOUNT", "masterdata:bank-account:view", + "masterdata:bank-account:edit"), + "contracts", new ResourceDefinition("CONTRACT", "masterdata:contract:view", "masterdata:contract:edit"), + "cost-categories", new ResourceDefinition("COST_CATEGORY", "masterdata:dictionary:view", + "masterdata:dictionary:edit"), + "accounts", new ResourceDefinition("ACCOUNT", "masterdata:dictionary:view", "masterdata:dictionary:edit"), + "tax-rates", new ResourceDefinition("TAX_RATE", "masterdata:dictionary:view", "masterdata:dictionary:edit") + ); + + private final LegacyIdentifierMapper mapper; + private final MasterDataApplicationService masterDataService; + private final CatalogMasterDataApplicationService catalogService; + private final ProjectApplicationService projectService; + private final AuthorizationService authorizationService; + private final IdentityContext identityContext; + private final AuditService auditService; + private final UlidGenerator ulidGenerator; + + public LegacyIdentifierApplicationService(LegacyIdentifierMapper mapper, + MasterDataApplicationService masterDataService, + CatalogMasterDataApplicationService catalogService, + ProjectApplicationService projectService, + AuthorizationService authorizationService, + IdentityContext identityContext, + AuditService auditService, + UlidGenerator ulidGenerator) { + this.mapper = mapper; + this.masterDataService = masterDataService; + this.catalogService = catalogService; + this.projectService = projectService; + this.authorizationService = authorizationService; + this.identityContext = identityContext; + this.auditService = auditService; + this.ulidGenerator = ulidGenerator; + } + + public boolean canView(String resource) { + ResourceDefinition definition = RESOURCES.get(resource); + return definition != null && authorizationService.hasPermission(definition.viewPermission()); + } + + public boolean canEdit(String resource) { + ResourceDefinition definition = RESOURCES.get(resource); + return definition != null && authorizationService.hasPermission(definition.editPermission()); + } + + @Transactional(readOnly = true) + public MappingPage list(String resource, String targetPublicId, String status, String keyword, + int page, int size) { + ResourceDefinition definition = definition(resource); + TargetAccess target = requireTarget(resource, targetPublicId); + Page request = page(page, size); + String safeStatus = status(status); + String safeKeyword = nullableTrim(keyword, 128); + boolean editable = hasEditScope(definition, target); + long total = mapper.count(definition.resourceType(), targetPublicId, safeStatus, safeKeyword); + var items = mapper.list(definition.resourceType(), targetPublicId, safeStatus, safeKeyword, + request.size(), request.offset()).stream() + .map(row -> view(resource, row, editable)) + .toList(); + Set allowedActions = editable ? Set.of("CREATE") : Set.of(); + return new MappingPage(new PageResult<>(items, total, request.page(), request.size()), allowedActions); + } + + @Transactional + public LegacyIdentifierView create(String resource, String targetPublicId, + LegacyIdentifierCreateRequest request) { + ResourceDefinition definition = definition(resource); + TargetAccess target = requireTarget(resource, targetPublicId); + requireEditScope(definition, target); + String sourceSystem = sourceSystem(request.sourceSystem()); + String legacyCode = request.legacyCode().trim(); + String normalizedLegacyCode = normalizeLegacyCode(legacyCode); + String publicId = ulidGenerator.next(); + long actorId = identityContext.requirePrincipal().userId(); + try { + mapper.insert(publicId, definition.resourceType(), sourceSystem, legacyCode, + normalizedLegacyCode, targetPublicId, actorId); + } catch (DuplicateKeyException exception) { + throw new BusinessException(HttpStatus.CONFLICT, ErrorCode.DUPLICATE_RESOURCE, + "该来源系统的旧编号已存在有效映射"); + } + LegacyIdentifierView after = view(resource, mapper.findByPublicId(publicId), true); + auditService.recordScoped(target.companyPublicId(), target.projectPublicId(), + "MASTERDATA_LEGACY_IDENTIFIER_CREATE", "MASTERDATA_LEGACY_IDENTIFIER", publicId, + "SUCCESS", null, null, after); + return after; + } + + @Transactional(readOnly = true) + public LegacyIdentifierResolutionView resolve(String resource, String sourceSystem, String legacyCode) { + ResourceDefinition definition = definition(resource); + String safeSourceSystem = sourceSystem(sourceSystem); + String safeLegacyCode = normalizeLegacyCode(legacyCode); + LegacyIdentifierMapper.LegacyIdentifierRow row = mapper.resolve(definition.resourceType(), + safeSourceSystem, safeLegacyCode); + if (row == null) { + throw notFound("未找到有效的旧编号映射"); + } + requireTarget(resource, row.targetPublicId()); + return new LegacyIdentifierResolutionView(row.publicId(), resource, row.sourceSystem(), + row.legacyCode(), row.targetPublicId()); + } + + @Transactional + public LegacyIdentifierView disable(String resource, String targetPublicId, + String mappingPublicId, long version) { + ResourceDefinition definition = definition(resource); + TargetAccess target = requireTarget(resource, targetPublicId); + requireEditScope(definition, target); + validateMappingPublicId(mappingPublicId); + LegacyIdentifierMapper.LegacyIdentifierRow current = mapper.findForTarget(mappingPublicId, + definition.resourceType(), targetPublicId); + if (current == null) { + throw notFound("旧编号映射不存在或不属于当前主数据"); + } + if (current.version() != version) { + throw conflict(); + } + if (!"ACTIVE".equals(current.status())) { + throw new BusinessException(HttpStatus.CONFLICT, ErrorCode.INVALID_STATE_TRANSITION, + "当前映射已经停用"); + } + LegacyIdentifierView before = view(resource, current, true); + if (mapper.disable(current.id(), version, identityContext.requirePrincipal().userId()) != 1) { + throw conflict(); + } + LegacyIdentifierView after = view(resource, mapper.findByPublicId(mappingPublicId), true); + auditService.recordScoped(target.companyPublicId(), target.projectPublicId(), + "MASTERDATA_LEGACY_IDENTIFIER_DISABLE", "MASTERDATA_LEGACY_IDENTIFIER", mappingPublicId, + "SUCCESS", null, before, after); + return after; + } + + private TargetAccess requireTarget(String resource, String publicId) { + return switch (resource) { + case "companies" -> { + masterDataService.getCompany(publicId); + yield new TargetAccess(publicId, null, null); + } + case "counterparties" -> { + masterDataService.getCounterparty(publicId); + yield new TargetAccess(null, null, null); + } + case "bank-accounts" -> { + BankAccountView view = catalogService.getBankAccount(publicId); + String companyId = "COMPANY".equals(view.ownerType()) ? view.owner().publicId() : null; + yield new TargetAccess(companyId, null, null); + } + case "contracts" -> { + ContractView view = catalogService.getContract(publicId); + yield new TargetAccess(view.company().publicId(), view.project().publicId(), + new BigDecimal(view.originalAmount())); + } + case "departments", "cost-categories", "accounts", "tax-rates" -> { + catalogService.getDictionary(resource, publicId); + yield new TargetAccess(null, null, null); + } + case "projects" -> { + ProjectDetailView view = projectService.get(publicId); + yield new TargetAccess(view.company().publicId(), view.publicId(), null); + } + default -> throw queryInvalid("不支持的主数据资源"); + }; + } + + private boolean hasEditScope(ResourceDefinition definition, TargetAccess target) { + return authorizationService.hasScope(definition.editPermission(), target.companyPublicId(), + target.projectPublicId(), target.amount()); + } + + private void requireEditScope(ResourceDefinition definition, TargetAccess target) { + authorizationService.requireScope(definition.editPermission(), target.companyPublicId(), + target.projectPublicId(), target.amount()); + } + + private LegacyIdentifierView view(String resource, LegacyIdentifierMapper.LegacyIdentifierRow row, + boolean editable) { + Set actions = new LinkedHashSet<>(); + if (editable && "ACTIVE".equals(row.status())) { + actions.add("DISABLE"); + } + return new LegacyIdentifierView(row.publicId(), resource, row.sourceSystem(), row.legacyCode(), + row.targetPublicId(), row.status(), row.version(), row.createdAt(), row.updatedAt(), Set.copyOf(actions)); + } + + private ResourceDefinition definition(String resource) { + ResourceDefinition definition = RESOURCES.get(resource); + if (definition == null) { + throw queryInvalid("不支持的主数据资源"); + } + return definition; + } + + private Page page(int page, int size) { + if (page < 1 || !Set.of(20, 50, 100).contains(size)) { + throw queryInvalid("分页参数不符合要求"); + } + try { + return new Page(page, size, Math.toIntExact(Math.multiplyExact((long) page - 1L, size))); + } catch (ArithmeticException exception) { + throw queryInvalid("分页参数超出允许范围"); + } + } + + private String status(String value) { + if (value == null || value.isBlank()) { + return null; + } + String normalized = value.trim().toUpperCase(Locale.ROOT); + if (!STATUSES.contains(normalized)) { + throw queryInvalid("映射状态不存在"); + } + return normalized; + } + + private String sourceSystem(String value) { + String normalized = Normalizer.normalize(value.trim(), Normalizer.Form.NFKC).toUpperCase(Locale.ROOT); + if (!normalized.matches("[A-Z0-9][A-Z0-9._-]{0,63}")) { + throw validation("来源系统只能使用字母、数字、点、下划线和短横线"); + } + return normalized; + } + + private String normalizeLegacyCode(String value) { + String normalized = Normalizer.normalize(value.trim(), Normalizer.Form.NFKC).toUpperCase(Locale.ROOT); + if (normalized.isBlank() || normalized.length() > 128) { + throw validation("旧编号不能为空且长度不得超过 128 个字符"); + } + return normalized; + } + + private String nullableTrim(String value, int maxLength) { + if (value == null || value.isBlank()) { + return null; + } + String result = value.trim(); + if (result.length() > maxLength) { + throw queryInvalid("查询条件过长"); + } + return result; + } + + private void validateMappingPublicId(String publicId) { + if (publicId == null || !publicId.matches(ULID_PATTERN)) { + throw notFound("旧编号映射不存在或已不可用"); + } + } + + private BusinessException validation(String message) { + return new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.VALIDATION_FAILED, message); + } + + private BusinessException queryInvalid(String message) { + return new BusinessException(HttpStatus.BAD_REQUEST, ErrorCode.QUERY_PARAMETER_INVALID, message); + } + + private BusinessException notFound(String message) { + return new BusinessException(HttpStatus.NOT_FOUND, ErrorCode.RESOURCE_NOT_FOUND, message); + } + + private BusinessException conflict() { + return new BusinessException(HttpStatus.CONFLICT, ErrorCode.VERSION_CONFLICT, + "数据已被其他人更新,请重新加载后再操作"); + } + + public record MappingPage(PageResult page, Set allowedActions) { + } + + private record ResourceDefinition(String resourceType, String viewPermission, String editPermission) { + } + + private record TargetAccess(String companyPublicId, String projectPublicId, BigDecimal amount) { + } + + private record Page(int page, int size, int offset) { + } +} diff --git a/backend/src/main/java/com/kaidi/finance/masterdata/application/MasterDataApplicationService.java b/backend/src/main/java/com/kaidi/finance/masterdata/application/MasterDataApplicationService.java new file mode 100644 index 0000000..3621c43 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/masterdata/application/MasterDataApplicationService.java @@ -0,0 +1,461 @@ +package com.kaidi.finance.masterdata.application; + +import com.kaidi.finance.iam.domain.FinancePrincipal; +import com.kaidi.finance.masterdata.api.CompanyCreateRequest; +import com.kaidi.finance.masterdata.api.CompanyUpdateRequest; +import com.kaidi.finance.masterdata.api.CompanyView; +import com.kaidi.finance.masterdata.api.CounterpartyCreateRequest; +import com.kaidi.finance.masterdata.api.CounterpartyUpdateRequest; +import com.kaidi.finance.masterdata.api.CounterpartyView; +import com.kaidi.finance.masterdata.api.ReviewRequest; +import com.kaidi.finance.masterdata.domain.CompanyRecord; +import com.kaidi.finance.masterdata.domain.CounterpartyRecord; +import com.kaidi.finance.masterdata.infrastructure.MasterDataMapper; +import com.kaidi.finance.shared.api.BusinessException; +import com.kaidi.finance.shared.api.ErrorCode; +import com.kaidi.finance.shared.api.PageResult; +import com.kaidi.finance.shared.audit.AuditService; +import com.kaidi.finance.shared.id.UlidGenerator; +import com.kaidi.finance.shared.security.AuthorizationService; +import com.kaidi.finance.shared.security.IdentityContext; +import java.text.Normalizer; +import java.util.LinkedHashSet; +import java.util.Locale; +import java.util.Set; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class MasterDataApplicationService { + + private static final Set COMPANY_STATUSES = Set.of("DRAFT", "REVIEWING", "RETURNED", "ACTIVE", "DISABLED"); + private static final Set COUNTERPARTY_TYPES = Set.of("CUSTOMER", "SUPPLIER", "BOTH"); + private static final String ULID_PATTERN = "[0-9A-HJKMNP-TV-Z]{26}"; + + private final MasterDataMapper mapper; + private final IdentityContext identityContext; + private final AuthorizationService authorizationService; + private final AuditService auditService; + private final UlidGenerator ulidGenerator; + + public MasterDataApplicationService(MasterDataMapper mapper, IdentityContext identityContext, + AuthorizationService authorizationService, AuditService auditService, + UlidGenerator ulidGenerator) { + this.mapper = mapper; + this.identityContext = identityContext; + this.authorizationService = authorizationService; + this.auditService = auditService; + this.ulidGenerator = ulidGenerator; + } + + @Transactional + public CompanyView createCompany(CompanyCreateRequest request) { + authorizationService.requireGlobalScope("masterdata:company:create"); + FinancePrincipal actor = identityContext.requirePrincipal(); + CompanyRecord company = new CompanyRecord(); + company.setPublicId(ulidGenerator.next()); + company.setBusinessNo(normalizeCode(request.businessNo())); + company.setName(request.name().trim()); + company.setNormalizedTaxNo(normalizeNullableCode(request.taxNo())); + company.setCreatedBy(actor.userId()); + company.setUpdatedBy(actor.userId()); + try { + mapper.insertCompany(company); + } catch (DuplicateKeyException exception) { + throw duplicate("公司编号或税号已存在"); + } + CompanyRecord stored = mapper.findCompany(company.getPublicId()); + CompanyView view = companyView(stored); + auditService.record("MASTERDATA_COMPANY_CREATE", "COMPANY", stored.getPublicId(), "SUCCESS", null, null, view); + return view; + } + + @Transactional(readOnly = true) + public PageResult listCompanies(String keyword, String status, int page, int size) { + authorizationService.requirePermission("masterdata:company:view"); + Page pageRequest = page(page, size); + String normalizedStatus = status(status); + FinancePrincipal actor = identityContext.requirePrincipal(); + String role = identityContext.requireActiveRole(); + String safeKeyword = nullableTrim(keyword, 100); + long total = mapper.countCompanies(actor.userId(), role, safeKeyword, normalizedStatus); + var items = mapper.listCompanies(actor.userId(), role, safeKeyword, normalizedStatus, + pageRequest.size(), pageRequest.offset()).stream().map(this::companyView).toList(); + return new PageResult<>(items, total, pageRequest.page(), pageRequest.size()); + } + + @Transactional(readOnly = true) + public CompanyView getCompany(String publicId) { + CompanyRecord company = requireCompany(publicId); + authorizationService.requireScope("masterdata:company:view", company.getPublicId(), null); + return companyView(company); + } + + @Transactional + public CompanyView updateCompany(String publicId, CompanyUpdateRequest request) { + CompanyRecord current = requireCompany(publicId); + authorizationService.requireScope("masterdata:company:edit", current.getPublicId(), null); + requireVersion(current.getVersion(), request.version()); + requireState(current.getStatus(), Set.of("DRAFT", "RETURNED")); + CompanyView before = companyView(current); + current.setName(request.name().trim()); + current.setNormalizedTaxNo(normalizeNullableCode(request.taxNo())); + current.setUpdatedBy(identityContext.requirePrincipal().userId()); + try { + if (mapper.updateCompany(current) != 1) { + throw conflict(); + } + } catch (DuplicateKeyException exception) { + throw duplicate("公司税号已存在"); + } + CompanyView after = companyView(mapper.findCompany(publicId)); + auditService.record("MASTERDATA_COMPANY_UPDATE", "COMPANY", publicId, "SUCCESS", null, before, after); + return after; + } + + @Transactional + public CompanyView submitCompany(String publicId, long version) { + CompanyRecord current = requireCompany(publicId); + authorizationService.requireScope("masterdata:company:submit", current.getPublicId(), null); + requireVersion(current.getVersion(), version); + requireState(current.getStatus(), Set.of("DRAFT", "RETURNED")); + CompanyView before = companyView(current); + long actorId = identityContext.requirePrincipal().userId(); + if (mapper.submitCompany(current.getId(), version, actorId) != 1) { + throw conflict(); + } + CompanyView after = companyView(mapper.findCompany(publicId)); + auditService.record("MASTERDATA_COMPANY_SUBMIT", "COMPANY", publicId, "SUCCESS", null, before, after); + return after; + } + + @Transactional + public CompanyView reviewCompany(String publicId, ReviewRequest request) { + CompanyRecord current = requireCompany(publicId); + authorizationService.requireScope("masterdata:company:review", current.getPublicId(), null); + requireVersion(current.getVersion(), request.version()); + requireState(current.getStatus(), Set.of("REVIEWING")); + long actorId = identityContext.requirePrincipal().userId(); + if (current.getSubmittedBy() != null && current.getSubmittedBy() == actorId) { + throw sod(); + } + String target = reviewTarget(request.decision()); + CompanyView before = companyView(current); + if (mapper.reviewCompany(current.getId(), request.version(), actorId, target, request.opinion().trim()) != 1) { + throw conflict(); + } + CompanyView after = companyView(mapper.findCompany(publicId)); + auditService.record("MASTERDATA_COMPANY_REVIEW", "COMPANY", publicId, "SUCCESS", + request.opinion().trim(), before, after); + return after; + } + + @Transactional + public CompanyView disableCompany(String publicId, long version) { + CompanyRecord current = requireCompany(publicId); + authorizationService.requireScope("masterdata:company:disable", current.getPublicId(), null); + requireVersion(current.getVersion(), version); + requireState(current.getStatus(), Set.of("ACTIVE")); + CompanyView before = companyView(current); + if (mapper.disableCompany(current.getId(), version, identityContext.requirePrincipal().userId()) != 1) { + throw conflict(); + } + CompanyView after = companyView(mapper.findCompany(publicId)); + auditService.record("MASTERDATA_COMPANY_DISABLE", "COMPANY", publicId, "SUCCESS", null, before, after); + return after; + } + + @Transactional + public CounterpartyView createCounterparty(CounterpartyCreateRequest request) { + authorizationService.requireGlobalScope("masterdata:counterparty:create"); + FinancePrincipal actor = identityContext.requirePrincipal(); + CounterpartyRecord counterparty = new CounterpartyRecord(); + counterparty.setPublicId(ulidGenerator.next()); + counterparty.setBusinessNo(normalizeCode(request.businessNo())); + counterparty.setCounterpartyType(type(request.type())); + counterparty.setName(request.name().trim()); + counterparty.setNormalizedTaxNo(normalizeNullableCode(request.taxNo())); + counterparty.setContactName(nullableTrim(request.contactName(), 100)); + counterparty.setContactPhone(nullableTrim(request.contactPhone(), 32)); + counterparty.setCreatedBy(actor.userId()); + counterparty.setUpdatedBy(actor.userId()); + try { + mapper.insertCounterparty(counterparty); + } catch (DuplicateKeyException exception) { + throw duplicate("往来单位编号或税号已存在"); + } + CounterpartyRecord stored = mapper.findCounterparty(counterparty.getPublicId()); + CounterpartyView view = counterpartyView(stored); + auditService.record("MASTERDATA_COUNTERPARTY_CREATE", "COUNTERPARTY", stored.getPublicId(), "SUCCESS", + null, null, view); + return view; + } + + @Transactional(readOnly = true) + public PageResult listCounterparties(String keyword, String status, String type, + int page, int size) { + authorizationService.requirePermission("masterdata:counterparty:view"); + Page pageRequest = page(page, size); + String normalizedStatus = status(status); + String normalizedType = type == null || type.isBlank() ? null : type(type); + FinancePrincipal actor = identityContext.requirePrincipal(); + String role = identityContext.requireActiveRole(); + String safeKeyword = nullableTrim(keyword, 100); + long total = mapper.countCounterparties(actor.userId(), role, safeKeyword, normalizedStatus, normalizedType); + var items = mapper.listCounterparties(actor.userId(), role, safeKeyword, normalizedStatus, normalizedType, + pageRequest.size(), pageRequest.offset()).stream().map(this::counterpartyView).toList(); + return new PageResult<>(items, total, pageRequest.page(), pageRequest.size()); + } + + @Transactional(readOnly = true) + public CounterpartyView getCounterparty(String publicId) { + authorizationService.requireGlobalScope("masterdata:counterparty:view"); + return counterpartyView(requireCounterparty(publicId)); + } + + @Transactional + public CounterpartyView updateCounterparty(String publicId, CounterpartyUpdateRequest request) { + CounterpartyRecord current = requireCounterparty(publicId); + authorizationService.requireGlobalScope("masterdata:counterparty:edit"); + requireVersion(current.getVersion(), request.version()); + requireState(current.getStatus(), Set.of("DRAFT", "RETURNED")); + CounterpartyView before = counterpartyView(current); + current.setCounterpartyType(type(request.type())); + current.setName(request.name().trim()); + current.setNormalizedTaxNo(normalizeNullableCode(request.taxNo())); + current.setContactName(nullableTrim(request.contactName(), 100)); + current.setContactPhone(nullableTrim(request.contactPhone(), 32)); + current.setUpdatedBy(identityContext.requirePrincipal().userId()); + try { + if (mapper.updateCounterparty(current) != 1) { + throw conflict(); + } + } catch (DuplicateKeyException exception) { + throw duplicate("往来单位税号已存在"); + } + CounterpartyView after = counterpartyView(mapper.findCounterparty(publicId)); + auditService.record("MASTERDATA_COUNTERPARTY_UPDATE", "COUNTERPARTY", publicId, "SUCCESS", null, + before, after); + return after; + } + + @Transactional + public CounterpartyView submitCounterparty(String publicId, long version) { + CounterpartyRecord current = requireCounterparty(publicId); + authorizationService.requireGlobalScope("masterdata:counterparty:submit"); + requireVersion(current.getVersion(), version); + requireState(current.getStatus(), Set.of("DRAFT", "RETURNED")); + CounterpartyView before = counterpartyView(current); + long actorId = identityContext.requirePrincipal().userId(); + if (mapper.submitCounterparty(current.getId(), version, actorId) != 1) { + throw conflict(); + } + CounterpartyView after = counterpartyView(mapper.findCounterparty(publicId)); + auditService.record("MASTERDATA_COUNTERPARTY_SUBMIT", "COUNTERPARTY", publicId, "SUCCESS", null, + before, after); + return after; + } + + @Transactional + public CounterpartyView reviewCounterparty(String publicId, ReviewRequest request) { + CounterpartyRecord current = requireCounterparty(publicId); + authorizationService.requireGlobalScope("masterdata:counterparty:review"); + requireVersion(current.getVersion(), request.version()); + requireState(current.getStatus(), Set.of("REVIEWING")); + long actorId = identityContext.requirePrincipal().userId(); + if (current.getCreatedBy() == actorId + || (current.getSubmittedBy() != null && current.getSubmittedBy() == actorId)) { + throw sod(); + } + String target = reviewTarget(request.decision()); + CounterpartyView before = counterpartyView(current); + if (mapper.reviewCounterparty(current.getId(), request.version(), actorId, target, + request.opinion().trim()) != 1) { + throw conflict(); + } + CounterpartyView after = counterpartyView(mapper.findCounterparty(publicId)); + auditService.record("MASTERDATA_COUNTERPARTY_REVIEW", "COUNTERPARTY", publicId, "SUCCESS", + request.opinion().trim(), before, after); + return after; + } + + @Transactional + public CounterpartyView disableCounterparty(String publicId, long version) { + CounterpartyRecord current = requireCounterparty(publicId); + authorizationService.requireGlobalScope("masterdata:counterparty:disable"); + requireVersion(current.getVersion(), version); + requireState(current.getStatus(), Set.of("ACTIVE")); + CounterpartyView before = counterpartyView(current); + if (mapper.disableCounterparty(current.getId(), version, identityContext.requirePrincipal().userId()) != 1) { + throw conflict(); + } + CounterpartyView after = counterpartyView(mapper.findCounterparty(publicId)); + auditService.record("MASTERDATA_COUNTERPARTY_DISABLE", "COUNTERPARTY", publicId, "SUCCESS", null, + before, after); + return after; + } + + private CompanyRecord requireCompany(String publicId) { + validatePublicId(publicId); + CompanyRecord company = mapper.findCompany(publicId); + if (company == null) { + throw notFound("公司不存在或已不可用"); + } + return company; + } + + private CounterpartyRecord requireCounterparty(String publicId) { + validatePublicId(publicId); + CounterpartyRecord counterparty = mapper.findCounterparty(publicId); + if (counterparty == null) { + throw notFound("往来单位不存在或已不可用"); + } + return counterparty; + } + + private CompanyView companyView(CompanyRecord company) { + Set actions = new LinkedHashSet<>(); + if (Set.of("DRAFT", "RETURNED").contains(company.getStatus()) + && authorizationService.hasScope("masterdata:company:edit", company.getPublicId(), null)) { + actions.add("EDIT"); + } + if (Set.of("DRAFT", "RETURNED").contains(company.getStatus()) + && authorizationService.hasScope("masterdata:company:submit", company.getPublicId(), null)) { + actions.add("SUBMIT"); + } + if ("REVIEWING".equals(company.getStatus()) + && authorizationService.hasScope("masterdata:company:review", company.getPublicId(), null)) { + actions.add("REVIEW"); + } + if ("ACTIVE".equals(company.getStatus()) + && authorizationService.hasScope("masterdata:company:disable", company.getPublicId(), null)) { + actions.add("DISABLE"); + } + return new CompanyView(company.getPublicId(), company.getBusinessNo(), company.getName(), + company.getNormalizedTaxNo(), company.getStatus(), company.getVersion(), company.getCreatedAt(), + company.getUpdatedAt(), Set.copyOf(actions)); + } + + private CounterpartyView counterpartyView(CounterpartyRecord counterparty) { + Set actions = new LinkedHashSet<>(); + if (Set.of("DRAFT", "RETURNED").contains(counterparty.getStatus()) + && authorizationService.hasScope("masterdata:counterparty:edit", null, null)) { + actions.add("EDIT"); + } + if (Set.of("DRAFT", "RETURNED").contains(counterparty.getStatus()) + && authorizationService.hasScope("masterdata:counterparty:submit", null, null)) { + actions.add("SUBMIT"); + } + if ("REVIEWING".equals(counterparty.getStatus()) + && authorizationService.hasScope("masterdata:counterparty:review", null, null)) { + actions.add("REVIEW"); + } + if ("ACTIVE".equals(counterparty.getStatus()) + && authorizationService.hasScope("masterdata:counterparty:disable", null, null)) { + actions.add("DISABLE"); + } + return new CounterpartyView(counterparty.getPublicId(), counterparty.getBusinessNo(), + counterparty.getCounterpartyType(), counterparty.getName(), counterparty.getNormalizedTaxNo(), + counterparty.getContactName(), counterparty.getContactPhone(), counterparty.getStatus(), + counterparty.getVersion(), counterparty.getCreatedAt(), counterparty.getUpdatedAt(), Set.copyOf(actions)); + } + + private Page page(int page, int size) { + if (page < 1 || !Set.of(20, 50, 100).contains(size)) { + throw new BusinessException(HttpStatus.BAD_REQUEST, ErrorCode.QUERY_PARAMETER_INVALID, + "分页参数不符合要求"); + } + return new Page(page, size, Math.multiplyExact(page - 1, size)); + } + + private String status(String value) { + if (value == null || value.isBlank()) { + return null; + } + String normalized = value.trim().toUpperCase(Locale.ROOT); + if (!COMPANY_STATUSES.contains(normalized)) { + throw new BusinessException(HttpStatus.BAD_REQUEST, ErrorCode.QUERY_PARAMETER_INVALID, "状态筛选值不存在"); + } + return normalized; + } + + private String type(String value) { + String normalized = value.trim().toUpperCase(Locale.ROOT); + if (!COUNTERPARTY_TYPES.contains(normalized)) { + throw new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.VALIDATION_FAILED, + "往来单位类型必须是 CUSTOMER、SUPPLIER 或 BOTH"); + } + return normalized; + } + + private String reviewTarget(String decision) { + return switch (decision.trim().toUpperCase(Locale.ROOT)) { + case "APPROVE" -> "ACTIVE"; + case "RETURN" -> "RETURNED"; + default -> throw new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.VALIDATION_FAILED, + "复核决定必须是 APPROVE 或 RETURN"); + }; + } + + private String normalizeCode(String value) { + return Normalizer.normalize(value.trim(), Normalizer.Form.NFKC).toUpperCase(Locale.ROOT); + } + + private String normalizeNullableCode(String value) { + return value == null || value.isBlank() ? null : normalizeCode(value); + } + + private String nullableTrim(String value, int maxLength) { + if (value == null || value.isBlank()) { + return null; + } + String trimmed = value.trim(); + if (trimmed.length() > maxLength) { + throw new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.VALIDATION_FAILED, + "查询或字段内容超过长度限制"); + } + return trimmed; + } + + private void validatePublicId(String publicId) { + if (publicId == null || !publicId.matches(ULID_PATTERN)) { + throw notFound("业务对象不存在或已不可用"); + } + } + + private void requireVersion(long current, long requested) { + if (current != requested) { + throw conflict(); + } + } + + private void requireState(String current, Set allowed) { + if (!allowed.contains(current)) { + throw new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.INVALID_STATE_TRANSITION, + "当前状态不允许执行该操作"); + } + } + + private BusinessException duplicate(String message) { + return new BusinessException(HttpStatus.CONFLICT, ErrorCode.DUPLICATE_RESOURCE, message); + } + + private BusinessException conflict() { + return new BusinessException(HttpStatus.CONFLICT, ErrorCode.VERSION_CONFLICT, + "数据已被其他人更新,请重新加载后再操作"); + } + + private BusinessException notFound(String message) { + return new BusinessException(HttpStatus.NOT_FOUND, ErrorCode.RESOURCE_NOT_FOUND, message); + } + + private BusinessException sod() { + return new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.SOD_VIOLATION, + "经办人与复核人必须是不同人员"); + } + + private record Page(int page, int size, int offset) { + } +} diff --git a/backend/src/main/java/com/kaidi/finance/masterdata/application/MasterDataVersionApplicationService.java b/backend/src/main/java/com/kaidi/finance/masterdata/application/MasterDataVersionApplicationService.java new file mode 100644 index 0000000..b838424 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/masterdata/application/MasterDataVersionApplicationService.java @@ -0,0 +1,182 @@ +package com.kaidi.finance.masterdata.application; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.kaidi.finance.masterdata.api.MasterDataVersionView; +import com.kaidi.finance.masterdata.infrastructure.MasterDataVersionMapper; +import com.kaidi.finance.shared.api.BusinessException; +import com.kaidi.finance.shared.api.ErrorCode; +import com.kaidi.finance.shared.api.PageResult; +import com.kaidi.finance.shared.security.AuthorizationService; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class MasterDataVersionApplicationService { + + private static final Map RESOURCES = Map.of( + "companies", new ResourceDefinition("COMPANY", "MASTERDATA_COMPANY_", "masterdata:company:view"), + "departments", new ResourceDefinition("DEPARTMENT", "MASTERDATA_DICTIONARY_", "masterdata:dictionary:view"), + "counterparties", new ResourceDefinition("COUNTERPARTY", "MASTERDATA_COUNTERPARTY_", + "masterdata:counterparty:view"), + "bank-accounts", new ResourceDefinition("BANK_ACCOUNT", "MASTERDATA_BANK_ACCOUNT_", + "masterdata:bank-account:view"), + "contracts", new ResourceDefinition("CONTRACT", "MASTERDATA_CONTRACT_", "masterdata:contract:view"), + "cost-categories", new ResourceDefinition("COST_CATEGORY", "MASTERDATA_DICTIONARY_", + "masterdata:dictionary:view"), + "accounts", new ResourceDefinition("ACCOUNT", "MASTERDATA_DICTIONARY_", "masterdata:dictionary:view"), + "tax-rates", new ResourceDefinition("TAX_RATE", "MASTERDATA_DICTIONARY_", "masterdata:dictionary:view") + ); + + private final MasterDataVersionMapper mapper; + private final MasterDataApplicationService masterDataService; + private final CatalogMasterDataApplicationService catalogService; + private final AuthorizationService authorizationService; + private final ObjectMapper objectMapper; + + public MasterDataVersionApplicationService(MasterDataVersionMapper mapper, + MasterDataApplicationService masterDataService, + CatalogMasterDataApplicationService catalogService, + AuthorizationService authorizationService, + ObjectMapper objectMapper) { + this.mapper = mapper; + this.masterDataService = masterDataService; + this.catalogService = catalogService; + this.authorizationService = authorizationService; + this.objectMapper = objectMapper; + } + + public boolean canView(String resource) { + ResourceDefinition definition = RESOURCES.get(resource); + return definition != null && authorizationService.hasPermission(definition.viewPermission()); + } + + @Transactional(readOnly = true) + public PageResult listVersions(String resource, String publicId, int page, int size) { + ResourceDefinition definition = definition(resource); + requireResourceAccess(resource, publicId); + Page request = page(page, size); + long total = mapper.count(definition.objectType(), publicId, definition.actionPrefix()); + var items = mapper.list(definition.objectType(), publicId, definition.actionPrefix(), + request.size(), request.offset()).stream() + .map(this::view) + .toList(); + return new PageResult<>(items, total, request.page(), request.size()); + } + + private void requireResourceAccess(String resource, String publicId) { + switch (resource) { + case "companies" -> masterDataService.getCompany(publicId); + case "counterparties" -> masterDataService.getCounterparty(publicId); + case "bank-accounts" -> catalogService.getBankAccount(publicId); + case "contracts" -> catalogService.getContract(publicId); + case "departments", "cost-categories", "accounts", "tax-rates" -> + catalogService.getDictionary(resource, publicId); + default -> throw queryInvalid("不支持的主数据资源"); + } + } + + private MasterDataVersionView view(MasterDataVersionMapper.VersionRow row) { + JsonNode before = snapshot(row.beforeJson()); + JsonNode after = snapshot(row.afterJson()); + JsonNode current = after != null && !after.isNull() ? after : before; + long version = current == null ? 0 : current.path("version").asLong(); + String status = current == null || current.path("status").isMissingNode() + ? null : current.path("status").asText(); + return new MasterDataVersionView(row.auditId(), version, status, row.actionCode(), row.actorName(), + row.changedAt(), before, after); + } + + private JsonNode snapshot(String json) { + if (json == null) { + return null; + } + try { + JsonNode node = objectMapper.readTree(json); + if (!(node instanceof ObjectNode)) { + throw new IllegalStateException("主数据历史快照必须是 JSON 对象"); + } + removeAllowedActions(node); + maskSensitiveFields(node); + return node; + } catch (JsonProcessingException exception) { + throw new IllegalStateException("主数据历史快照解析失败", exception); + } + } + + private void removeAllowedActions(JsonNode node) { + if (node instanceof ObjectNode object) { + object.remove("allowedActions"); + object.elements().forEachRemaining(this::removeAllowedActions); + } else if (node instanceof ArrayNode array) { + array.elements().forEachRemaining(this::removeAllowedActions); + } + } + + private void maskSensitiveFields(JsonNode node) { + if (node instanceof ObjectNode object) { + object.properties().forEach(entry -> { + if (isSensitiveField(entry.getKey())) { + object.put(entry.getKey(), "***"); + } else { + maskSensitiveFields(entry.getValue()); + } + }); + } else if (node instanceof ArrayNode array) { + array.elements().forEachRemaining(this::maskSensitiveFields); + } + } + + private boolean isSensitiveField(String fieldName) { + String normalized = fieldName.replaceAll("[^A-Za-z0-9]", "").toLowerCase(Locale.ROOT); + return normalized.contains("password") + || normalized.contains("token") + || normalized.contains("secret") + || normalized.contains("privatekey") + || normalized.contains("encryptionkey") + || normalized.endsWith("accountno") + || normalized.endsWith("accountnumber") + || normalized.endsWith("routingnumber") + || normalized.contains("idcard") + || normalized.contains("identitynumber") + || normalized.endsWith("phone") + || normalized.endsWith("mobile") + || normalized.contains("salary"); + } + + private ResourceDefinition definition(String resource) { + ResourceDefinition definition = RESOURCES.get(resource); + if (definition == null) { + throw queryInvalid("不支持的主数据资源"); + } + return definition; + } + + private Page page(int page, int size) { + if (page < 1 || !Set.of(20, 50, 100).contains(size)) { + throw queryInvalid("分页参数不符合要求"); + } + try { + return new Page(page, size, Math.toIntExact(Math.multiplyExact((long) page - 1L, size))); + } catch (ArithmeticException exception) { + throw queryInvalid("分页参数超出允许范围"); + } + } + + private BusinessException queryInvalid(String message) { + return new BusinessException(HttpStatus.BAD_REQUEST, ErrorCode.QUERY_PARAMETER_INVALID, message); + } + + private record ResourceDefinition(String objectType, String actionPrefix, String viewPermission) { + } + + private record Page(int page, int size, int offset) { + } +} diff --git a/backend/src/main/java/com/kaidi/finance/masterdata/domain/BankAccountRecord.java b/backend/src/main/java/com/kaidi/finance/masterdata/domain/BankAccountRecord.java new file mode 100644 index 0000000..54edde4 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/masterdata/domain/BankAccountRecord.java @@ -0,0 +1,94 @@ +package com.kaidi.finance.masterdata.domain; + +import java.time.LocalDateTime; + +public class BankAccountRecord { + private long id; + private String publicId; + private String ownerType; + private Long companyId; + private String companyPublicId; + private String companyName; + private Long counterpartyId; + private String counterpartyPublicId; + private String counterpartyName; + private String accountCategory; + private String accountName; + private String bankName; + private byte[] accountNoCiphertext; + private String accountNoHash; + private String maskedAccountNo; + private int versionNo; + private LocalDateTime validFrom; + private LocalDateTime validTo; + private String status; + private long version; + private long createdBy; + private long updatedBy; + private Long submittedBy; + private LocalDateTime submittedAt; + private Long reviewedBy; + private LocalDateTime reviewedAt; + private String reviewOpinion; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + public long getId() { return id; } + public void setId(long id) { this.id = id; } + public String getPublicId() { return publicId; } + public void setPublicId(String publicId) { this.publicId = publicId; } + public String getOwnerType() { return ownerType; } + public void setOwnerType(String ownerType) { this.ownerType = ownerType; } + public Long getCompanyId() { return companyId; } + public void setCompanyId(Long companyId) { this.companyId = companyId; } + public String getCompanyPublicId() { return companyPublicId; } + public void setCompanyPublicId(String companyPublicId) { this.companyPublicId = companyPublicId; } + public String getCompanyName() { return companyName; } + public void setCompanyName(String companyName) { this.companyName = companyName; } + public Long getCounterpartyId() { return counterpartyId; } + public void setCounterpartyId(Long counterpartyId) { this.counterpartyId = counterpartyId; } + public String getCounterpartyPublicId() { return counterpartyPublicId; } + public void setCounterpartyPublicId(String counterpartyPublicId) { this.counterpartyPublicId = counterpartyPublicId; } + public String getCounterpartyName() { return counterpartyName; } + public void setCounterpartyName(String counterpartyName) { this.counterpartyName = counterpartyName; } + public String getAccountCategory() { return accountCategory; } + public void setAccountCategory(String accountCategory) { this.accountCategory = accountCategory; } + public String getAccountName() { return accountName; } + public void setAccountName(String accountName) { this.accountName = accountName; } + public String getBankName() { return bankName; } + public void setBankName(String bankName) { this.bankName = bankName; } + public byte[] getAccountNoCiphertext() { return accountNoCiphertext; } + public void setAccountNoCiphertext(byte[] accountNoCiphertext) { this.accountNoCiphertext = accountNoCiphertext; } + public String getAccountNoHash() { return accountNoHash; } + public void setAccountNoHash(String accountNoHash) { this.accountNoHash = accountNoHash; } + public String getMaskedAccountNo() { return maskedAccountNo; } + public void setMaskedAccountNo(String maskedAccountNo) { this.maskedAccountNo = maskedAccountNo; } + public int getVersionNo() { return versionNo; } + public void setVersionNo(int versionNo) { this.versionNo = versionNo; } + public LocalDateTime getValidFrom() { return validFrom; } + public void setValidFrom(LocalDateTime validFrom) { this.validFrom = validFrom; } + public LocalDateTime getValidTo() { return validTo; } + public void setValidTo(LocalDateTime validTo) { this.validTo = validTo; } + public String getStatus() { return status; } + public void setStatus(String status) { this.status = status; } + public long getVersion() { return version; } + public void setVersion(long version) { this.version = version; } + public long getCreatedBy() { return createdBy; } + public void setCreatedBy(long createdBy) { this.createdBy = createdBy; } + public long getUpdatedBy() { return updatedBy; } + public void setUpdatedBy(long updatedBy) { this.updatedBy = updatedBy; } + public Long getSubmittedBy() { return submittedBy; } + public void setSubmittedBy(Long submittedBy) { this.submittedBy = submittedBy; } + public LocalDateTime getSubmittedAt() { return submittedAt; } + public void setSubmittedAt(LocalDateTime submittedAt) { this.submittedAt = submittedAt; } + public Long getReviewedBy() { return reviewedBy; } + public void setReviewedBy(Long reviewedBy) { this.reviewedBy = reviewedBy; } + public LocalDateTime getReviewedAt() { return reviewedAt; } + public void setReviewedAt(LocalDateTime reviewedAt) { this.reviewedAt = reviewedAt; } + public String getReviewOpinion() { return reviewOpinion; } + public void setReviewOpinion(String reviewOpinion) { this.reviewOpinion = reviewOpinion; } + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } + public LocalDateTime getUpdatedAt() { return updatedAt; } + public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; } +} diff --git a/backend/src/main/java/com/kaidi/finance/masterdata/domain/CompanyRecord.java b/backend/src/main/java/com/kaidi/finance/masterdata/domain/CompanyRecord.java new file mode 100644 index 0000000..a081e09 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/masterdata/domain/CompanyRecord.java @@ -0,0 +1,55 @@ +package com.kaidi.finance.masterdata.domain; + +import java.time.LocalDateTime; + +public class CompanyRecord { + private long id; + private String publicId; + private String businessNo; + private String name; + private String normalizedTaxNo; + private String status; + private long version; + private long createdBy; + private long updatedBy; + private Long submittedBy; + private LocalDateTime submittedAt; + private Long reviewedBy; + private LocalDateTime reviewedAt; + private String reviewOpinion; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + public long getId() { return id; } + public void setId(long id) { this.id = id; } + public String getPublicId() { return publicId; } + public void setPublicId(String publicId) { this.publicId = publicId; } + public String getBusinessNo() { return businessNo; } + public void setBusinessNo(String businessNo) { this.businessNo = businessNo; } + public String getName() { return name; } + public void setName(String name) { this.name = name; } + public String getNormalizedTaxNo() { return normalizedTaxNo; } + public void setNormalizedTaxNo(String normalizedTaxNo) { this.normalizedTaxNo = normalizedTaxNo; } + public String getStatus() { return status; } + public void setStatus(String status) { this.status = status; } + public long getVersion() { return version; } + public void setVersion(long version) { this.version = version; } + public long getCreatedBy() { return createdBy; } + public void setCreatedBy(long createdBy) { this.createdBy = createdBy; } + public long getUpdatedBy() { return updatedBy; } + public void setUpdatedBy(long updatedBy) { this.updatedBy = updatedBy; } + public Long getSubmittedBy() { return submittedBy; } + public void setSubmittedBy(Long submittedBy) { this.submittedBy = submittedBy; } + public LocalDateTime getSubmittedAt() { return submittedAt; } + public void setSubmittedAt(LocalDateTime submittedAt) { this.submittedAt = submittedAt; } + public Long getReviewedBy() { return reviewedBy; } + public void setReviewedBy(Long reviewedBy) { this.reviewedBy = reviewedBy; } + public LocalDateTime getReviewedAt() { return reviewedAt; } + public void setReviewedAt(LocalDateTime reviewedAt) { this.reviewedAt = reviewedAt; } + public String getReviewOpinion() { return reviewOpinion; } + public void setReviewOpinion(String reviewOpinion) { this.reviewOpinion = reviewOpinion; } + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } + public LocalDateTime getUpdatedAt() { return updatedAt; } + public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; } +} diff --git a/backend/src/main/java/com/kaidi/finance/masterdata/domain/ContractMasterRecord.java b/backend/src/main/java/com/kaidi/finance/masterdata/domain/ContractMasterRecord.java new file mode 100644 index 0000000..58ba74c --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/masterdata/domain/ContractMasterRecord.java @@ -0,0 +1,92 @@ +package com.kaidi.finance.masterdata.domain; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +public class ContractMasterRecord { + private long id; + private String publicId; + private long companyId; + private String companyPublicId; + private String companyName; + private long projectId; + private String projectPublicId; + private String projectName; + private long counterpartyId; + private String counterpartyPublicId; + private String counterpartyName; + private String businessNo; + private String name; + private BigDecimal originalAmount; + private BigDecimal approvedChangeAmount; + private BigDecimal settlementAmount; + private String currency; + private String status; + private long version; + private long createdBy; + private long updatedBy; + private Long submittedBy; + private LocalDateTime submittedAt; + private Long reviewedBy; + private LocalDateTime reviewedAt; + private String reviewOpinion; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + public long getId() { return id; } + public void setId(long id) { this.id = id; } + public String getPublicId() { return publicId; } + public void setPublicId(String publicId) { this.publicId = publicId; } + public long getCompanyId() { return companyId; } + public void setCompanyId(long companyId) { this.companyId = companyId; } + public String getCompanyPublicId() { return companyPublicId; } + public void setCompanyPublicId(String companyPublicId) { this.companyPublicId = companyPublicId; } + public String getCompanyName() { return companyName; } + public void setCompanyName(String companyName) { this.companyName = companyName; } + public long getProjectId() { return projectId; } + public void setProjectId(long projectId) { this.projectId = projectId; } + public String getProjectPublicId() { return projectPublicId; } + public void setProjectPublicId(String projectPublicId) { this.projectPublicId = projectPublicId; } + public String getProjectName() { return projectName; } + public void setProjectName(String projectName) { this.projectName = projectName; } + public long getCounterpartyId() { return counterpartyId; } + public void setCounterpartyId(long counterpartyId) { this.counterpartyId = counterpartyId; } + public String getCounterpartyPublicId() { return counterpartyPublicId; } + public void setCounterpartyPublicId(String counterpartyPublicId) { this.counterpartyPublicId = counterpartyPublicId; } + public String getCounterpartyName() { return counterpartyName; } + public void setCounterpartyName(String counterpartyName) { this.counterpartyName = counterpartyName; } + public String getBusinessNo() { return businessNo; } + public void setBusinessNo(String businessNo) { this.businessNo = businessNo; } + public String getName() { return name; } + public void setName(String name) { this.name = name; } + public BigDecimal getOriginalAmount() { return originalAmount; } + public void setOriginalAmount(BigDecimal originalAmount) { this.originalAmount = originalAmount; } + public BigDecimal getApprovedChangeAmount() { return approvedChangeAmount; } + public void setApprovedChangeAmount(BigDecimal approvedChangeAmount) { this.approvedChangeAmount = approvedChangeAmount; } + public BigDecimal getSettlementAmount() { return settlementAmount; } + public void setSettlementAmount(BigDecimal settlementAmount) { this.settlementAmount = settlementAmount; } + public String getCurrency() { return currency; } + public void setCurrency(String currency) { this.currency = currency; } + public String getStatus() { return status; } + public void setStatus(String status) { this.status = status; } + public long getVersion() { return version; } + public void setVersion(long version) { this.version = version; } + public long getCreatedBy() { return createdBy; } + public void setCreatedBy(long createdBy) { this.createdBy = createdBy; } + public long getUpdatedBy() { return updatedBy; } + public void setUpdatedBy(long updatedBy) { this.updatedBy = updatedBy; } + public Long getSubmittedBy() { return submittedBy; } + public void setSubmittedBy(Long submittedBy) { this.submittedBy = submittedBy; } + public LocalDateTime getSubmittedAt() { return submittedAt; } + public void setSubmittedAt(LocalDateTime submittedAt) { this.submittedAt = submittedAt; } + public Long getReviewedBy() { return reviewedBy; } + public void setReviewedBy(Long reviewedBy) { this.reviewedBy = reviewedBy; } + public LocalDateTime getReviewedAt() { return reviewedAt; } + public void setReviewedAt(LocalDateTime reviewedAt) { this.reviewedAt = reviewedAt; } + public String getReviewOpinion() { return reviewOpinion; } + public void setReviewOpinion(String reviewOpinion) { this.reviewOpinion = reviewOpinion; } + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } + public LocalDateTime getUpdatedAt() { return updatedAt; } + public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; } +} diff --git a/backend/src/main/java/com/kaidi/finance/masterdata/domain/CounterpartyRecord.java b/backend/src/main/java/com/kaidi/finance/masterdata/domain/CounterpartyRecord.java new file mode 100644 index 0000000..296fae7 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/masterdata/domain/CounterpartyRecord.java @@ -0,0 +1,64 @@ +package com.kaidi.finance.masterdata.domain; + +import java.time.LocalDateTime; + +public class CounterpartyRecord { + private long id; + private String publicId; + private String businessNo; + private String counterpartyType; + private String name; + private String normalizedTaxNo; + private String contactName; + private String contactPhone; + private String status; + private long version; + private long createdBy; + private long updatedBy; + private Long submittedBy; + private LocalDateTime submittedAt; + private Long reviewedBy; + private LocalDateTime reviewedAt; + private String reviewOpinion; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + public long getId() { return id; } + public void setId(long id) { this.id = id; } + public String getPublicId() { return publicId; } + public void setPublicId(String publicId) { this.publicId = publicId; } + public String getBusinessNo() { return businessNo; } + public void setBusinessNo(String businessNo) { this.businessNo = businessNo; } + public String getCounterpartyType() { return counterpartyType; } + public void setCounterpartyType(String counterpartyType) { this.counterpartyType = counterpartyType; } + public String getName() { return name; } + public void setName(String name) { this.name = name; } + public String getNormalizedTaxNo() { return normalizedTaxNo; } + public void setNormalizedTaxNo(String normalizedTaxNo) { this.normalizedTaxNo = normalizedTaxNo; } + public String getContactName() { return contactName; } + public void setContactName(String contactName) { this.contactName = contactName; } + public String getContactPhone() { return contactPhone; } + public void setContactPhone(String contactPhone) { this.contactPhone = contactPhone; } + public String getStatus() { return status; } + public void setStatus(String status) { this.status = status; } + public long getVersion() { return version; } + public void setVersion(long version) { this.version = version; } + public long getCreatedBy() { return createdBy; } + public void setCreatedBy(long createdBy) { this.createdBy = createdBy; } + public long getUpdatedBy() { return updatedBy; } + public void setUpdatedBy(long updatedBy) { this.updatedBy = updatedBy; } + public Long getSubmittedBy() { return submittedBy; } + public void setSubmittedBy(Long submittedBy) { this.submittedBy = submittedBy; } + public LocalDateTime getSubmittedAt() { return submittedAt; } + public void setSubmittedAt(LocalDateTime submittedAt) { this.submittedAt = submittedAt; } + public Long getReviewedBy() { return reviewedBy; } + public void setReviewedBy(Long reviewedBy) { this.reviewedBy = reviewedBy; } + public LocalDateTime getReviewedAt() { return reviewedAt; } + public void setReviewedAt(LocalDateTime reviewedAt) { this.reviewedAt = reviewedAt; } + public String getReviewOpinion() { return reviewOpinion; } + public void setReviewOpinion(String reviewOpinion) { this.reviewOpinion = reviewOpinion; } + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } + public LocalDateTime getUpdatedAt() { return updatedAt; } + public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; } +} diff --git a/backend/src/main/java/com/kaidi/finance/masterdata/domain/DictionaryRecord.java b/backend/src/main/java/com/kaidi/finance/masterdata/domain/DictionaryRecord.java new file mode 100644 index 0000000..bbccc0e --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/masterdata/domain/DictionaryRecord.java @@ -0,0 +1,71 @@ +package com.kaidi.finance.masterdata.domain; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +public class DictionaryRecord { + private long id; + private String publicId; + private String code; + private String name; + private Long parentId; + private String parentPublicId; + private String parentName; + private String accountType; + private Boolean auxiliaryRequired; + private BigDecimal rate; + private String status; + private long version; + private Long createdBy; + private Long updatedBy; + private Long submittedBy; + private LocalDateTime submittedAt; + private Long reviewedBy; + private LocalDateTime reviewedAt; + private String reviewOpinion; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + public long getId() { return id; } + public void setId(long id) { this.id = id; } + public String getPublicId() { return publicId; } + public void setPublicId(String publicId) { this.publicId = publicId; } + public String getCode() { return code; } + public void setCode(String code) { this.code = code; } + public String getName() { return name; } + public void setName(String name) { this.name = name; } + public Long getParentId() { return parentId; } + public void setParentId(Long parentId) { this.parentId = parentId; } + public String getParentPublicId() { return parentPublicId; } + public void setParentPublicId(String parentPublicId) { this.parentPublicId = parentPublicId; } + public String getParentName() { return parentName; } + public void setParentName(String parentName) { this.parentName = parentName; } + public String getAccountType() { return accountType; } + public void setAccountType(String accountType) { this.accountType = accountType; } + public Boolean getAuxiliaryRequired() { return auxiliaryRequired; } + public void setAuxiliaryRequired(Boolean auxiliaryRequired) { this.auxiliaryRequired = auxiliaryRequired; } + public BigDecimal getRate() { return rate; } + public void setRate(BigDecimal rate) { this.rate = rate; } + public String getStatus() { return status; } + public void setStatus(String status) { this.status = status; } + public long getVersion() { return version; } + public void setVersion(long version) { this.version = version; } + public Long getCreatedBy() { return createdBy; } + public void setCreatedBy(Long createdBy) { this.createdBy = createdBy; } + public Long getUpdatedBy() { return updatedBy; } + public void setUpdatedBy(Long updatedBy) { this.updatedBy = updatedBy; } + public Long getSubmittedBy() { return submittedBy; } + public void setSubmittedBy(Long submittedBy) { this.submittedBy = submittedBy; } + public LocalDateTime getSubmittedAt() { return submittedAt; } + public void setSubmittedAt(LocalDateTime submittedAt) { this.submittedAt = submittedAt; } + public Long getReviewedBy() { return reviewedBy; } + public void setReviewedBy(Long reviewedBy) { this.reviewedBy = reviewedBy; } + public LocalDateTime getReviewedAt() { return reviewedAt; } + public void setReviewedAt(LocalDateTime reviewedAt) { this.reviewedAt = reviewedAt; } + public String getReviewOpinion() { return reviewOpinion; } + public void setReviewOpinion(String reviewOpinion) { this.reviewOpinion = reviewOpinion; } + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } + public LocalDateTime getUpdatedAt() { return updatedAt; } + public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; } +} diff --git a/backend/src/main/java/com/kaidi/finance/masterdata/domain/MasterDataReferenceRecord.java b/backend/src/main/java/com/kaidi/finance/masterdata/domain/MasterDataReferenceRecord.java new file mode 100644 index 0000000..6b5101c --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/masterdata/domain/MasterDataReferenceRecord.java @@ -0,0 +1,5 @@ +package com.kaidi.finance.masterdata.domain; + +public record MasterDataReferenceRecord(long id, String publicId, String code, String name, + String companyPublicId, String type) { +} diff --git a/backend/src/main/java/com/kaidi/finance/masterdata/infrastructure/CatalogMasterDataMapper.java b/backend/src/main/java/com/kaidi/finance/masterdata/infrastructure/CatalogMasterDataMapper.java new file mode 100644 index 0000000..d7b29e7 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/masterdata/infrastructure/CatalogMasterDataMapper.java @@ -0,0 +1,609 @@ +package com.kaidi.finance.masterdata.infrastructure; + +import com.kaidi.finance.masterdata.domain.BankAccountRecord; +import com.kaidi.finance.masterdata.domain.ContractMasterRecord; +import com.kaidi.finance.masterdata.domain.DictionaryRecord; +import com.kaidi.finance.masterdata.domain.MasterDataReferenceRecord; +import java.util.List; +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +@org.apache.ibatis.annotations.Mapper +public interface CatalogMasterDataMapper { + + @Select(""" + SELECT company.id, company.public_id, company.business_no AS code, company.name, + company.public_id AS company_public_id, 'COMPANY' AS type + FROM md_company company + WHERE company.public_id = #{publicId} AND company.status = 'ACTIVE' + """) + MasterDataReferenceRecord findCompanyReference(String publicId); + + @Select(""" + SELECT project.id, project.public_id, project.business_no AS code, project.name, + company.public_id AS company_public_id, 'PROJECT' AS type + FROM md_project project + JOIN md_company company ON company.id = project.company_id + WHERE project.public_id = #{publicId} AND project.status = 'ACTIVE' AND company.status = 'ACTIVE' + """) + MasterDataReferenceRecord findProjectReference(String publicId); + + @Select(""" + SELECT counterparty.id, counterparty.public_id, counterparty.business_no AS code, counterparty.name, + NULL AS company_public_id, counterparty.counterparty_type AS type + FROM md_counterparty counterparty + WHERE counterparty.public_id = #{publicId} AND counterparty.status = 'ACTIVE' + """) + MasterDataReferenceRecord findCounterpartyReference(String publicId); + + @Select(""" + SELECT category.id, category.public_id, category.code, category.name, + NULL AS company_public_id, 'COST_CATEGORY' AS type + FROM md_cost_category category + WHERE category.public_id = #{publicId} AND category.status = 'ACTIVE' + """) + MasterDataReferenceRecord findCostCategoryReference(String publicId); + + @Select(""" + SELECT company.id, company.public_id, company.business_no AS code, company.name, + company.public_id AS company_public_id, 'COMPANY' AS type + FROM md_company company + WHERE company.status = 'ACTIVE' + AND EXISTS ( + SELECT 1 FROM iam_scope scope + JOIN iam_role role ON role.id = scope.role_id + JOIN iam_permission permission ON permission.id = scope.permission_id + WHERE scope.user_id = #{userId} AND role.code = #{roleCode} + AND permission.code = 'masterdata:company:view' + AND scope.status = 'ACTIVE' + AND scope.valid_from <= UTC_TIMESTAMP(3) + AND (scope.valid_to IS NULL OR scope.valid_to >= UTC_TIMESTAMP(3)) + AND (scope.scope_type = 'GLOBAL' + OR (scope.scope_type = 'COMPANY' AND scope.company_public_id = company.public_id)) + ) + ORDER BY company.business_no + """) + List listCompanyReferences(@Param("userId") long userId, + @Param("roleCode") String roleCode); + + @Select(""" + SELECT project.id, project.public_id, project.business_no AS code, project.name, + company.public_id AS company_public_id, 'PROJECT' AS type + FROM md_project project + JOIN md_company company ON company.id = project.company_id + WHERE project.status = 'ACTIVE' AND company.status = 'ACTIVE' + AND EXISTS ( + SELECT 1 FROM iam_scope scope + JOIN iam_role role ON role.id = scope.role_id + JOIN iam_permission permission ON permission.id = scope.permission_id + WHERE scope.user_id = #{userId} AND role.code = #{roleCode} + AND permission.code = 'project:project:view' + AND scope.status = 'ACTIVE' + AND scope.valid_from <= UTC_TIMESTAMP(3) + AND (scope.valid_to IS NULL OR scope.valid_to >= UTC_TIMESTAMP(3)) + AND (scope.scope_type = 'GLOBAL' + OR (scope.scope_type = 'COMPANY' AND scope.company_public_id = company.public_id) + OR (scope.scope_type = 'PROJECT' AND scope.project_public_id = project.public_id)) + ) + ORDER BY project.business_no + """) + List listProjectReferences(@Param("userId") long userId, + @Param("roleCode") String roleCode); + + @Select(""" + SELECT counterparty.id, counterparty.public_id, counterparty.business_no AS code, counterparty.name, + NULL AS company_public_id, counterparty.counterparty_type AS type + FROM md_counterparty counterparty + WHERE counterparty.status = 'ACTIVE' + ORDER BY counterparty.business_no + """) + List listCounterpartyReferences(); + + @Select(""" + SELECT category.id, category.public_id, category.code, category.name, + NULL AS company_public_id, 'COST_CATEGORY' AS type + FROM md_cost_category category + WHERE category.status = 'ACTIVE' + ORDER BY category.code + """) + List listCostCategoryReferences(); + + @Insert(""" + INSERT INTO md_bank_account_version ( + public_id, owner_type, company_id, counterparty_id, account_category, + account_name, bank_name, account_no_ciphertext, account_no_hash, masked_account_no, + version_no, valid_from, valid_to, created_by, updated_by + ) VALUES ( + #{publicId}, #{ownerType}, #{companyId}, #{counterpartyId}, #{accountCategory}, + #{accountName}, #{bankName}, #{accountNoCiphertext}, #{accountNoHash}, #{maskedAccountNo}, + #{versionNo}, #{validFrom}, #{validTo}, #{createdBy}, #{updatedBy} + ) + """) + int insertBankAccount(BankAccountRecord record); + + @Select(""" + SELECT account.*, company.public_id AS company_public_id, company.name AS company_name, + counterparty.public_id AS counterparty_public_id, counterparty.name AS counterparty_name + FROM md_bank_account_version account + LEFT JOIN md_company company ON company.id = account.company_id + LEFT JOIN md_counterparty counterparty ON counterparty.id = account.counterparty_id + WHERE account.public_id = #{publicId} + """) + BankAccountRecord findBankAccount(String publicId); + + @Select(""" + + """) + List listBankAccounts(@Param("userId") long userId, + @Param("roleCode") String roleCode, + @Param("keyword") String keyword, + @Param("status") String status, + @Param("limit") int limit, + @Param("offset") int offset); + + @Select(""" + + """) + long countBankAccounts(@Param("userId") long userId, @Param("roleCode") String roleCode, + @Param("keyword") String keyword, @Param("status") String status); + + @Select(""" + SELECT COALESCE(MAX(version_no), 0) + 1 + FROM md_bank_account_version + WHERE (#{companyId} IS NOT NULL AND company_id = #{companyId}) + OR (#{counterpartyId} IS NOT NULL AND counterparty_id = #{counterpartyId}) + """) + int nextBankAccountVersion(@Param("companyId") Long companyId, + @Param("counterpartyId") Long counterpartyId); + + @Select(""" + SELECT COUNT(*) FROM md_bank_account_version + WHERE account_no_hash = #{hash} AND status <> 'DISABLED' + AND (#{excludeId} IS NULL OR id <> #{excludeId}) + AND ((#{companyId} IS NOT NULL AND company_id = #{companyId}) + OR (#{counterpartyId} IS NOT NULL AND counterparty_id = #{counterpartyId})) + """) + long countDuplicateBankAccount(@Param("companyId") Long companyId, + @Param("counterpartyId") Long counterpartyId, + @Param("hash") String hash, + @Param("excludeId") Long excludeId); + + @Update(""" + UPDATE md_bank_account_version + SET account_category = #{accountCategory}, account_name = #{accountName}, bank_name = #{bankName}, + account_no_ciphertext = #{accountNoCiphertext}, account_no_hash = #{accountNoHash}, + masked_account_no = #{maskedAccountNo}, valid_from = #{validFrom}, valid_to = #{validTo}, + updated_by = #{updatedBy}, version = version + 1 + WHERE id = #{id} AND version = #{version} AND status IN ('DRAFT', 'RETURNED') + """) + int updateBankAccount(BankAccountRecord record); + + @Update(""" + UPDATE md_bank_account_version + SET status = 'REVIEWING', submitted_by = #{actorId}, submitted_at = UTC_TIMESTAMP(3), + reviewed_by = NULL, reviewed_at = NULL, review_opinion = NULL, + updated_by = #{actorId}, version = version + 1 + WHERE id = #{id} AND version = #{version} AND status IN ('DRAFT', 'RETURNED') + """) + int submitBankAccount(@Param("id") long id, @Param("version") long version, + @Param("actorId") long actorId); + + @Update(""" + UPDATE md_bank_account_version + SET status = #{targetStatus}, reviewed_by = #{actorId}, reviewed_at = UTC_TIMESTAMP(3), + review_opinion = #{opinion}, updated_by = #{actorId}, version = version + 1 + WHERE id = #{id} AND version = #{version} AND status = 'REVIEWING' + AND submitted_by <> #{actorId} + """) + int reviewBankAccount(@Param("id") long id, @Param("version") long version, + @Param("actorId") long actorId, @Param("targetStatus") String targetStatus, + @Param("opinion") String opinion); + + @Select(""" + SELECT CASE + WHEN account.company_id IS NOT NULL THEN company.status + ELSE counterparty.status + END + FROM md_bank_account_version account + LEFT JOIN md_company company ON company.id = account.company_id + LEFT JOIN md_counterparty counterparty ON counterparty.id = account.counterparty_id + WHERE account.id = #{accountId} + """) + String findBankAccountOwnerStatus(long accountId); + + @Update(""" + UPDATE md_bank_account_version + SET status = 'DISABLED', valid_to = COALESCE(valid_to, UTC_TIMESTAMP(3)), + updated_by = #{actorId}, version = version + 1 + WHERE id = #{id} AND version = #{version} AND status = 'ACTIVE' + """) + int disableBankAccount(@Param("id") long id, @Param("version") long version, + @Param("actorId") long actorId); + + @Insert(""" + INSERT INTO md_contract ( + public_id, company_id, project_id, counterparty_id, business_no, name, + original_amount, approved_change_amount, currency, created_by, updated_by + ) VALUES ( + #{publicId}, #{companyId}, #{projectId}, #{counterpartyId}, #{businessNo}, #{name}, + #{originalAmount}, 0, #{currency}, #{createdBy}, #{updatedBy} + ) + """) + int insertContract(ContractMasterRecord record); + + @Select(""" + SELECT contract.*, company.public_id AS company_public_id, company.name AS company_name, + project.public_id AS project_public_id, project.name AS project_name, + counterparty.public_id AS counterparty_public_id, counterparty.name AS counterparty_name + FROM md_contract contract + JOIN md_company company ON company.id = contract.company_id + JOIN md_project project ON project.id = contract.project_id + JOIN md_counterparty counterparty ON counterparty.id = contract.counterparty_id + WHERE contract.public_id = #{publicId} + """) + ContractMasterRecord findContract(String publicId); + + @Select(""" + + """) + List listContracts(@Param("userId") long userId, + @Param("roleCode") String roleCode, + @Param("keyword") String keyword, + @Param("status") String status, + @Param("limit") int limit, + @Param("offset") int offset); + + @Select(""" + + """) + long countContracts(@Param("userId") long userId, @Param("roleCode") String roleCode, + @Param("keyword") String keyword, @Param("status") String status); + + @Update(""" + UPDATE md_contract + SET company_id = #{companyId}, project_id = #{projectId}, counterparty_id = #{counterpartyId}, + name = #{name}, original_amount = #{originalAmount}, currency = #{currency}, + updated_by = #{updatedBy}, version = version + 1 + WHERE id = #{id} AND version = #{version} AND status IN ('DRAFT', 'RETURNED') + """) + int updateContract(ContractMasterRecord record); + + @Update(""" + UPDATE md_contract + SET status = 'REVIEWING', submitted_by = #{actorId}, submitted_at = UTC_TIMESTAMP(3), + reviewed_by = NULL, reviewed_at = NULL, review_opinion = NULL, + updated_by = #{actorId}, version = version + 1 + WHERE id = #{id} AND version = #{version} AND status IN ('DRAFT', 'RETURNED') + """) + int submitContract(@Param("id") long id, @Param("version") long version, + @Param("actorId") long actorId); + + @Update(""" + UPDATE md_contract + SET status = #{targetStatus}, reviewed_by = #{actorId}, reviewed_at = UTC_TIMESTAMP(3), + review_opinion = #{opinion}, updated_by = #{actorId}, version = version + 1 + WHERE id = #{id} AND version = #{version} AND status = 'REVIEWING' + AND submitted_by <> #{actorId} + """) + int reviewContract(@Param("id") long id, @Param("version") long version, + @Param("actorId") long actorId, @Param("targetStatus") String targetStatus, + @Param("opinion") String opinion); + + @Update(""" + UPDATE md_contract + SET status = 'DISABLED', updated_by = #{actorId}, version = version + 1 + WHERE id = #{id} AND version = #{version} AND status = 'ACTIVE' + """) + int disableContract(@Param("id") long id, @Param("version") long version, + @Param("actorId") long actorId); + + @Insert(""" + + """) + int insertDictionary(@Param("resource") String resource, @Param("record") DictionaryRecord record); + + @Select(""" + + """) + DictionaryRecord findDictionary(@Param("resource") String resource, @Param("publicId") String publicId); + + @Select(""" + + """) + List listDictionaries(@Param("resource") String resource, + @Param("keyword") String keyword, + @Param("status") String status, + @Param("limit") int limit, + @Param("offset") int offset); + + @Select(""" + + """) + long countDictionaries(@Param("resource") String resource, @Param("keyword") String keyword, + @Param("status") String status); + + @Update(""" + + """) + int updateDictionary(@Param("resource") String resource, @Param("record") DictionaryRecord record); + + @Update(""" + + """) + int submitDictionary(@Param("resource") String resource, @Param("id") long id, + @Param("version") long version, @Param("actorId") long actorId); + + @Update(""" + + """) + int reviewDictionary(@Param("resource") String resource, @Param("id") long id, + @Param("version") long version, @Param("actorId") long actorId, + @Param("targetStatus") String targetStatus, @Param("opinion") String opinion); + + @Update(""" + + """) + int disableDictionary(@Param("resource") String resource, @Param("id") long id, + @Param("version") long version, @Param("actorId") long actorId); + +} diff --git a/backend/src/main/java/com/kaidi/finance/masterdata/infrastructure/LegacyIdentifierMapper.java b/backend/src/main/java/com/kaidi/finance/masterdata/infrastructure/LegacyIdentifierMapper.java new file mode 100644 index 0000000..773e6e0 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/masterdata/infrastructure/LegacyIdentifierMapper.java @@ -0,0 +1,97 @@ +package com.kaidi.finance.masterdata.infrastructure; + +import java.time.LocalDateTime; +import java.util.List; +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +@org.apache.ibatis.annotations.Mapper +public interface LegacyIdentifierMapper { + + String TARGET_FILTERS = """ + resource_type = #{resourceType} + AND target_public_id = #{targetPublicId} + AND status = #{status} + + AND (source_system LIKE CONCAT('%', #{keyword}, '%') + OR legacy_code LIKE CONCAT('%', #{keyword}, '%')) + + """; + + @Select("") + List list(@Param("resourceType") String resourceType, + @Param("targetPublicId") String targetPublicId, + @Param("status") String status, + @Param("keyword") String keyword, + @Param("limit") int limit, + @Param("offset") int offset); + + @Select("") + long count(@Param("resourceType") String resourceType, + @Param("targetPublicId") String targetPublicId, + @Param("status") String status, + @Param("keyword") String keyword); + + @Insert(""" + INSERT INTO md_legacy_identifier_mapping ( + public_id, resource_type, source_system, legacy_code, normalized_legacy_code, + target_public_id, status, version, created_by, updated_by + ) VALUES ( + #{publicId}, #{resourceType}, #{sourceSystem}, #{legacyCode}, #{normalizedLegacyCode}, + #{targetPublicId}, 'ACTIVE', 0, #{actorId}, #{actorId} + ) + """) + int insert(@Param("publicId") String publicId, + @Param("resourceType") String resourceType, + @Param("sourceSystem") String sourceSystem, + @Param("legacyCode") String legacyCode, + @Param("normalizedLegacyCode") String normalizedLegacyCode, + @Param("targetPublicId") String targetPublicId, + @Param("actorId") long actorId); + + @Select(""" + SELECT id, public_id, resource_type, source_system, legacy_code, target_public_id, status, + version, created_at, updated_at + FROM md_legacy_identifier_mapping + WHERE public_id = #{publicId} + """) + LegacyIdentifierRow findByPublicId(String publicId); + + @Select(""" + SELECT id, public_id, resource_type, source_system, legacy_code, target_public_id, status, + version, created_at, updated_at + FROM md_legacy_identifier_mapping + WHERE public_id = #{publicId} AND resource_type = #{resourceType} + AND target_public_id = #{targetPublicId} + """) + LegacyIdentifierRow findForTarget(@Param("publicId") String publicId, + @Param("resourceType") String resourceType, + @Param("targetPublicId") String targetPublicId); + + @Select(""" + SELECT id, public_id, resource_type, source_system, legacy_code, target_public_id, status, + version, created_at, updated_at + FROM md_legacy_identifier_mapping + WHERE resource_type = #{resourceType} AND source_system = #{sourceSystem} + AND normalized_legacy_code = #{normalizedLegacyCode} AND status = 'ACTIVE' + """) + LegacyIdentifierRow resolve(@Param("resourceType") String resourceType, + @Param("sourceSystem") String sourceSystem, + @Param("normalizedLegacyCode") String normalizedLegacyCode); + + @Update(""" + UPDATE md_legacy_identifier_mapping + SET status = 'DISABLED', version = version + 1, updated_by = #{actorId} + WHERE id = #{id} AND version = #{version} AND status = 'ACTIVE' + """) + int disable(@Param("id") long id, @Param("version") long version, @Param("actorId") long actorId); + + record LegacyIdentifierRow(long id, String publicId, String resourceType, String sourceSystem, + String legacyCode, String targetPublicId, String status, long version, + LocalDateTime createdAt, LocalDateTime updatedAt) { + } +} diff --git a/backend/src/main/java/com/kaidi/finance/masterdata/infrastructure/MasterDataMapper.java b/backend/src/main/java/com/kaidi/finance/masterdata/infrastructure/MasterDataMapper.java new file mode 100644 index 0000000..ff89ba5 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/masterdata/infrastructure/MasterDataMapper.java @@ -0,0 +1,279 @@ +package com.kaidi.finance.masterdata.infrastructure; + +import com.kaidi.finance.masterdata.domain.CompanyRecord; +import com.kaidi.finance.masterdata.domain.CounterpartyRecord; +import java.util.List; +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +@org.apache.ibatis.annotations.Mapper +public interface MasterDataMapper { + + String COMPANY_COLUMNS = """ + company.id, company.public_id, company.business_no, company.name, company.normalized_tax_no, + company.status, company.version, company.created_by, company.updated_by, + company.submitted_by, company.submitted_at, company.reviewed_by, company.reviewed_at, + company.review_opinion, company.created_at, company.updated_at + """; + + String COUNTERPARTY_COLUMNS = """ + counterparty.id, counterparty.public_id, counterparty.business_no, counterparty.counterparty_type, + counterparty.name, counterparty.normalized_tax_no, counterparty.contact_name, + counterparty.contact_phone, counterparty.status, counterparty.version, + counterparty.created_by, counterparty.updated_by, counterparty.submitted_by, + counterparty.submitted_at, counterparty.reviewed_by, counterparty.reviewed_at, + counterparty.review_opinion, counterparty.created_at, counterparty.updated_at + """; + + @Insert(""" + INSERT INTO md_company ( + public_id, business_no, name, normalized_tax_no, status, created_by, updated_by + ) VALUES ( + #{publicId}, #{businessNo}, #{name}, #{normalizedTaxNo}, 'DRAFT', #{createdBy}, #{updatedBy} + ) + """) + int insertCompany(CompanyRecord company); + + @Select("SELECT " + COMPANY_COLUMNS + " FROM md_company company WHERE company.public_id = #{publicId}") + CompanyRecord findCompany(String publicId); + + @Select(""" + + """) + @org.apache.ibatis.annotations.Results(id = "companyColumns", value = { + @org.apache.ibatis.annotations.Result(column = "id", property = "id"), + @org.apache.ibatis.annotations.Result(column = "public_id", property = "publicId"), + @org.apache.ibatis.annotations.Result(column = "business_no", property = "businessNo"), + @org.apache.ibatis.annotations.Result(column = "name", property = "name"), + @org.apache.ibatis.annotations.Result(column = "normalized_tax_no", property = "normalizedTaxNo"), + @org.apache.ibatis.annotations.Result(column = "status", property = "status"), + @org.apache.ibatis.annotations.Result(column = "version", property = "version"), + @org.apache.ibatis.annotations.Result(column = "created_by", property = "createdBy"), + @org.apache.ibatis.annotations.Result(column = "updated_by", property = "updatedBy"), + @org.apache.ibatis.annotations.Result(column = "submitted_by", property = "submittedBy"), + @org.apache.ibatis.annotations.Result(column = "submitted_at", property = "submittedAt"), + @org.apache.ibatis.annotations.Result(column = "reviewed_by", property = "reviewedBy"), + @org.apache.ibatis.annotations.Result(column = "reviewed_at", property = "reviewedAt"), + @org.apache.ibatis.annotations.Result(column = "review_opinion", property = "reviewOpinion"), + @org.apache.ibatis.annotations.Result(column = "created_at", property = "createdAt"), + @org.apache.ibatis.annotations.Result(column = "updated_at", property = "updatedAt") + }) + List listCompanies(@Param("userId") long userId, @Param("roleCode") String roleCode, + @Param("keyword") String keyword, @Param("status") String status, + @Param("limit") int limit, @Param("offset") int offset); + + @Select(""" + + """) + long countCompanies(@Param("userId") long userId, @Param("roleCode") String roleCode, + @Param("keyword") String keyword, @Param("status") String status); + + @Update(""" + UPDATE md_company + SET name = #{name}, normalized_tax_no = #{normalizedTaxNo}, updated_by = #{updatedBy}, + version = version + 1 + WHERE id = #{id} AND version = #{version} AND status IN ('DRAFT', 'RETURNED') + """) + int updateCompany(CompanyRecord company); + + @Update(""" + UPDATE md_company + SET status = 'REVIEWING', submitted_by = #{actorId}, submitted_at = UTC_TIMESTAMP(3), + reviewed_by = NULL, reviewed_at = NULL, review_opinion = NULL, + updated_by = #{actorId}, version = version + 1 + WHERE id = #{id} AND version = #{version} AND status IN ('DRAFT', 'RETURNED') + """) + int submitCompany(@Param("id") long id, @Param("version") long version, @Param("actorId") long actorId); + + @Update(""" + UPDATE md_company + SET status = #{targetStatus}, reviewed_by = #{actorId}, reviewed_at = UTC_TIMESTAMP(3), + review_opinion = #{opinion}, updated_by = #{actorId}, version = version + 1 + WHERE id = #{id} AND version = #{version} AND status = 'REVIEWING' AND submitted_by <> #{actorId} + """) + int reviewCompany(@Param("id") long id, @Param("version") long version, + @Param("actorId") long actorId, @Param("targetStatus") String targetStatus, + @Param("opinion") String opinion); + + @Update(""" + UPDATE md_company + SET status = 'DISABLED', updated_by = #{actorId}, version = version + 1 + WHERE id = #{id} AND version = #{version} AND status = 'ACTIVE' + """) + int disableCompany(@Param("id") long id, @Param("version") long version, @Param("actorId") long actorId); + + @Insert(""" + INSERT INTO md_counterparty ( + public_id, business_no, counterparty_type, name, normalized_tax_no, contact_name, + contact_phone, status, created_by, updated_by + ) VALUES ( + #{publicId}, #{businessNo}, #{counterpartyType}, #{name}, #{normalizedTaxNo}, #{contactName}, + #{contactPhone}, 'DRAFT', #{createdBy}, #{updatedBy} + ) + """) + int insertCounterparty(CounterpartyRecord counterparty); + + @Select("SELECT " + COUNTERPARTY_COLUMNS + " FROM md_counterparty counterparty WHERE counterparty.public_id = #{publicId}") + CounterpartyRecord findCounterparty(String publicId); + + @Select(""" + + """) + @org.apache.ibatis.annotations.Results(id = "counterpartyColumns", value = { + @org.apache.ibatis.annotations.Result(column = "id", property = "id"), + @org.apache.ibatis.annotations.Result(column = "public_id", property = "publicId"), + @org.apache.ibatis.annotations.Result(column = "business_no", property = "businessNo"), + @org.apache.ibatis.annotations.Result(column = "counterparty_type", property = "counterpartyType"), + @org.apache.ibatis.annotations.Result(column = "name", property = "name"), + @org.apache.ibatis.annotations.Result(column = "normalized_tax_no", property = "normalizedTaxNo"), + @org.apache.ibatis.annotations.Result(column = "contact_name", property = "contactName"), + @org.apache.ibatis.annotations.Result(column = "contact_phone", property = "contactPhone"), + @org.apache.ibatis.annotations.Result(column = "status", property = "status"), + @org.apache.ibatis.annotations.Result(column = "version", property = "version"), + @org.apache.ibatis.annotations.Result(column = "created_by", property = "createdBy"), + @org.apache.ibatis.annotations.Result(column = "updated_by", property = "updatedBy"), + @org.apache.ibatis.annotations.Result(column = "submitted_by", property = "submittedBy"), + @org.apache.ibatis.annotations.Result(column = "submitted_at", property = "submittedAt"), + @org.apache.ibatis.annotations.Result(column = "reviewed_by", property = "reviewedBy"), + @org.apache.ibatis.annotations.Result(column = "reviewed_at", property = "reviewedAt"), + @org.apache.ibatis.annotations.Result(column = "review_opinion", property = "reviewOpinion"), + @org.apache.ibatis.annotations.Result(column = "created_at", property = "createdAt"), + @org.apache.ibatis.annotations.Result(column = "updated_at", property = "updatedAt") + }) + List listCounterparties(@Param("userId") long userId, + @Param("roleCode") String roleCode, + @Param("keyword") String keyword, + @Param("status") String status, + @Param("type") String type, + @Param("limit") int limit, + @Param("offset") int offset); + + @Select(""" + + """) + long countCounterparties(@Param("userId") long userId, @Param("roleCode") String roleCode, + @Param("keyword") String keyword, @Param("status") String status, + @Param("type") String type); + + @Update(""" + UPDATE md_counterparty + SET counterparty_type = #{counterpartyType}, name = #{name}, + normalized_tax_no = #{normalizedTaxNo}, contact_name = #{contactName}, + contact_phone = #{contactPhone}, updated_by = #{updatedBy}, version = version + 1 + WHERE id = #{id} AND version = #{version} AND status IN ('DRAFT', 'RETURNED') + """) + int updateCounterparty(CounterpartyRecord counterparty); + + @Update(""" + UPDATE md_counterparty + SET status = 'REVIEWING', submitted_by = #{actorId}, submitted_at = UTC_TIMESTAMP(3), + reviewed_by = NULL, reviewed_at = NULL, review_opinion = NULL, + updated_by = #{actorId}, version = version + 1 + WHERE id = #{id} AND version = #{version} AND status IN ('DRAFT', 'RETURNED') + """) + int submitCounterparty(@Param("id") long id, @Param("version") long version, + @Param("actorId") long actorId); + + @Update(""" + UPDATE md_counterparty + SET status = #{targetStatus}, reviewed_by = #{actorId}, reviewed_at = UTC_TIMESTAMP(3), + review_opinion = #{opinion}, updated_by = #{actorId}, version = version + 1 + WHERE id = #{id} AND version = #{version} AND status = 'REVIEWING' AND submitted_by <> #{actorId} + """) + int reviewCounterparty(@Param("id") long id, @Param("version") long version, + @Param("actorId") long actorId, @Param("targetStatus") String targetStatus, + @Param("opinion") String opinion); + + @Update(""" + UPDATE md_counterparty + SET status = 'DISABLED', updated_by = #{actorId}, version = version + 1 + WHERE id = #{id} AND version = #{version} AND status = 'ACTIVE' + """) + int disableCounterparty(@Param("id") long id, @Param("version") long version, + @Param("actorId") long actorId); + +} diff --git a/backend/src/main/java/com/kaidi/finance/masterdata/infrastructure/MasterDataVersionMapper.java b/backend/src/main/java/com/kaidi/finance/masterdata/infrastructure/MasterDataVersionMapper.java new file mode 100644 index 0000000..7044be7 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/masterdata/infrastructure/MasterDataVersionMapper.java @@ -0,0 +1,36 @@ +package com.kaidi.finance.masterdata.infrastructure; + +import java.time.LocalDateTime; +import java.util.List; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@org.apache.ibatis.annotations.Mapper +public interface MasterDataVersionMapper { + + String FILTER = """ + object_type = #{objectType} + AND object_public_id = #{objectPublicId} + AND result_code = 'SUCCESS' + AND action_code LIKE CONCAT(#{actionPrefix}, '%') + """; + + @Select("SELECT public_id AS audit_id, action_code, username AS actor_name, " + + "CAST(before_json AS CHAR) AS before_json, CAST(after_json AS CHAR) AS after_json, " + + "created_at AS changed_at FROM audit_log WHERE " + FILTER + + " ORDER BY created_at DESC, id DESC LIMIT #{limit} OFFSET #{offset}") + List list(@Param("objectType") String objectType, + @Param("objectPublicId") String objectPublicId, + @Param("actionPrefix") String actionPrefix, + @Param("limit") int limit, + @Param("offset") int offset); + + @Select("SELECT COUNT(*) FROM audit_log WHERE " + FILTER) + long count(@Param("objectType") String objectType, + @Param("objectPublicId") String objectPublicId, + @Param("actionPrefix") String actionPrefix); + + record VersionRow(String auditId, String actionCode, String actorName, + String beforeJson, String afterJson, LocalDateTime changedAt) { + } +} diff --git a/backend/src/main/java/com/kaidi/finance/operations/api/ContractCostContracts.java b/backend/src/main/java/com/kaidi/finance/operations/api/ContractCostContracts.java new file mode 100644 index 0000000..d9e83e0 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/operations/api/ContractCostContracts.java @@ -0,0 +1,55 @@ +package com.kaidi.finance.operations.api; + +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.Digits; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.PositiveOrZero; +import jakarta.validation.constraints.Size; +import java.math.BigDecimal; +import java.time.LocalDate; + +public final class ContractCostContracts { + + private ContractCostContracts() { + } + + public record ContractChangeCreateRequest( + @NotBlank @Size(max = 64) String changeNo, + @NotNull @Digits(integer = 18, fraction = 2) BigDecimal changeAmount, + @NotNull LocalDate effectiveDate, + @NotBlank @Size(max = 1000) String reason, + @NotNull @PositiveOrZero Long contractVersion + ) { + } + + public record ContractSettlementCreateRequest( + @NotBlank @Size(max = 64) String settlementNo, + @NotNull @DecimalMin("0.00") @Digits(integer = 18, fraction = 2) BigDecimal settlementAmount, + @NotNull LocalDate settlementDate, + @NotBlank @Size(min = 26, max = 26) String sourceDocumentId, + @NotNull @PositiveOrZero Long contractVersion + ) { + } + + public record PayableCreateRequest( + @NotBlank @Size(max = 64) String businessNo, + @NotBlank @Size(max = 26) String costCategoryId, + @NotBlank @Size(min = 26, max = 26) String sourceDocumentId, + @NotNull LocalDate businessDate, + @NotNull @DecimalMin("0.01") @Digits(integer = 18, fraction = 2) BigDecimal amount, + @NotNull @PositiveOrZero Long contractVersion + ) { + } + + public record VersionCommandRequest( + @NotNull @PositiveOrZero Long version + ) { + } + + public record VersionReasonCommandRequest( + @NotNull @PositiveOrZero Long version, + @NotBlank @Size(max = 1000) String reason + ) { + } +} diff --git a/backend/src/main/java/com/kaidi/finance/operations/api/ContractCostController.java b/backend/src/main/java/com/kaidi/finance/operations/api/ContractCostController.java new file mode 100644 index 0000000..47071eb --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/operations/api/ContractCostController.java @@ -0,0 +1,173 @@ +package com.kaidi.finance.operations.api; + +import com.kaidi.finance.operations.api.ContractCostContracts.ContractChangeCreateRequest; +import com.kaidi.finance.operations.api.ContractCostContracts.ContractSettlementCreateRequest; +import com.kaidi.finance.operations.api.ContractCostContracts.PayableCreateRequest; +import com.kaidi.finance.operations.api.ContractCostContracts.VersionCommandRequest; +import com.kaidi.finance.operations.api.ContractCostContracts.VersionReasonCommandRequest; +import com.kaidi.finance.operations.api.ContractCostViews.ContractLedgerDetailView; +import com.kaidi.finance.operations.api.ContractCostViews.ContractLedgerPageView; +import com.kaidi.finance.operations.api.ContractCostViews.FilterOptionsView; +import com.kaidi.finance.operations.application.ContractCostApplicationService; +import com.kaidi.finance.operations.application.ContractCostApplicationService.LedgerPage; +import com.kaidi.finance.shared.api.ApiResponse; +import com.kaidi.finance.shared.idempotency.IdempotencyApplicationService; +import com.fasterxml.jackson.core.type.TypeReference; +import java.time.LocalDate; +import java.util.function.Supplier; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.ContentDisposition; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +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; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/contract-costs") +public class ContractCostController { + + private final ContractCostApplicationService service; + private final IdempotencyApplicationService idempotencyService; + + public ContractCostController(ContractCostApplicationService service, + IdempotencyApplicationService idempotencyService) { + this.service = service; + this.idempotencyService = idempotencyService; + } + + @GetMapping("/contracts") + @PreAuthorize("@authorizationService.hasPermission('contractcost:ledger:view')") + public ApiResponse list( + @RequestParam(required = false) String companyId, + @RequestParam(required = false) String projectId, + @RequestParam(required = false) String counterpartyId, + @RequestParam(required = false) String keyword, + @RequestParam(required = false) String status, + @RequestParam(required = false) String costCategoryId, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate businessDateFrom, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate businessDateTo, + @RequestParam(defaultValue = "updatedAt,desc") String sort, + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int size) { + LedgerPage result = service.list(companyId, projectId, counterpartyId, keyword, status, + costCategoryId, businessDateFrom, businessDateTo, sort, page, size); + return ApiResponse.ok(new ContractLedgerPageView(result.page().items(), result.summary()), + result.page().meta()); + } + + @GetMapping("/contracts/{publicId}") + @PreAuthorize("@authorizationService.hasPermission('contractcost:ledger:view')") + public ApiResponse detail(@PathVariable String publicId) { + return ApiResponse.ok(service.detail(publicId)); + } + + @GetMapping("/references") + @PreAuthorize("@authorizationService.hasPermission('contractcost:ledger:view')") + public ApiResponse references() { + return ApiResponse.ok(service.references()); + } + + @PostMapping("/contracts/{contractId}/changes") + @PreAuthorize("@authorizationService.hasPermission('contractcost:contract:maintain')") + public ApiResponse createChange( + @PathVariable String contractId, @Valid @RequestBody ContractChangeCreateRequest request, + @RequestHeader(value = "Idempotency-Key", required = false) String key, + HttpServletRequest httpRequest) { + return ApiResponse.ok(command(key, httpRequest, request, () -> service.createChange(contractId, request))); + } + + @PostMapping("/changes/{changeId}/void") + @PreAuthorize("@authorizationService.hasPermission('contractcost:contract:maintain')") + public ApiResponse voidChange( + @PathVariable String changeId, @Valid @RequestBody VersionReasonCommandRequest request, + @RequestHeader(value = "Idempotency-Key", required = false) String key, + HttpServletRequest httpRequest) { + return ApiResponse.ok(command(key, httpRequest, request, + () -> service.voidChange(changeId, request.version(), request.reason()))); + } + + @PostMapping("/contracts/{contractId}/settlements") + @PreAuthorize("@authorizationService.hasPermission('contractcost:contract:maintain')") + public ApiResponse createSettlement( + @PathVariable String contractId, @Valid @RequestBody ContractSettlementCreateRequest request, + @RequestHeader(value = "Idempotency-Key", required = false) String key, + HttpServletRequest httpRequest) { + return ApiResponse.ok(command(key, httpRequest, request, + () -> service.createSettlement(contractId, request))); + } + + @PostMapping("/settlements/{settlementId}/void") + @PreAuthorize("@authorizationService.hasPermission('contractcost:contract:maintain')") + public ApiResponse voidSettlement( + @PathVariable String settlementId, @Valid @RequestBody VersionReasonCommandRequest request, + @RequestHeader(value = "Idempotency-Key", required = false) String key, + HttpServletRequest httpRequest) { + return ApiResponse.ok(command(key, httpRequest, request, + () -> service.voidSettlement(settlementId, request.version(), request.reason()))); + } + + @PostMapping("/contracts/{contractId}/payables") + @PreAuthorize("@authorizationService.hasPermission('contractcost:payable:create')") + public ApiResponse createPayable( + @PathVariable String contractId, @Valid @RequestBody PayableCreateRequest request, + @RequestHeader(value = "Idempotency-Key", required = false) String key, + HttpServletRequest httpRequest) { + return ApiResponse.ok(command(key, httpRequest, request, () -> service.createPayable(contractId, request))); + } + + @PostMapping("/payables/{payableId}/confirm") + @PreAuthorize("@authorizationService.hasPermission('contractcost:payable:confirm')") + public ApiResponse confirmPayable( + @PathVariable String payableId, @Valid @RequestBody VersionCommandRequest request, + @RequestHeader(value = "Idempotency-Key", required = false) String key, + HttpServletRequest httpRequest) { + return ApiResponse.ok(command(key, httpRequest, request, + () -> service.confirmPayable(payableId, request.version()))); + } + + @PostMapping("/payables/{payableId}/void") + @PreAuthorize("@authorizationService.hasPermission('contractcost:payable:confirm')") + public ApiResponse voidPayable( + @PathVariable String payableId, @Valid @RequestBody VersionReasonCommandRequest request, + @RequestHeader(value = "Idempotency-Key", required = false) String key, + HttpServletRequest httpRequest) { + return ApiResponse.ok(command(key, httpRequest, request, + () -> service.voidPayable(payableId, request.version(), request.reason()))); + } + + @GetMapping(value = "/contracts/export", produces = "text/csv") + @PreAuthorize("@authorizationService.hasPermission('contractcost:ledger:export')") + public ResponseEntity export( + @RequestParam(required = false) String companyId, + @RequestParam(required = false) String projectId, + @RequestParam(required = false) String counterpartyId, + @RequestParam(required = false) String keyword, + @RequestParam(required = false) String status, + @RequestParam(required = false) String costCategoryId, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate businessDateFrom, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate businessDateTo, + @RequestParam(defaultValue = "updatedAt,desc") String sort) { + byte[] data = service.export(companyId, projectId, counterpartyId, keyword, status, + costCategoryId, businessDateFrom, businessDateTo, sort); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(new MediaType("text", "csv", java.nio.charset.StandardCharsets.UTF_8)); + headers.setContentDisposition(ContentDisposition.attachment().filename("contract-cost-ledger.csv").build()); + return ResponseEntity.ok().headers(headers).body(data); + } + + private ContractLedgerDetailView command(String key, HttpServletRequest request, Object body, + Supplier action) { + return idempotencyService.execute(key, request.getMethod(), request.getRequestURI(), body, + new TypeReference() { }, action); + } +} diff --git a/backend/src/main/java/com/kaidi/finance/operations/api/ContractCostViews.java b/backend/src/main/java/com/kaidi/finance/operations/api/ContractCostViews.java new file mode 100644 index 0000000..369d76b --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/operations/api/ContractCostViews.java @@ -0,0 +1,101 @@ +package com.kaidi.finance.operations.api; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +public final class ContractCostViews { + + private ContractCostViews() { + } + + public record ReferenceView(String publicId, String code, String name) { + } + + public record FilterOptionsView(List companies, + List projects, + List counterparties, + List costCategories) { + } + + public record ContractLedgerItemView( + String publicId, + String businessNo, + String name, + ReferenceView company, + ReferenceView project, + ReferenceView counterparty, + String originalAmount, + String approvedChangeAmount, + String settlementAmount, + String invoicedAmount, + String pendingPayableAmount, + String payableAmount, + String paidAmount, + String availableAmount, + String currency, + String status, + long version, + LocalDateTime updatedAt, + List allowedActions + ) { + } + + public record ContractLedgerSummaryView( + long contractCount, + String originalAmount, + String approvedChangeAmount, + String settlementAmount, + String invoicedAmount, + String pendingPayableAmount, + String payableAmount, + String paidAmount, + String availableAmount + ) { + } + + public record ContractLedgerPageView(List items, + ContractLedgerSummaryView summary) { + } + + public record PayableLineView(String publicId, String businessNo, LocalDate businessDate, + String sourceDocumentId, String sourceVersionId, + ReferenceView costCategory, String amount, String invoicedAmount, + String paidAmount, String unpaidAmount, String status, + long version, LocalDateTime updatedAt, + List allowedActions) { + } + + public record ContractChangeLineView(String publicId, String changeNo, String changeAmount, + LocalDate effectiveDate, String reason, String status, + long version, String createdByName, LocalDateTime createdAt, + List allowedActions) { + } + + public record ContractSettlementLineView(String publicId, String settlementNo, + String settlementAmount, LocalDate settlementDate, + String sourceDocumentId, String sourceVersionId, + String status, long version, + String createdByName, LocalDateTime createdAt, + List allowedActions) { + } + + public record InvoiceLineView(String publicId, String businessNo, LocalDate requestedDate, + String invoiceType, String amount, String taxRate, + String invoiceNo, LocalDate issuedDate, String status, + LocalDateTime updatedAt) { + } + + public record PaymentLineView(String publicId, String businessNo, LocalDate requestedDate, + String requestedAmount, String approvedAmount, String purpose, + String status, LocalDateTime updatedAt) { + } + + public record ContractLedgerDetailView(ContractLedgerItemView contract, + List changes, + List settlements, + List payables, + List invoices, + List payments) { + } +} diff --git a/backend/src/main/java/com/kaidi/finance/operations/api/FinanceOperationsController.java b/backend/src/main/java/com/kaidi/finance/operations/api/FinanceOperationsController.java new file mode 100644 index 0000000..29146bf --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/operations/api/FinanceOperationsController.java @@ -0,0 +1,103 @@ +package com.kaidi.finance.operations.api; + +import com.kaidi.finance.operations.application.FinanceOperationsApplicationService; +import com.kaidi.finance.shared.api.ApiResponse; +import com.kaidi.finance.shared.api.BusinessException; +import com.kaidi.finance.shared.api.ErrorCode; +import com.kaidi.finance.shared.api.PageResult; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.springframework.http.HttpStatus; +import org.springframework.util.MultiValueMap; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1") +public class FinanceOperationsController { + + private final FinanceOperationsApplicationService service; + + private static final Set LIST_QUERY_PARAMS = Set.of("keyword", "status", "sort", "page", "size"); + + public FinanceOperationsController(FinanceOperationsApplicationService service) { + this.service = service; + } + + @GetMapping("/contracts") + @Operation(operationId = "listFinanceContracts", summary = "查询合同运营台账") + @Parameter(name = "sort", in = ParameterIn.QUERY, + description = "可重复;格式 field,asc|desc", + array = @ArraySchema(schema = @Schema(type = "string", + pattern = "^(updatedAt|businessNo|name|status|contractAmount|settlementAmount),(asc|desc)$"))) + public ApiResponse> contracts( + @RequestParam(required = false) String keyword, + @Parameter(schema = @Schema(allowableValues = { + "DRAFT", "REVIEWING", "RETURNED", "ACTIVE", "SETTLED", "DISABLED", "VOID" + })) + @RequestParam(required = false) String status, + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int size, + @Parameter(hidden = true) + @RequestParam MultiValueMap query) { + validateQuery(query); + return page(service.listContracts(keyword, status, query.get("sort"), page, size)); + } + + @GetMapping("/costs") + @Operation(operationId = "listFinanceCosts", summary = "查询成本运营台账") + @Parameter(name = "sort", in = ParameterIn.QUERY, + description = "可重复;格式 field,asc|desc", + array = @ArraySchema(schema = @Schema(type = "string", + pattern = "^(updatedAt|businessNo|businessDate|amount|status|projectName|counterpartyName),(asc|desc)$"))) + public ApiResponse> costs( + @RequestParam(required = false) String keyword, + @Parameter(schema = @Schema(allowableValues = {"PENDING", "CONFIRMED", "PART_PAID", "PAID", "VOID"})) + @RequestParam(required = false) String status, + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int size, + @Parameter(hidden = true) + @RequestParam MultiValueMap query) { + validateQuery(query); + return page(service.listCosts(keyword, status, query.get("sort"), page, size)); + } + + @GetMapping("/payables") + @Operation(operationId = "listFinancePayables", summary = "查询应付运营台账") + @Parameter(name = "sort", in = ParameterIn.QUERY, + description = "可重复;格式 field,asc|desc", + array = @ArraySchema(schema = @Schema(type = "string", + pattern = "^(updatedAt|businessNo|businessDate|amount|status|projectName|counterpartyName),(asc|desc)$"))) + public ApiResponse> payables( + @RequestParam(required = false) String keyword, + @Parameter(schema = @Schema(allowableValues = {"PENDING", "CONFIRMED", "PART_PAID", "PAID", "VOID"})) + @RequestParam(required = false) String status, + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int size, + @Parameter(hidden = true) + @RequestParam MultiValueMap query) { + validateQuery(query); + return page(service.listPayables(keyword, status, query.get("sort"), page, size)); + } + + private static void validateQuery(Map query) { + for (String name : query.keySet()) { + if (!LIST_QUERY_PARAMS.contains(name)) { + throw new BusinessException(HttpStatus.BAD_REQUEST, ErrorCode.QUERY_PARAMETER_UNKNOWN, + "不支持的查询参数: " + name); + } + } + } + + private static ApiResponse> page(PageResult result) { + return ApiResponse.ok(result.items(), result.meta()); + } +} diff --git a/backend/src/main/java/com/kaidi/finance/operations/api/FinanceOperationsViews.java b/backend/src/main/java/com/kaidi/finance/operations/api/FinanceOperationsViews.java new file mode 100644 index 0000000..5f276ff --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/operations/api/FinanceOperationsViews.java @@ -0,0 +1,45 @@ +package com.kaidi.finance.operations.api; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.time.Instant; +import java.time.LocalDate; + +/** Stable read contracts for the compatibility finance list endpoints. */ +public final class FinanceOperationsViews { + + private FinanceOperationsViews() { + } + + public record ContractListView( + @JsonProperty("public_id") String publicId, + @JsonProperty("business_no") String businessNo, + String name, + @JsonProperty("contract_amount") String contractAmount, + @JsonProperty("change_amount") String changeAmount, + @JsonProperty("settlement_amount") String settlementAmount, + String currency, + String status, + @JsonProperty("project_business_no") String projectBusinessNo, + @JsonProperty("project_name") String projectName, + @JsonProperty("company_name") String companyName, + @JsonProperty("counterparty_name") String counterpartyName, + @JsonProperty("updated_at") Instant updatedAt + ) { + } + + public record PayableListView( + @JsonProperty("public_id") String publicId, + @JsonProperty("business_no") String businessNo, + @JsonProperty("business_date") LocalDate businessDate, + String amount, + @JsonProperty("invoiced_amount") String invoicedAmount, + @JsonProperty("paid_amount") String paidAmount, + String status, + @JsonProperty("project_business_no") String projectBusinessNo, + @JsonProperty("project_name") String projectName, + @JsonProperty("counterparty_name") String counterpartyName, + @JsonProperty("cost_category_name") String costCategoryName, + @JsonProperty("updated_at") Instant updatedAt + ) { + } +} diff --git a/backend/src/main/java/com/kaidi/finance/operations/application/ApprovedPayableProjectionService.java b/backend/src/main/java/com/kaidi/finance/operations/application/ApprovedPayableProjectionService.java new file mode 100644 index 0000000..a5229c3 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/operations/application/ApprovedPayableProjectionService.java @@ -0,0 +1,245 @@ +package com.kaidi.finance.operations.application; + +import com.kaidi.finance.operations.infrastructure.ContractCostMapper; +import com.kaidi.finance.operations.infrastructure.ContractCostMapper.ApprovedPayableCostCategory; +import com.kaidi.finance.operations.infrastructure.ContractCostMapper.ApprovedPayableReferences; +import com.kaidi.finance.operations.infrastructure.ContractCostMapper.PayableProjection; +import com.kaidi.finance.shared.api.BusinessException; +import com.kaidi.finance.shared.api.ErrorCode; +import com.kaidi.finance.shared.audit.AuditService; +import com.kaidi.finance.shared.id.UlidGenerator; +import java.math.BigDecimal; +import java.text.Normalizer; +import java.time.LocalDate; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class ApprovedPayableProjectionService { + + private final ContractCostMapper mapper; + private final UlidGenerator ulidGenerator; + private final AuditService auditService; + + public ApprovedPayableProjectionService( + ContractCostMapper mapper, + UlidGenerator ulidGenerator, + AuditService auditService + ) { + this.mapper = mapper; + this.ulidGenerator = ulidGenerator; + this.auditService = auditService; + } + + @Transactional(propagation = Propagation.MANDATORY) + public void project(ApprovedPayableCommand command) { + validate(command); + PayableProjection existing = mapper.findPayableProjectionBySourceDocument(command.sourceDocumentId()); + if (existing != null) { + if (existing.sourceVersionId() != command.sourceVersionId()) { + throw invalidState("来源单据已有其他版本生成的应付记录"); + } + return; + } + if (command.createdBy() == command.confirmedBy()) { + throw new BusinessException( + HttpStatus.UNPROCESSABLE_ENTITY, + ErrorCode.SOD_VIOLATION, + "应付来源申请人与财务终审人必须为不同人员" + ); + } + + ApprovedPayableReferences references = mapper.lockApprovedPayableReferences(command.contractPublicId()); + validateReferences(command, references); + ApprovedPayableCostCategory category = mapper.findApprovedPayableCostCategory( + command.costCategoryPublicId() + ); + validateCategory(command, category); + + BigDecimal contractLimit = references.settlementAmount() == null + ? references.originalAmount().add(references.approvedChangeAmount()) + : references.settlementAmount(); + if (references.payableAmount().add(command.amount()).compareTo(contractLimit) > 0) { + throw new BusinessException( + HttpStatus.UNPROCESSABLE_ENTITY, + ErrorCode.CONTRACT_LIMIT_EXCEEDED, + "OA-04 应付终审后将超过当前合同或结算额度" + ); + } + + String payablePublicId = ulidGenerator.next(); + try { + if (mapper.insertProjectedPayable( + payablePublicId, + "PBL-" + payablePublicId, + command.companyId(), + command.projectId(), + references.contractId(), + references.counterpartyId(), + command.sourceDocumentId(), + command.sourceVersionId(), + category.id(), + command.businessDate(), + command.amount(), + command.invoicedAmount(), + command.createdBy(), + command.confirmedBy() + ) != 1) { + throw conflict(); + } + } catch (DuplicateKeyException exception) { + throw new BusinessException( + HttpStatus.CONFLICT, + ErrorCode.DUPLICATE_SOURCE, + "该 OA-04 来源单据已生成应付记录" + ); + } + if (mapper.touchContract(references.contractId(), references.contractVersion(), command.confirmedBy()) != 1) { + throw conflict(); + } + if (mapper.insertPayableAccountingEvent( + ulidGenerator.next(), + "EVT-" + payablePublicId, + payablePublicId, + "PAYABLE-" + payablePublicId, + 1, + command.companyId(), + command.projectId(), + references.counterpartyId(), + command.businessDate(), + command.amount(), + references.currency(), + command.confirmedBy() + ) != 1 || !"PENDING".equals(mapper.lockPayableAccountingEventStatus(payablePublicId))) { + throw conflict(); + } + + auditService.recordScoped( + references.companyPublicId(), + references.projectPublicId(), + "SOURCE_OA04_PAYABLE_PROJECT", + "PAYABLE", + payablePublicId, + "SUCCESS", + command.opinion(), + null, + Map.of( + "sourceDocumentId", command.sourceDocumentPublicId(), + "status", "CONFIRMED", + "contractId", references.contractPublicId(), + "supplierId", references.counterpartyPublicId(), + "costCategoryId", category.publicId(), + "amount", command.amount().toPlainString(), + "invoicedAmount", command.invoicedAmount().toPlainString(), + "currency", references.currency() + ) + ); + } + + private void validate(ApprovedPayableCommand command) { + if (command == null || command.companyId() <= 0 || command.projectId() <= 0 + || command.sourceDocumentId() <= 0 || command.sourceVersionId() <= 0 + || command.createdBy() <= 0 || command.confirmedBy() <= 0 + || blank(command.sourceDocumentPublicId()) || blank(command.contractPublicId()) + || blank(command.supplierPublicId()) || blank(command.costCategoryPublicId()) + || blank(command.contractNo()) || blank(command.supplierName()) || blank(command.costType()) + || command.businessDate() == null || invalidMoney(command.amount(), true) + || invalidMoney(command.invoicedAmount(), false)) { + throw validation("payableAmount", "应付投影参数无效"); + } + } + + private void validateReferences(ApprovedPayableCommand command, ApprovedPayableReferences references) { + if (references == null || !"ACTIVE".equals(references.contractStatus()) + || !"ACTIVE".equals(references.companyStatus()) || !"ACTIVE".equals(references.projectStatus()) + || references.companyId() != command.companyId() || references.projectId() != command.projectId()) { + throw validation("contractId", "公司、项目或合同不存在、未生效,或归属关系不一致"); + } + if (!Set.of("SUPPLIER", "BOTH").contains(references.counterpartyType()) + || !"ACTIVE".equals(references.counterpartyStatus()) + || !references.counterpartyPublicId().equals(command.supplierPublicId())) { + throw validation("supplierId", "所选供应商未生效、类型不符或不属于该合同"); + } + if (!normalized(references.contractBusinessNo()).equals(normalized(command.contractNo()))) { + throw validation("costContractNo", "成本合同编号与所选生效合同不一致"); + } + if (!normalized(references.counterpartyName()).equals(normalized(command.supplierName()))) { + throw validation("supplierName", "供应商名称与所选生效供应商不一致"); + } + } + + private void validateCategory(ApprovedPayableCommand command, ApprovedPayableCostCategory category) { + if (category == null || !"ACTIVE".equals(category.status())) { + throw validation("costCategoryId", "成本分类不存在或未生效"); + } + if (!normalizedCostType(category.name()).equals(normalizedCostType(command.costType()))) { + throw validation("costType", "费用成本类型与所选成本分类不一致"); + } + } + + private boolean invalidMoney(BigDecimal value, boolean positive) { + return value == null || value.scale() != 2 || value.precision() > 20 + || (positive ? value.signum() <= 0 : value.signum() < 0); + } + + private String normalizedCostType(String value) { + String normalized = normalized(value); + return normalized.endsWith("费") ? normalized.substring(0, normalized.length() - 1) : normalized; + } + + private String normalized(String value) { + return Normalizer.normalize(value, Normalizer.Form.NFKC).strip().toUpperCase(Locale.ROOT); + } + + private boolean blank(String value) { + return value == null || value.isBlank(); + } + + private BusinessException validation(String field, String message) { + return new BusinessException( + HttpStatus.UNPROCESSABLE_ENTITY, + ErrorCode.VALIDATION_FAILED, + "审批来源不能生成应付记录", + Map.of(field, message) + ); + } + + private BusinessException invalidState(String message) { + return new BusinessException(HttpStatus.CONFLICT, ErrorCode.INVALID_STATE_TRANSITION, message); + } + + private BusinessException conflict() { + return new BusinessException( + HttpStatus.CONFLICT, + ErrorCode.CONCURRENT_MODIFICATION, + "应付投影并发冲突,请重试" + ); + } + + public record ApprovedPayableCommand( + long companyId, + long projectId, + long sourceDocumentId, + String sourceDocumentPublicId, + long sourceVersionId, + String contractPublicId, + String supplierPublicId, + String costCategoryPublicId, + String contractNo, + String supplierName, + String costType, + LocalDate businessDate, + BigDecimal amount, + BigDecimal invoicedAmount, + long createdBy, + long confirmedBy, + String opinion + ) { + } +} diff --git a/backend/src/main/java/com/kaidi/finance/operations/application/ApprovedSettlementProjectionService.java b/backend/src/main/java/com/kaidi/finance/operations/application/ApprovedSettlementProjectionService.java new file mode 100644 index 0000000..15c2410 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/operations/application/ApprovedSettlementProjectionService.java @@ -0,0 +1,217 @@ +package com.kaidi.finance.operations.application; + +import com.kaidi.finance.operations.infrastructure.ContractCostMapper; +import com.kaidi.finance.operations.infrastructure.ContractCostMapper.ApprovedSettlementReferences; +import com.kaidi.finance.operations.infrastructure.ContractCostMapper.SettlementProjection; +import com.kaidi.finance.shared.api.BusinessException; +import com.kaidi.finance.shared.api.ErrorCode; +import com.kaidi.finance.shared.audit.AuditService; +import com.kaidi.finance.shared.id.UlidGenerator; +import java.math.BigDecimal; +import java.text.Normalizer; +import java.time.LocalDate; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class ApprovedSettlementProjectionService { + + private final ContractCostMapper mapper; + private final UlidGenerator ulidGenerator; + private final AuditService auditService; + + public ApprovedSettlementProjectionService( + ContractCostMapper mapper, + UlidGenerator ulidGenerator, + AuditService auditService + ) { + this.mapper = mapper; + this.ulidGenerator = ulidGenerator; + this.auditService = auditService; + } + + @Transactional(propagation = Propagation.MANDATORY) + public void project(ApprovedSettlementCommand command) { + validate(command); + if (existingProjection(command)) { + return; + } + + ApprovedSettlementReferences references = mapper.lockApprovedSettlementReferences( + command.companyId(), command.contractNo() + ); + validateReferences(command, references); + + if (command.settlementAmount().compareTo(references.payableAmount()) < 0) { + throw contractLimitExceeded("OA-11 结算金额不能低于现有有效应付金额"); + } + + String settlementPublicId = ulidGenerator.next(); + try { + if (mapper.insertContractSettlement( + settlementPublicId, + references.contractId(), + command.settlementNo(), + command.settlementAmount(), + command.settlementDate(), + command.sourceDocumentId(), + command.sourceVersionId(), + command.confirmedBy() + ) != 1) { + throw conflict(); + } + } catch (DuplicateKeyException exception) { + if (existingProjection(command)) { + return; + } + throw new BusinessException( + HttpStatus.CONFLICT, + ErrorCode.DUPLICATE_RESOURCE, + "OA-11 合同结算编号已存在" + ); + } + + if (mapper.refreshSettlementAmount( + references.contractId(), references.contractVersion(), command.confirmedBy() + ) != 1) { + throw conflict(); + } + + auditService.recordScoped( + references.companyPublicId(), + references.projectPublicId(), + "SOURCE_OA11_SETTLEMENT_PROJECT", + "CONTRACT_SETTLEMENT", + settlementPublicId, + "SUCCESS", + command.opinion(), + null, + Map.of( + "sourceDocumentId", command.sourceDocumentPublicId(), + "sourceVersionId", command.sourceVersionId(), + "contractId", references.contractPublicId(), + "settlementNo", command.settlementNo(), + "settlementAmount", command.settlementAmount().toPlainString(), + "settlementDate", command.settlementDate().toString(), + "status", "CONFIRMED" + ) + ); + } + + private boolean existingProjection(ApprovedSettlementCommand command) { + SettlementProjection existing = mapper.findSettlementProjectionBySourceDocument( + command.sourceDocumentId() + ); + if (existing == null) { + return false; + } + if (existing.sourceVersionId() == null || existing.sourceVersionId() != command.sourceVersionId()) { + throw new BusinessException( + HttpStatus.CONFLICT, + ErrorCode.INVALID_STATE_TRANSITION, + "来源单据已有其他版本生成的合同结算" + ); + } + return true; + } + + private void validate(ApprovedSettlementCommand command) { + if (command == null || command.companyId() <= 0 || command.projectId() <= 0 + || command.sourceDocumentId() <= 0 || command.sourceVersionId() <= 0 + || command.confirmedBy() <= 0 || blank(command.sourceDocumentPublicId()) + || blank(command.contractNo()) || blank(command.supplierName()) + || blank(command.settlementNo()) || command.settlementDate() == null + || invalidMoney(command.contractAmount()) || invalidMoney(command.settlementAmount()) + || invalidMoney(command.invoicedAmount()) || invalidMoney(command.paidAmount())) { + throw validation("amounts", "合同结算投影参数无效"); + } + } + + private void validateReferences( + ApprovedSettlementCommand command, + ApprovedSettlementReferences references + ) { + if (references == null || !"ACTIVE".equals(references.contractStatus()) + || !"ACTIVE".equals(references.companyStatus()) || !"ACTIVE".equals(references.projectStatus()) + || references.companyId() != command.companyId() || references.projectId() != command.projectId()) { + throw validation("costContractNo", "公司、项目或合同不存在、未生效,或归属关系不一致"); + } + if (!Set.of("SUPPLIER", "BOTH").contains(references.counterpartyType()) + || !"ACTIVE".equals(references.counterpartyStatus()) + || !normalized(references.counterpartyName()).equals(normalized(command.supplierName()))) { + throw validation("supplierName", "供应商不存在、未生效、类型不符或与合同不一致"); + } + BigDecimal contractAmount = references.originalAmount().add(references.approvedChangeAmount()); + if (contractAmount.compareTo(command.contractAmount()) != 0) { + throw validation("amounts", "合同金额与当前合同及已批准变更金额不一致"); + } + if (references.invoicedAmount().compareTo(command.invoicedAmount()) != 0) { + throw validation("amounts", "累计已开票金额与系统记录不一致"); + } + if (references.paidAmount().compareTo(command.paidAmount()) != 0) { + throw validation("amounts", "累计已付款金额与系统记录不一致"); + } + } + + private boolean invalidMoney(BigDecimal value) { + return value == null || value.scale() != 2 || value.precision() > 20 || value.signum() < 0; + } + + private String normalized(String value) { + return Normalizer.normalize(value, Normalizer.Form.NFKC).strip().toUpperCase(Locale.ROOT); + } + + private boolean blank(String value) { + return value == null || value.isBlank(); + } + + private BusinessException validation(String field, String message) { + return new BusinessException( + HttpStatus.UNPROCESSABLE_ENTITY, + ErrorCode.VALIDATION_FAILED, + "审批来源不能生成合同结算", + Map.of(field, message) + ); + } + + private BusinessException contractLimitExceeded(String message) { + return new BusinessException( + HttpStatus.UNPROCESSABLE_ENTITY, + ErrorCode.CONTRACT_LIMIT_EXCEEDED, + message + ); + } + + private BusinessException conflict() { + return new BusinessException( + HttpStatus.CONFLICT, + ErrorCode.CONCURRENT_MODIFICATION, + "合同结算投影并发冲突,请重试" + ); + } + + public record ApprovedSettlementCommand( + long companyId, + long projectId, + long sourceDocumentId, + String sourceDocumentPublicId, + long sourceVersionId, + String contractNo, + String supplierName, + BigDecimal contractAmount, + BigDecimal settlementAmount, + BigDecimal invoicedAmount, + BigDecimal paidAmount, + String settlementNo, + LocalDate settlementDate, + long confirmedBy, + String opinion + ) { + } +} diff --git a/backend/src/main/java/com/kaidi/finance/operations/application/ContractCostApplicationService.java b/backend/src/main/java/com/kaidi/finance/operations/application/ContractCostApplicationService.java new file mode 100644 index 0000000..94e19a8 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/operations/application/ContractCostApplicationService.java @@ -0,0 +1,754 @@ +package com.kaidi.finance.operations.application; + +import com.kaidi.finance.iam.domain.FinancePrincipal; +import com.kaidi.finance.operations.api.ContractCostContracts.ContractChangeCreateRequest; +import com.kaidi.finance.operations.api.ContractCostContracts.ContractSettlementCreateRequest; +import com.kaidi.finance.operations.api.ContractCostContracts.PayableCreateRequest; +import com.kaidi.finance.operations.api.ContractCostViews.ContractLedgerDetailView; +import com.kaidi.finance.operations.api.ContractCostViews.ContractChangeLineView; +import com.kaidi.finance.operations.api.ContractCostViews.ContractLedgerItemView; +import com.kaidi.finance.operations.api.ContractCostViews.ContractLedgerSummaryView; +import com.kaidi.finance.operations.api.ContractCostViews.ContractSettlementLineView; +import com.kaidi.finance.operations.api.ContractCostViews.FilterOptionsView; +import com.kaidi.finance.operations.api.ContractCostViews.InvoiceLineView; +import com.kaidi.finance.operations.api.ContractCostViews.PayableLineView; +import com.kaidi.finance.operations.api.ContractCostViews.PaymentLineView; +import com.kaidi.finance.operations.api.ContractCostViews.ReferenceView; +import com.kaidi.finance.operations.infrastructure.ContractCostMapper; +import com.kaidi.finance.operations.infrastructure.ContractCostMapper.ContractChangeCommandRow; +import com.kaidi.finance.operations.infrastructure.ContractCostMapper.ContractChangeRow; +import com.kaidi.finance.operations.infrastructure.ContractCostMapper.ContractCommandRow; +import com.kaidi.finance.operations.infrastructure.ContractCostMapper.ContractRow; +import com.kaidi.finance.operations.infrastructure.ContractCostMapper.ContractSettlementCommandRow; +import com.kaidi.finance.operations.infrastructure.ContractCostMapper.ContractSettlementRow; +import com.kaidi.finance.operations.infrastructure.ContractCostMapper.InvoiceRow; +import com.kaidi.finance.operations.infrastructure.ContractCostMapper.PayableRow; +import com.kaidi.finance.operations.infrastructure.ContractCostMapper.PayableCommandRow; +import com.kaidi.finance.operations.infrastructure.ContractCostMapper.PaymentRow; +import com.kaidi.finance.operations.infrastructure.ContractCostMapper.ReferenceRow; +import com.kaidi.finance.operations.infrastructure.ContractCostMapper.SummaryRow; +import com.kaidi.finance.operations.infrastructure.ContractCostMapper.SourceDocumentRow; +import com.kaidi.finance.shared.api.BusinessException; +import com.kaidi.finance.shared.api.ErrorCode; +import com.kaidi.finance.shared.api.PageResult; +import com.kaidi.finance.shared.audit.AuditService; +import com.kaidi.finance.shared.id.UlidGenerator; +import com.kaidi.finance.shared.security.AuthorizationService; +import com.kaidi.finance.shared.security.IdentityContext; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.nio.charset.StandardCharsets; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.regex.Pattern; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class ContractCostApplicationService { + + private static final Pattern PUBLIC_ID = Pattern.compile("[0-9A-HJKMNP-TV-Z]{26}"); + private static final Set STATUSES = Set.of( + "DRAFT", "REVIEWING", "RETURNED", "ACTIVE", "SETTLED", "DISABLED", "VOID" + ); + private static final Map SORT_COLUMNS = Map.of( + "updatedAt", "contract.updated_at", + "originalAmount", "contract.original_amount", + "settlementAmount", "COALESCE(contract.settlement_amount, contract.original_amount + contract.approved_change_amount)", + "payableAmount", "COALESCE(payable_summary.payable_amount, 0)", + "paidAmount", "COALESCE(payable_summary.paid_amount, 0)" + ); + + private final ContractCostMapper mapper; + private final IdentityContext identityContext; + private final AuthorizationService authorizationService; + private final AuditService auditService; + private final UlidGenerator ulidGenerator; + + public ContractCostApplicationService(ContractCostMapper mapper, + IdentityContext identityContext, + AuthorizationService authorizationService, + AuditService auditService, + UlidGenerator ulidGenerator) { + this.mapper = mapper; + this.identityContext = identityContext; + this.authorizationService = authorizationService; + this.auditService = auditService; + this.ulidGenerator = ulidGenerator; + } + + @Transactional(readOnly = true) + public LedgerPage list(String companyId, String projectId, String counterpartyId, + String keyword, String status, String costCategoryId, + LocalDate businessDateFrom, LocalDate businessDateTo, + String sort, int page, int size) { + authorizationService.requirePermission("contractcost:ledger:view"); + Query query = query(companyId, projectId, counterpartyId, keyword, status, costCategoryId, + businessDateFrom, businessDateTo, sort, page, size); + Actor actor = actor(); + long total = count(actor, query); + List items = rows(actor, query, query.size(), query.offset()) + .stream().map(this::view).toList(); + SummaryRow summary = mapper.summarizeContracts(actor.userId(), actor.roleCode(), query.companyId(), + query.projectId(), query.counterpartyId(), query.keyword(), query.status(), query.costCategoryId(), + query.businessDateFrom(), query.businessDateTo()); + return new LedgerPage(new PageResult<>(items, total, query.page(), query.size()), summary(summary)); + } + + @Transactional(readOnly = true) + public FilterOptionsView references() { + authorizationService.requirePermission("contractcost:ledger:view"); + Actor actor = actor(); + return new FilterOptionsView( + references(mapper.listCompanies(actor.userId(), actor.roleCode())), + references(mapper.listProjects(actor.userId(), actor.roleCode())), + references(mapper.listCounterparties(actor.userId(), actor.roleCode())), + references(mapper.listCostCategories()) + ); + } + + @Transactional(readOnly = true) + public ContractLedgerDetailView detail(String publicId) { + authorizationService.requirePermission("contractcost:ledger:view"); + String safeId = publicId(publicId); + Actor actor = actor(); + ContractRow row = mapper.findContract(actor.userId(), actor.roleCode(), safeId); + if (row == null) { + throw new BusinessException(HttpStatus.NOT_FOUND, ErrorCode.RESOURCE_NOT_FOUND, + "合同不存在或当前身份无权查看"); + } + boolean canMaintain = "ACTIVE".equals(row.status()) + && authorizationService.hasScope("contractcost:contract:maintain", + row.companyPublicId(), row.projectPublicId()); + boolean canConfirmPayable = authorizationService.hasScope("contractcost:payable:confirm", + row.companyPublicId(), row.projectPublicId()); + return new ContractLedgerDetailView(view(row, true), + mapper.listContractChanges(row.id()).stream().map(item -> change(item, canMaintain)).toList(), + mapper.listContractSettlements(row.id()).stream().map(item -> settlement(item, canMaintain)).toList(), + mapper.listPayables(row.id()).stream() + .map(item -> payable(item, actor, canConfirmPayable)).toList(), + mapper.listInvoices(row.id()).stream().map(this::invoice).toList(), + mapper.listPayments(row.id()).stream().map(this::payment).toList()); + } + + @Transactional + public ContractLedgerDetailView createChange(String contractPublicId, ContractChangeCreateRequest request) { + authorizationService.requirePermission("contractcost:contract:maintain"); + Actor actor = actor(); + ContractCommandRow found = contractCommand(contractPublicId); + BigDecimal amount = signedMoney(request.changeAmount()); + authorizationService.requireScope("contractcost:contract:maintain", found.companyPublicId(), + found.projectPublicId(), amount.abs()); + ContractCommandRow current = lockContract(found.id(), request.contractVersion()); + requireActive(current.status(), "只有生效中的合同可以登记变更"); + BigDecimal projectedAmount = current.originalAmount() + .add(current.approvedChangeAmount()).add(amount); + if (projectedAmount.signum() < 0) { + throw validation("变更后的合同总额不能小于零"); + } + if (current.settlementAmount() == null + && projectedAmount.compareTo(current.payableAmount()) < 0) { + throw contractLimitExceeded("变更后的合同总额不能低于现有有效应付金额"); + } + String publicId = ulidGenerator.next(); + try { + mapper.insertContractChange(publicId, current.id(), cleanRequired(request.changeNo(), 64), + amount, request.effectiveDate(), cleanRequired(request.reason(), 1000), actor.userId()); + if (mapper.addApprovedChangeAmount(current.id(), current.version(), amount, actor.userId()) != 1) { + throw conflict(); + } + } catch (DuplicateKeyException exception) { + throw duplicate("同一合同下的变更编号已存在"); + } + ContractLedgerDetailView result = detail(current.publicId()); + auditService.recordScoped(current.companyPublicId(), current.projectPublicId(), + "CONTRACT_CHANGE_CREATE", "CONTRACT_CHANGE", publicId, "SUCCESS", + request.reason().trim(), current, result); + return result; + } + + @Transactional + public ContractLedgerDetailView voidChange(String changePublicId, long expectedVersion, String reason) { + authorizationService.requirePermission("contractcost:contract:maintain"); + Actor actor = actor(); + ContractChangeCommandRow found = contractChangeCommand(changePublicId); + authorizationService.requireScope("contractcost:contract:maintain", found.companyPublicId(), + found.projectPublicId(), found.changeAmount().abs()); + ContractCommandRow contract = lockContract(found.contractId(), null); + requireActive(contract.status(), "只有生效中合同的变更可以作废"); + ContractChangeCommandRow current = mapper.lockContractChange(found.id()); + if (current == null) throw notFound("合同变更不存在"); + requireVersion(current.version(), expectedVersion); + if (!"APPROVED".equals(current.status())) throw transition("只有已批准的合同变更可以作废"); + BigDecimal projectedAmount = contract.originalAmount() + .add(contract.approvedChangeAmount()).subtract(current.changeAmount()); + if (projectedAmount.signum() < 0) { + throw validation("作废变更后的合同总额不能小于零"); + } + if (contract.settlementAmount() == null + && projectedAmount.compareTo(contract.payableAmount()) < 0) { + throw contractLimitExceeded("作废变更后的合同总额不能低于现有有效应付金额"); + } + if (mapper.voidContractChange(current.id(), expectedVersion) != 1 + || mapper.addApprovedChangeAmount(contract.id(), contract.version(), + current.changeAmount().negate(), actor.userId()) != 1) { + throw conflict(); + } + ContractLedgerDetailView result = detail(current.contractPublicId()); + auditService.recordScoped(found.companyPublicId(), found.projectPublicId(), + "CONTRACT_CHANGE_VOID", "CONTRACT_CHANGE", current.publicId(), "SUCCESS", + cleanRequired(reason, 1000), current, result); + return result; + } + + @Transactional + public ContractLedgerDetailView createSettlement(String contractPublicId, + ContractSettlementCreateRequest request) { + authorizationService.requirePermission("contractcost:contract:maintain"); + Actor actor = actor(); + ContractCommandRow found = contractCommand(contractPublicId); + BigDecimal amount = nonnegativeMoney(request.settlementAmount()); + authorizationService.requireScope("contractcost:contract:maintain", found.companyPublicId(), + found.projectPublicId(), amount); + ContractCommandRow current = lockContract(found.id(), request.contractVersion()); + requireActive(current.status(), "只有生效中的合同可以登记结算"); + SourceDocumentRow source = sourceDocument(current, request.sourceDocumentId(), "OA-11"); + String publicId = ulidGenerator.next(); + try { + mapper.insertContractSettlement(publicId, current.id(), + cleanRequired(request.settlementNo(), 64), amount, request.settlementDate(), + source.id(), source.currentVersionId(), actor.userId()); + if (mapper.refreshSettlementAmount(current.id(), current.version(), actor.userId()) != 1) { + throw conflict(); + } + ensurePayablesWithinLimit(mapper.lockContract(current.id())); + } catch (DuplicateKeyException exception) { + if (duplicateConstraint(exception, "uk_fin_contract_settlement_source_document")) { + throw duplicateSource("该 OA-11 来源单据已生成合同结算"); + } + throw duplicate("同一合同下的结算编号已存在"); + } + ContractLedgerDetailView result = detail(current.publicId()); + auditService.recordScoped(current.companyPublicId(), current.projectPublicId(), + "CONTRACT_SETTLEMENT_CREATE", "CONTRACT_SETTLEMENT", publicId, "SUCCESS", + null, current, result); + return result; + } + + @Transactional + public ContractLedgerDetailView voidSettlement(String settlementPublicId, long expectedVersion, + String reason) { + authorizationService.requirePermission("contractcost:contract:maintain"); + Actor actor = actor(); + ContractSettlementCommandRow found = contractSettlementCommand(settlementPublicId); + authorizationService.requireScope("contractcost:contract:maintain", found.companyPublicId(), + found.projectPublicId(), found.settlementAmount()); + ContractCommandRow contract = lockContract(found.contractId(), null); + requireActive(contract.status(), "只有生效中合同的结算可以作废"); + ContractSettlementCommandRow current = mapper.lockContractSettlement(found.id()); + if (current == null) throw notFound("合同结算不存在"); + requireVersion(current.version(), expectedVersion); + if (!"CONFIRMED".equals(current.status())) throw transition("只有已确认的合同结算可以作废"); + if (mapper.voidContractSettlement(current.id(), expectedVersion) != 1 + || mapper.refreshSettlementAmount(contract.id(), contract.version(), actor.userId()) != 1) { + throw conflict(); + } + ensurePayablesWithinLimit(mapper.lockContract(contract.id())); + ContractLedgerDetailView result = detail(current.contractPublicId()); + auditService.recordScoped(found.companyPublicId(), found.projectPublicId(), + "CONTRACT_SETTLEMENT_VOID", "CONTRACT_SETTLEMENT", current.publicId(), + "SUCCESS", cleanRequired(reason, 1000), current, result); + return result; + } + + @Transactional + public ContractLedgerDetailView createPayable(String contractPublicId, PayableCreateRequest request) { + authorizationService.requirePermission("contractcost:payable:create"); + Actor actor = actor(); + ContractCommandRow found = contractCommand(contractPublicId); + BigDecimal amount = positiveMoney(request.amount()); + authorizationService.requireScope("contractcost:payable:create", found.companyPublicId(), + found.projectPublicId(), amount); + ContractCommandRow current = lockContract(found.id(), request.contractVersion()); + requireActive(current.status(), "只有生效中的合同可以登记应付"); + if (current.payableAmount().add(amount).compareTo(contractLimit(current)) > 0) { + throw contractLimitExceeded("新增应付后将超过当前合同或结算额度"); + } + Long categoryId = mapper.findActiveCostCategoryId(publicId(request.costCategoryId())); + if (categoryId == null) throw notFound("成本分类不存在或未启用"); + SourceDocumentRow source = sourceDocument(current, request.sourceDocumentId(), "OA-04"); + String publicId = ulidGenerator.next(); + try { + mapper.insertPayable(publicId, cleanRequired(request.businessNo(), 64), current.companyId(), + current.projectId(), current.id(), current.counterpartyId(), source.id(), source.currentVersionId(), categoryId, + request.businessDate(), amount, actor.userId()); + if (mapper.touchContract(current.id(), current.version(), actor.userId()) != 1) throw conflict(); + } catch (DuplicateKeyException exception) { + if (duplicateConstraint(exception, "uk_fin_payable_source_document")) { + throw duplicateSource("该 OA-04 来源单据已生成应付记录"); + } + throw duplicate("应付编号已存在"); + } + ContractLedgerDetailView result = detail(current.publicId()); + auditService.recordScoped(current.companyPublicId(), current.projectPublicId(), + "PAYABLE_CREATE", "PAYABLE", publicId, "SUCCESS", null, current, result); + return result; + } + + @Transactional + public ContractLedgerDetailView confirmPayable(String payablePublicId, long expectedVersion) { + authorizationService.requirePermission("contractcost:payable:confirm"); + Actor actor = actor(); + PayableCommandRow found = payableCommand(payablePublicId); + authorizationService.requireScope("contractcost:payable:confirm", found.companyPublicId(), + found.projectPublicId(), found.amount()); + PayableCommandRow current = mapper.lockPayable(found.id()); + if (current == null) throw notFound("应付记录不存在"); + requireVersion(current.version(), expectedVersion); + if (!"PENDING".equals(current.status())) throw transition("只有待确认应付可以确认"); + if (current.createdBy() == actor.userId()) { + throw sod("应付登记人与确认人必须为不同人员"); + } + if (mapper.confirmPayable(current.id(), expectedVersion, actor.userId()) != 1) throw conflict(); + mapper.insertPayableAccountingEvent(ulidGenerator.next(), "EVT-" + current.publicId(), + current.publicId(), "PAYABLE-" + current.publicId(), 1, + current.companyId(), current.projectId(), current.counterpartyId(), + current.businessDate(), current.amount(), current.currency(), actor.userId()); + if (!"PENDING".equals(mapper.lockPayableAccountingEventStatus(current.publicId()))) { + throw conflict(); + } + ContractLedgerDetailView result = detail(current.contractPublicId()); + auditService.recordScoped(current.companyPublicId(), current.projectPublicId(), + "PAYABLE_CONFIRM", "PAYABLE", current.publicId(), "SUCCESS", null, current, result); + return result; + } + + @Transactional + public ContractLedgerDetailView voidPayable(String payablePublicId, long expectedVersion, String reason) { + authorizationService.requirePermission("contractcost:payable:confirm"); + Actor actor = actor(); + PayableCommandRow found = payableCommand(payablePublicId); + authorizationService.requireScope("contractcost:payable:confirm", found.companyPublicId(), + found.projectPublicId(), found.amount()); + PayableCommandRow current = mapper.lockPayable(found.id()); + if (current == null) throw notFound("应付记录不存在"); + requireVersion(current.version(), expectedVersion); + if (!Set.of("PENDING", "CONFIRMED").contains(current.status())) { + throw transition("只有待确认或已确认且未付款的应付可以作废"); + } + if (current.paidAmount().signum() != 0 || current.paymentCount() > 0) { + throw transition("已有付款金额或付款申请的应付不能作废"); + } + String accountingStatus = mapper.lockPayableAccountingEventStatus(current.publicId()); + if (accountingStatus != null && !Set.of("PENDING", "VOID").contains(accountingStatus)) { + throw transition("该应付已经进入记账处理,需通过记账更正流程处理"); + } + if (mapper.voidPayable(current.id(), expectedVersion, actor.userId()) != 1) throw conflict(); + if ("PENDING".equals(accountingStatus) + && mapper.voidPayableAccountingEvent(current.publicId()) != 1) { + throw conflict(); + } + ContractLedgerDetailView result = detail(current.contractPublicId()); + auditService.recordScoped(current.companyPublicId(), current.projectPublicId(), + "PAYABLE_VOID", "PAYABLE", current.publicId(), "SUCCESS", + cleanRequired(reason, 1000), current, result); + return result; + } + + @Transactional + public byte[] export(String companyId, String projectId, String counterpartyId, + String keyword, String status, String costCategoryId, + LocalDate businessDateFrom, LocalDate businessDateTo, + String sort) { + authorizationService.requirePermission("contractcost:ledger:export"); + Query query = query(companyId, projectId, counterpartyId, keyword, status, costCategoryId, + businessDateFrom, businessDateTo, sort, 1, 20); + Actor actor = actor(); + long total = count(actor, query); + if (total > 10_000) { + throw new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.VALIDATION_FAILED, + "单次最多导出 10000 条,请缩小筛选范围"); + } + List items = rows(actor, query, Math.max(1, (int) total), 0) + .stream().map(this::view).toList(); + StringBuilder csv = new StringBuilder("\uFEFF合同编号,合同名称,公司,项目,供应商,原合同额,批准变更,结算金额,已开票,待确认应付,已应付,已付款,可付余额,币种,状态,更新时间\r\n"); + for (ContractLedgerItemView item : items) { + csv.append(csv(item.businessNo())).append(',').append(csv(item.name())).append(',') + .append(csv(item.company().name())).append(',').append(csv(item.project().name())).append(',') + .append(csv(item.counterparty().name())).append(',').append(item.originalAmount()).append(',') + .append(item.approvedChangeAmount()).append(',').append(csv(item.settlementAmount())).append(',') + .append(item.invoicedAmount()).append(',').append(item.pendingPayableAmount()).append(',') + .append(item.payableAmount()).append(',') + .append(item.paidAmount()).append(',').append(item.availableAmount()).append(',') + .append(item.currency()).append(',').append(item.status()).append(',') + .append(item.updatedAt()).append("\r\n"); + } + auditService.recordScoped(query.companyId(), query.projectId(), + "CONTRACT_LEDGER_EXPORT", "CONTRACT_LEDGER", "FILTERED", "SUCCESS", + "导出 " + total + " 条", null, Map.of("count", total)); + return csv.toString().getBytes(StandardCharsets.UTF_8); + } + + private List rows(Actor actor, Query query, int limit, int offset) { + return mapper.listContracts(actor.userId(), actor.roleCode(), query.companyId(), query.projectId(), + query.counterpartyId(), query.keyword(), query.status(), query.costCategoryId(), + query.businessDateFrom(), query.businessDateTo(), query.orderBy(), limit, offset); + } + + private long count(Actor actor, Query query) { + return mapper.countContracts(actor.userId(), actor.roleCode(), query.companyId(), query.projectId(), + query.counterpartyId(), query.keyword(), query.status(), query.costCategoryId(), + query.businessDateFrom(), query.businessDateTo()); + } + + private Query query(String companyId, String projectId, String counterpartyId, + String keyword, String status, String costCategoryId, + LocalDate businessDateFrom, LocalDate businessDateTo, + String sort, int page, int size) { + if (page < 1 || !Set.of(20, 50, 100).contains(size)) { + throw queryError("分页参数只能使用 page>=1 且 size 为 20、50 或 100"); + } + if (businessDateFrom != null && businessDateTo != null && businessDateTo.isBefore(businessDateFrom)) { + throw queryError("业务日期结束值不能早于开始值"); + } + long rawOffset = (long) (page - 1) * size; + if (rawOffset > Integer.MAX_VALUE) { + throw queryError("页码超出允许范围"); + } + return new Query(optionalPublicId(companyId), optionalPublicId(projectId), + optionalPublicId(counterpartyId), clean(keyword), optionalStatus(status), + optionalPublicId(costCategoryId), businessDateFrom, businessDateTo, + orderBy(sort), page, size, (int) rawOffset); + } + + private ContractLedgerItemView view(ContractRow row) { + return view(row, false); + } + + private ContractLedgerItemView view(ContractRow row, boolean includeActions) { + return new ContractLedgerItemView(row.publicId(), row.businessNo(), row.name(), + reference(row.companyPublicId(), row.companyCode(), row.companyName()), + reference(row.projectPublicId(), row.projectCode(), row.projectName()), + reference(row.counterpartyPublicId(), row.counterpartyCode(), row.counterpartyName()), + money(row.originalAmount()), money(row.approvedChangeAmount()), nullableMoney(row.settlementAmount()), + money(row.invoicedAmount()), money(row.pendingPayableAmount()), money(row.payableAmount()), + money(row.paidAmount()), + money(row.availableAmount()), row.currency(), row.status(), row.version(), row.updatedAt(), + includeActions ? contractActions(row) : List.of()); + } + + private ContractLedgerSummaryView summary(SummaryRow row) { + return new ContractLedgerSummaryView(row == null ? 0 : row.contractCount(), + money(row == null ? null : row.originalAmount()), + money(row == null ? null : row.approvedChangeAmount()), + money(row == null ? null : row.settlementAmount()), + money(row == null ? null : row.invoicedAmount()), + money(row == null ? null : row.pendingPayableAmount()), + money(row == null ? null : row.payableAmount()), + money(row == null ? null : row.paidAmount()), + money(row == null ? null : row.availableAmount())); + } + + private PayableLineView payable(PayableRow row, Actor actor, boolean canConfirm) { + ReferenceView category = row.categoryPublicId() == null ? null + : reference(row.categoryPublicId(), row.categoryCode(), row.categoryName()); + List actions = new ArrayList<>(); + if (canConfirm && row.createdBy() != actor.userId() && "PENDING".equals(row.status())) { + actions.add("CONFIRM"); + } + if (canConfirm && Set.of("PENDING", "CONFIRMED").contains(row.status()) + && row.paidAmount().signum() == 0 && row.paymentCount() == 0) { + actions.add("VOID"); + } + return new PayableLineView(row.publicId(), row.businessNo(), row.businessDate(), + row.sourceDocumentPublicId(), row.sourceVersionPublicId(), category, + money(row.amount()), money(row.invoicedAmount()), money(row.paidAmount()), money(row.unpaidAmount()), + row.status(), row.version(), row.updatedAt(), actions); + } + + private ContractChangeLineView change(ContractChangeRow row, boolean canMaintain) { + List actions = canMaintain && "APPROVED".equals(row.status()) ? List.of("VOID") : List.of(); + return new ContractChangeLineView(row.publicId(), row.changeNo(), money(row.changeAmount()), + row.effectiveDate(), row.reason(), row.status(), row.version(), row.createdByName(), + row.createdAt(), actions); + } + + private ContractSettlementLineView settlement(ContractSettlementRow row, boolean canMaintain) { + List actions = canMaintain && "CONFIRMED".equals(row.status()) ? List.of("VOID") : List.of(); + return new ContractSettlementLineView(row.publicId(), row.settlementNo(), + money(row.settlementAmount()), row.settlementDate(), row.sourceDocumentPublicId(), + row.sourceVersionPublicId(), row.status(), + row.version(), row.createdByName(), row.createdAt(), actions); + } + + private InvoiceLineView invoice(InvoiceRow row) { + return new InvoiceLineView(row.publicId(), row.businessNo(), row.requestedDate(), row.invoiceType(), + money(row.amount()), decimal(row.taxRate()), row.invoiceNo(), row.issuedDate(), row.status(), row.updatedAt()); + } + + private PaymentLineView payment(PaymentRow row) { + return new PaymentLineView(row.publicId(), row.businessNo(), row.requestedDate(), + money(row.requestedAmount()), nullableMoney(row.approvedAmount()), row.purpose(), row.status(), row.updatedAt()); + } + + private List references(List rows) { + return rows.stream().map(row -> reference(row.publicId(), row.code(), row.name())).toList(); + } + + private ReferenceView reference(String publicId, String code, String name) { + return new ReferenceView(publicId, code, name); + } + + private List contractActions(ContractRow row) { + if (!"ACTIVE".equals(row.status())) return List.of(); + List actions = new ArrayList<>(); + if (authorizationService.hasScope("contractcost:contract:maintain", + row.companyPublicId(), row.projectPublicId())) { + actions.add("CREATE_CHANGE"); + actions.add("CREATE_SETTLEMENT"); + } + if (authorizationService.hasScope("contractcost:payable:create", + row.companyPublicId(), row.projectPublicId())) { + actions.add("CREATE_PAYABLE"); + } + return actions; + } + + private String orderBy(String value) { + String safe = value == null || value.isBlank() ? "updatedAt,desc" : value.trim(); + String[] parts = safe.split(",", -1); + if (parts.length != 2 || !SORT_COLUMNS.containsKey(parts[0])) { + throw queryError("排序字段不在允许范围内"); + } + String direction = parts[1].toUpperCase(Locale.ROOT); + if (!Set.of("ASC", "DESC").contains(direction)) { + throw queryError("排序方向只允许 asc 或 desc"); + } + return SORT_COLUMNS.get(parts[0]) + " " + direction; + } + + private String optionalStatus(String value) { + if (value == null || value.isBlank()) return null; + String safe = value.trim().toUpperCase(Locale.ROOT); + if (!STATUSES.contains(safe)) throw queryError("合同状态筛选值不存在"); + return safe; + } + + private String optionalPublicId(String value) { + if (value == null || value.isBlank()) return null; + return publicId(value); + } + + private String publicId(String value) { + String safe = value == null ? "" : value.trim().toUpperCase(Locale.ROOT); + if (!PUBLIC_ID.matcher(safe).matches()) throw queryError("业务标识格式不正确"); + return safe; + } + + private String clean(String value) { + if (value == null || value.isBlank()) return null; + String safe = value.trim(); + if (safe.length() > 100) throw queryError("关键词不能超过 100 个字符"); + return safe; + } + + private String cleanRequired(String value, int max) { + if (value == null || value.isBlank()) throw validation("必填文本不能为空"); + String safe = value.trim(); + if (safe.length() > max) throw validation("文本长度超过限制"); + return safe; + } + + private BigDecimal positiveMoney(BigDecimal value) { + BigDecimal safe = scaledMoney(value); + if (safe.signum() <= 0) throw validation("金额必须大于零"); + return safe; + } + + private BigDecimal nonnegativeMoney(BigDecimal value) { + BigDecimal safe = scaledMoney(value); + if (safe.signum() < 0) throw validation("金额不能小于零"); + return safe; + } + + private BigDecimal signedMoney(BigDecimal value) { + BigDecimal safe = scaledMoney(value); + if (safe.signum() == 0) throw validation("变更金额不能为零"); + return safe; + } + + private BigDecimal scaledMoney(BigDecimal value) { + if (value == null) throw validation("金额不能为空"); + try { + return value.setScale(2, RoundingMode.UNNECESSARY); + } catch (ArithmeticException exception) { + throw validation("金额最多保留两位小数"); + } + } + + private String money(BigDecimal value) { + return (value == null ? BigDecimal.ZERO : value).setScale(2, RoundingMode.HALF_UP).toPlainString(); + } + + private String nullableMoney(BigDecimal value) { + return value == null ? null : money(value); + } + + private String decimal(BigDecimal value) { + return value == null ? null : value.stripTrailingZeros().toPlainString(); + } + + private String csv(String value) { + if (value == null) return ""; + String escaped = value.replace("\"", "\"\""); + return escaped.contains(",") || escaped.contains("\"") || escaped.contains("\n") + ? "\"" + escaped + "\"" : escaped; + } + + private Actor actor() { + FinancePrincipal principal = identityContext.requirePrincipal(); + return new Actor(principal.userId(), identityContext.requireActiveRole()); + } + + private ContractCommandRow contractCommand(String value) { + ContractCommandRow row = mapper.findContractCommand(publicId(value)); + if (row == null) throw notFound("合同不存在"); + return row; + } + + private ContractChangeCommandRow contractChangeCommand(String value) { + ContractChangeCommandRow row = mapper.findContractChangeCommand(publicId(value)); + if (row == null) throw notFound("合同变更不存在"); + return row; + } + + private ContractSettlementCommandRow contractSettlementCommand(String value) { + ContractSettlementCommandRow row = mapper.findContractSettlementCommand(publicId(value)); + if (row == null) throw notFound("合同结算不存在"); + return row; + } + + private PayableCommandRow payableCommand(String value) { + PayableCommandRow row = mapper.findPayableCommand(publicId(value)); + if (row == null) throw notFound("应付记录不存在"); + return row; + } + + private ContractCommandRow lockContract(long id, Long expectedVersion) { + ContractCommandRow row = mapper.lockContract(id); + if (row == null) throw notFound("合同不存在"); + if (expectedVersion != null) requireVersion(row.version(), expectedVersion); + return row; + } + + private SourceDocumentRow sourceDocument(ContractCommandRow contract, String sourceDocumentPublicId, + String expectedFormType) { + if (sourceDocumentPublicId == null || sourceDocumentPublicId.isBlank()) { + throw validation("请选择来源审批单"); + } + SourceDocumentRow source = mapper.findSourceDocument(publicId(sourceDocumentPublicId)); + if (source == null || "VOID".equals(source.status())) throw notFound("来源单据不存在或已作废"); + if (!expectedFormType.equals(source.formType())) { + throw validation("来源单据类型不正确,应选择 " + expectedFormType + " 审批单"); + } + if (!"APPROVED".equals(source.status())) { + throw validation("来源单据尚未完成审批"); + } + if (source.currentVersionId() == null || !source.currentVersionApproved()) { + throw validation("来源单据当前版本尚未完成审批"); + } + if (source.companyId() != contract.companyId() || source.projectId() == null + || source.projectId() != contract.projectId()) { + throw validation("来源单据与合同的公司或项目不一致"); + } + return source; + } + + private boolean duplicateConstraint(DuplicateKeyException exception, String constraint) { + Throwable current = exception; + while (current != null) { + if (current.getMessage() != null && current.getMessage().contains(constraint)) return true; + current = current.getCause(); + } + return false; + } + + private BigDecimal contractLimit(ContractCommandRow contract) { + return contract.settlementAmount() == null + ? contract.originalAmount().add(contract.approvedChangeAmount()) + : contract.settlementAmount(); + } + + private void ensurePayablesWithinLimit(ContractCommandRow contract) { + if (contract == null) throw notFound("合同不存在"); + if (contract.payableAmount().compareTo(contractLimit(contract)) > 0) { + throw contractLimitExceeded("结算调整后不能低于现有有效应付金额"); + } + } + + private void requireVersion(long actual, long expected) { + if (actual != expected) throw conflict(); + } + + private void requireActive(String status, String message) { + if (!"ACTIVE".equals(status)) throw transition(message); + } + + private BusinessException queryError(String message) { + return new BusinessException(HttpStatus.BAD_REQUEST, ErrorCode.QUERY_PARAMETER_INVALID, message); + } + + private BusinessException validation(String message) { + return new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.VALIDATION_FAILED, message); + } + + private BusinessException notFound(String message) { + return new BusinessException(HttpStatus.NOT_FOUND, ErrorCode.RESOURCE_NOT_FOUND, message); + } + + private BusinessException conflict() { + return new BusinessException(HttpStatus.CONFLICT, ErrorCode.VERSION_CONFLICT, + "记录已被其他人更新,请刷新后重试"); + } + + private BusinessException duplicate(String message) { + return new BusinessException(HttpStatus.CONFLICT, ErrorCode.DUPLICATE_RESOURCE, message); + } + + private BusinessException duplicateSource(String message) { + return new BusinessException(HttpStatus.CONFLICT, ErrorCode.DUPLICATE_SOURCE, message); + } + + private BusinessException transition(String message) { + return new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, + ErrorCode.INVALID_STATE_TRANSITION, message); + } + + private BusinessException sod(String message) { + return new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.SOD_VIOLATION, message); + } + + private BusinessException contractLimitExceeded(String message) { + return new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, + ErrorCode.CONTRACT_LIMIT_EXCEEDED, message); + } + + public record LedgerPage(PageResult page, + ContractLedgerSummaryView summary) { + } + + private record Actor(long userId, String roleCode) { + } + + private record Query(String companyId, String projectId, String counterpartyId, + String keyword, String status, String costCategoryId, + LocalDate businessDateFrom, LocalDate businessDateTo, + String orderBy, int page, int size, int offset) { + } +} diff --git a/backend/src/main/java/com/kaidi/finance/operations/application/FinanceOperationsApplicationService.java b/backend/src/main/java/com/kaidi/finance/operations/application/FinanceOperationsApplicationService.java new file mode 100644 index 0000000..72910ac --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/operations/application/FinanceOperationsApplicationService.java @@ -0,0 +1,296 @@ +package com.kaidi.finance.operations.application; + +import com.kaidi.finance.iam.domain.FinancePrincipal; +import com.kaidi.finance.operations.api.FinanceOperationsViews.ContractListView; +import com.kaidi.finance.operations.api.FinanceOperationsViews.PayableListView; +import com.kaidi.finance.operations.infrastructure.FinanceOperationsMapper.ContractListRow; +import com.kaidi.finance.operations.infrastructure.FinanceOperationsMapper.PayableListRow; +import com.kaidi.finance.shared.api.BusinessException; +import com.kaidi.finance.shared.api.ErrorCode; +import com.kaidi.finance.shared.api.PageResult; +import com.kaidi.finance.shared.security.AuthorizationService; +import com.kaidi.finance.shared.security.IdentityContext; +import com.kaidi.finance.operations.infrastructure.FinanceOperationsMapper; +import java.math.BigDecimal; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class FinanceOperationsApplicationService { + + private static final Set MASTER_RESOURCES = Set.of( + "bank-accounts", "contracts", "cost-categories", "accounts", "tax-rates"); + private static final Set FINANCE_RESOURCES = Set.of("contracts", "costs", "payables"); + private static final Set ARCHIVE_RESOURCES = Set.of("packages", "files", "borrows"); + private static final Set REPORT_CODES = Set.of( + "project-ledger", "contract-execution", "receipt-invoice", "payment-progress", + "workflow-duration", "archive-completeness"); + private static final Set ADMIN_RESOURCES = Set.of("users", "roles", "scopes", "templates", "parameters"); + private static final Set CONTRACT_STATUSES = Set.of( + "DRAFT", "REVIEWING", "RETURNED", "ACTIVE", "SETTLED", "DISABLED", "VOID"); + private static final Set PAYABLE_STATUSES = Set.of( + "PENDING", "CONFIRMED", "PART_PAID", "PAID", "VOID"); + private static final Map> FINANCE_SORT_FIELDS = Map.of( + "contracts", Set.of("updatedAt", "businessNo", "name", "status", "contractAmount", "settlementAmount"), + "costs", Set.of("updatedAt", "businessNo", "businessDate", "amount", "status", "projectName", + "counterpartyName"), + "payables", Set.of("updatedAt", "businessNo", "businessDate", "amount", "status", "projectName", + "counterpartyName") + ); + + private final FinanceOperationsMapper mapper; + private final AuthorizationService authorizationService; + private final IdentityContext identityContext; + + public FinanceOperationsApplicationService(FinanceOperationsMapper mapper, + AuthorizationService authorizationService, + IdentityContext identityContext) { + this.mapper = mapper; + this.authorizationService = authorizationService; + this.identityContext = identityContext; + } + + @Transactional(readOnly = true) + public PageResult> listMaster(String resource, String keyword, String status, + int page, int size) { + requireResource(MASTER_RESOURCES, resource, "主数据资源"); + authorizationService.requirePermission(masterPermission(resource)); + PageRequest request = page(page, size); + String safeKeyword = clean(keyword); + String safeStatus = clean(status); + long total = mapper.countMaster(resource, safeKeyword, safeStatus); + return new PageResult<>(mapper.listMaster(resource, safeKeyword, safeStatus, + request.size(), request.offset()), total, request.page(), request.size()); + } + + @Transactional(readOnly = true) + public PageResult listContracts(String keyword, String status, List sort, + int page, int size) { + FinanceQuery query = financeQuery("contracts", keyword, status, sort, page, size); + long total = mapper.countContractRows(query.userId(), query.roleCode(), query.permissionCode(), + query.keyword(), query.status()); + return new PageResult<>(mapper.listContractRows(query.userId(), query.roleCode(), query.permissionCode(), + query.keyword(), query.status(), query.sort(), query.page().size(), query.page().offset()).stream() + .map(this::contractView).toList(), total, query.page().page(), query.page().size()); + } + + @Transactional(readOnly = true) + public PageResult listCosts(String keyword, String status, List sort, + int page, int size) { + return listPayableRows("costs", keyword, status, sort, page, size); + } + + @Transactional(readOnly = true) + public PageResult listPayables(String keyword, String status, List sort, + int page, int size) { + return listPayableRows("payables", keyword, status, sort, page, size); + } + + private PageResult listPayableRows(String resource, String keyword, String status, + List sort, int page, int size) { + FinanceQuery query = financeQuery(resource, keyword, status, sort, page, size); + long total = mapper.countPayableRows(query.userId(), query.roleCode(), query.permissionCode(), + query.keyword(), query.status()); + return new PageResult<>(mapper.listPayableRows(query.userId(), query.roleCode(), query.permissionCode(), + query.keyword(), query.status(), query.sort(), query.page().size(), query.page().offset()).stream() + .map(this::payableView).toList(), total, query.page().page(), query.page().size()); + } + + private FinanceQuery financeQuery(String resource, String keyword, String status, List sort, + int page, int size) { + requireResource(FINANCE_RESOURCES, resource, "财务资源"); + String permissionCode = financePermission(resource); + authorizationService.requirePermission(permissionCode); + FinancePrincipal actor = identityContext.requirePrincipal(); + String roleCode = identityContext.requireActiveRole(); + String safeStatus = clean(status); + if (safeStatus != null) { + safeStatus = safeStatus.toUpperCase(Locale.ROOT); + Set allowedStatuses = "contracts".equals(resource) ? CONTRACT_STATUSES : PAYABLE_STATUSES; + if (!allowedStatuses.contains(safeStatus)) throw invalid("不支持的状态筛选值"); + } + return new FinanceQuery(actor.userId(), roleCode, permissionCode, clean(keyword), safeStatus, + sort(resource, sort), page(page, size)); + } + + private static List> sort(String resource, List requested) { + if (requested == null || requested.isEmpty()) { + return List.of(Map.of("field", "updatedAt", "direction", "desc")); + } + if (requested.size() > 3) throw sortNotAllowed("排序字段最多指定 3 个"); + Set allowedFields = FINANCE_SORT_FIELDS.get(resource); + Set seen = new HashSet<>(); + List> result = new ArrayList<>(requested.size()); + for (String value : requested) { + String[] parts = value == null ? new String[0] : value.trim().split(",", -1); + if (parts.length != 2) throw sortNotAllowed("排序格式必须为 sort=field,asc|desc"); + String field = parts[0].trim(); + String direction = parts[1].trim().toLowerCase(Locale.ROOT); + if (!allowedFields.contains(field) || !("asc".equals(direction) || "desc".equals(direction))) { + throw sortNotAllowed("不支持的排序字段或方向"); + } + if (!seen.add(field)) throw sortNotAllowed("排序字段不能重复"); + result.add(Map.of("field", field, "direction", direction)); + } + return List.copyOf(result); + } + + @Transactional(readOnly = true) + public PageResult> listArchive(String resource, String keyword, String status, + int page, int size) { + requireResource(ARCHIVE_RESOURCES, resource, "档案资源"); + authorizationService.requirePermission(archivePermission(resource)); + PageRequest request = page(page, size); + String safeKeyword = clean(keyword); + String safeStatus = clean(status); + long total = mapper.countArchive(resource, safeKeyword, safeStatus); + return new PageResult<>(mapper.listArchive(resource, safeKeyword, safeStatus, + request.size(), request.offset()), total, request.page(), request.size()); + } + + @Transactional(readOnly = true) + public PageResult> listReport(String reportCode, String keyword, + int page, int size) { + requireResource(REPORT_CODES, reportCode, "报表"); + authorizationService.requirePermission("report:finance:view"); + PageRequest request = page(page, size); + String safeKeyword = clean(keyword); + long total = mapper.countReport(reportCode, safeKeyword); + return new PageResult<>(mapper.listReport(reportCode, safeKeyword, + request.size(), request.offset()), total, request.page(), request.size()); + } + + @Transactional(readOnly = true) + public PageResult> listAudit(String keyword, String resultCode, + int page, int size) { + authorizationService.requirePermission("audit:log:view"); + PageRequest request = page(page, size); + String safeKeyword = clean(keyword); + String safeResult = clean(resultCode); + long total = mapper.countAudit(safeKeyword, safeResult); + return new PageResult<>(mapper.listAudit(safeKeyword, safeResult, + request.size(), request.offset()), total, request.page(), request.size()); + } + + @Transactional(readOnly = true) + public PageResult> listAdmin(String resource, String keyword, int page, int size) { + requireResource(ADMIN_RESOURCES, resource, "系统配置资源"); + authorizationService.requirePermission(adminPermission(resource)); + PageRequest request = page(page, size); + String safeKeyword = clean(keyword); + long total = mapper.countAdmin(resource, safeKeyword); + return new PageResult<>(mapper.listAdmin(resource, safeKeyword, + request.size(), request.offset()), total, request.page(), request.size()); + } + + private ContractListView contractView(ContractListRow row) { + return new ContractListView( + row.publicId(), row.businessNo(), row.name(), money(row.contractAmount()), + money(row.changeAmount()), money(row.settlementAmount()), row.currency(), row.status(), + row.projectBusinessNo(), row.projectName(), row.companyName(), row.counterpartyName(), + instant(row.updatedAt()) + ); + } + + private PayableListView payableView(PayableListRow row) { + return new PayableListView( + row.publicId(), row.businessNo(), row.businessDate(), money(row.amount()), + money(row.invoicedAmount()), money(row.paidAmount()), row.status(), row.projectBusinessNo(), + row.projectName(), row.counterpartyName(), row.costCategoryName(), instant(row.updatedAt()) + ); + } + + private static String money(BigDecimal value) { + return value == null ? null : value.setScale(2, java.math.RoundingMode.HALF_UP).toPlainString(); + } + + private static Instant instant(LocalDateTime value) { + return value == null ? null : value.toInstant(ZoneOffset.UTC); + } + + private String masterPermission(String resource) { + return switch (resource) { + case "bank-accounts" -> "masterdata:bank-account:view"; + case "contracts" -> "masterdata:contract:view"; + default -> "masterdata:dictionary:view"; + }; + } + + private String financePermission(String resource) { + return switch (resource) { + case "contracts" -> "contractcost:ledger:view"; + case "costs", "payables" -> "contractcost:payable:view"; + default -> throw invalid("未知财务资源"); + }; + } + + private String archivePermission(String resource) { + return switch (resource) { + case "packages" -> "archive:package:view"; + default -> "archive:file:view"; + }; + } + + private String adminPermission(String resource) { + return switch (resource) { + case "users" -> "admin:user:view"; + case "roles" -> "admin:role:view"; + case "scopes" -> "admin:scope:view"; + case "templates" -> "admin:template:view"; + case "parameters" -> "admin:parameter:view"; + default -> throw invalid("未知系统配置资源"); + }; + } + + private static void requireResource(Set resources, String resource, String label) { + if (resource == null || !resources.contains(resource)) { + throw invalid("不支持的" + label); + } + } + + private static String clean(String value) { + if (value == null || value.isBlank()) return null; + String result = value.trim(); + if (result.length() > 100) { + throw invalid("查询条件不能超过 100 个字符"); + } + return result; + } + + private static PageRequest page(int page, int size) { + if (page < 1 || !Set.of(20, 50, 100).contains(size)) { + throw invalid("分页参数只能使用 page>=1 且 size 为 20、50 或 100"); + } + return new PageRequest(page, size); + } + + private static BusinessException invalid(String message) { + return new BusinessException(HttpStatus.BAD_REQUEST, ErrorCode.QUERY_PARAMETER_INVALID, message); + } + + private static BusinessException sortNotAllowed(String message) { + return new BusinessException(HttpStatus.BAD_REQUEST, ErrorCode.SORT_FIELD_NOT_ALLOWED, message); + } + + private record PageRequest(int page, int size) { + int offset() { + long result = (long) (page - 1) * size; + return result > Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) result; + } + } + + private record FinanceQuery(long userId, String roleCode, String permissionCode, + String keyword, String status, List> sort, + PageRequest page) { + } +} diff --git a/backend/src/main/java/com/kaidi/finance/operations/infrastructure/ContractCostMapper.java b/backend/src/main/java/com/kaidi/finance/operations/infrastructure/ContractCostMapper.java new file mode 100644 index 0000000..7724166 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/operations/infrastructure/ContractCostMapper.java @@ -0,0 +1,871 @@ +package com.kaidi.finance.operations.infrastructure; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +@org.apache.ibatis.annotations.Mapper +public interface ContractCostMapper { + + String CONTRACT_SELECT = """ + SELECT contract.id AS id, contract.public_id AS public_id, + contract.business_no AS business_no, contract.name AS name, + company.public_id AS company_public_id, company.business_no AS company_code, + company.name AS company_name, + project.public_id AS project_public_id, project.business_no AS project_code, + project.name AS project_name, + counterparty.public_id AS counterparty_public_id, + counterparty.business_no AS counterparty_code, counterparty.name AS counterparty_name, + contract.original_amount AS original_amount, + contract.approved_change_amount AS approved_change_amount, + contract.settlement_amount AS settlement_amount, + COALESCE(invoice_summary.invoiced_amount, 0) AS invoiced_amount, + COALESCE(payable_summary.pending_payable_amount, 0) AS pending_payable_amount, + COALESCE(payable_summary.payable_amount, 0) AS payable_amount, + COALESCE(payable_summary.paid_amount, 0) AS paid_amount, + GREATEST( + COALESCE(contract.settlement_amount, + contract.original_amount + contract.approved_change_amount) + - COALESCE(payable_summary.paid_amount, 0), 0 + ) AS available_amount, + contract.currency AS currency, contract.status AS status, contract.version AS version, + contract.updated_at AS updated_at + FROM md_contract contract + JOIN md_company company ON company.id = contract.company_id + JOIN md_project project ON project.id = contract.project_id + JOIN md_counterparty counterparty ON counterparty.id = contract.counterparty_id + LEFT JOIN ( + SELECT invoice.contract_id, + SUM(CASE WHEN invoice.invoice_type = 'RED_LETTER' + THEN -invoice.amount ELSE invoice.amount END) AS invoiced_amount + FROM fin_invoice invoice + WHERE invoice.status IN ('ISSUED', 'RED_LETTERED') + GROUP BY invoice.contract_id + ) invoice_summary ON invoice_summary.contract_id = contract.id + LEFT JOIN ( + SELECT payable.contract_id, + SUM(CASE WHEN payable.status = 'PENDING' + THEN payable.amount ELSE 0 END) AS pending_payable_amount, + SUM(CASE WHEN payable.status IN ('CONFIRMED', 'PART_PAID', 'PAID') + THEN payable.amount ELSE 0 END) AS payable_amount, + SUM(payable.paid_amount) AS paid_amount + FROM fin_payable payable + WHERE payable.status <> 'VOID' + GROUP BY payable.contract_id + ) payable_summary ON payable_summary.contract_id = contract.id + """; + + String SCOPE_FILTER = """ + EXISTS ( + SELECT 1 + FROM iam_scope scope + JOIN iam_role role ON role.id = scope.role_id + JOIN iam_permission permission ON permission.id = scope.permission_id + WHERE scope.user_id = #{userId} + AND role.code = #{roleCode} + AND permission.code = 'contractcost:ledger:view' + AND scope.status = 'ACTIVE' + AND scope.valid_from <= UTC_TIMESTAMP(3) + AND (scope.valid_to IS NULL OR scope.valid_to >= UTC_TIMESTAMP(3)) + AND ( + scope.scope_type = 'GLOBAL' + OR (scope.scope_type = 'COMPANY' AND scope.company_public_id = company.public_id) + OR (scope.scope_type = 'PROJECT' AND scope.project_public_id = project.public_id) + ) + ) + """; + + String QUERY_FILTERS = """ + AND company.public_id = #{companyId} + AND project.public_id = #{projectId} + AND counterparty.public_id = #{counterpartyId} + + AND (contract.business_no LIKE CONCAT('%', #{keyword}, '%') + OR contract.name LIKE CONCAT('%', #{keyword}, '%') + OR project.name LIKE CONCAT('%', #{keyword}, '%')) + + AND contract.status = #{status} + + AND EXISTS ( + SELECT 1 FROM fin_payable filter_payable + LEFT JOIN md_cost_category filter_category ON filter_category.id = filter_payable.cost_category_id + WHERE filter_payable.contract_id = contract.id AND filter_payable.status <> 'VOID' + AND filter_category.public_id = #{costCategoryId} + AND filter_payable.business_date >= #{businessDateFrom} + AND filter_payable.business_date <= #{businessDateTo} + ) + + """; + + @Select(""" + + """) + List listContracts(@Param("userId") long userId, + @Param("roleCode") String roleCode, + @Param("companyId") String companyId, + @Param("projectId") String projectId, + @Param("counterpartyId") String counterpartyId, + @Param("keyword") String keyword, + @Param("status") String status, + @Param("costCategoryId") String costCategoryId, + @Param("businessDateFrom") LocalDate businessDateFrom, + @Param("businessDateTo") LocalDate businessDateTo, + @Param("orderBy") String orderBy, + @Param("limit") int limit, + @Param("offset") int offset); + + @Select(""" + + """) + long countContracts(@Param("userId") long userId, + @Param("roleCode") String roleCode, + @Param("companyId") String companyId, + @Param("projectId") String projectId, + @Param("counterpartyId") String counterpartyId, + @Param("keyword") String keyword, + @Param("status") String status, + @Param("costCategoryId") String costCategoryId, + @Param("businessDateFrom") LocalDate businessDateFrom, + @Param("businessDateTo") LocalDate businessDateTo); + + @Select(""" + + """) + SummaryRow summarizeContracts(@Param("userId") long userId, + @Param("roleCode") String roleCode, + @Param("companyId") String companyId, + @Param("projectId") String projectId, + @Param("counterpartyId") String counterpartyId, + @Param("keyword") String keyword, + @Param("status") String status, + @Param("costCategoryId") String costCategoryId, + @Param("businessDateFrom") LocalDate businessDateFrom, + @Param("businessDateTo") LocalDate businessDateTo); + + @Select(""" + + """) + ContractRow findContract(@Param("userId") long userId, + @Param("roleCode") String roleCode, + @Param("publicId") String publicId); + + @Select(""" + SELECT payable.public_id, payable.business_no, payable.business_date, + source.public_id AS source_document_public_id, + source_version.public_id AS source_version_public_id, + category.public_id AS category_public_id, category.code AS category_code, + category.name AS category_name, payable.amount, payable.invoiced_amount, + payable.paid_amount, payable.amount - payable.paid_amount AS unpaid_amount, + payable.status, payable.version, payable.updated_at, payable.created_by, + (SELECT COUNT(*) FROM fin_payment_request payment + WHERE payment.payable_id = payable.id AND payment.status <> 'VOID') AS payment_count + FROM fin_payable payable + LEFT JOIN src_source_document source ON source.id = payable.source_document_id + LEFT JOIN src_document_version source_version ON source_version.id = payable.source_version_id + LEFT JOIN md_cost_category category ON category.id = payable.cost_category_id + WHERE payable.contract_id = #{contractId} + ORDER BY payable.business_date DESC, payable.public_id DESC + """) + List listPayables(long contractId); + + @Select(""" + SELECT contract_change.public_id, contract_change.change_no, contract_change.change_amount, + contract_change.effective_date, contract_change.reason, contract_change.status, + contract_change.version, creator.display_name AS created_by_name, + contract_change.created_at + FROM fin_contract_change contract_change + JOIN iam_user creator ON creator.id = contract_change.created_by + WHERE contract_change.contract_id = #{contractId} + ORDER BY contract_change.effective_date DESC, contract_change.public_id DESC + """) + List listContractChanges(long contractId); + + @Select(""" + SELECT settlement.public_id, settlement.settlement_no, settlement.settlement_amount, + settlement.settlement_date, source.public_id AS source_document_public_id, + source_version.public_id AS source_version_public_id, + settlement.status, settlement.version, creator.display_name AS created_by_name, + settlement.created_at + FROM fin_contract_settlement settlement + LEFT JOIN src_source_document source ON source.id = settlement.source_document_id + LEFT JOIN src_document_version source_version ON source_version.id = settlement.source_version_id + JOIN iam_user creator ON creator.id = settlement.created_by + WHERE settlement.contract_id = #{contractId} + ORDER BY settlement.settlement_date DESC, settlement.public_id DESC + """) + List listContractSettlements(long contractId); + + @Select(""" + SELECT invoice.public_id, invoice.business_no, invoice.requested_date, + invoice.invoice_type, + CASE WHEN invoice.invoice_type = 'RED_LETTER' + THEN -invoice.amount ELSE invoice.amount END AS amount, + invoice.tax_rate, + invoice.invoice_no, invoice.issued_date, invoice.status, invoice.updated_at + FROM fin_invoice invoice + WHERE invoice.contract_id = #{contractId} + ORDER BY invoice.requested_date DESC, invoice.public_id DESC + """) + List listInvoices(long contractId); + + @Select(""" + SELECT payment.public_id, payment.business_no, payment.requested_date, + payment.requested_amount, payment.approved_amount, payment.purpose, + payment.status, payment.updated_at + FROM fin_payment_request payment + WHERE payment.contract_id = #{contractId} + ORDER BY payment.requested_date DESC, payment.public_id DESC + """) + List listPayments(long contractId); + + @Select(""" + + """) + List listCompanies(@Param("userId") long userId, @Param("roleCode") String roleCode); + + @Select(""" + + """) + List listProjects(@Param("userId") long userId, @Param("roleCode") String roleCode); + + @Select(""" + + """) + List listCounterparties(@Param("userId") long userId, @Param("roleCode") String roleCode); + + @Select(""" + SELECT category.public_id, category.code, category.name + FROM md_cost_category category + WHERE category.status = 'ACTIVE' + ORDER BY category.code, category.public_id + """) + List listCostCategories(); + + @Select(""" + SELECT contract.id, contract.public_id, contract.company_id, contract.project_id, + contract.counterparty_id, company.public_id AS company_public_id, + project.public_id AS project_public_id, contract.currency, contract.status, + contract.version, contract.original_amount, contract.approved_change_amount, + contract.settlement_amount, + (SELECT COALESCE(SUM(payable.amount), 0) FROM fin_payable payable + WHERE payable.contract_id = contract.id AND payable.status <> 'VOID') AS payable_amount + FROM md_contract contract + JOIN md_company company ON company.id = contract.company_id + JOIN md_project project ON project.id = contract.project_id + WHERE contract.public_id = #{publicId} + """) + ContractCommandRow findContractCommand(String publicId); + + @Select(""" + SELECT contract.id, contract.public_id, contract.company_id, contract.project_id, + contract.counterparty_id, company.public_id AS company_public_id, + project.public_id AS project_public_id, contract.currency, contract.status, + contract.version, contract.original_amount, contract.approved_change_amount, + contract.settlement_amount, + (SELECT COALESCE(SUM(payable.amount), 0) FROM fin_payable payable + WHERE payable.contract_id = contract.id AND payable.status <> 'VOID') AS payable_amount + FROM md_contract contract + JOIN md_company company ON company.id = contract.company_id + JOIN md_project project ON project.id = contract.project_id + WHERE contract.id = #{id} + FOR UPDATE + """) + ContractCommandRow lockContract(long id); + + @Select(""" + SELECT contract_change.id, contract_change.public_id, + contract_change.contract_id, contract.public_id AS contract_public_id, + company.public_id AS company_public_id, project.public_id AS project_public_id, + contract_change.change_no, contract_change.change_amount, + contract_change.effective_date, contract_change.reason, + contract_change.status, contract_change.version + FROM fin_contract_change contract_change + JOIN md_contract contract ON contract.id = contract_change.contract_id + JOIN md_company company ON company.id = contract.company_id + JOIN md_project project ON project.id = contract.project_id + WHERE contract_change.public_id = #{publicId} + """) + ContractChangeCommandRow findContractChangeCommand(String publicId); + + @Select(""" + SELECT contract_change.id, contract_change.public_id, + contract_change.contract_id, contract.public_id AS contract_public_id, + company.public_id AS company_public_id, project.public_id AS project_public_id, + contract_change.change_no, contract_change.change_amount, + contract_change.effective_date, contract_change.reason, + contract_change.status, contract_change.version + FROM fin_contract_change contract_change + JOIN md_contract contract ON contract.id = contract_change.contract_id + JOIN md_company company ON company.id = contract.company_id + JOIN md_project project ON project.id = contract.project_id + WHERE contract_change.id = #{id} + FOR UPDATE + """) + ContractChangeCommandRow lockContractChange(long id); + + @Select(""" + SELECT settlement.id, settlement.public_id, settlement.contract_id, + contract.public_id AS contract_public_id, company.public_id AS company_public_id, + project.public_id AS project_public_id, settlement.settlement_no, + settlement.settlement_amount, settlement.settlement_date, + source.public_id AS source_document_public_id, + settlement.status, settlement.version + FROM fin_contract_settlement settlement + JOIN md_contract contract ON contract.id = settlement.contract_id + JOIN md_company company ON company.id = contract.company_id + JOIN md_project project ON project.id = contract.project_id + LEFT JOIN src_source_document source ON source.id = settlement.source_document_id + WHERE settlement.public_id = #{publicId} + """) + ContractSettlementCommandRow findContractSettlementCommand(String publicId); + + @Select(""" + SELECT settlement.id, settlement.public_id, settlement.contract_id, + contract.public_id AS contract_public_id, company.public_id AS company_public_id, + project.public_id AS project_public_id, settlement.settlement_no, + settlement.settlement_amount, settlement.settlement_date, + source.public_id AS source_document_public_id, + settlement.status, settlement.version + FROM fin_contract_settlement settlement + JOIN md_contract contract ON contract.id = settlement.contract_id + JOIN md_company company ON company.id = contract.company_id + JOIN md_project project ON project.id = contract.project_id + LEFT JOIN src_source_document source ON source.id = settlement.source_document_id + WHERE settlement.id = #{id} + FOR UPDATE + """) + ContractSettlementCommandRow lockContractSettlement(long id); + + @Select(""" + SELECT payable.id, payable.public_id, payable.contract_id, + contract.public_id AS contract_public_id, company.public_id AS company_public_id, + project.public_id AS project_public_id, payable.company_id, payable.project_id, + payable.counterparty_id, payable.business_no, payable.business_date, contract.currency, + payable.amount, payable.paid_amount, payable.status, payable.version, payable.created_by, + (SELECT COUNT(*) FROM fin_payment_request payment + WHERE payment.payable_id = payable.id AND payment.status <> 'VOID') AS payment_count + FROM fin_payable payable + JOIN md_contract contract ON contract.id = payable.contract_id + JOIN md_company company ON company.id = contract.company_id + JOIN md_project project ON project.id = contract.project_id + WHERE payable.public_id = #{publicId} + """) + PayableCommandRow findPayableCommand(String publicId); + + @Select(""" + SELECT payable.id, payable.public_id, payable.contract_id, + contract.public_id AS contract_public_id, company.public_id AS company_public_id, + project.public_id AS project_public_id, payable.company_id, payable.project_id, + payable.counterparty_id, payable.business_no, payable.business_date, contract.currency, + payable.amount, payable.paid_amount, payable.status, payable.version, payable.created_by, + (SELECT COUNT(*) FROM fin_payment_request payment + WHERE payment.payable_id = payable.id AND payment.status <> 'VOID') AS payment_count + FROM fin_payable payable + JOIN md_contract contract ON contract.id = payable.contract_id + JOIN md_company company ON company.id = contract.company_id + JOIN md_project project ON project.id = contract.project_id + WHERE payable.id = #{id} + FOR UPDATE + """) + PayableCommandRow lockPayable(long id); + + @Select("SELECT id FROM md_cost_category WHERE public_id = #{publicId} AND status = 'ACTIVE'") + Long findActiveCostCategoryId(String publicId); + + @Select(""" + SELECT source_version_id, status + FROM fin_contract_settlement + WHERE source_document_id = #{sourceDocumentId} + """) + SettlementProjection findSettlementProjectionBySourceDocument(long sourceDocumentId); + + @Select(""" + SELECT contract.id AS contract_id, contract.public_id AS contract_public_id, + contract.business_no AS contract_business_no, + contract.company_id, company.public_id AS company_public_id, + company.status AS company_status, + contract.project_id, project.public_id AS project_public_id, + project.status AS project_status, + counterparty.id AS counterparty_id, + counterparty.public_id AS counterparty_public_id, + counterparty.name AS counterparty_name, + counterparty.counterparty_type, counterparty.status AS counterparty_status, + contract.status AS contract_status, contract.version AS contract_version, + contract.original_amount, contract.approved_change_amount, + (SELECT COALESCE(SUM(payable.invoiced_amount), 0) + FROM fin_payable payable + WHERE payable.contract_id = contract.id AND payable.status <> 'VOID') AS invoiced_amount, + (SELECT COALESCE(SUM(payable.amount), 0) + FROM fin_payable payable + WHERE payable.contract_id = contract.id AND payable.status <> 'VOID') AS payable_amount, + (SELECT COALESCE(SUM(payable.paid_amount), 0) + FROM fin_payable payable + WHERE payable.contract_id = contract.id AND payable.status <> 'VOID') AS paid_amount + FROM md_contract contract + JOIN md_company company ON company.id = contract.company_id + JOIN md_project project ON project.id = contract.project_id + JOIN md_counterparty counterparty ON counterparty.id = contract.counterparty_id + WHERE contract.company_id = #{companyId} + AND contract.business_no = #{contractBusinessNo} + FOR UPDATE + """) + ApprovedSettlementReferences lockApprovedSettlementReferences( + @Param("companyId") long companyId, + @Param("contractBusinessNo") String contractBusinessNo + ); + + @Select(""" + SELECT source_version_id, status + FROM fin_payable + WHERE source_document_id = #{sourceDocumentId} + """) + PayableProjection findPayableProjectionBySourceDocument(long sourceDocumentId); + + @Select(""" + SELECT contract.id AS contract_id, contract.public_id AS contract_public_id, + contract.business_no AS contract_business_no, contract.name AS contract_name, + contract.company_id, company.public_id AS company_public_id, + company.status AS company_status, + contract.project_id, project.public_id AS project_public_id, + project.status AS project_status, + contract.counterparty_id, counterparty.public_id AS counterparty_public_id, + counterparty.name AS counterparty_name, + counterparty.counterparty_type, counterparty.status AS counterparty_status, + contract.currency, contract.status AS contract_status, contract.version AS contract_version, + contract.original_amount, contract.approved_change_amount, contract.settlement_amount, + (SELECT COALESCE(SUM(payable.amount), 0) + FROM fin_payable payable + WHERE payable.contract_id = contract.id AND payable.status <> 'VOID') AS payable_amount + FROM md_contract contract + JOIN md_company company ON company.id = contract.company_id + JOIN md_project project ON project.id = contract.project_id + JOIN md_counterparty counterparty ON counterparty.id = contract.counterparty_id + WHERE contract.public_id = #{contractPublicId} + FOR UPDATE + """) + ApprovedPayableReferences lockApprovedPayableReferences(String contractPublicId); + + @Select(""" + SELECT id, public_id, code, name, status + FROM md_cost_category + WHERE public_id = #{publicId} + FOR UPDATE + """) + ApprovedPayableCostCategory findApprovedPayableCostCategory(String publicId); + + @Select(""" + SELECT document.id, document.company_id, document.project_id, document.form_type, + document.status, document.current_version_id, + EXISTS ( + SELECT 1 + FROM wf_instance instance + WHERE instance.source_version_id = document.current_version_id + AND instance.status = 'APPROVED' + ) AS current_version_approved + FROM src_source_document document + WHERE document.public_id = #{publicId} + FOR SHARE + """) + SourceDocumentRow findSourceDocument(String publicId); + + @Insert(""" + INSERT INTO fin_contract_change + (public_id, contract_id, change_no, change_amount, effective_date, reason, status, created_by) + VALUES + (#{publicId}, #{contractId}, #{changeNo}, #{changeAmount}, #{effectiveDate}, #{reason}, 'APPROVED', #{actorId}) + """) + int insertContractChange(@Param("publicId") String publicId, + @Param("contractId") long contractId, + @Param("changeNo") String changeNo, + @Param("changeAmount") BigDecimal changeAmount, + @Param("effectiveDate") LocalDate effectiveDate, + @Param("reason") String reason, + @Param("actorId") long actorId); + + @Update(""" + UPDATE md_contract + SET approved_change_amount = approved_change_amount + #{delta}, + updated_by = #{actorId}, version = version + 1 + WHERE id = #{contractId} AND version = #{expectedVersion} AND status = 'ACTIVE' + AND original_amount + approved_change_amount + #{delta} >= 0 + """) + int addApprovedChangeAmount(@Param("contractId") long contractId, + @Param("expectedVersion") long expectedVersion, + @Param("delta") BigDecimal delta, + @Param("actorId") long actorId); + + @Update(""" + UPDATE fin_contract_change + SET status = 'VOID', version = version + 1 + WHERE id = #{id} AND version = #{expectedVersion} AND status = 'APPROVED' + """) + int voidContractChange(@Param("id") long id, @Param("expectedVersion") long expectedVersion); + + @Insert(""" + INSERT INTO fin_contract_settlement + (public_id, contract_id, settlement_no, settlement_amount, settlement_date, + source_document_id, source_version_id, status, created_by) + VALUES + (#{publicId}, #{contractId}, #{settlementNo}, #{settlementAmount}, #{settlementDate}, + #{sourceDocumentId}, #{sourceVersionId}, 'CONFIRMED', #{actorId}) + """) + int insertContractSettlement(@Param("publicId") String publicId, + @Param("contractId") long contractId, + @Param("settlementNo") String settlementNo, + @Param("settlementAmount") BigDecimal settlementAmount, + @Param("settlementDate") LocalDate settlementDate, + @Param("sourceDocumentId") Long sourceDocumentId, + @Param("sourceVersionId") long sourceVersionId, + @Param("actorId") long actorId); + + @Update(""" + UPDATE md_contract contract + SET settlement_amount = ( + SELECT settlement.settlement_amount + FROM fin_contract_settlement settlement + WHERE settlement.contract_id = contract.id AND settlement.status = 'CONFIRMED' + ORDER BY settlement.settlement_date DESC, settlement.id DESC + LIMIT 1 + ), updated_by = #{actorId}, version = version + 1 + WHERE contract.id = #{contractId} AND contract.version = #{expectedVersion} + AND contract.status = 'ACTIVE' + """) + int refreshSettlementAmount(@Param("contractId") long contractId, + @Param("expectedVersion") long expectedVersion, + @Param("actorId") long actorId); + + @Update(""" + UPDATE fin_contract_settlement + SET status = 'VOID', version = version + 1 + WHERE id = #{id} AND version = #{expectedVersion} AND status = 'CONFIRMED' + """) + int voidContractSettlement(@Param("id") long id, @Param("expectedVersion") long expectedVersion); + + @Insert(""" + INSERT INTO fin_payable + (public_id, business_no, company_id, project_id, contract_id, counterparty_id, + source_document_id, source_version_id, cost_category_id, business_date, amount, status, created_by, updated_by) + VALUES + (#{publicId}, #{businessNo}, #{companyId}, #{projectId}, #{contractId}, #{counterpartyId}, + #{sourceDocumentId}, #{sourceVersionId}, #{costCategoryId}, #{businessDate}, #{amount}, 'PENDING', #{actorId}, #{actorId}) + """) + int insertPayable(@Param("publicId") String publicId, + @Param("businessNo") String businessNo, + @Param("companyId") long companyId, + @Param("projectId") long projectId, + @Param("contractId") long contractId, + @Param("counterpartyId") long counterpartyId, + @Param("sourceDocumentId") Long sourceDocumentId, + @Param("sourceVersionId") long sourceVersionId, + @Param("costCategoryId") long costCategoryId, + @Param("businessDate") LocalDate businessDate, + @Param("amount") BigDecimal amount, + @Param("actorId") long actorId); + + @Insert(""" + INSERT INTO fin_payable + (public_id, business_no, company_id, project_id, contract_id, counterparty_id, + source_document_id, source_version_id, cost_category_id, business_date, + amount, invoiced_amount, status, confirmed_by, confirmed_at, created_by, updated_by) + VALUES + (#{publicId}, #{businessNo}, #{companyId}, #{projectId}, #{contractId}, #{counterpartyId}, + #{sourceDocumentId}, #{sourceVersionId}, #{costCategoryId}, #{businessDate}, + #{amount}, #{invoicedAmount}, 'CONFIRMED', #{confirmedBy}, UTC_TIMESTAMP(3), + #{createdBy}, #{confirmedBy}) + """) + int insertProjectedPayable(@Param("publicId") String publicId, + @Param("businessNo") String businessNo, + @Param("companyId") long companyId, + @Param("projectId") long projectId, + @Param("contractId") long contractId, + @Param("counterpartyId") long counterpartyId, + @Param("sourceDocumentId") long sourceDocumentId, + @Param("sourceVersionId") long sourceVersionId, + @Param("costCategoryId") long costCategoryId, + @Param("businessDate") LocalDate businessDate, + @Param("amount") BigDecimal amount, + @Param("invoicedAmount") BigDecimal invoicedAmount, + @Param("createdBy") long createdBy, + @Param("confirmedBy") long confirmedBy); + + @Update(""" + UPDATE md_contract + SET updated_by = #{actorId}, version = version + 1 + WHERE id = #{contractId} AND version = #{expectedVersion} AND status = 'ACTIVE' + """) + int touchContract(@Param("contractId") long contractId, + @Param("expectedVersion") long expectedVersion, + @Param("actorId") long actorId); + + @Update(""" + UPDATE fin_payable + SET status = 'CONFIRMED', confirmed_by = #{actorId}, confirmed_at = UTC_TIMESTAMP(3), + updated_by = #{actorId}, version = version + 1 + WHERE id = #{id} AND version = #{expectedVersion} AND status = 'PENDING' + """) + int confirmPayable(@Param("id") long id, @Param("expectedVersion") long expectedVersion, + @Param("actorId") long actorId); + + @Insert(""" + INSERT IGNORE INTO acc_event ( + public_id, business_no, event_type, source_type, source_public_id, source_version, event_sequence, + company_id, project_id, counterparty_id, business_date, period, + amount, currency, status, created_by + ) VALUES ( + #{publicId}, #{businessNo}, 'PAYABLE', 'PAYABLE', #{sourcePublicId}, #{sourceVersion}, #{eventSequence}, + #{companyId}, #{projectId}, #{counterpartyId}, #{businessDate}, + DATE_FORMAT(#{businessDate}, '%Y-%m'), #{amount}, #{currency}, 'PENDING', #{actorId} + ) + """) + int insertPayableAccountingEvent(@Param("publicId") String publicId, + @Param("businessNo") String businessNo, + @Param("sourcePublicId") String sourcePublicId, + @Param("sourceVersion") String sourceVersion, + @Param("eventSequence") int eventSequence, + @Param("companyId") long companyId, + @Param("projectId") long projectId, + @Param("counterpartyId") long counterpartyId, + @Param("businessDate") LocalDate businessDate, + @Param("amount") BigDecimal amount, + @Param("currency") String currency, + @Param("actorId") long actorId); + + @Select(""" + SELECT status + FROM acc_event + WHERE event_type = 'PAYABLE' AND source_type = 'PAYABLE' + AND source_public_id = #{sourcePublicId} + FOR UPDATE + """) + String lockPayableAccountingEventStatus(String sourcePublicId); + + @Update(""" + UPDATE acc_event + SET status = 'VOID', version = version + 1 + WHERE event_type = 'PAYABLE' AND source_type = 'PAYABLE' + AND source_public_id = #{sourcePublicId} AND status = 'PENDING' + """) + int voidPayableAccountingEvent(String sourcePublicId); + + @Update(""" + UPDATE fin_payable payable + SET status = 'VOID', updated_by = #{actorId}, version = version + 1 + WHERE payable.id = #{id} AND payable.version = #{expectedVersion} + AND payable.status IN ('PENDING', 'CONFIRMED') AND payable.paid_amount = 0 + AND NOT EXISTS ( + SELECT 1 FROM fin_payment_request payment + WHERE payment.payable_id = payable.id AND payment.status <> 'VOID' + ) + """) + int voidPayable(@Param("id") long id, @Param("expectedVersion") long expectedVersion, + @Param("actorId") long actorId); + + record ContractRow(long id, String publicId, String businessNo, String name, + String companyPublicId, String companyCode, String companyName, + String projectPublicId, String projectCode, String projectName, + String counterpartyPublicId, String counterpartyCode, String counterpartyName, + BigDecimal originalAmount, BigDecimal approvedChangeAmount, + BigDecimal settlementAmount, BigDecimal invoicedAmount, + BigDecimal pendingPayableAmount, BigDecimal payableAmount, + BigDecimal paidAmount, BigDecimal availableAmount, + String currency, String status, long version, LocalDateTime updatedAt) { + } + + record SummaryRow(long contractCount, BigDecimal originalAmount, BigDecimal approvedChangeAmount, + BigDecimal settlementAmount, BigDecimal invoicedAmount, + BigDecimal pendingPayableAmount, BigDecimal payableAmount, + BigDecimal paidAmount, BigDecimal availableAmount) { + } + + record PayableRow(String publicId, String businessNo, LocalDate businessDate, + String sourceDocumentPublicId, String sourceVersionPublicId, + String categoryPublicId, String categoryCode, String categoryName, + BigDecimal amount, BigDecimal invoicedAmount, BigDecimal paidAmount, + BigDecimal unpaidAmount, String status, long version, LocalDateTime updatedAt, + long createdBy, long paymentCount) { + } + + record ContractChangeRow(String publicId, String changeNo, BigDecimal changeAmount, + LocalDate effectiveDate, String reason, String status, long version, + String createdByName, LocalDateTime createdAt) { + } + + record ContractSettlementRow(String publicId, String settlementNo, BigDecimal settlementAmount, + LocalDate settlementDate, String sourceDocumentPublicId, + String sourceVersionPublicId, String status, + long version, String createdByName, LocalDateTime createdAt) { + } + + record InvoiceRow(String publicId, String businessNo, LocalDate requestedDate, String invoiceType, + BigDecimal amount, BigDecimal taxRate, String invoiceNo, LocalDate issuedDate, + String status, LocalDateTime updatedAt) { + } + + record PaymentRow(String publicId, String businessNo, LocalDate requestedDate, + BigDecimal requestedAmount, BigDecimal approvedAmount, String purpose, + String status, LocalDateTime updatedAt) { + } + + record ReferenceRow(String publicId, String code, String name) { + } + + record ContractCommandRow(long id, String publicId, long companyId, long projectId, + long counterpartyId, String companyPublicId, String projectPublicId, + String currency, String status, long version, + BigDecimal originalAmount, BigDecimal approvedChangeAmount, + BigDecimal settlementAmount, BigDecimal payableAmount) { + } + + record ContractChangeCommandRow(long id, String publicId, long contractId, + String contractPublicId, String companyPublicId, + String projectPublicId, String changeNo, BigDecimal changeAmount, + LocalDate effectiveDate, String reason, String status, long version) { + } + + record ContractSettlementCommandRow(long id, String publicId, long contractId, + String contractPublicId, String companyPublicId, + String projectPublicId, String settlementNo, + BigDecimal settlementAmount, LocalDate settlementDate, + String sourceDocumentPublicId, String status, long version) { + } + + record PayableCommandRow(long id, String publicId, long contractId, String contractPublicId, + String companyPublicId, String projectPublicId, long companyId, + long projectId, long counterpartyId, String businessNo, + LocalDate businessDate, String currency, BigDecimal amount, + BigDecimal paidAmount, String status, long version, long createdBy, + long paymentCount) { + } + + record SourceDocumentRow(long id, long companyId, Long projectId, String formType, + String status, Long currentVersionId, boolean currentVersionApproved) { + } + + record SettlementProjection(Long sourceVersionId, String status) { + } + + record ApprovedSettlementReferences( + long contractId, + String contractPublicId, + String contractBusinessNo, + long companyId, + String companyPublicId, + String companyStatus, + long projectId, + String projectPublicId, + String projectStatus, + long counterpartyId, + String counterpartyPublicId, + String counterpartyName, + String counterpartyType, + String counterpartyStatus, + String contractStatus, + long contractVersion, + BigDecimal originalAmount, + BigDecimal approvedChangeAmount, + BigDecimal invoicedAmount, + BigDecimal payableAmount, + BigDecimal paidAmount + ) { + } + + record PayableProjection(long sourceVersionId, String status) { + } + + record ApprovedPayableReferences( + long contractId, + String contractPublicId, + String contractBusinessNo, + String contractName, + long companyId, + String companyPublicId, + String companyStatus, + long projectId, + String projectPublicId, + String projectStatus, + long counterpartyId, + String counterpartyPublicId, + String counterpartyName, + String counterpartyType, + String counterpartyStatus, + String currency, + String contractStatus, + long contractVersion, + BigDecimal originalAmount, + BigDecimal approvedChangeAmount, + BigDecimal settlementAmount, + BigDecimal payableAmount + ) { + } + + record ApprovedPayableCostCategory(long id, String publicId, String code, String name, String status) { + } +} diff --git a/backend/src/main/java/com/kaidi/finance/operations/infrastructure/FinanceOperationsMapper.java b/backend/src/main/java/com/kaidi/finance/operations/infrastructure/FinanceOperationsMapper.java new file mode 100644 index 0000000..5215861 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/operations/infrastructure/FinanceOperationsMapper.java @@ -0,0 +1,567 @@ +package com.kaidi.finance.operations.infrastructure; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +/** Read-only projections used by the first-round finance pages. */ +@org.apache.ibatis.annotations.Mapper +public interface FinanceOperationsMapper { + + /** + * Every generic finance projection is filtered in SQL, not just guarded by + * a feature permission. The permission code is supplied by the service + * from an internal resource whitelist; it is never accepted from a + * request parameter. + */ + String FINANCE_SCOPE = """ + EXISTS ( + SELECT 1 + FROM iam_scope scope + JOIN iam_role role ON role.id = scope.role_id + JOIN iam_permission permission ON permission.id = scope.permission_id + WHERE scope.user_id = #{userId} + AND role.code = #{roleCode} + AND permission.code = #{permissionCode} + AND scope.status = 'ACTIVE' + AND scope.valid_from <= UTC_TIMESTAMP(3) + AND (scope.valid_to IS NULL OR scope.valid_to >= UTC_TIMESTAMP(3)) + AND ( + scope.scope_type = 'GLOBAL' + OR (scope.scope_type = 'COMPANY' + AND scope.company_public_id = company.public_id) + OR (scope.scope_type = 'PROJECT' + AND scope.project_public_id = project.public_id + AND (scope.company_public_id IS NULL + OR scope.company_public_id = company.public_id)) + ) + ) + """; + + @Select(""" + + """) + List> listMaster(@Param("resource") String resource, + @Param("keyword") String keyword, + @Param("status") String status, + @Param("limit") int limit, + @Param("offset") int offset); + + @Select(""" + + """) + long countMaster(@Param("resource") String resource, + @Param("keyword") String keyword, + @Param("status") String status); + + @Select(""" + + """) + List listContractRows(@Param("userId") long userId, + @Param("roleCode") String roleCode, + @Param("permissionCode") String permissionCode, + @Param("keyword") String keyword, + @Param("status") String status, + @Param("sort") List> sort, + @Param("limit") int limit, + @Param("offset") int offset); + + @Select(""" + + """) + long countContractRows(@Param("userId") long userId, + @Param("roleCode") String roleCode, + @Param("permissionCode") String permissionCode, + @Param("keyword") String keyword, + @Param("status") String status); + + @Select(""" + + """) + List listPayableRows(@Param("userId") long userId, + @Param("roleCode") String roleCode, + @Param("permissionCode") String permissionCode, + @Param("keyword") String keyword, + @Param("status") String status, + @Param("sort") List> sort, + @Param("limit") int limit, + @Param("offset") int offset); + + @Select(""" + + """) + long countPayableRows(@Param("userId") long userId, + @Param("roleCode") String roleCode, + @Param("permissionCode") String permissionCode, + @Param("keyword") String keyword, + @Param("status") String status); + + @Select(""" + + """) + List> listArchive(@Param("resource") String resource, + @Param("keyword") String keyword, + @Param("status") String status, + @Param("limit") int limit, + @Param("offset") int offset); + + @Select(""" + + """) + long countArchive(@Param("resource") String resource, + @Param("keyword") String keyword, + @Param("status") String status); + + @Select(""" + + """) + List> listReport(@Param("reportCode") String reportCode, + @Param("keyword") String keyword, + @Param("limit") int limit, + @Param("offset") int offset); + + @Select(""" + + """) + long countReport(@Param("reportCode") String reportCode, @Param("keyword") String keyword); + + @Select(""" + + """) + List> listAudit(@Param("keyword") String keyword, + @Param("resultCode") String resultCode, + @Param("limit") int limit, + @Param("offset") int offset); + + @Select(""" + SELECT COUNT(*) FROM audit_log + WHERE (#{keyword} IS NULL OR username LIKE CONCAT('%', #{keyword}, '%') + OR action_code LIKE CONCAT('%', #{keyword}, '%') + OR object_public_id LIKE CONCAT('%', #{keyword}, '%')) + AND (#{resultCode} IS NULL OR result_code = #{resultCode}) + """) + long countAudit(@Param("keyword") String keyword, @Param("resultCode") String resultCode); + + @Select(""" + + """) + List> listAdmin(@Param("resource") String resource, + @Param("keyword") String keyword, + @Param("limit") int limit, + @Param("offset") int offset); + + @Select(""" + + """) + long countAdmin(@Param("resource") String resource, @Param("keyword") String keyword); + + record ContractListRow(String publicId, String businessNo, String name, + BigDecimal contractAmount, BigDecimal changeAmount, + BigDecimal settlementAmount, String currency, String status, + String projectBusinessNo, String projectName, String companyName, + String counterpartyName, LocalDateTime updatedAt) { + } + + record PayableListRow(String publicId, String businessNo, LocalDate businessDate, + BigDecimal amount, BigDecimal invoicedAmount, BigDecimal paidAmount, + String status, String projectBusinessNo, String projectName, + String counterpartyName, String costCategoryName, + LocalDateTime updatedAt) { + } +} diff --git a/backend/src/main/java/com/kaidi/finance/payment/api/PaymentContracts.java b/backend/src/main/java/com/kaidi/finance/payment/api/PaymentContracts.java new file mode 100644 index 0000000..61ac461 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/payment/api/PaymentContracts.java @@ -0,0 +1,160 @@ +package com.kaidi.finance.payment.api; + +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; + +public final class PaymentContracts { + + private PaymentContracts() { + } + + public record PaymentCreateRequest( + @NotBlank @Size(max = 26) String companyId, + @NotBlank @Size(max = 26) String projectId, + @NotBlank @Size(max = 26) String contractId, + @Size(max = 26) String payableId, + @NotBlank @Size(max = 26) String supplierId, + @NotBlank @Size(max = 26) String bankAccountVersionId, + @Size(max = 26) String sourceDocumentId, + @NotNull @DecimalMin("0.01") BigDecimal requestedAmount, + @NotBlank @Pattern(regexp = "[A-Z]{3}") String currency, + @NotNull LocalDate requestedDate, + @NotBlank @Size(max = 500) String purpose, + @Size(max = 20) List<@NotBlank @Size(max = 26) String> attachmentFileIds, + @Size(max = 20) List attachments, + @Size(max = 20) List attachmentExceptions + ) { + } + + public record PaymentUpdateRequest( + @NotBlank @Size(max = 26) String companyId, + @NotBlank @Size(max = 26) String projectId, + @NotBlank @Size(max = 26) String contractId, + @Size(max = 26) String payableId, + @NotBlank @Size(max = 26) String supplierId, + @NotBlank @Size(max = 26) String bankAccountVersionId, + @Size(max = 26) String sourceDocumentId, + @NotNull @DecimalMin("0.01") BigDecimal requestedAmount, + @NotBlank @Pattern(regexp = "[A-Z]{3}") String currency, + @NotNull LocalDate requestedDate, + @NotBlank @Size(max = 500) String purpose, + @Size(max = 20) List<@NotBlank @Size(max = 26) String> attachmentFileIds, + @Size(max = 20) List attachments, + @Size(max = 20) List attachmentExceptions, + @NotNull Long version + ) { + } + + public record PaymentAttachmentInput( + @NotBlank @Size(max = 26) String fileId, + @NotBlank @Pattern(regexp = "[A-Z][A-Z0-9_]{1,63}") String documentType + ) { + } + + public record PaymentAttachmentExceptionInput( + @NotBlank @Pattern(regexp = "[A-Z][A-Z0-9_]{1,63}") String documentType, + @NotBlank @Size(max = 500) String reason + ) { + } + + public record PaymentAttachmentExceptionReviewRequest( + @NotNull Long version, + @NotBlank @Pattern(regexp = "APPROVE|REJECT") String decision, + @NotBlank @Size(max = 1000) String opinion + ) { + } + + public record PaymentControlExceptionRequest( + @NotNull Long paymentVersion, + @NotBlank @Pattern(regexp = "[A-Z][A-Z0-9_]{1,63}") String controlCode, + @NotBlank @Size(max = 500) String reason, + @NotNull LocalDate validUntil + ) { + } + + public record PaymentControlExceptionReviewRequest( + @NotNull Long paymentVersion, + @NotNull Long version, + @NotBlank @Pattern(regexp = "APPROVE|REJECT") String decision, + @NotBlank @Size(max = 1000) String opinion + ) { + } + + public record VersionCommandRequest(@NotNull Long version) { + } + + public record ReturnRequest( + @NotNull Long version, + @NotBlank @Size(max = 1000) String opinion + ) { + } + + public record OfflineResultRequest( + @NotNull Long version, + @NotNull LocalDate resultAt, + @NotNull @DecimalMin("0.01") BigDecimal amount, + @NotEmpty @Size(max = 20) List<@NotBlank @Size(max = 26) String> receiptFileIds, + @Size(max = 1000) String remark + ) { + } + + public record FailureResultRequest( + @NotNull Long version, + @NotNull LocalDate resultAt, + @NotBlank @Size(max = 1000) String reason, + @Size(max = 20) List<@NotBlank @Size(max = 26) String> receiptFileIds, + @Size(max = 1000) String remark + ) { + } + + public record RefundResultRequest( + @NotNull Long version, + @NotNull LocalDate resultAt, + @NotNull @DecimalMin("0.01") BigDecimal amount, + @NotBlank @Size(max = 1000) String reason, + @NotEmpty @Size(max = 20) List<@NotBlank @Size(max = 26) String> receiptFileIds, + @Size(max = 1000) String remark + ) { + } + + public record VerifyResultRequest( + @NotNull Long version, + @NotBlank @Size(max = 1000) String opinion + ) { + } + + public record ReturnResultRequest( + @NotNull Long version, + @NotBlank @Size(max = 1000) String opinion + ) { + } + + public record RepaymentRequest( + @NotNull Long version, + @NotNull LocalDate requestedDate, + @NotNull @DecimalMin("0.01") BigDecimal requestedAmount, + @NotBlank @Size(max = 500) String purpose + ) { + } + + public record PaymentExportRequest( + @Size(max = 32) String tab, + @Size(max = 26) String companyId, + @Size(max = 26) String projectId, + @Size(max = 26) String supplierId, + @Size(max = 32) String status, + @Size(max = 100) String keyword, + @Size(max = 32) String riskCode, + LocalDate submittedDateFrom, + LocalDate submittedDateTo, + @Size(max = 40) String sort + ) { + } +} diff --git a/backend/src/main/java/com/kaidi/finance/payment/api/PaymentController.java b/backend/src/main/java/com/kaidi/finance/payment/api/PaymentController.java new file mode 100644 index 0000000..6346777 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/payment/api/PaymentController.java @@ -0,0 +1,300 @@ +package com.kaidi.finance.payment.api; + +import com.kaidi.finance.payment.api.PaymentContracts.FailureResultRequest; +import com.kaidi.finance.payment.api.PaymentContracts.OfflineResultRequest; +import com.kaidi.finance.payment.api.PaymentContracts.PaymentCreateRequest; +import com.kaidi.finance.payment.api.PaymentContracts.PaymentUpdateRequest; +import com.kaidi.finance.payment.api.PaymentContracts.PaymentAttachmentExceptionReviewRequest; +import com.kaidi.finance.payment.api.PaymentContracts.PaymentControlExceptionRequest; +import com.kaidi.finance.payment.api.PaymentContracts.PaymentControlExceptionReviewRequest; +import com.kaidi.finance.payment.api.PaymentContracts.PaymentExportRequest; +import com.kaidi.finance.payment.api.PaymentContracts.RefundResultRequest; +import com.kaidi.finance.payment.api.PaymentContracts.RepaymentRequest; +import com.kaidi.finance.payment.api.PaymentContracts.ReturnRequest; +import com.kaidi.finance.payment.api.PaymentContracts.ReturnResultRequest; +import com.kaidi.finance.payment.api.PaymentContracts.VerifyResultRequest; +import com.kaidi.finance.payment.api.PaymentContracts.VersionCommandRequest; +import com.kaidi.finance.payment.api.PaymentViews.PaymentCheckView; +import com.kaidi.finance.payment.api.PaymentViews.PaymentDetailView; +import com.kaidi.finance.payment.api.PaymentViews.PaymentView; +import com.kaidi.finance.payment.api.PaymentViews.ReferenceOptionsView; +import com.kaidi.finance.payment.application.PaymentApplicationService; +import com.kaidi.finance.shared.idempotency.IdempotencyApplicationService; +import com.kaidi.finance.shared.api.ApiResponse; +import com.kaidi.finance.shared.api.PageResult; +import com.fasterxml.jackson.core.type.TypeReference; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import java.nio.charset.StandardCharsets; +import java.time.LocalDate; +import java.util.List; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.ContentDisposition; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import java.util.function.Supplier; + +@RestController +@RequestMapping("/api/v1/payments") +public class PaymentController { + + private final PaymentApplicationService service; + private final IdempotencyApplicationService idempotencyService; + + public PaymentController(PaymentApplicationService service, IdempotencyApplicationService idempotencyService) { + this.service = service; + this.idempotencyService = idempotencyService; + } + + @GetMapping("/references") + @PreAuthorize("@authorizationService.hasPermission('payment:request:view')") + public ApiResponse references() { + return ApiResponse.ok(service.references()); + } + + @GetMapping + @PreAuthorize("@authorizationService.hasPermission('payment:request:view')") + public ApiResponse> payments( + @RequestParam(required = false) String tab, + @RequestParam(required = false) String companyId, + @RequestParam(required = false) String projectId, + @RequestParam(required = false) String supplierId, + @RequestParam(required = false) String status, + @RequestParam(required = false) String keyword, + @RequestParam(required = false) String riskCode, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate submittedDateFrom, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate submittedDateTo, + @RequestParam(defaultValue = "updatedAt,desc") String sort, + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int size) { + PageResult result = service.listPayments(tab, companyId, projectId, supplierId, status, + keyword, riskCode, submittedDateFrom, submittedDateTo, sort, page, size); + return ApiResponse.ok(result.items(), result.meta()); + } + + @PostMapping + @PreAuthorize("@authorizationService.hasPermission('payment:request:create')") + public ApiResponse create(@Valid @RequestBody PaymentCreateRequest request, + @RequestHeader(value = "Idempotency-Key", required = false) String key, + HttpServletRequest httpRequest) { + return ApiResponse.ok(command(key, httpRequest, request, () -> service.createPayment(request))); + } + + @GetMapping("/{publicId}") + @PreAuthorize("@authorizationService.hasPermission('payment:request:view')") + public ApiResponse detail(@PathVariable String publicId) { + return ApiResponse.ok(service.getPayment(publicId)); + } + + @GetMapping("/{publicId}/checks") + @PreAuthorize("@authorizationService.hasPermission('payment:request:view')") + public ApiResponse> checks(@PathVariable String publicId) { + return ApiResponse.ok(service.getChecks(publicId)); + } + + @PatchMapping("/{publicId}") + @PreAuthorize("@authorizationService.hasPermission('payment:request:create')") + public ApiResponse update(@PathVariable String publicId, + @Valid @RequestBody PaymentUpdateRequest request, + @RequestHeader(value = "Idempotency-Key", required = false) String key, + HttpServletRequest httpRequest) { + return ApiResponse.ok(command(key, httpRequest, request, () -> service.updatePayment(publicId, request))); + } + + @PostMapping("/{publicId}/submit") + @PreAuthorize("@authorizationService.hasPermission('payment:request:create')") + public ApiResponse submit(@PathVariable String publicId, + @Valid @RequestBody VersionCommandRequest request, + @RequestHeader(value = "Idempotency-Key", required = false) String key, + HttpServletRequest httpRequest) { + return ApiResponse.ok(command(key, httpRequest, request, + () -> service.submitPayment(publicId, request.version()))); + } + + @PostMapping("/{publicId}/approve") + @PreAuthorize("@authorizationService.hasPermission('payment:request:approve')") + public ApiResponse approve(@PathVariable String publicId, + @Valid @RequestBody VersionCommandRequest request, + @RequestHeader(value = "Idempotency-Key", required = false) String key, + HttpServletRequest httpRequest) { + return ApiResponse.ok(approveCommand(key, httpRequest, request, + () -> service.approvePayment(publicId, request.version()))); + } + + @PostMapping("/{publicId}/return") + @PreAuthorize("@authorizationService.hasPermission('payment:request:review')") + public ApiResponse returnPayment(@PathVariable String publicId, + @Valid @RequestBody ReturnRequest request, + @RequestHeader(value = "Idempotency-Key", required = false) String key, + HttpServletRequest httpRequest) { + return ApiResponse.ok(command(key, httpRequest, request, + () -> service.returnPayment(publicId, request))); + } + + @PostMapping("/{publicId}/attachment-exceptions/{exceptionPublicId}/review") + @PreAuthorize("@authorizationService.hasPermission('payment:request:review')") + public ApiResponse reviewAttachmentException( + @PathVariable String publicId, + @PathVariable String exceptionPublicId, + @Valid @RequestBody PaymentAttachmentExceptionReviewRequest request, + @RequestHeader(value = "Idempotency-Key", required = false) String key, + HttpServletRequest httpRequest) { + return ApiResponse.ok(command(key, httpRequest, request, + () -> service.reviewAttachmentException(publicId, exceptionPublicId, request))); + } + + @PostMapping("/{publicId}/control-exceptions") + @PreAuthorize("@authorizationService.hasPermission('payment:request:create')") + public ApiResponse requestControlException( + @PathVariable String publicId, + @Valid @RequestBody PaymentControlExceptionRequest request, + @RequestHeader(value = "Idempotency-Key", required = false) String key, + HttpServletRequest httpRequest) { + return ApiResponse.ok(command(key, httpRequest, request, + () -> service.requestControlException(publicId, request))); + } + + @PostMapping("/{publicId}/control-exceptions/{exceptionPublicId}/review") + @PreAuthorize("@authorizationService.hasPermission('payment:request:review')") + public ApiResponse reviewControlException( + @PathVariable String publicId, + @PathVariable String exceptionPublicId, + @Valid @RequestBody PaymentControlExceptionReviewRequest request, + @RequestHeader(value = "Idempotency-Key", required = false) String key, + HttpServletRequest httpRequest) { + return ApiResponse.ok(command(key, httpRequest, request, + () -> service.reviewControlException(publicId, exceptionPublicId, request))); + } + + @PostMapping("/{publicId}/void") + @PreAuthorize("@authorizationService.hasPermission('payment:request:create')") + public ApiResponse voidPayment(@PathVariable String publicId, + @Valid @RequestBody ReturnRequest request, + @RequestHeader(value = "Idempotency-Key", required = false) String key, + HttpServletRequest httpRequest) { + return ApiResponse.ok(command(key, httpRequest, request, + () -> service.voidPayment(publicId, request))); + } + + @PostMapping("/{publicId}/confirm-offline-result") + @PreAuthorize("@authorizationService.hasPermission('payment:request:record-result')") + public ApiResponse confirmOfflineResult(@PathVariable String publicId, + @Valid @RequestBody OfflineResultRequest request, + @RequestHeader(value = "Idempotency-Key", required = false) String key, + HttpServletRequest httpRequest) { + return ApiResponse.ok(command(key, httpRequest, request, + () -> service.confirmOfflineResult(publicId, request))); + } + + @PostMapping("/{publicId}/record-failure") + @PreAuthorize("@authorizationService.hasPermission('payment:request:record-result')") + public ApiResponse recordFailure(@PathVariable String publicId, + @Valid @RequestBody FailureResultRequest request, + @RequestHeader(value = "Idempotency-Key", required = false) String key, + HttpServletRequest httpRequest) { + return ApiResponse.ok(command(key, httpRequest, request, + () -> service.recordFailure(publicId, request))); + } + + @PostMapping("/{publicId}/record-refund") + @PreAuthorize("@authorizationService.hasPermission('payment:request:refund')") + public ApiResponse recordRefund(@PathVariable String publicId, + @Valid @RequestBody RefundResultRequest request, + @RequestHeader(value = "Idempotency-Key", required = false) String key, + HttpServletRequest httpRequest) { + return ApiResponse.ok(command(key, httpRequest, request, + () -> service.recordRefund(publicId, request))); + } + + @PostMapping("/{publicId}/verify-result") + @PreAuthorize("@authorizationService.hasPermission('payment:request:verify-result')") + public ApiResponse verifyResult(@PathVariable String publicId, + @Valid @RequestBody VerifyResultRequest request, + @RequestHeader(value = "Idempotency-Key", required = false) String key, + HttpServletRequest httpRequest) { + return ApiResponse.ok(command(key, httpRequest, request, + () -> service.verifyResult(publicId, request))); + } + + @PostMapping("/{publicId}/return-result") + @PreAuthorize("@authorizationService.hasPermission('payment:request:verify-result')") + public ApiResponse returnResult(@PathVariable String publicId, + @Valid @RequestBody ReturnResultRequest request, + @RequestHeader(value = "Idempotency-Key", required = false) String key, + HttpServletRequest httpRequest) { + return ApiResponse.ok(command(key, httpRequest, request, + () -> service.returnResult(publicId, request))); + } + + @PostMapping("/{publicId}/create-repayment") + @PreAuthorize("@authorizationService.hasPermission('payment:request:create')") + public ApiResponse createRepayment(@PathVariable String publicId, + @Valid @RequestBody RepaymentRequest request, + @RequestHeader(value = "Idempotency-Key", required = false) String key, + HttpServletRequest httpRequest) { + return ApiResponse.ok(command(key, httpRequest, request, + () -> service.createRepayment(publicId, request))); + } + + @PostMapping(value = "/exports", produces = "text/csv") + @PreAuthorize("@authorizationService.hasPermission('payment:request:export')") + public ResponseEntity export(@Valid @RequestBody(required = false) PaymentExportRequest request) { + PaymentExportRequest filter = request == null + ? new PaymentExportRequest(null, null, null, null, null, null, null, null, null, null) + : request; + return exportResponse(filter.tab(), filter.companyId(), filter.projectId(), filter.supplierId(), + filter.status(), filter.keyword(), filter.riskCode(), filter.submittedDateFrom(), + filter.submittedDateTo(), filter.sort()); + } + + @GetMapping(value = "/export", produces = "text/csv") + @PreAuthorize("@authorizationService.hasPermission('payment:request:export')") + public ResponseEntity export( + @RequestParam(required = false) String tab, + @RequestParam(required = false) String companyId, + @RequestParam(required = false) String projectId, + @RequestParam(required = false) String supplierId, + @RequestParam(required = false) String status, + @RequestParam(required = false) String keyword, + @RequestParam(required = false) String riskCode, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate submittedDateFrom, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate submittedDateTo, + @RequestParam(defaultValue = "updatedAt,desc") String sort) { + return exportResponse(tab, companyId, projectId, supplierId, status, keyword, riskCode, + submittedDateFrom, submittedDateTo, sort); + } + + private ResponseEntity exportResponse( + String tab, String companyId, String projectId, String supplierId, String status, String keyword, + String riskCode, LocalDate submittedDateFrom, LocalDate submittedDateTo, String sort) { + byte[] data = service.exportPayments(tab, companyId, projectId, supplierId, status, keyword, + riskCode, submittedDateFrom, submittedDateTo, sort); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(new MediaType("text", "csv", StandardCharsets.UTF_8)); + headers.setContentDisposition(ContentDisposition.attachment().filename("payments.csv").build()); + return ResponseEntity.ok().headers(headers).body(data); + } + + private PaymentView command(String key, HttpServletRequest httpRequest, Object body, + Supplier action) { + return idempotencyService.execute(key, httpRequest.getMethod(), httpRequest.getRequestURI(), body, + new TypeReference() { }, action); + } + + private PaymentView approveCommand(String key, HttpServletRequest httpRequest, Object body, + Supplier action) { + return idempotencyService.executeAllowingBusinessException(key, httpRequest.getMethod(), + httpRequest.getRequestURI(), body, new TypeReference() { }, action); + } +} diff --git a/backend/src/main/java/com/kaidi/finance/payment/api/PaymentViews.java b/backend/src/main/java/com/kaidi/finance/payment/api/PaymentViews.java new file mode 100644 index 0000000..3aab38e --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/payment/api/PaymentViews.java @@ -0,0 +1,194 @@ +package com.kaidi.finance.payment.api; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +public final class PaymentViews { + + private PaymentViews() { + } + + public record ReferenceView(String publicId, String code, String name) { + } + + public record BankAccountReferenceView( + String publicId, + String supplierId, + String accountName, + String bankName, + String maskedAccountNo, + int versionNo + ) { + } + + public record PayableReferenceView( + String publicId, + String businessNo, + String projectId, + String contractId, + String supplierId, + String costCategoryCode, + String costCategoryName, + String amount, + String paidAmount, + String remainingAmount + ) { + } + + public record ContractReferenceView( + String publicId, + String code, + String name, + String companyId, + String projectId, + String supplierId + ) { + } + + public record PaymentAttachmentRequirementView( + String documentType, + String name, + boolean required, + boolean allowNotApplicable + ) { + } + + public record PaymentAttachmentRuleView( + String parameterId, + int versionNo, + String costCategoryCode, + String costCategoryName, + List requirements + ) { + } + + public record ReferenceOptionsView( + List companies, + List projects, + List suppliers, + List contracts, + List payables, + List bankAccounts, + List attachmentRules + ) { + } + + public record PaymentView( + String publicId, + String businessNo, + ReferenceView company, + ReferenceView project, + ReferenceView contract, + ReferenceView payable, + ReferenceView supplier, + BankAccountReferenceView bankAccount, + String sourceDocumentId, + String originalPaymentId, + int repaymentSequence, + String requestedAmount, + String approvedAmount, + String frozenAmount, + String paidAmount, + String currency, + LocalDate requestedDate, + String purpose, + String status, + String submittedByName, + String approvedByName, + String resultRecordedByName, + String resultVerifiedByName, + long version, + LocalDateTime createdAt, + LocalDateTime updatedAt, + List allowedActions + ) { + } + + public record PaymentCheckView( + String publicId, + String code, + String result, + String message, + LocalDateTime checkedAt + ) { + } + + public record PaymentResultView( + String publicId, + String resultNo, + String resultType, + LocalDate resultAt, + String amount, + String failureReason, + String remark, + String recordedByName, + LocalDateTime recordedAt, + String verifiedByName, + LocalDateTime verifiedAt, + String verificationOpinion, + String recordedFromStatus, + String returnedByName, + LocalDateTime returnedAt, + String returnOpinion, + String status, + long version, + List receiptFileIds + ) { + } + + public record PaymentAttachmentView( + String publicId, + String fileId, + String fileName, + String documentType, + String scanStatus, + LocalDateTime createdAt + ) { + } + + public record PaymentAttachmentExceptionView( + String publicId, + String documentType, + String documentName, + long version, + String reason, + String status, + String requestedByName, + LocalDateTime requestedAt, + String reviewedByName, + LocalDateTime reviewedAt, + String reviewOpinion, + LocalDateTime consumedAt, + List allowedActions + ) { + } + + public record PaymentControlExceptionView( + String publicId, + String controlCode, + String findingSummary, + long version, + String reason, + LocalDate validUntil, + String status, + String requestedByName, + LocalDateTime requestedAt, + String reviewedByName, + LocalDateTime reviewedAt, + String reviewOpinion, + LocalDateTime consumedAt, + List allowedActions + ) { + } + + public record PaymentDetailView( + PaymentView payment, + List checks, + List results, + List attachments, + List attachmentExceptions, + List controlExceptions + ) { + } +} diff --git a/backend/src/main/java/com/kaidi/finance/payment/application/PaymentApplicationService.java b/backend/src/main/java/com/kaidi/finance/payment/application/PaymentApplicationService.java new file mode 100644 index 0000000..f475a20 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/payment/application/PaymentApplicationService.java @@ -0,0 +1,1973 @@ +package com.kaidi.finance.payment.application; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.kaidi.finance.fund.application.FundLedgerApplicationService; +import com.kaidi.finance.fund.application.FundLedgerApplicationService.FundLedgerCommand; +import com.kaidi.finance.iam.domain.FinancePrincipal; +import com.kaidi.finance.payment.api.PaymentContracts.FailureResultRequest; +import com.kaidi.finance.payment.api.PaymentContracts.OfflineResultRequest; +import com.kaidi.finance.payment.api.PaymentContracts.PaymentAttachmentExceptionInput; +import com.kaidi.finance.payment.api.PaymentContracts.PaymentAttachmentExceptionReviewRequest; +import com.kaidi.finance.payment.api.PaymentContracts.PaymentAttachmentInput; +import com.kaidi.finance.payment.api.PaymentContracts.PaymentControlExceptionRequest; +import com.kaidi.finance.payment.api.PaymentContracts.PaymentControlExceptionReviewRequest; +import com.kaidi.finance.payment.api.PaymentContracts.PaymentCreateRequest; +import com.kaidi.finance.payment.api.PaymentContracts.PaymentUpdateRequest; +import com.kaidi.finance.payment.api.PaymentContracts.RefundResultRequest; +import com.kaidi.finance.payment.api.PaymentContracts.RepaymentRequest; +import com.kaidi.finance.payment.api.PaymentContracts.ReturnRequest; +import com.kaidi.finance.payment.api.PaymentContracts.ReturnResultRequest; +import com.kaidi.finance.payment.api.PaymentContracts.VerifyResultRequest; +import com.kaidi.finance.payment.api.PaymentViews.BankAccountReferenceView; +import com.kaidi.finance.payment.api.PaymentViews.ContractReferenceView; +import com.kaidi.finance.payment.api.PaymentViews.PaymentAttachmentExceptionView; +import com.kaidi.finance.payment.api.PaymentViews.PaymentAttachmentRequirementView; +import com.kaidi.finance.payment.api.PaymentViews.PaymentAttachmentRuleView; +import com.kaidi.finance.payment.api.PaymentViews.PayableReferenceView; +import com.kaidi.finance.payment.api.PaymentViews.PaymentAttachmentView; +import com.kaidi.finance.payment.api.PaymentViews.PaymentCheckView; +import com.kaidi.finance.payment.api.PaymentViews.PaymentControlExceptionView; +import com.kaidi.finance.payment.api.PaymentViews.PaymentDetailView; +import com.kaidi.finance.payment.api.PaymentViews.PaymentResultView; +import com.kaidi.finance.payment.api.PaymentViews.PaymentView; +import com.kaidi.finance.payment.api.PaymentViews.ReferenceOptionsView; +import com.kaidi.finance.payment.api.PaymentViews.ReferenceView; +import com.kaidi.finance.payment.infrastructure.PaymentMapper; +import com.kaidi.finance.payment.infrastructure.PaymentMapper.AttachmentRow; +import com.kaidi.finance.payment.infrastructure.PaymentMapper.AttachmentExceptionRow; +import com.kaidi.finance.payment.infrastructure.PaymentMapper.AttachmentExceptionLock; +import com.kaidi.finance.payment.infrastructure.PaymentMapper.AttachmentRuleParameterRow; +import com.kaidi.finance.payment.infrastructure.PaymentMapper.BankAccountOptionRow; +import com.kaidi.finance.payment.infrastructure.PaymentMapper.BankAccountRef; +import com.kaidi.finance.payment.infrastructure.PaymentMapper.CheckRow; +import com.kaidi.finance.payment.infrastructure.PaymentMapper.ApprovedControlExceptionRow; +import com.kaidi.finance.payment.infrastructure.PaymentMapper.BlockingRiskRow; +import com.kaidi.finance.payment.infrastructure.PaymentMapper.ControlExceptionLock; +import com.kaidi.finance.payment.infrastructure.PaymentMapper.ControlExceptionRow; +import com.kaidi.finance.payment.infrastructure.PaymentMapper.ContractRef; +import com.kaidi.finance.payment.infrastructure.PaymentMapper.EntityRef; +import com.kaidi.finance.payment.infrastructure.PaymentMapper.DuplicateCandidateRow; +import com.kaidi.finance.payment.infrastructure.PaymentMapper.FileRef; +import com.kaidi.finance.payment.infrastructure.PaymentMapper.FundControlRow; +import com.kaidi.finance.payment.infrastructure.PaymentMapper.PayableOptionRow; +import com.kaidi.finance.payment.infrastructure.PaymentMapper.PayableRef; +import com.kaidi.finance.payment.infrastructure.PaymentMapper.PaymentLock; +import com.kaidi.finance.payment.infrastructure.PaymentMapper.PaymentProjectionRow; +import com.kaidi.finance.payment.infrastructure.PaymentMapper.PaymentFileRef; +import com.kaidi.finance.payment.infrastructure.PaymentMapper.PaymentRow; +import com.kaidi.finance.payment.infrastructure.PaymentMapper.ProjectRef; +import com.kaidi.finance.payment.infrastructure.PaymentMapper.ResultLock; +import com.kaidi.finance.payment.infrastructure.PaymentMapper.ResultRow; +import com.kaidi.finance.payment.infrastructure.PaymentMapper.SourceRef; +import com.kaidi.finance.payment.domain.PaymentAttachmentMatrix; +import com.kaidi.finance.payment.domain.PaymentAttachmentMatrix.Category; +import com.kaidi.finance.payment.domain.PaymentAttachmentMatrix.Matrix; +import com.kaidi.finance.payment.domain.PaymentAttachmentMatrix.Requirement; +import com.kaidi.finance.shared.api.BusinessException; +import com.kaidi.finance.shared.api.ErrorCode; +import com.kaidi.finance.shared.api.PageResult; +import com.kaidi.finance.shared.audit.AuditService; +import com.kaidi.finance.shared.id.UlidGenerator; +import com.kaidi.finance.shared.security.AuthorizationService; +import com.kaidi.finance.shared.security.IdentityContext; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.text.Normalizer; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.regex.Pattern; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.annotation.Propagation; + +@Service +public class PaymentApplicationService { + + private static final Pattern PUBLIC_ID = Pattern.compile("[0-9A-HJKMNP-TV-Z]{26}"); + private static final String CHECK_RULE_VERSION = "PAYMENT-CHECK-V3"; + private static final Pattern DOCUMENT_TYPE = Pattern.compile("[A-Z][A-Z0-9_]{1,63}"); + private static final Set TABS = Set.of("all", "my", "finance-check", "offline", "result", "paid", "failed"); + private static final Set STATUSES = Set.of( + "DRAFT", "REVIEWING", "RETURNED", "REJECTED", "APPROVED", "OFFLINE_PENDING", + "RESULT_RECORDED", "RESULT_VERIFIED", "PART_PAID", "PAID", "FAILED", "REFUNDED", "VOID" + ); + private static final Set CLEAN_FILE_STATES = Set.of("AVAILABLE"); + private static final Set APPROVED_SOURCE_STATES = Set.of( + "APPROVED", "RESULT_PENDING", "ARCHIVE_PENDING", "ARCHIVED" + ); + private static final Set EXCEPTIONABLE_CONTROL_CODES = Set.of("DUPLICATE", "PROJECT_RISK"); + private static final Set HARD_CONTROL_CODES = Set.of( + "PROJECT_FUNDS", "CONTRACT_LIMIT", "PAYABLE_LIMIT", "ACCOUNT_ACTIVE", + "ATTACHMENTS", "SOURCE_APPROVED", "SOD" + ); + private static final int CONTROL_EXCEPTION_MAX_VALID_DAYS = 7; + + private final PaymentMapper mapper; + private final AuthorizationService authorizationService; + private final IdentityContext identityContext; + private final UlidGenerator ulidGenerator; + private final AuditService auditService; + private final ObjectMapper objectMapper; + private final FundLedgerApplicationService fundLedgerService; + + public PaymentApplicationService(PaymentMapper mapper, AuthorizationService authorizationService, + IdentityContext identityContext, UlidGenerator ulidGenerator, + AuditService auditService, ObjectMapper objectMapper, + FundLedgerApplicationService fundLedgerService) { + this.mapper = mapper; + this.authorizationService = authorizationService; + this.identityContext = identityContext; + this.ulidGenerator = ulidGenerator; + this.auditService = auditService; + this.objectMapper = objectMapper; + this.fundLedgerService = fundLedgerService; + } + + @Transactional(readOnly = true) + public ReferenceOptionsView references() { + authorizationService.requirePermission("payment:request:view"); + Actor actor = actor(); + return new ReferenceOptionsView( + mapper.listCompanies(actor.userId(), actor.roleCode()).stream().map(this::reference).toList(), + mapper.listProjects(actor.userId(), actor.roleCode()).stream().map(this::reference).toList(), + mapper.listSuppliers(actor.userId(), actor.roleCode()).stream().map(this::reference).toList(), + mapper.listContracts(actor.userId(), actor.roleCode()).stream().map(this::contractOption).toList(), + mapper.listPayables(actor.userId(), actor.roleCode()).stream().map(this::payableOption).toList(), + mapper.listBankAccounts(actor.userId(), actor.roleCode()).stream().map(this::bankAccountOption).toList(), + attachmentRuleViews() + ); + } + + @Transactional(readOnly = true) + public PageResult listPayments(String tab, String companyId, String projectId, + String supplierId, String status, String keyword, + String riskCode, LocalDate dateFrom, LocalDate dateTo, + String sort, int page, int size) { + authorizationService.requirePermission("payment:request:view"); + Actor actor = actor(); + Page requestedPage = page(page, size); + String effectiveTab = blank(tab); + if (effectiveTab == null) { + effectiveTab = identityContext.isSystemAdministrator() + ? "all" : "PROJECT_MANAGER".equals(actor.roleCode()) ? "my" : "finance-check"; + } + requireTab(effectiveTab); + requireStatus(status); + requireOptionalId(companyId); + requireOptionalId(projectId); + requireOptionalId(supplierId); + if (dateFrom != null && dateTo != null && dateTo.isBefore(dateFrom)) throw validation("提交日期范围无效"); + List items = mapper.listPayments(actor.userId(), actor.roleCode(), effectiveTab, + blank(companyId), blank(projectId), blank(supplierId), blank(status), clean(keyword, 100), + clean(riskCode, 32), dateFrom, dateTo, orderBy(sort), requestedPage.size(), requestedPage.offset()) + .stream().map(this::paymentView).toList(); + long total = mapper.countPayments(actor.userId(), actor.roleCode(), effectiveTab, + blank(companyId), blank(projectId), blank(supplierId), blank(status), clean(keyword, 100), + clean(riskCode, 32), dateFrom, dateTo); + return new PageResult<>(items, total, requestedPage.page(), requestedPage.size()); + } + + @Transactional(readOnly = true) + public PaymentDetailView getPayment(String publicId) { + PaymentRow row = payment(publicId); + return detail(row); + } + + @Transactional(readOnly = true) + public List getChecks(String publicId) { + PaymentRow row = payment(publicId); + return mapper.listChecks(row.id()).stream().map(this::checkView).toList(); + } + + @Transactional(propagation = Propagation.MANDATORY) + public void projectApprovedOa06(ApprovedOa06PaymentCommand command) { + validateProjectionCommand(command); + PaymentProjectionRow existing = mapper.findPaymentProjectionBySourceDocument(command.sourceDocumentId()); + if (existing != null) { + if (existing.sourceVersionId() == null || existing.sourceVersionId() != command.sourceVersionId()) { + throw new BusinessException(HttpStatus.CONFLICT, ErrorCode.INVALID_STATE_TRANSITION, + "OA-06 来源已有其他版本生成的付款申请"); + } + return; + } + if (command.createdBy() == command.approvedBy()) { + throw sod("OA-06 来源申请人与财务终审人必须不同"); + } + + BigDecimal amount = money(command.requestedAmount()); + Refs refs = resolveRefs(command.companyPublicId(), command.projectPublicId(), + command.contractPublicId(), command.payablePublicId(), command.supplierPublicId(), + command.bankAccountPublicId(), command.sourceDocumentPublicId(), null, amount, null); + if (refs.source() == null || refs.source().id() != command.sourceDocumentId() + || refs.source().currentVersionId() == null + || refs.source().currentVersionId() != command.sourceVersionId()) { + throw transition("OA-06 来源版本与付款投影版本不一致"); + } + validateProjectionSnapshots(command, refs); + + AttachmentRuleSelection attachmentRule = attachmentRule(refs.payable()); + List attachments = resolveProjectionAttachments(command.attachmentFileIds()); + String publicId = ulidGenerator.next(); + String businessNo = businessNo("PAY", publicId); + try { + mapper.insertPayment(publicId, businessNo, refs.company().id(), refs.project().id(), + refs.contract().id(), refs.payable().id(), refs.supplier().id(), refs.account().id(), + refs.source().id(), command.sourceVersionId(), sourceEventKey(refs.source(), publicId), + id(attachmentRule), version(attachmentRule), snapshot(attachmentRule), null, 0, amount, + refs.project().currency(), command.requestedDate(), cleanRequired(command.purpose(), 500), + command.createdBy()); + } catch (DataIntegrityViolationException exception) { + throw duplicate("该 OA-06 来源已生成付款申请"); + } + + PaymentProjectionRow created = mapper.findPaymentProjectionBySourceDocument(command.sourceDocumentId()); + if (created == null) throw new IllegalStateException("OA-06 payment projection was not persisted"); + replaceAttachments(created.id(), attachments, command.createdBy()); + if (mapper.submitPayment(created.id(), 0, id(attachmentRule), version(attachmentRule), + snapshot(attachmentRule), command.createdBy()) != 1) { + throw new IllegalStateException("OA-06 payment projection was not submitted"); + } + + FundControlRow fund = lockFundControl(refs.project().id(), refs.project().currency()); + PaymentLock current = lock(created.id(), 1); + List checks = checks(current, fund, List.of()); + for (Check check : checks) { + mapper.insertCheck(ulidGenerator.next(), current.id(), check.code(), check.result(), + check.message(), command.approvedBy()); + } + Check blocked = checks.stream().filter(Check::blocked).findFirst().orElse(null); + if (blocked != null) throw blocked.error(); + if (mapper.reserveFunds(fund.id(), fund.version(), current.requestedAmount()) != 1) { + throw funds("项目资金已变化,请重新执行 OA-06 财务终审"); + } + if (mapper.approvePayment(current.id(), current.version(), json(checks), CHECK_RULE_VERSION, + command.approvedBy()) != 1) { + throw new IllegalStateException("OA-06 payment projection changed while approving"); + } + appendFundEntry(current, businessNo, "SOURCE_OA06_PAYMENT_RESERVE:" + command.sourceVersionId(), + "PAYMENT_FUNDS_RESERVED", "OA-06 财务终审冻结项目资金", current.requestedAmount(), + current.requestedAmount(), zero(null), command.approvedBy()); + auditService.recordScoped(command.companyPublicId(), command.projectPublicId(), + "SOURCE_OA06_PAYMENT_PROJECT", "PAYMENT", publicId, "SUCCESS", command.opinion(), null, + Map.of("sourceDocumentId", command.sourceDocumentPublicId(), + "sourceVersionId", command.sourceVersionId(), "status", "OFFLINE_PENDING", + "amount", amount.toPlainString(), "currency", refs.project().currency())); + } + + @Transactional + public PaymentView createPayment(PaymentCreateRequest request) { + authorizationService.requirePermission("payment:request:create"); + Actor actor = actor(); + BigDecimal amount = money(request.requestedAmount()); + Refs refs = resolveRefs(request.companyId(), request.projectId(), request.contractId(), + request.payableId(), request.supplierId(), request.bankAccountVersionId(), + request.sourceDocumentId(), request.currency(), amount, "payment:request:create"); + AttachmentRuleSelection attachmentRule = attachmentRule(refs.payable()); + if (request.attachmentFileIds() != null && !request.attachmentFileIds().isEmpty() + && attachmentRule != null) { + throw validation("当前应付已启用按成本类型附件规则,请使用结构化 attachments 并为每个文件填写 documentType"); + } + List files = resolvePaymentAttachments(request.attachmentFileIds(), + request.attachments(), attachmentRule, 0, + refs.company().publicId(), refs.project().publicId()); + List exceptions = resolveAttachmentExceptions(request.attachmentExceptions(), + attachmentRule, files); + String publicId = ulidGenerator.next(); + String sourceKey = sourceEventKey(refs.source(), publicId); + try { + mapper.insertPayment(publicId, businessNo("PAY", publicId), refs.company().id(), refs.project().id(), + refs.contract().id(), id(refs.payable()), refs.supplier().id(), refs.account().id(), + id(refs.source()), sourceVersionId(refs.source()), sourceKey, + id(attachmentRule), version(attachmentRule), snapshot(attachmentRule), + null, 0, amount, request.currency(), request.requestedDate(), + cleanRequired(request.purpose(), 500), actor.userId()); + PaymentRow created = payment(publicId); + replaceAttachments(created.id(), files, actor.userId()); + replaceAttachmentExceptions(created.id(), exceptions, actor.userId()); + PaymentView result = paymentView(payment(publicId)); + auditService.recordScoped(created.companyPublicId(), created.projectPublicId(), + "PAYMENT_CREATE", "PAYMENT", publicId, "SUCCESS", null, null, result); + return result; + } catch (DataIntegrityViolationException exception) { + throw duplicate("相同来源已存在有效付款申请"); + } + } + + @Transactional + public PaymentView updatePayment(String publicId, PaymentUpdateRequest request) { + authorizationService.requirePermission("payment:request:create"); + Actor actor = actor(); + PaymentRow before = payment(publicId); + PaymentLock current = lock(before.id(), request.version()); + requireDraftOwner(current, actor); + BigDecimal amount = money(request.requestedAmount()); + Refs refs = resolveRefs(request.companyId(), request.projectId(), request.contractId(), + request.payableId(), request.supplierId(), request.bankAccountVersionId(), + request.sourceDocumentId(), request.currency(), amount, "payment:request:create"); + AttachmentRuleSelection attachmentRule = currentAttachmentRule(refs.payable(), current); + if (request.attachmentFileIds() != null && !request.attachmentFileIds().isEmpty() + && attachmentRule != null) { + throw validation("当前应付已启用按成本类型附件规则,请使用结构化 attachments 并为每个文件填写 documentType"); + } + List files = resolvePaymentAttachments(request.attachmentFileIds(), + request.attachments(), attachmentRule, current.id(), + refs.company().publicId(), refs.project().publicId()); + List exceptions = resolveAttachmentExceptions(request.attachmentExceptions(), + attachmentRule, files); + String sourceKey = sourceEventKey(refs.source(), current.publicId()); + try { + if (mapper.updatePayment(current.id(), refs.company().id(), refs.project().id(), refs.contract().id(), + id(refs.payable()), refs.supplier().id(), refs.account().id(), id(refs.source()), + sourceVersionId(refs.source()), sourceKey, + id(attachmentRule), version(attachmentRule), snapshot(attachmentRule), + amount, request.currency(), request.requestedDate(), cleanRequired(request.purpose(), 500), + actor.userId(), request.version()) != 1) throw conflict(); + replaceAttachments(current.id(), files, actor.userId()); + replaceAttachmentExceptions(current.id(), exceptions, actor.userId()); + PaymentView result = paymentView(payment(publicId)); + auditService.recordScoped(before.companyPublicId(), before.projectPublicId(), + "PAYMENT_UPDATE", "PAYMENT", publicId, "SUCCESS", null, paymentView(before), result); + return result; + } catch (DataIntegrityViolationException exception) { + throw duplicate("相同来源已存在有效付款申请"); + } + } + + @Transactional + public PaymentView submitPayment(String publicId, long version) { + authorizationService.requirePermission("payment:request:create"); + Actor actor = actor(); + PaymentRow before = payment(publicId); + PaymentLock current = lock(before.id(), version); + requireDraftOwner(current, actor); + authorizationService.requireScope("payment:request:create", before.companyPublicId(), + before.projectPublicId(), current.requestedAmount()); + if (current.attachmentRuleParameterId() != null) { + if (current.attachmentRuleVersionNo() == null || current.attachmentRuleSnapshotJson() == null) { + throw validation("付款附件规则快照不完整,请重新编辑付款申请"); + } + try { + categorySnapshot(current.attachmentRuleSnapshotJson()); + } catch (IllegalStateException exception) { + throw validation("付款附件规则快照无效,请重新编辑付款申请"); + } + } else if (current.attachmentRuleVersionNo() != null || current.attachmentRuleSnapshotJson() != null) { + throw validation("付款附件规则快照字段不一致,请重新编辑付款申请"); + } + if (mapper.submitPayment(current.id(), version, current.attachmentRuleParameterId(), + current.attachmentRuleVersionNo(), current.attachmentRuleSnapshotJson(), actor.userId()) != 1) { + throw conflict(); + } + PaymentView result = paymentView(payment(publicId)); + auditService.recordScoped(before.companyPublicId(), before.projectPublicId(), + "PAYMENT_SUBMIT", "PAYMENT", publicId, "SUCCESS", null, paymentView(before), result); + return result; + } + + @Transactional(noRollbackFor = BusinessException.class) + public PaymentView approvePayment(String publicId, long version) { + authorizationService.requirePermission("payment:request:approve"); + Actor actor = actor(); + PaymentRow before = payment(publicId); + authorizationService.requireScope("payment:request:approve", before.companyPublicId(), + before.projectPublicId(), before.requestedAmount()); + // Funds commands always lock the project control row before their payment, contract, and payable rows. + FundControlRow fund = lockFundControl(before.projectId(), before.currency()); + PaymentLock current = lock(before.id(), version); + requireStatus(current.status(), "REVIEWING", "只有复核中的付款申请可以批准"); + if (current.submittedBy() != null && current.submittedBy() == actor.userId()) { + throw sod("付款提交人与批准人必须不同"); + } + LocalDate asOfDate = LocalDate.now(); + List approvedControlExceptions = + mapper.lockApprovedControlExceptions(current.id(), asOfDate); + List checks = checks(current, fund, approvedControlExceptions); + for (Check check : checks) { + mapper.insertCheck(ulidGenerator.next(), current.id(), check.code(), check.result(), + check.message(), actor.userId()); + } + Check blocked = checks.stream().filter(Check::blocked).findFirst().orElse(null); + if (blocked != null) { + auditService.recordScoped(before.companyPublicId(), before.projectPublicId(), + "PAYMENT_APPROVE", "PAYMENT", publicId, "BLOCKED", blocked.message(), paymentView(before), checks); + throw blocked.error(); + } + if (mapper.reserveFunds(fund.id(), fund.version(), current.requestedAmount()) != 1) { + throw new IllegalStateException("Project funds changed while approving payment"); + } + int consumableAttachmentExceptions = mapper.countConsumableApprovedAttachmentExceptions(current.id()); + if (mapper.approvePayment(current.id(), version, json(checks), CHECK_RULE_VERSION, actor.userId()) != 1) { + throw new IllegalStateException("Payment changed while approving"); + } + int consumedAttachmentExceptions = mapper.consumeApprovedAttachmentExceptions(current.id(), actor.userId()); + if (consumedAttachmentExceptions != consumableAttachmentExceptions) { + throw new IllegalStateException("Approved payment attachment exceptions were not consumed atomically"); + } + for (Check check : checks) { + if (check.controlExceptionId() != null + && mapper.consumeControlException(check.controlExceptionId(), actor.userId(), asOfDate) != 1) { + throw new IllegalStateException("Approved payment control exception was not consumed atomically"); + } + } + appendFundEntry(current, before, "PAYMENT_APPROVE:" + current.publicId(), "PAYMENT_FUNDS_RESERVED", + "付款批准冻结项目资金", current.requestedAmount(), current.requestedAmount(), zero(null), actor.userId()); + PaymentView result = paymentView(payment(publicId)); + auditService.recordScoped(before.companyPublicId(), before.projectPublicId(), + "PAYMENT_APPROVE", "PAYMENT", publicId, "SUCCESS", null, paymentView(before), result); + return result; + } + + @Transactional + public PaymentView returnPayment(String publicId, ReturnRequest request) { + authorizationService.requirePermission("payment:request:review"); + Actor actor = actor(); + PaymentRow before = payment(publicId); + authorizationService.requireScope("payment:request:review", before.companyPublicId(), + before.projectPublicId(), before.requestedAmount()); + PaymentLock current = lock(before.id(), request.version()); + requireStatus(current.status(), "REVIEWING", "只有复核中的付款申请可以退回"); + if (current.submittedBy() != null && current.submittedBy() == actor.userId()) { + throw sod("付款提交人与退回处理人必须不同"); + } + if (mapper.countPendingAttachmentExceptionsRequestedBy(current.id(), actor.userId()) > 0) { + throw sod("附件不涉及申请人与财务复核人必须不同"); + } + if (mapper.countPendingControlExceptionsRequestedBy(current.id(), actor.userId()) > 0) { + throw sod("付款检查例外申请人与财务复核人必须不同"); + } + String opinion = cleanRequired(request.opinion(), 1000); + mapper.rejectPendingAttachmentExceptions(current.id(), opinion, actor.userId()); + mapper.rejectPendingControlExceptions(current.id(), opinion, actor.userId()); + mapper.deactivateReviewedAttachmentExceptions(current.id()); + mapper.deactivateReviewedControlExceptions(current.id()); + if (mapper.returnPayment(current.id(), request.version(), opinion, + actor.userId()) != 1) throw conflict(); + PaymentView result = paymentView(payment(publicId)); + auditService.recordScoped(before.companyPublicId(), before.projectPublicId(), + "PAYMENT_RETURN", "PAYMENT", publicId, "SUCCESS", request.opinion(), paymentView(before), result); + return result; + } + + @Transactional + public PaymentView requestControlException(String paymentPublicId, + PaymentControlExceptionRequest request) { + authorizationService.requirePermission("payment:request:create"); + Actor actor = actor(); + PaymentRow before = payment(paymentPublicId); + authorizationService.requireScope("payment:request:create", before.companyPublicId(), + before.projectPublicId(), before.requestedAmount()); + PaymentLock payment = lock(before.id(), request.paymentVersion()); + requireStatus(payment.status(), "REVIEWING", "只有复核中的付款申请可以申请检查例外"); + if (payment.createdBy() != actor.userId() && !identityContext.isSystemAdministrator()) { + throw new BusinessException(HttpStatus.FORBIDDEN, ErrorCode.PERMISSION_DENIED, + "只有付款申请人可以申请检查例外"); + } + String controlCode = request.controlCode().trim().toUpperCase(Locale.ROOT); + if (HARD_CONTROL_CODES.contains(controlCode) || !EXCEPTIONABLE_CONTROL_CODES.contains(controlCode)) { + throw validation("该检查属于硬控制,不允许申请例外"); + } + LocalDate today = LocalDate.now(); + if (request.validUntil().isBefore(today) + || request.validUntil().isAfter(today.plusDays(CONTROL_EXCEPTION_MAX_VALID_DAYS))) { + throw validation("检查例外有效期必须在今天至未来 7 天内"); + } + ControlFinding finding = controlFinding(payment, controlCode); + if (!finding.blocked()) { + throw validation("当前付款不存在可申请例外的" + controlLabel(controlCode) + "阻断"); + } + mapper.deactivateExpiredOrConsumedControlExceptions(payment.id(), controlCode, today); + if (mapper.countActiveControlExceptions(payment.id(), controlCode, today) > 0) { + throw new BusinessException(HttpStatus.CONFLICT, ErrorCode.DUPLICATE_RESOURCE, + "该检查已有有效的例外申请"); + } + String exceptionPublicId = ulidGenerator.next(); + int versionNo = mapper.nextControlExceptionVersion(payment.id(), controlCode); + try { + mapper.insertControlException(exceptionPublicId, payment.id(), controlCode, + finding.fingerprint(), finding.message(), finding.snapshotJson(), versionNo, + cleanRequired(request.reason(), 500), request.validUntil(), actor.userId()); + } catch (DataIntegrityViolationException exception) { + throw new BusinessException(HttpStatus.CONFLICT, ErrorCode.DUPLICATE_RESOURCE, + "该检查已有有效的例外申请"); + } + PaymentView result = paymentView(payment(paymentPublicId)); + auditService.recordScoped(before.companyPublicId(), before.projectPublicId(), + "PAYMENT_CONTROL_EXCEPTION_REQUEST", "PAYMENT_CONTROL_EXCEPTION", exceptionPublicId, + "SUCCESS", request.reason(), finding, result); + return result; + } + + @Transactional + public PaymentView reviewControlException(String paymentPublicId, String exceptionPublicId, + PaymentControlExceptionReviewRequest request) { + authorizationService.requirePermission("payment:request:review"); + Actor actor = actor(); + PaymentRow before = payment(paymentPublicId); + authorizationService.requireScope("payment:request:review", before.companyPublicId(), + before.projectPublicId(), before.requestedAmount()); + PaymentLock payment = lock(before.id(), request.paymentVersion()); + requireStatus(payment.status(), "REVIEWING", "只有复核中的付款申请可以处理检查例外"); + ControlExceptionLock exception = mapper.lockControlException(publicId(exceptionPublicId)); + if (exception == null || exception.paymentId() != payment.id()) { + throw notFound("付款检查例外不存在或不在当前付款中"); + } + if (!"PENDING".equals(exception.status())) { + throw transition("付款检查例外已经处理"); + } + if (exception.requestedBy() == actor.userId()) { + throw sod("付款检查例外申请人与复核人必须不同"); + } + String decision = request.decision().trim().toUpperCase(Locale.ROOT); + String status = "APPROVE".equals(decision) ? "APPROVED" : "REJECTED"; + if ("APPROVED".equals(status)) { + if (exception.validUntil().isBefore(LocalDate.now())) { + throw validation("付款检查例外已过有效期"); + } + ControlFinding finding = controlFinding(payment, exception.controlCode()); + if (!finding.blocked() || !finding.fingerprint().equals(exception.findingFingerprint())) { + throw validation("付款检查结果已变化,请退回后重新申请例外"); + } + } + String opinion = cleanRequired(request.opinion(), 1000); + if (mapper.reviewControlException(exception.id(), request.version(), status, opinion, + actor.userId()) != 1) { + throw sod("付款检查例外已被其他人处理,或复核人与申请人相同"); + } + PaymentView result = paymentView(payment(paymentPublicId)); + auditService.recordScoped(before.companyPublicId(), before.projectPublicId(), + "PAYMENT_CONTROL_EXCEPTION_" + status, "PAYMENT_CONTROL_EXCEPTION", exceptionPublicId, + "SUCCESS", opinion, exception, result); + return result; + } + + @Transactional + public PaymentView reviewAttachmentException(String paymentPublicId, String exceptionPublicId, + PaymentAttachmentExceptionReviewRequest request) { + authorizationService.requirePermission("payment:request:review"); + Actor actor = actor(); + PaymentRow before = payment(paymentPublicId); + authorizationService.requireScope("payment:request:review", before.companyPublicId(), + before.projectPublicId(), before.requestedAmount()); + PaymentLock payment = lock(before.id(), before.version()); + requireStatus(payment.status(), "REVIEWING", "只有复核中的付款申请可以处理附件不涉及申请"); + AttachmentExceptionLock exception = mapper.lockAttachmentException(publicId(exceptionPublicId)); + if (exception == null || exception.paymentId() != payment.id()) { + throw notFound("附件不涉及申请不存在或不在当前付款中"); + } + if (!"PENDING".equals(exception.status())) { + throw transition("附件不涉及申请已经处理"); + } + if (exception.requestedBy() == actor.userId()) { + throw sod("附件不涉及申请人与财务复核人必须不同"); + } + Category category; + try { + category = categorySnapshot(payment.attachmentRuleSnapshotJson()); + } catch (IllegalStateException snapshotException) { + throw validation("付款附件规则快照无效,不能处理不涉及申请"); + } + Requirement requirement = category.requirements().stream() + .filter(item -> item.documentType().equals(exception.documentType())).findFirst().orElse(null); + if (requirement == null || !requirement.allowNotApplicable()) { + throw validation("当前规则不允许该资料标记为不涉及"); + } + String decision = request.decision().trim().toUpperCase(java.util.Locale.ROOT); + String status = "APPROVE".equals(decision) ? "APPROVED" : "REJECTED"; + String opinion = cleanRequired(request.opinion(), 1000); + if (mapper.reviewAttachmentException(exception.id(), request.version(), status, opinion, + actor.userId()) != 1) { + throw sod("附件不涉及申请已被其他人处理,或复核人与申请人相同"); + } + PaymentView result = paymentView(payment(paymentPublicId)); + auditService.recordScoped(before.companyPublicId(), before.projectPublicId(), + "PAYMENT_ATTACHMENT_EXCEPTION_" + status, "PAYMENT_ATTACHMENT_EXCEPTION", exceptionPublicId, + "SUCCESS", opinion, exception, result); + return result; + } + + @Transactional + public PaymentView voidPayment(String publicId, ReturnRequest request) { + authorizationService.requirePermission("payment:request:create"); + Actor actor = actor(); + PaymentRow before = payment(publicId); + authorizationService.requireScope("payment:request:create", before.companyPublicId(), + before.projectPublicId(), before.requestedAmount()); + PaymentLock current = lock(before.id(), request.version()); + requireDraftOwner(current, actor); + if (mapper.voidPayment(current.id(), request.version(), actor.userId()) != 1) throw transition("当前状态不能作废"); + PaymentView result = paymentView(payment(publicId)); + auditService.recordScoped(before.companyPublicId(), before.projectPublicId(), + "PAYMENT_VOID", "PAYMENT", publicId, "SUCCESS", request.opinion(), paymentView(before), result); + return result; + } + + @Transactional + public PaymentView confirmOfflineResult(String publicId, OfflineResultRequest request) { + return recordResult(publicId, request.version(), "SUCCESS", request.resultAt(), + money(request.amount()), request.receiptFileIds(), null, clean(request.remark(), 1000), + "payment:request:record-result"); + } + + @Transactional + public PaymentView recordFailure(String publicId, FailureResultRequest request) { + return recordResult(publicId, request.version(), "FAILURE", request.resultAt(), + null, + request.receiptFileIds(), cleanRequired(request.reason(), 1000), clean(request.remark(), 1000), + "payment:request:record-result"); + } + + @Transactional + public PaymentView recordRefund(String publicId, RefundResultRequest request) { + return recordResult(publicId, request.version(), "REFUND", request.resultAt(), + money(request.amount()), request.receiptFileIds(), cleanRequired(request.reason(), 1000), + clean(request.remark(), 1000), "payment:request:refund"); + } + + @Transactional + public PaymentView verifyResult(String publicId, VerifyResultRequest request) { + authorizationService.requirePermission("payment:request:verify-result"); + Actor actor = actor(); + PaymentRow before = payment(publicId); + authorizationService.requireScope("payment:request:verify-result", before.companyPublicId(), + before.projectPublicId()); + FundControlRow fund = lockFundControl(before.projectId(), before.currency()); + PaymentLock current = lock(before.id(), request.version()); + requireStatus(current.status(), "RESULT_RECORDED", "只有待复核的线下结果可以复核"); + ResultLock result = mapper.lockPendingResult(current.id()); + if (result == null) throw transition("未找到待复核的线下付款结果"); + if (result.recordedBy() == actor.userId()) throw sod("线下付款结果登记人与复核人必须不同"); + BigDecimal paidBefore = zero(current.paidAmount()); + BigDecimal frozenBefore = zero(current.frozenAmount()); + BigDecimal nextPaid = paidBefore; + BigDecimal nextFrozen = frozenBefore; + authorizationService.requireScope("payment:request:verify-result", before.companyPublicId(), + before.projectPublicId(), result.amount()); + String finalStatus; + if ("SUCCESS".equals(result.resultType())) { + if (result.amount() == null || result.amount().signum() <= 0 + || result.amount().compareTo(frozenBefore) > 0) { + throw validation("线下付款金额不能超过剩余冻结金额"); + } + ContractRef contract = mapper.lockContract(current.contractId()); + BigDecimal contractLimit = contract == null ? BigDecimal.ZERO + : contract.settlementAmount() == null + ? contract.originalAmount().add(contract.approvedChangeAmount()) : contract.settlementAmount(); + BigDecimal committed = mapper.contractCommittedAmount(current.contractId(), current.id()); + if (contract == null || !"ACTIVE".equals(contract.status()) + || committed.add(result.amount()).compareTo(contractLimit) > 0) { + throw new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.CONTRACT_LIMIT_EXCEEDED, + "合同可付余额不足,不能确认付款结果"); + } + if (current.payableId() != null && mapper.lockPayable(current.payableId()) == null) { + throw payableLimit("应付记录已失效,不能确认付款结果"); + } + if (mapper.settleFundsPaid(fund.id(), fund.version(), result.amount()) != 1) { + throw funds("付款冻结金额已变化,请重新检查"); + } + if (current.payableId() != null && mapper.addPayablePaid(current.payableId(), result.amount(), actor.userId()) != 1) { + throw payableLimit("应付余额不足,不能确认付款结果"); + } + nextPaid = paidBefore.add(result.amount()); + nextFrozen = frozenBefore.subtract(result.amount()); + finalStatus = nextFrozen.signum() == 0 ? "PAID" : "PART_PAID"; + mapper.insertAccountingEvent(ulidGenerator.next(), businessNo("EVT", result.publicId()), + result.publicId(), "PAYMENT-" + current.publicId(), 1, + current.companyId(), current.projectId(), current.supplierId(), + result.paidDate(), result.amount(), current.currency(), actor.userId()); + appendFundEntry(current, before, "PAYMENT_RESULT_VERIFY:" + result.publicId(), "PAYMENT_PAID", + "线下付款结果复核通过", result.amount(), result.amount().negate(), result.amount(), actor.userId()); + } else if ("FAILURE".equals(result.resultType())) { + if (frozenBefore.signum() > 0 + && mapper.releaseFrozenFunds(fund.id(), fund.version(), frozenBefore) != 1) { + throw funds("付款冻结金额已变化,请重新检查"); + } + if (frozenBefore.signum() > 0) { + appendFundEntry(current, before, "PAYMENT_RESULT_VERIFY:" + result.publicId(), + "PAYMENT_FUNDS_RELEASED_FAILURE", "付款失败复核后释放冻结资金", + frozenBefore, frozenBefore.negate(), zero(null), actor.userId()); + } + nextFrozen = BigDecimal.ZERO.setScale(2); + finalStatus = "FAILED"; + } else { + if (paidBefore.signum() == 0) { + if (result.amount() == null || result.amount().compareTo(frozenBefore) != 0) { + throw validation("待付款退汇必须释放全部剩余冻结金额"); + } + if (frozenBefore.signum() > 0 + && mapper.releaseFrozenFunds(fund.id(), fund.version(), frozenBefore) != 1) { + throw funds("付款冻结金额已变化,请重新检查"); + } + if (frozenBefore.signum() > 0) { + appendFundEntry(current, before, "PAYMENT_RESULT_VERIFY:" + result.publicId(), + "PAYMENT_FUNDS_RELEASED_REFUND", "待付款退汇复核后释放冻结资金", + frozenBefore, frozenBefore.negate(), zero(null), actor.userId()); + } + nextFrozen = BigDecimal.ZERO.setScale(2); + } else { + if (result.amount() == null || result.amount().signum() <= 0 + || result.amount().compareTo(paidBefore) != 0) { + throw validation("退汇金额必须等于本次付款已付净额"); + } + if (current.payableId() != null && mapper.lockPayable(current.payableId()) == null) { + throw payableLimit("应付记录已失效,不能登记退汇"); + } + if (mapper.reversePaidAndReleaseFrozenFunds(fund.id(), fund.version(), result.amount(), + frozenBefore) != 1) { + throw funds("项目已付金额不足,不能登记退汇"); + } + if (current.payableId() != null + && mapper.reversePayablePaid(current.payableId(), result.amount(), actor.userId()) != 1) { + throw payableLimit("应付已付金额不足,不能登记退汇"); + } + nextPaid = paidBefore.subtract(result.amount()); + nextFrozen = BigDecimal.ZERO.setScale(2); + appendFundEntry(current, before, "PAYMENT_RESULT_VERIFY:" + result.publicId(), + "PAYMENT_REFUNDED", "已付款退汇复核后返还项目资金", result.amount(), + frozenBefore.negate(), result.amount().negate(), actor.userId()); + } + finalStatus = "REFUNDED"; + mapper.insertRefund(ulidGenerator.next(), result.id(), result.amount(), + cleanRequired(result.failureReason(), 1000), actor.userId()); + mapper.insertRefundAccountingEvent(ulidGenerator.next(), businessNo("EVT-REFUND", result.publicId()), + result.publicId(), "PAYMENT-RESULT-" + result.publicId(), 1, + current.companyId(), current.projectId(), current.supplierId(), + result.paidDate(), result.amount(), current.currency(), actor.userId()); + } + if (mapper.verifyResult(result.id(), result.version(), cleanRequired(request.opinion(), 1000), + actor.userId()) != 1) throw sod("线下付款结果登记人与复核人必须不同"); + if (mapper.completePayment(current.id(), request.version(), finalStatus, nextPaid, nextFrozen, + actor.userId()) != 1) throw conflict(); + PaymentView response = paymentView(payment(publicId)); + auditService.recordScoped(before.companyPublicId(), before.projectPublicId(), + "PAYMENT_VERIFY_RESULT", "PAYMENT", publicId, "SUCCESS", request.opinion(), paymentView(before), response); + return response; + } + + private void appendFundEntry(PaymentLock payment, PaymentRow row, String sourceEventKey, + String entryType, String description, BigDecimal eventAmount, + BigDecimal frozenAmountDelta, BigDecimal paidAmountDelta, long actorId) { + appendFundEntry(payment, row.businessNo(), sourceEventKey, entryType, description, + eventAmount, frozenAmountDelta, paidAmountDelta, actorId); + } + + private void appendFundEntry(PaymentLock payment, String businessNo, String sourceEventKey, + String entryType, String description, BigDecimal eventAmount, + BigDecimal frozenAmountDelta, BigDecimal paidAmountDelta, long actorId) { + BigDecimal empty = zero(null); + fundLedgerService.append(new FundLedgerCommand(payment.projectId(), payment.currency(), sourceEventKey, + entryType, "PAYMENT", payment.publicId(), businessNo, description, eventAmount, + empty, empty, empty, frozenAmountDelta, empty, paidAmountDelta, actorId)); + } + + @Transactional + public PaymentView returnResult(String publicId, ReturnResultRequest request) { + authorizationService.requirePermission("payment:request:verify-result"); + Actor actor = actor(); + PaymentRow before = payment(publicId); + authorizationService.requireScope("payment:request:verify-result", before.companyPublicId(), + before.projectPublicId()); + PaymentLock current = lock(before.id(), request.version()); + requireStatus(current.status(), "RESULT_RECORDED", "只有待复核的线下结果可以退回"); + ResultLock result = mapper.lockPendingResult(current.id()); + if (result == null) throw transition("未找到待复核的线下付款结果"); + if (result.recordedBy() == actor.userId()) throw sod("线下付款结果登记人与复核人必须不同"); + authorizationService.requireScope("payment:request:verify-result", before.companyPublicId(), + before.projectPublicId(), result.amount()); + if (!Set.of("OFFLINE_PENDING", "PART_PAID", "PAID").contains(result.recordedFromStatus())) { + throw transition("付款结果缺少可恢复的登记前状态"); + } + String opinion = cleanRequired(request.opinion(), 1000); + if (mapper.returnResult(result.id(), result.version(), opinion, actor.userId()) != 1) { + throw sod("线下付款结果登记人与复核人必须不同"); + } + if (mapper.restorePaymentAfterResultReturn(current.id(), request.version(), result.recordedFromStatus(), + actor.userId()) != 1) throw conflict(); + PaymentView response = paymentView(payment(publicId)); + auditService.recordScoped(before.companyPublicId(), before.projectPublicId(), + "PAYMENT_RETURN_RESULT", "PAYMENT", publicId, "SUCCESS", opinion, paymentView(before), response); + return response; + } + + @Transactional + public PaymentView createRepayment(String publicId, RepaymentRequest request) { + authorizationService.requirePermission("payment:request:create"); + Actor actor = actor(); + PaymentRow before = payment(publicId); + PaymentLock current = lock(before.id(), request.version()); + if (!Set.of("FAILED", "REFUNDED").contains(current.status())) { + throw transition("只有失败或退汇付款可以创建重付申请"); + } + requireDraftOwnerOrFinance(current, actor); + authorizationService.requireScope("payment:request:create", before.companyPublicId(), + before.projectPublicId(), request.requestedAmount()); + BigDecimal approved = current.approvedAmount() == null ? current.requestedAmount() : current.approvedAmount(); + BigDecimal remaining = approved.subtract(zero(current.paidAmount())); + BigDecimal repaymentAmount = money(request.requestedAmount()); + if (repaymentAmount.compareTo(remaining) > 0) { + throw validation("重付金额不能超过未支付余额"); + } + BankAccountRef account = mapper.lockBankAccountById(current.bankAccountVersionId()); + if (!isActiveAccount(account, current.supplierId())) throw accountInactive("原收款账户已失效,请先修改账户"); + long rootId = current.originalPaymentId() == null ? current.id() : current.originalPaymentId(); + if (mapper.lockPayment(rootId) == null) { + throw transition("原付款申请已不存在,不能创建重付"); + } + if (mapper.countActiveRepayments(rootId) > 0) { + throw duplicate("原付款已有未终结的重付申请"); + } + int sequence = mapper.maxRepaymentSequence(rootId) + 1; + String newId = ulidGenerator.next(); + mapper.insertPayment(newId, businessNo("PAY-RE", newId), current.companyId(), current.projectId(), + current.contractId(), current.payableId(), current.supplierId(), current.bankAccountVersionId(), + null, null, sha256("PAYMENT_REPAYMENT|" + rootId + "|" + sequence), + current.attachmentRuleParameterId(), current.attachmentRuleVersionNo(), + current.attachmentRuleSnapshotJson(), rootId, + sequence, repaymentAmount, current.currency(), request.requestedDate(), + cleanRequired(request.purpose(), 500), actor.userId()); + PaymentRow created = payment(newId); + for (AttachmentRow attachment : mapper.listAttachments(current.id())) { + FileRef file = mapper.findFile(attachment.filePublicId()); + if (file != null) mapper.insertAttachment(ulidGenerator.next(), created.id(), file.id(), + attachment.documentType(), actor.userId()); + } + for (AttachmentExceptionRow exception : mapper.listAttachmentExceptions(current.id())) { + mapper.insertAttachmentException(ulidGenerator.next(), created.id(), exception.documentType(), + exception.documentName(), 1, exception.reason(), actor.userId()); + } + PaymentView response = paymentView(payment(newId)); + auditService.recordScoped(created.companyPublicId(), created.projectPublicId(), + "PAYMENT_CREATE_REPAYMENT", "PAYMENT", newId, "SUCCESS", null, paymentView(before), response); + return response; + } + + @Transactional + public byte[] exportPayments(String tab, String companyId, String projectId, String supplierId, + String status, String keyword, String riskCode, LocalDate dateFrom, + LocalDate dateTo, String sort) { + authorizationService.requirePermission("payment:request:export"); + Actor actor = actor(); + String effectiveTab = blank(tab); + if (effectiveTab == null) { + effectiveTab = identityContext.isSystemAdministrator() + ? "all" : "PROJECT_MANAGER".equals(actor.roleCode()) ? "my" : "finance-check"; + } + requireTab(effectiveTab); + requireStatus(status); + requireOptionalId(companyId); + requireOptionalId(projectId); + requireOptionalId(supplierId); + if (dateFrom != null && dateTo != null && dateTo.isBefore(dateFrom)) throw validation("提交日期范围无效"); + String safeCompanyId = blank(companyId); + String safeProjectId = blank(projectId); + String safeSupplierId = blank(supplierId); + List rows = mapper.listPayments(actor.userId(), actor.roleCode(), effectiveTab, + safeCompanyId, safeProjectId, safeSupplierId, blank(status), clean(keyword, 100), + clean(riskCode, 32), dateFrom, dateTo, orderBy(sort), 50000, 0).stream().map(this::paymentView).toList(); + StringBuilder csv = new StringBuilder("\uFEFF付款编号,公司,项目,合同,供应商,申请金额,批准金额,账户版本,状态,申请日期\n"); + for (PaymentView row : rows) { + csv.append(csv(row.businessNo())).append(',').append(csv(name(row.company()))).append(',') + .append(csv(name(row.project()))).append(',').append(csv(name(row.contract()))).append(',') + .append(csv(name(row.supplier()))).append(',').append(row.requestedAmount()).append(',') + .append(row.approvedAmount() == null ? "" : row.approvedAmount()).append(',') + .append(row.bankAccount().versionNo()).append(',').append(row.status()).append(',') + .append(row.requestedDate()).append('\n'); + } + recordExportAudit(rows, safeCompanyId, safeProjectId); + return csv.toString().getBytes(StandardCharsets.UTF_8); + } + + /** + * An export can span several projects even when no project filter was sent. + * Keep one audit row per actual project so project-scoped users can see the + * export they initiated without granting visibility to another project. + */ + private void recordExportAudit(List rows, String companyId, String projectId) { + if (rows.isEmpty()) { + auditService.recordScoped(companyId, projectId, "PAYMENT_EXPORT", "PAYMENT", null, "SUCCESS", null, null, + new ExportSummary(0)); + return; + } + Map scopes = new LinkedHashMap<>(); + for (PaymentView row : rows) { + String rowProjectId = row.project().publicId(); + String rowCompanyId = row.company().publicId(); + scopes.compute(rowProjectId, (key, current) -> current == null + ? new ExportScope(rowCompanyId, rowProjectId, 1) + : new ExportScope(current.companyPublicId(), current.projectPublicId(), current.count() + 1)); + } + for (ExportScope scope : scopes.values()) { + auditService.recordScoped(scope.companyPublicId(), scope.projectPublicId(), "PAYMENT_EXPORT", "PAYMENT", + null, "SUCCESS", null, null, new ExportSummary(scope.count())); + } + } + + private PaymentView recordResult(String publicId, long version, String type, LocalDate resultAt, + BigDecimal amount, List fileIds, String failureReason, + String remark, String permission) { + authorizationService.requirePermission(permission); + Actor actor = actor(); + PaymentRow before = payment(publicId); + PaymentLock current = lock(before.id(), version); + BigDecimal paid = zero(current.paidAmount()); + BigDecimal frozen = zero(current.frozenAmount()); + if ("REFUND".equals(type)) { + if (!Set.of("OFFLINE_PENDING", "PART_PAID", "PAID").contains(current.status())) { + throw transition("只有待线下处理、部分支付或已支付付款可以登记退汇"); + } + BigDecimal maximum = "OFFLINE_PENDING".equals(current.status()) ? frozen : paid; + if (amount == null || amount.signum() <= 0 || amount.compareTo(maximum) > 0) { + throw validation("退汇金额不能超过当前可退金额"); + } + if ("OFFLINE_PENDING".equals(current.status()) && amount.compareTo(frozen) != 0) { + throw validation("待付款退汇必须释放全部剩余冻结金额"); + } + if (!"OFFLINE_PENDING".equals(current.status()) && amount.compareTo(paid) != 0) { + throw validation("退汇金额必须等于本次付款已付净额"); + } + } else { + if (!Set.of("OFFLINE_PENDING", "PART_PAID").contains(current.status())) { + throw transition("只有待线下处理或部分支付的付款可以登记结果"); + } + if ("FAILURE".equals(type)) { + if (frozen.signum() <= 0) throw transition("付款已无剩余冻结金额"); + amount = frozen; + } else if (amount == null || amount.signum() <= 0 || amount.compareTo(frozen) > 0) { + throw validation("线下付款金额不能超过剩余冻结金额"); + } + } + authorizationService.requireScope(permission, before.companyPublicId(), before.projectPublicId(), amount); + List files = resolveFiles(fileIds, !"FAILURE".equals(type), current.id(), + before.companyPublicId(), before.projectPublicId()); + ResultLock pending = mapper.lockPendingResult(current.id()); + if (pending != null) throw duplicate("已有待复核的线下付款结果"); + String resultId = ulidGenerator.next(); + mapper.insertResult(resultId, businessNo("PAY-RESULT", resultId), current.id(), current.status(), type, + resultAt, amount, files.isEmpty() ? null : files.get(0).id(), failureReason, remark, actor.userId()); + Long internalResultId = mapper.resultId(resultId); + if (internalResultId == null) throw new IllegalStateException("Payment result was not created"); + for (FileRef file : files) { + mapper.insertResultFile(ulidGenerator.next(), internalResultId, file.id(), actor.userId()); + } + if (mapper.markResultRecorded(current.id(), version, actor.userId()) != 1) throw conflict(); + PaymentView response = paymentView(payment(publicId)); + auditService.recordScoped(before.companyPublicId(), before.projectPublicId(), + "PAYMENT_RECORD_" + type, "PAYMENT", publicId, "SUCCESS", failureReason, + paymentView(before), response); + return response; + } + + private List checks(PaymentLock payment, FundControlRow fund, + List approvedControlExceptions) { + List result = new ArrayList<>(); + BigDecimal available = fund.confirmedReceipt().subtract(fund.confirmedDeduction()) + .add(fund.approvedAdjustment()).subtract(fund.paidAmount()).subtract(fund.frozenAmount()) + .subtract(fund.approvedUnpaid()); + result.add(check("PROJECT_FUNDS", available.compareTo(payment.requestedAmount()) >= 0, + "项目可用余额 " + moneyText(available), ErrorCode.FUNDS_INSUFFICIENT, "项目可用余额不足")); + + ContractRef contract = mapper.lockContract(payment.contractId()); + BigDecimal contractLimit = contract == null ? BigDecimal.ZERO + : contract.settlementAmount() == null + ? contract.originalAmount().add(contract.approvedChangeAmount()) : contract.settlementAmount(); + BigDecimal contractAvailable = contractLimit.subtract( + mapper.contractCommittedAmount(payment.contractId(), payment.id())); + boolean contractPass = contract != null && "ACTIVE".equals(contract.status()) + && contractAvailable.compareTo(payment.requestedAmount()) >= 0; + result.add(check("CONTRACT_LIMIT", contractPass, "合同可付余额 " + moneyText(contractAvailable), + ErrorCode.CONTRACT_LIMIT_EXCEEDED, "合同可付余额不足或合同未生效")); + + PayableRef payable = payment.payableId() == null ? null : mapper.lockPayable(payment.payableId()); + BigDecimal payableAvailable = payable == null ? payment.requestedAmount() + : payable.amount().subtract(mapper.payableCommittedAmount(payable.id(), payment.id())); + boolean payablePass = payable == null || (Set.of("CONFIRMED", "PART_PAID").contains(payable.status()) + && payableAvailable.compareTo(payment.requestedAmount()) >= 0); + result.add(check("PAYABLE_LIMIT", payablePass, + payable == null ? "未关联应付,按付款申请审批" : "应付可付余额 " + moneyText(payableAvailable), + ErrorCode.PAYABLE_LIMIT_EXCEEDED, "应付余额不足或应付已失效")); + + BankAccountRef account = mapper.lockBankAccountById(payment.bankAccountVersionId()); + boolean accountPass = isActiveAccount(account, payment.supplierId()); + result.add(check("ACCOUNT_ACTIVE", accountPass, + accountPass ? "供应商账户版本已复核生效" : "供应商账户未生效、已过期或不属于当前供应商", + ErrorCode.ACCOUNT_NOT_ACTIVE, "供应商账户未生效、已过期或不属于当前供应商")); + + result.add(attachmentCheck(payment, payable)); + + result.add(controlCheck(duplicateFinding(payment), approvedControlExceptions, + ErrorCode.DUPLICATE_SOURCE)); + + SourceRef source = payment.sourceDocumentId() == null ? null : mapper.findSourceById(payment.sourceDocumentId()); + boolean sourcePass = source == null || validPaymentSource(source, payment.companyId(), payment.projectId()); + result.add(check("SOURCE_APPROVED", sourcePass, + source == null ? "系统内付款申请审批" : sourcePass ? "OA-06 来源当前版本已批准" : "OA-06 来源无效或当前版本尚未批准", + ErrorCode.INVALID_STATE_TRANSITION, "OA-06 来源无效或当前版本尚未批准")); + + result.add(controlCheck(projectRiskFinding(payment), approvedControlExceptions, + ErrorCode.PROJECT_RISK_BLOCKED)); + return result; + } + + private Check check(String code, boolean pass, String passMessage, ErrorCode errorCode, String blockedMessage) { + return new Check(code, pass ? "PASS" : "BLOCK", pass ? passMessage : blockedMessage, + errorCode, null, null); + } + + private Check controlCheck(ControlFinding finding, + List approvedControlExceptions, + ErrorCode errorCode) { + if (!finding.blocked()) { + return new Check(finding.code(), finding.result(), finding.message(), errorCode, null, null); + } + ApprovedControlExceptionRow approved = approvedControlExceptions.stream() + .filter(item -> item.controlCode().equals(finding.code()) + && item.findingFingerprint().equals(finding.fingerprint())) + .findFirst().orElse(null); + if (approved == null) { + return new Check(finding.code(), "BLOCK", finding.message(), errorCode, null, null); + } + return new Check(finding.code(), "WARNING", + finding.message() + ";已匹配一次性例外 " + approved.publicId(), errorCode, + approved.id(), approved.publicId()); + } + + private ControlFinding controlFinding(PaymentLock payment, String controlCode) { + return switch (controlCode) { + case "DUPLICATE" -> duplicateFinding(payment); + case "PROJECT_RISK" -> projectRiskFinding(payment); + default -> throw validation("该检查不允许申请例外"); + }; + } + + private ControlFinding duplicateFinding(PaymentLock payment) { + BigDecimal minimumAmount = payment.requestedAmount().multiply(new BigDecimal("0.95")) + .setScale(2, RoundingMode.FLOOR); + BigDecimal maximumAmount = payment.requestedAmount().multiply(new BigDecimal("1.05")) + .setScale(2, RoundingMode.CEILING); + String normalizedPurpose = normalizedPurpose(payment.purpose()); + List matches = new ArrayList<>(); + int highestLevel = 0; + for (DuplicateCandidateRow candidate : mapper.listDuplicateCandidates( + payment.id(), payment.projectId(), payment.supplierId(), minimumAmount, maximumAmount, + payment.requestedDate().minusDays(7), payment.requestedDate().plusDays(7))) { + long dateDistance = Math.abs(ChronoUnit.DAYS.between( + payment.requestedDate(), candidate.requestedDate())); + BigDecimal amountDifference = payment.requestedAmount().subtract(candidate.requestedAmount()).abs(); + String candidatePurpose = normalizedPurpose(candidate.purpose()); + boolean samePurpose = normalizedPurpose.equals(candidatePurpose); + int level; + if (dateDistance == 0 && amountDifference.signum() == 0 && samePurpose) { + level = 3; + } else if (dateDistance <= 3 && withinPercent(amountDifference, payment.requestedAmount(), 1) + && samePurpose) { + level = 2; + } else if (dateDistance <= 7 && withinPercent(amountDifference, payment.requestedAmount(), 5) + && (samePurpose || amountDifference.signum() == 0)) { + level = 1; + } else { + continue; + } + highestLevel = Math.max(highestLevel, level); + matches.add(new DuplicateCandidateSnapshot( + candidate.publicId(), candidate.requestedAmount().toPlainString(), + candidate.requestedDate(), candidate.purpose(), candidate.status(), level)); + } + if (matches.isEmpty()) { + return new ControlFinding("DUPLICATE", "PASS", "未发现重复或近似付款", "", + json(new DuplicateFindingSnapshot(payment.publicId(), "NONE", List.of()))); + } + String level = switch (highestLevel) { + case 3 -> "EXACT"; + case 2 -> "HIGH"; + default -> "MEDIUM"; + }; + String candidateIds = matches.stream().map(DuplicateCandidateSnapshot::publicId) + .limit(5).reduce((left, right) -> left + "、" + right).orElse(""); + String message = switch (level) { + case "EXACT" -> "发现精确重复付款:" + candidateIds; + case "HIGH" -> "发现强近似付款(同事由、日期相差不超过 3 天、金额差不超过 1%):" + candidateIds; + default -> "发现中度近似付款,已记录警告:" + candidateIds; + }; + String canonical = payment.publicId() + '|' + payment.requestedAmount().toPlainString() + + '|' + payment.requestedDate() + '|' + normalizedPurpose + '|' + level + '|' + + matches.stream().map(item -> item.publicId() + ':' + item.amount() + ':' + + item.requestedDate() + ':' + normalizedPurpose(item.purpose()) + + ':' + item.level()).reduce((left, right) -> left + '|' + right).orElse(""); + return new ControlFinding("DUPLICATE", "MEDIUM".equals(level) ? "WARNING" : "BLOCK", + message, sha256(canonical), json(new DuplicateFindingSnapshot(payment.publicId(), level, matches))); + } + + private ControlFinding projectRiskFinding(PaymentLock payment) { + List risks = mapper.listBlockingRisks(payment.projectId()).stream() + .map(item -> new RiskFindingSnapshot(item.publicId(), item.riskType(), item.reason(), + item.effectiveFrom(), item.effectiveUntil())) + .toList(); + if (risks.isEmpty()) { + return new ControlFinding("PROJECT_RISK", "PASS", "项目无有效阻断风险", "", + json(new ProjectRiskFindingSnapshot(payment.publicId(), List.of()))); + } + String canonical = payment.publicId() + '|' + risks.stream() + .map(item -> item.publicId() + ':' + item.riskType() + ':' + item.reason() + ':' + + item.effectiveFrom() + ':' + item.effectiveUntil()) + .reduce((left, right) -> left + '|' + right).orElse(""); + String types = risks.stream().map(RiskFindingSnapshot::riskType).distinct() + .reduce((left, right) -> left + "、" + right).orElse(""); + String message = "项目存在 " + risks.size() + " 条有效阻断风险(" + types + ")"; + return new ControlFinding("PROJECT_RISK", "BLOCK", message, sha256(canonical), + json(new ProjectRiskFindingSnapshot(payment.publicId(), risks))); + } + + private boolean withinPercent(BigDecimal difference, BigDecimal amount, int percent) { + return difference.multiply(BigDecimal.valueOf(100)) + .compareTo(amount.multiply(BigDecimal.valueOf(percent))) <= 0; + } + + private String normalizedPurpose(String value) { + String normalized = Normalizer.normalize(value == null ? "" : value, Normalizer.Form.NFKC) + .toUpperCase(Locale.ROOT); + StringBuilder result = new StringBuilder(normalized.length()); + normalized.codePoints().filter(Character::isLetterOrDigit).forEach(result::appendCodePoint); + return result.toString(); + } + + private String controlLabel(String controlCode) { + return "DUPLICATE".equals(controlCode) ? "重复付款" : "项目风险"; + } + + private Check attachmentCheck(PaymentLock payment, PayableRef payable) { + List attachments = mapper.listAttachments(payment.id()); + List exceptions = mapper.listAttachmentExceptions(payment.id()); + if (payment.attachmentRuleSnapshotJson() == null) { + boolean pass = !attachments.isEmpty() + && attachments.stream().allMatch(item -> CLEAN_FILE_STATES.contains(item.scanStatus())); + ErrorCode error = attachments.isEmpty() ? ErrorCode.ATTACHMENT_REQUIRED : ErrorCode.FILE_REJECTED; + String message = attachments.isEmpty() ? "缺少付款附件" : "存在未通过检查的付款附件"; + return check("ATTACHMENTS", pass, "付款附件齐全且已通过文件检查", error, message); + } + + Category category = categorySnapshot(payment.attachmentRuleSnapshotJson()); + if (payable == null || !category.costCategoryCode().equals(payable.costCategoryCode())) { + return check("ATTACHMENTS", false, "", ErrorCode.INVALID_STATE_TRANSITION, + "应付成本类型已变化,请退回并重新编辑付款申请"); + } + + List missing = new ArrayList<>(); + for (Requirement requirement : category.requirements()) { + if (!requirement.required()) continue; + boolean present = attachments.stream() + .anyMatch(item -> requirement.documentType().equals(item.documentType())); + boolean notApplicable = requirement.allowNotApplicable() && exceptions.stream() + .anyMatch(item -> requirement.documentType().equals(item.documentType()) + && "APPROVED".equals(item.status()) && item.consumedAt() == null); + if (!present && !notApplicable) missing.add(requirement.name()); + } + if (!missing.isEmpty()) { + return check("ATTACHMENTS", false, "", ErrorCode.ATTACHMENT_REQUIRED, + category.costCategoryName() + "缺少必需资料:" + String.join("、", missing)); + } + + List unsafe = attachments.stream() + .filter(item -> !CLEAN_FILE_STATES.contains(item.scanStatus())) + .map(item -> requirementName(category, item.documentType())) + .distinct().toList(); + if (!unsafe.isEmpty()) { + return check("ATTACHMENTS", false, "", ErrorCode.FILE_REJECTED, + "以下资料尚未通过文件检查:" + String.join("、", unsafe)); + } + long approvedExceptions = exceptions.stream().filter(item -> "APPROVED".equals(item.status()) + && item.consumedAt() == null).count(); + String exceptionMessage = approvedExceptions == 0 ? "" : ";" + approvedExceptions + " 项不涉及说明已复核"; + return check("ATTACHMENTS", true, + category.costCategoryName() + "必需资料齐全,规则 V" + payment.attachmentRuleVersionNo() + + exceptionMessage, + ErrorCode.ATTACHMENT_REQUIRED, ""); + } + + private String requirementName(Category category, String documentType) { + return category.requirements().stream() + .filter(item -> documentType.equals(item.documentType())) + .map(Requirement::name) + .findFirst().orElse(documentType); + } + + private List attachmentRuleViews() { + AttachmentRuleParameterRow parameter = mapper.findActiveAttachmentRule(); + if (parameter == null) return List.of(); + Matrix matrix = matrix(parameter.valueJson()); + return matrix.categories().stream() + .map(category -> attachmentRuleView(parameter, category)) + .toList(); + } + + private PaymentAttachmentRuleView attachmentRuleView(AttachmentRuleParameterRow parameter, + Category category) { + return new PaymentAttachmentRuleView(parameter.publicId(), parameter.versionNo(), + category.costCategoryCode(), category.costCategoryName(), category.requirements().stream() + .map(item -> new PaymentAttachmentRequirementView(item.documentType(), item.name(), + item.required(), item.allowNotApplicable())) + .toList()); + } + + private AttachmentRuleSelection attachmentRule(PayableRef payable) { + if (payable == null || payable.costCategoryCode() == null) return null; + AttachmentRuleParameterRow parameter = mapper.findActiveAttachmentRule(); + if (parameter == null) return null; + Category category = matrix(parameter.valueJson()).category(payable.costCategoryCode()); + if (category == null) return null; + return new AttachmentRuleSelection(parameter.id(), parameter.publicId(), parameter.versionNo(), + PaymentAttachmentMatrix.categoryJson(objectMapper, category)); + } + + private AttachmentRuleSelection currentAttachmentRule(PayableRef payable, PaymentLock current) { + AttachmentRuleSelection active = attachmentRule(payable); + if (active == null && current.attachmentRuleSnapshotJson() != null) { + throw validation("当前应付已没有生效的附件规则,请重新选择应付或清理规则快照"); + } + return active; + } + + private void validateProjectionCommand(ApprovedOa06PaymentCommand command) { + if (command == null || command.sourceDocumentId() <= 0 || command.sourceVersionId() <= 0 + || command.createdBy() <= 0 || command.approvedBy() <= 0 + || blank(command.sourceDocumentPublicId()) == null || blank(command.companyPublicId()) == null + || blank(command.projectPublicId()) == null || blank(command.contractPublicId()) == null + || blank(command.payablePublicId()) == null || blank(command.supplierPublicId()) == null + || blank(command.bankAccountPublicId()) == null || command.requestedDate() == null + || command.requestedAmount() == null || command.contractAmount() == null + || command.payableAmount() == null || command.paidAmount() == null + || blank(command.contractNo()) == null || blank(command.supplierName()) == null + || blank(command.purpose()) == null || blank(command.opinion()) == null) { + throw validation("OA-06 付款投影参数不完整"); + } + } + + private void validateProjectionSnapshots(ApprovedOa06PaymentCommand command, Refs refs) { + if (refs.payable() == null) throw validation("OA-06 必须关联已确认应付"); + if (!sameText(command.contractNo(), refs.contract().code())) { + throw validation("OA-06 成本合同编号快照与所选生效合同不一致"); + } + if (!sameText(command.supplierName(), refs.supplier().name())) { + throw validation("OA-06 供应商快照与所选生效供应商不一致"); + } + BigDecimal contractLimit = refs.contract().settlementAmount() == null + ? refs.contract().originalAmount().add(refs.contract().approvedChangeAmount()) + : refs.contract().settlementAmount(); + if (command.contractAmount().compareTo(contractLimit) != 0) { + throw validation("OA-06 合同金额快照已变化,请按当前合同重新发起"); + } + if (command.payableAmount().compareTo(refs.payable().amount()) != 0 + || command.paidAmount().compareTo(refs.payable().paidAmount()) != 0) { + throw validation("OA-06 应付金额快照已变化,请按当前应付重新发起"); + } + } + + private List resolveProjectionAttachments(List fileIds) { + if (fileIds == null || fileIds.isEmpty()) return List.of(); + Set unique = new HashSet<>(); + List result = new ArrayList<>(); + for (String value : fileIds) { + String fileId = publicId(value); + if (!unique.add(fileId)) continue; + FileRef file = mapper.findFile(fileId); + if (file == null) throw notFound("OA-06 付款附件不存在"); + if (!CLEAN_FILE_STATES.contains(file.scanStatus())) { + throw fileRejected("OA-06 付款附件尚未通过文件检查"); + } + result.add(new PaymentAttachmentFile(file, "PAYMENT_SUPPORT")); + } + return result; + } + + private boolean sameText(String left, String right) { + return Normalizer.normalize(left, Normalizer.Form.NFKC).strip().equalsIgnoreCase( + Normalizer.normalize(right, Normalizer.Form.NFKC).strip() + ); + } + + private Matrix matrix(String json) { + try { + return PaymentAttachmentMatrix.parse(objectMapper, json); + } catch (IllegalArgumentException exception) { + throw new IllegalStateException("生效的付款附件矩阵无效", exception); + } + } + + private Category categorySnapshot(String json) { + try { + return PaymentAttachmentMatrix.parseCategorySnapshot(objectMapper, json); + } catch (IllegalArgumentException exception) { + throw new IllegalStateException("付款附件规则快照无效", exception); + } + } + + private Refs resolveRefs(String companyId, String projectId, String contractId, String payableId, + String supplierId, String accountId, String sourceId, String currency, + BigDecimal amount, String permission) { + EntityRef company = mapper.findCompany(publicId(companyId)); + if (company == null) throw notFound("公司不存在或未生效"); + ProjectRef project = mapper.findProject(publicId(projectId)); + if (project == null) throw notFound("项目不存在或未生效"); + EntityRef supplier = mapper.findSupplier(publicId(supplierId)); + if (supplier == null) throw notFound("供应商不存在或未生效"); + ContractRef contract = mapper.findContract(publicId(contractId)); + if (contract == null) throw notFound("合同不存在或未生效"); + PayableRef payable = blank(payableId) == null ? null : mapper.findPayable(publicId(payableId)); + if (blank(payableId) != null && payable == null) throw notFound("应付记录不存在或不可付款"); + BankAccountRef account = mapper.findBankAccount(publicId(accountId)); + if (account == null) throw accountInactive("供应商账户不存在、未生效或已过期"); + SourceRef source = blank(sourceId) == null ? null : mapper.findSource(publicId(sourceId)); + if (blank(sourceId) != null && source == null) throw notFound("来源单据不存在"); + if (project.companyId() != company.id()) throw validation("项目不属于所选公司"); + if (contract.companyId() != company.id() || contract.projectId() != project.id()) { + throw validation("合同不属于所选公司和项目"); + } + if (contract.counterpartyId() != supplier.id()) throw validation("合同供应商与所选供应商不一致"); + if (payable != null && (payable.companyId() != company.id() || payable.projectId() != project.id() + || payable.contractId() != contract.id() || payable.supplierId() != supplier.id())) { + throw validation("应付记录与公司、项目、合同或供应商不一致"); + } + if (account.supplierId() != supplier.id()) throw accountInactive("收款账户不属于所选供应商"); + if (source != null) requirePaymentSource(source, company.id(), project.id()); + String effectiveCurrency = blank(currency) == null ? project.currency() : currency; + if (!effectiveCurrency.equals(project.currency()) || !effectiveCurrency.equals(contract.currency())) { + throw validation("付款币种必须与项目和合同币种一致"); + } + if (permission != null) { + authorizationService.requireScope(permission, company.publicId(), project.publicId(), amount); + } + return new Refs(company, project, contract, payable, supplier, account, source); + } + + private void requirePaymentSource(SourceRef source, long companyId, long projectId) { + if (!"OA-06".equals(source.formType())) { + throw validation("付款来源类型不正确,应选择 OA-06 项目进度款支付审批单"); + } + if (!APPROVED_SOURCE_STATES.contains(source.status())) { + throw validation("OA-06 来源单据尚未完成审批"); + } + if (source.currentVersionId() == null || !source.currentVersionApproved()) { + throw validation("OA-06 来源单据当前版本尚未完成审批"); + } + if (source.companyId() != companyId || source.projectId() == null || source.projectId() != projectId) { + throw validation("OA-06 来源单据不属于所选公司和项目"); + } + } + + private boolean validPaymentSource(SourceRef source, long companyId, long projectId) { + return "OA-06".equals(source.formType()) + && APPROVED_SOURCE_STATES.contains(source.status()) + && source.currentVersionId() != null + && source.currentVersionApproved() + && source.companyId() == companyId + && source.projectId() != null + && source.projectId() == projectId; + } + + private List resolveFiles(List fileIds, boolean required, long paymentId, + String companyPublicId, String projectPublicId) { + if (fileIds == null || fileIds.isEmpty()) { + if (required) throw attachmentRequired("必须上传线下付款回单"); + return List.of(); + } + Set unique = new HashSet<>(); + List files = new ArrayList<>(); + for (String value : fileIds) { + String fileId = publicId(value); + if (!unique.add(fileId)) continue; + PaymentFileRef scoped = mapper.findFileForPayment(fileId, actor().publicId(), paymentId, + companyPublicId, projectPublicId); + FileRef file = scoped == null ? null + : new FileRef(scoped.id(), scoped.publicId(), scoped.originalName(), scoped.scanStatus()); + if (file == null) throw notFound("附件文件不存在"); + if (!scoped.uploadedByActor() && !scoped.attachedToPayment() && !scoped.linkedToTarget()) { + throw new BusinessException(HttpStatus.FORBIDDEN, ErrorCode.DATA_SCOPE_DENIED, + "附件不属于当前账号或目标公司/项目"); + } + if ("REJECTED".equals(file.scanStatus())) throw fileRejected("附件未通过文件检查"); + if (required && !CLEAN_FILE_STATES.contains(file.scanStatus())) { + throw fileRejected("附件尚未通过文件检查"); + } + files.add(file); + } + if (required && files.isEmpty()) throw attachmentRequired("必须上传线下付款回单"); + return files; + } + + private List resolvePaymentAttachments(List legacyFileIds, + List attachments, + AttachmentRuleSelection attachmentRule, + long paymentId, + String companyPublicId, + String projectPublicId) { + boolean hasLegacy = legacyFileIds != null && !legacyFileIds.isEmpty(); + boolean hasTyped = attachments != null && !attachments.isEmpty(); + if (hasLegacy && hasTyped) throw validation("attachmentFileIds 与 attachments 不能同时提交"); + List result = new ArrayList<>(); + Set unique = new HashSet<>(); + Set allowedTypes = attachmentRule == null ? Set.of() + : categorySnapshot(attachmentRule.snapshotJson()).requirements().stream() + .map(Requirement::documentType).collect(java.util.stream.Collectors.toSet()); + if (hasTyped) { + for (PaymentAttachmentInput input : attachments) { + String documentType = documentType(input.documentType()); + String fileId = publicId(input.fileId()); + if (attachmentRule != null && !allowedTypes.contains(documentType)) { + throw validation("资料类型不属于当前应付的附件规则: " + documentType); + } + if (!unique.add(fileId)) throw validation("同一文件不能重复用于多个资料类型"); + result.add(new PaymentAttachmentFile( + resolvePaymentFile(fileId, paymentId, companyPublicId, projectPublicId), documentType)); + } + return result; + } + if (hasLegacy) { + for (String value : legacyFileIds) { + String fileId = publicId(value); + if (!unique.add(fileId)) continue; + result.add(new PaymentAttachmentFile( + resolvePaymentFile(fileId, paymentId, companyPublicId, projectPublicId), "PAYMENT_SUPPORT")); + } + } + return result; + } + + private List resolveAttachmentExceptions( + List inputs, + AttachmentRuleSelection attachmentRule, + List files + ) { + if (inputs == null || inputs.isEmpty()) return List.of(); + if (attachmentRule == null) throw validation("通用附件模式不支持标记资料不涉及"); + Category category = categorySnapshot(attachmentRule.snapshotJson()); + Set fileTypes = files.stream().map(PaymentAttachmentFile::documentType) + .collect(java.util.stream.Collectors.toSet()); + Set unique = new HashSet<>(); + List result = new ArrayList<>(); + for (PaymentAttachmentExceptionInput input : inputs) { + String type = documentType(input.documentType()); + Requirement requirement = category.requirements().stream() + .filter(item -> type.equals(item.documentType())).findFirst() + .orElseThrow(() -> validation("资料类型不属于当前应付的附件规则: " + type)); + if (!requirement.allowNotApplicable()) { + throw validation(requirement.name() + "不允许标记为不涉及"); + } + if (fileTypes.contains(type)) throw validation(requirement.name() + "不能同时上传文件并标记不涉及"); + if (!unique.add(type)) throw validation("同一资料类型不能重复申请不涉及"); + result.add(new AttachmentException(type, requirement.name(), cleanRequired(input.reason(), 500))); + } + return result; + } + + private FileRef resolvePaymentFile(String fileId, long paymentId, String companyPublicId, + String projectPublicId) { + PaymentFileRef scoped = mapper.findFileForPayment(fileId, actor().publicId(), paymentId, + companyPublicId, projectPublicId); + FileRef file = scoped == null ? null + : new FileRef(scoped.id(), scoped.publicId(), scoped.originalName(), scoped.scanStatus()); + if (file == null) throw notFound("附件文件不存在"); + if (!scoped.uploadedByActor() && !scoped.attachedToPayment() && !scoped.linkedToTarget()) { + throw new BusinessException(HttpStatus.FORBIDDEN, ErrorCode.DATA_SCOPE_DENIED, + "附件不属于当前账号或目标公司/项目"); + } + if ("REJECTED".equals(file.scanStatus())) throw fileRejected("附件未通过文件检查"); + return file; + } + + private void replaceAttachments(long paymentId, List files, long actorId) { + mapper.deleteAttachments(paymentId); + for (PaymentAttachmentFile file : files) { + mapper.insertAttachment(ulidGenerator.next(), paymentId, file.file().id(), + file.documentType(), actorId); + } + } + + private void replaceAttachmentExceptions(long paymentId, List exceptions, long actorId) { + mapper.deactivateAttachmentExceptions(paymentId); + for (AttachmentException exception : exceptions) { + int versionNo = mapper.nextAttachmentExceptionVersion(paymentId, exception.documentType()); + mapper.insertAttachmentException(ulidGenerator.next(), paymentId, exception.documentType(), + exception.documentName(), versionNo, exception.reason(), actorId); + } + } + + private PaymentRow payment(String publicId) { + Actor actor = actor(); + PaymentRow row = mapper.findPayment(actor.userId(), actor.roleCode(), publicId(publicId)); + if (row == null) throw notFound("付款申请不存在或不在当前数据范围"); + return row; + } + + private PaymentLock lock(long id, long version) { + PaymentLock row = mapper.lockPayment(id); + if (row == null) throw notFound("付款申请不存在"); + if (row.version() != version) throw conflict(); + return row; + } + + private FundControlRow lockFundControl(long projectId, String currency) { + FundControlRow fund = mapper.lockFundControl(projectId, currency); + if (fund != null) return fund; + mapper.ensureFundControl(projectId, currency); + fund = mapper.lockFundControl(projectId, currency); + if (fund == null) throw new IllegalStateException("Project fund control was not created"); + return fund; + } + + private PaymentDetailView detail(PaymentRow row) { + return new PaymentDetailView(paymentView(row), + mapper.listChecks(row.id()).stream().map(this::checkView).toList(), + mapper.listResults(row.id()).stream().map(this::resultView).toList(), + mapper.listAttachments(row.id()).stream().map(this::attachmentView).toList(), + mapper.listAttachmentExceptions(row.id()).stream() + .map(item -> attachmentExceptionView(item, row)).toList(), + mapper.listControlExceptions(row.id()).stream() + .map(item -> controlExceptionView(item, row)).toList()); + } + + private PaymentView paymentView(PaymentRow row) { + Actor actor = actor(); + List actions = new ArrayList<>(); + boolean ownerOrFinance = row.createdBy() == actor.userId() || "FINANCE_MANAGER".equals(actor.roleCode()) + || "SYSTEM_ADMIN".equals(actor.roleCode()); + boolean canCreate = canAct("payment:request:create", row, row.requestedAmount()); + boolean canApprove = canAct("payment:request:approve", row, row.requestedAmount()); + boolean canReview = canAct("payment:request:review", row, row.requestedAmount()); + boolean canRecordResult = canAct("payment:request:record-result", row, null); + boolean canVerifyResult = canAct("payment:request:verify-result", row, null); + boolean canRefund = canAct("payment:request:refund", row, null); + if (Set.of("DRAFT", "RETURNED").contains(row.status()) && ownerOrFinance + && canCreate) { + actions.add("EDIT"); + actions.add("SUBMIT"); + actions.add("VOID"); + } + if ("REVIEWING".equals(row.status())) { + if (canCreate && (row.createdBy() == actor.userId() || identityContext.isSystemAdministrator())) { + actions.add("REQUEST_CONTROL_EXCEPTION"); + } + if (canApprove && (row.submittedBy() == null || row.submittedBy() != actor.userId())) { + actions.add("APPROVE"); + } + if (canReview && (row.submittedBy() == null || row.submittedBy() != actor.userId())) { + actions.add("RETURN"); + } + } + if (Set.of("OFFLINE_PENDING", "PART_PAID").contains(row.status()) + && canRecordResult) { + actions.add("RECORD_SUCCESS"); + actions.add("RECORD_FAILURE"); + } + if ("RESULT_RECORDED".equals(row.status()) + && canVerifyResult + && (row.latestResultRecordedBy() == null || row.latestResultRecordedBy() != actor.userId())) { + actions.add("VERIFY_RESULT"); + actions.add("RETURN_RESULT"); + } + if (Set.of("OFFLINE_PENDING", "PART_PAID", "PAID").contains(row.status()) && canRefund) { + actions.add("RECORD_REFUND"); + } + if (Set.of("FAILED", "REFUNDED").contains(row.status()) && ownerOrFinance + && canCreate) { + actions.add("CREATE_REPAYMENT"); + } + return new PaymentView(row.publicId(), row.businessNo(), + ref(row.companyPublicId(), row.companyCode(), row.companyName()), + ref(row.projectPublicId(), row.projectCode(), row.projectName()), + ref(row.contractPublicId(), row.contractCode(), row.contractName()), + ref(row.payablePublicId(), row.payableCode(), row.payableCode()), + ref(row.supplierPublicId(), row.supplierCode(), row.supplierName()), + new BankAccountReferenceView(row.accountPublicId(), row.supplierPublicId(), row.accountName(), + row.bankName(), row.maskedAccountNo(), row.accountVersionNo()), + row.sourceDocumentPublicId(), row.originalPaymentPublicId(), row.repaymentSequence(), + moneyText(row.requestedAmount()), moneyText(row.approvedAmount()), moneyText(row.frozenAmount()), + moneyText(row.paidAmount()), + row.currency(), row.requestedDate(), row.purpose(), row.status(), row.submittedByName(), + row.approvedByName(), row.resultRecordedByName(), row.resultVerifiedByName(), row.version(), + row.createdAt(), row.updatedAt(), actions); + } + + private PaymentCheckView checkView(CheckRow row) { + return new PaymentCheckView(row.publicId(), row.checkCode(), row.result(), row.message(), row.checkedAt()); + } + + private PaymentResultView resultView(ResultRow row) { + return new PaymentResultView(row.publicId(), row.resultNo(), row.resultType(), row.paidDate(), + moneyText(row.amount()), row.failureReason(), row.remark(), row.recordedByName(), row.recordedAt(), + row.verifiedByName(), row.verifiedAt(), row.verificationOpinion(), row.recordedFromStatus(), + row.returnedByName(), row.returnedAt(), row.returnOpinion(), row.status(), row.version(), + mapper.listResultFileIds(row.id())); + } + + private PaymentAttachmentView attachmentView(AttachmentRow row) { + return new PaymentAttachmentView(row.publicId(), row.filePublicId(), row.originalName(), + row.documentType(), row.scanStatus(), row.createdAt()); + } + + private PaymentAttachmentExceptionView attachmentExceptionView(AttachmentExceptionRow row, PaymentRow payment) { + Actor actor = actor(); + boolean canReview = "REVIEWING".equals(payment.status()) && "PENDING".equals(row.status()) + && row.requestedBy() != actor.userId() + && canAct("payment:request:review", payment, payment.requestedAmount()); + return new PaymentAttachmentExceptionView(row.publicId(), row.documentType(), row.documentName(), row.version(), row.reason(), row.status(), + row.requestedByName(), row.requestedAt(), row.reviewedByName(), row.reviewedAt(), + row.reviewOpinion(), row.consumedAt(), canReview ? List.of("REVIEW") : List.of()); + } + + private PaymentControlExceptionView controlExceptionView(ControlExceptionRow row, PaymentRow payment) { + Actor actor = actor(); + boolean canReview = "REVIEWING".equals(payment.status()) && "PENDING".equals(row.status()) + && row.requestedBy() != actor.userId() + && canAct("payment:request:review", payment, payment.requestedAmount()); + return new PaymentControlExceptionView( + row.publicId(), row.controlCode(), row.findingSummary(), row.version(), row.reason(), + row.validUntil(), row.status(), row.requestedByName(), row.requestedAt(), + row.reviewedByName(), row.reviewedAt(), row.reviewOpinion(), row.consumedAt(), + canReview ? List.of("REVIEW") : List.of()); + } + + private ReferenceView reference(PaymentMapper.ReferenceRow row) { + return ref(row.publicId(), row.code(), row.name()); + } + + private PayableReferenceView payableOption(PayableOptionRow row) { + return new PayableReferenceView(row.publicId(), row.businessNo(), row.projectPublicId(), + row.contractPublicId(), row.supplierPublicId(), row.costCategoryCode(), row.costCategoryName(), + moneyText(row.amount()), + moneyText(row.paidAmount()), moneyText(row.remainingAmount())); + } + + private ContractReferenceView contractOption(PaymentMapper.ContractOptionRow row) { + return new ContractReferenceView(row.publicId(), row.code(), row.name(), row.companyPublicId(), + row.projectPublicId(), row.supplierPublicId()); + } + + private BankAccountReferenceView bankAccountOption(BankAccountOptionRow row) { + return new BankAccountReferenceView(row.publicId(), row.supplierPublicId(), row.accountName(), + row.bankName(), row.maskedAccountNo(), row.versionNo()); + } + + private ReferenceView ref(String publicId, String code, String name) { + return publicId == null ? null : new ReferenceView(publicId, code, name); + } + + private boolean isActiveAccount(BankAccountRef account, long supplierId) { + LocalDateTime now = LocalDateTime.now(); + return account != null && account.supplierId() == supplierId && "ACTIVE".equals(account.status()) + && !account.validFrom().isAfter(now) && (account.validTo() == null || !account.validTo().isBefore(now)); + } + + private void requireDraftOwner(PaymentLock payment, Actor actor) { + if (!Set.of("DRAFT", "RETURNED").contains(payment.status())) throw transition("只有草稿或退回申请可以修改"); + requireDraftOwnerOrFinance(payment, actor); + } + + private void requireDraftOwnerOrFinance(PaymentLock payment, Actor actor) { + if (payment.createdBy() != actor.userId() && !"FINANCE_MANAGER".equals(actor.roleCode()) + && !"SYSTEM_ADMIN".equals(actor.roleCode())) { + throw new BusinessException(HttpStatus.FORBIDDEN, ErrorCode.PERMISSION_DENIED, "只能修改本人付款申请"); + } + } + + private boolean canAct(String permission, PaymentRow payment, BigDecimal amount) { + return authorizationService.hasScope(permission, payment.companyPublicId(), payment.projectPublicId(), amount); + } + + private void requireStatus(String actual, String expected, String message) { + if (!expected.equals(actual)) throw transition(message); + } + + private void requireStatus(String status) { + if (blank(status) != null && !STATUSES.contains(status)) throw validation("付款状态筛选值无效"); + } + + private void requireTab(String tab) { + if (!TABS.contains(tab)) throw validation("付款页签参数无效"); + if ("all".equals(tab) && !identityContext.isSystemAdministrator()) { + throw new BusinessException(HttpStatus.FORBIDDEN, ErrorCode.PERMISSION_DENIED, + "只有超级管理员可以查看全部付款记录"); + } + } + + private void requireOptionalId(String value) { + if (blank(value) != null) publicId(value); + } + + private Actor actor() { + FinancePrincipal principal = identityContext.requirePrincipal(); + return new Actor(principal.userId(), principal.publicId(), identityContext.requireActiveRole()); + } + + private Page page(int page, int size) { + if (page < 1 || !Set.of(20, 50, 100).contains(size)) throw validation("分页参数无效"); + return new Page(page, size); + } + + private BigDecimal money(BigDecimal value) { + if (value == null || value.signum() <= 0) throw validation("金额必须大于零"); + try { + return value.setScale(2, RoundingMode.UNNECESSARY); + } catch (ArithmeticException exception) { + throw validation("金额最多保留两位小数"); + } + } + + private BigDecimal zero(BigDecimal value) { + return value == null ? BigDecimal.ZERO.setScale(2) : value; + } + + private String moneyText(BigDecimal value) { + return value == null ? null : value.setScale(2).toPlainString(); + } + + private String clean(String value, int max) { + if (value == null || value.isBlank()) return null; + String result = value.trim(); + if (result.length() > max) throw validation("文本长度超过限制"); + return result; + } + + private String cleanRequired(String value, int max) { + String result = clean(value, max); + if (result == null) throw validation("必填文本不能为空"); + return result; + } + + private String blank(String value) { + return value == null || value.isBlank() ? null : value.trim(); + } + + private String publicId(String value) { + if (value == null || !PUBLIC_ID.matcher(value.trim()).matches()) throw validation("公开编号格式无效"); + return value.trim(); + } + + private String documentType(String value) { + String result = value == null ? "" : value.trim(); + if (!DOCUMENT_TYPE.matcher(result).matches()) throw validation("付款资料类型格式无效"); + return result; + } + + private String sourceEventKey(SourceRef source, String paymentPublicId) { + return sha256(source == null ? "PAYMENT_REQUEST|MANUAL|" + paymentPublicId + : "PAYMENT_REQUEST|SOURCE|" + source.publicId()); + } + + private String sha256(String value) { + try { + byte[] hash = MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8)); + return java.util.HexFormat.of().formatHex(hash); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 is unavailable", exception); + } + } + + private String json(Object value) { + try { + return objectMapper.writeValueAsString(value); + } catch (JsonProcessingException exception) { + throw new IllegalStateException("Payment checks cannot be serialized", exception); + } + } + + private String orderBy(String sort) { + if (sort == null || sort.isBlank()) return "payment.updated_at DESC"; + String[] parts = sort.trim().split(",", 2); + String column = switch (parts[0]) { + case "submittedAt" -> "payment.submitted_at"; + case "amount" -> "payment.requested_amount"; + case "dueAt", "updatedAt" -> "payment.updated_at"; + default -> throw validation("不支持的付款排序字段"); + }; + String direction = parts.length == 2 && "asc".equalsIgnoreCase(parts[1]) ? "ASC" + : parts.length == 2 && "desc".equalsIgnoreCase(parts[1]) ? "DESC" : null; + if (direction == null) throw validation("排序方向必须为 asc 或 desc"); + return column + " " + direction; + } + + private String businessNo(String prefix, String publicId) { + return prefix + "-" + publicId; + } + + private Long id(PayableRef value) { + return value == null ? null : value.id(); + } + + private Long id(SourceRef value) { + return value == null ? null : value.id(); + } + + private Long sourceVersionId(SourceRef value) { + return value == null ? null : value.currentVersionId(); + } + + private Long id(AttachmentRuleSelection value) { + return value == null ? null : value.parameterId(); + } + + private Integer version(AttachmentRuleSelection value) { + return value == null ? null : value.versionNo(); + } + + private String snapshot(AttachmentRuleSelection value) { + return value == null ? null : value.snapshotJson(); + } + + private String name(ReferenceView value) { + return value == null ? "" : value.name(); + } + + private String csv(String value) { + if (value == null) return ""; + String safe = value.matches("^[=+@].*") ? "'" + value : value; + return "\"" + safe.replace("\"", "\"\"") + "\""; + } + + private BusinessException validation(String message) { + return new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.VALIDATION_FAILED, message); + } + + private BusinessException notFound(String message) { + return new BusinessException(HttpStatus.NOT_FOUND, ErrorCode.RESOURCE_NOT_FOUND, message); + } + + private BusinessException duplicate(String message) { + return new BusinessException(HttpStatus.CONFLICT, ErrorCode.DUPLICATE_SOURCE, message); + } + + private BusinessException conflict() { + return new BusinessException(HttpStatus.CONFLICT, ErrorCode.VERSION_CONFLICT, + "记录已被其他人更新,请刷新后重试"); + } + + private BusinessException transition(String message) { + return new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.INVALID_STATE_TRANSITION, message); + } + + private BusinessException sod(String message) { + return new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.SOD_VIOLATION, message); + } + + private BusinessException funds(String message) { + return new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.FUNDS_INSUFFICIENT, message); + } + + private BusinessException payableLimit(String message) { + return new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.PAYABLE_LIMIT_EXCEEDED, message); + } + + private BusinessException accountInactive(String message) { + return new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.ACCOUNT_NOT_ACTIVE, message); + } + + private BusinessException attachmentRequired(String message) { + return new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.ATTACHMENT_REQUIRED, message); + } + + private BusinessException fileRejected(String message) { + return new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.FILE_REJECTED, message); + } + + public record ApprovedOa06PaymentCommand( + long sourceDocumentId, + String sourceDocumentPublicId, + long sourceVersionId, + String companyPublicId, + String projectPublicId, + String contractPublicId, + String payablePublicId, + String supplierPublicId, + String bankAccountPublicId, + BigDecimal contractAmount, + BigDecimal payableAmount, + BigDecimal paidAmount, + BigDecimal requestedAmount, + LocalDate requestedDate, + String contractNo, + String supplierName, + String purpose, + List attachmentFileIds, + long createdBy, + long approvedBy, + String opinion + ) { + } + + private record Actor(long userId, String publicId, String roleCode) { + } + + private record Page(int page, int size) { + int offset() { + return Math.toIntExact(Math.min((long) (page - 1) * size, Integer.MAX_VALUE)); + } + } + + private record Refs(EntityRef company, ProjectRef project, ContractRef contract, + PayableRef payable, EntityRef supplier, BankAccountRef account, SourceRef source) { + } + + private record AttachmentRuleSelection(long parameterId, String parameterPublicId, int versionNo, + String snapshotJson) { + } + + private record PaymentAttachmentFile(FileRef file, String documentType) { + } + + private record AttachmentException(String documentType, String documentName, String reason) { + } + + private record Check(String code, String result, String message, ErrorCode errorCode, + Long controlExceptionId, String controlExceptionPublicId) { + boolean blocked() { + return "BLOCK".equals(result); + } + + BusinessException error() { + HttpStatus status = errorCode == ErrorCode.DUPLICATE_SOURCE ? HttpStatus.CONFLICT + : HttpStatus.UNPROCESSABLE_ENTITY; + return new BusinessException(status, errorCode, message); + } + } + + private record ControlFinding(String code, String result, String message, + String fingerprint, String snapshotJson) { + boolean blocked() { + return "BLOCK".equals(result); + } + } + + private record DuplicateCandidateSnapshot(String publicId, String amount, + LocalDate requestedDate, String purpose, + String status, int level) { + } + + private record DuplicateFindingSnapshot(String paymentPublicId, String level, + List candidates) { + } + + private record RiskFindingSnapshot(String publicId, String riskType, String reason, + LocalDateTime effectiveFrom, LocalDateTime effectiveUntil) { + } + + private record ProjectRiskFindingSnapshot(String paymentPublicId, + List risks) { + } + + private record ExportSummary(int count) { + } + + private record ExportScope(String companyPublicId, String projectPublicId, int count) { + } +} diff --git a/backend/src/main/java/com/kaidi/finance/payment/domain/PaymentAttachmentMatrix.java b/backend/src/main/java/com/kaidi/finance/payment/domain/PaymentAttachmentMatrix.java new file mode 100644 index 0000000..cd551e4 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/payment/domain/PaymentAttachmentMatrix.java @@ -0,0 +1,125 @@ +package com.kaidi.finance.payment.domain; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.regex.Pattern; + +public final class PaymentAttachmentMatrix { + + public static final String PARAMETER_GROUP = "PAYMENT_ATTACHMENT_MATRIX"; + private static final Pattern CODE = Pattern.compile("[A-Z][A-Z0-9_]{1,63}"); + + private PaymentAttachmentMatrix() { + } + + public static Matrix parse(ObjectMapper objectMapper, String json) { + JsonNode root = read(objectMapper, json); + if (!root.isObject() || root.path("schemaVersion").asInt(-1) != 1) { + throw invalid("schemaVersion 必须为 1"); + } + JsonNode categoryNodes = root.path("categories"); + if (!categoryNodes.isArray() || categoryNodes.isEmpty() || categoryNodes.size() > 100) { + throw invalid("categories 必须包含 1 至 100 个成本分类"); + } + List categories = new ArrayList<>(); + Set categoryCodes = new HashSet<>(); + for (JsonNode node : categoryNodes) { + Category category = category(node); + if (!categoryCodes.add(category.costCategoryCode())) { + throw invalid("成本分类代码重复: " + category.costCategoryCode()); + } + categories.add(category); + } + return new Matrix(1, List.copyOf(categories)); + } + + public static Category parseCategorySnapshot(ObjectMapper objectMapper, String json) { + return category(read(objectMapper, json)); + } + + public static String categoryJson(ObjectMapper objectMapper, Category category) { + try { + return objectMapper.writeValueAsString(category); + } catch (JsonProcessingException exception) { + throw new IllegalArgumentException("付款附件规则快照无法序列化", exception); + } + } + + private static Category category(JsonNode node) { + if (!node.isObject()) throw invalid("成本分类规则必须是 JSON 对象"); + String categoryCode = code(node, "costCategoryCode"); + String categoryName = text(node, "costCategoryName", 128); + JsonNode requirementNodes = node.path("requirements"); + if (!requirementNodes.isArray() || requirementNodes.isEmpty() || requirementNodes.size() > 100) { + throw invalid(categoryCode + " 的 requirements 必须包含 1 至 100 项"); + } + List requirements = new ArrayList<>(); + Set documentTypes = new HashSet<>(); + for (JsonNode requirementNode : requirementNodes) { + if (!requirementNode.isObject()) throw invalid(categoryCode + " 的附件要求必须是 JSON 对象"); + String documentType = code(requirementNode, "documentType"); + if (!documentTypes.add(documentType)) { + throw invalid(categoryCode + " 的资料类型重复: " + documentType); + } + JsonNode required = requirementNode.get("required"); + JsonNode allowNotApplicable = requirementNode.get("allowNotApplicable"); + if (required == null || !required.isBoolean() + || allowNotApplicable == null || !allowNotApplicable.isBoolean()) { + throw invalid(categoryCode + "/" + documentType + " 的 required 和 allowNotApplicable 必须为布尔值"); + } + requirements.add(new Requirement(documentType, text(requirementNode, "name", 128), + required.booleanValue(), allowNotApplicable.booleanValue())); + } + return new Category(categoryCode, categoryName, List.copyOf(requirements)); + } + + private static JsonNode read(ObjectMapper objectMapper, String json) { + try { + JsonNode node = objectMapper.readTree(json); + if (node == null) throw invalid("规则 JSON 不能为空"); + return node; + } catch (JsonProcessingException exception) { + throw new IllegalArgumentException("付款附件矩阵不是合法 JSON", exception); + } + } + + private static String code(JsonNode node, String field) { + String value = text(node, field, 64); + if (!CODE.matcher(value).matches()) throw invalid(field + " 格式无效: " + value); + return value; + } + + private static String text(JsonNode node, String field, int maxLength) { + JsonNode value = node.get(field); + if (value == null || !value.isTextual()) throw invalid(field + " 必须是字符串"); + String text = value.textValue().trim(); + if (text.isEmpty() || text.length() > maxLength) { + throw invalid(field + " 长度必须为 1 至 " + maxLength); + } + return text; + } + + private static IllegalArgumentException invalid(String message) { + return new IllegalArgumentException("付款附件矩阵无效: " + message); + } + + public record Matrix(int schemaVersion, List categories) { + public Category category(String code) { + if (code == null) return null; + return categories.stream().filter(item -> code.equals(item.costCategoryCode())).findFirst().orElse(null); + } + } + + public record Category(String costCategoryCode, String costCategoryName, + List requirements) { + } + + public record Requirement(String documentType, String name, boolean required, + boolean allowNotApplicable) { + } +} diff --git a/backend/src/main/java/com/kaidi/finance/payment/infrastructure/PaymentMapper.java b/backend/src/main/java/com/kaidi/finance/payment/infrastructure/PaymentMapper.java new file mode 100644 index 0000000..1313c51 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/payment/infrastructure/PaymentMapper.java @@ -0,0 +1,1431 @@ +package com.kaidi.finance.payment.infrastructure; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import org.apache.ibatis.annotations.Delete; +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +@org.apache.ibatis.annotations.Mapper +public interface PaymentMapper { + + String PAYMENT_SCOPE = """ + EXISTS ( + SELECT 1 FROM iam_scope scope + JOIN iam_role role ON role.id = scope.role_id + JOIN iam_permission permission ON permission.id = scope.permission_id + WHERE scope.user_id = #{userId} AND role.code = #{roleCode} + AND permission.code = 'payment:request:view' + AND scope.status = 'ACTIVE' + AND scope.valid_from <= UTC_TIMESTAMP(3) + AND (scope.valid_to IS NULL OR scope.valid_to >= UTC_TIMESTAMP(3)) + AND ( + scope.scope_type = 'GLOBAL' + OR (scope.scope_type = 'COMPANY' AND scope.company_public_id = company.public_id) + OR (scope.scope_type = 'PROJECT' AND scope.project_public_id = project.public_id) + ) + ) + """; + + String PAYMENT_SELECT = """ + SELECT payment.id, payment.public_id, payment.business_no, + company.public_id AS company_public_id, company.business_no AS company_code, + company.name AS company_name, + payment.project_id, project.public_id AS project_public_id, project.business_no AS project_code, + project.name AS project_name, + contract.public_id AS contract_public_id, contract.business_no AS contract_code, + contract.name AS contract_name, + payable.public_id AS payable_public_id, payable.business_no AS payable_code, + supplier.public_id AS supplier_public_id, supplier.business_no AS supplier_code, + supplier.name AS supplier_name, + account.public_id AS account_public_id, account.account_name, + account.bank_name, account.masked_account_no, account.version_no AS account_version_no, + source.public_id AS source_document_public_id, + original.public_id AS original_payment_public_id, + payment.repayment_sequence, payment.requested_amount, payment.approved_amount, + payment.frozen_amount, payment.paid_amount, payment.currency, payment.requested_date, payment.purpose, + payment.status, payment.version, payment.created_by, payment.submitted_by, + submitter.display_name AS submitted_by_name, + approver.display_name AS approved_by_name, + latest_result.recorded_by AS latest_result_recorded_by, + recorder.display_name AS result_recorded_by_name, + verifier.display_name AS result_verified_by_name, + payment.created_at, payment.updated_at + FROM fin_payment_request payment + JOIN md_company company ON company.id = payment.company_id + JOIN md_project project ON project.id = payment.project_id + JOIN md_contract contract ON contract.id = payment.contract_id + LEFT JOIN fin_payable payable ON payable.id = payment.payable_id + JOIN md_counterparty supplier ON supplier.id = payment.supplier_id + JOIN md_bank_account_version account ON account.id = payment.bank_account_version_id + LEFT JOIN src_source_document source ON source.id = payment.source_document_id + LEFT JOIN fin_payment_request original ON original.id = payment.original_payment_id + LEFT JOIN iam_user submitter ON submitter.id = payment.submitted_by + LEFT JOIN iam_user approver ON approver.id = payment.approved_by + LEFT JOIN fin_payment_result latest_result ON latest_result.id = ( + SELECT MAX(result_item.id) FROM fin_payment_result result_item + WHERE result_item.payment_id = payment.id + ) + LEFT JOIN iam_user recorder ON recorder.id = latest_result.recorded_by + LEFT JOIN iam_user verifier ON verifier.id = latest_result.verified_by + """; + + String PAYMENT_FILTERS = """ + AND payment.created_by = #{userId} + AND payment.status = 'REVIEWING' + AND payment.status = 'OFFLINE_PENDING' + AND payment.status = 'RESULT_RECORDED' + AND payment.status IN ('PAID', 'PART_PAID') + AND payment.status IN ('FAILED', 'REFUNDED') + AND company.public_id = #{companyId} + AND project.public_id = #{projectId} + AND supplier.public_id = #{supplierId} + AND payment.status = #{status} + + AND (payment.business_no LIKE CONCAT('%', #{keyword}, '%') + OR project.name LIKE CONCAT('%', #{keyword}, '%') + OR contract.business_no LIKE CONCAT('%', #{keyword}, '%') + OR supplier.name LIKE CONCAT('%', #{keyword}, '%') + OR payment.purpose LIKE CONCAT('%', #{keyword}, '%')) + + + AND EXISTS ( + SELECT 1 FROM project_risk_flag risk + WHERE risk.project_id = payment.project_id AND risk.status = 'ACTIVE' + AND risk.risk_type = #{riskCode} AND risk.blocking = TRUE + AND risk.effective_from <= UTC_TIMESTAMP(3) + AND (risk.effective_until IS NULL OR risk.effective_until >= UTC_TIMESTAMP(3)) + ) + + AND DATE(payment.submitted_at) >= #{dateFrom} + AND DATE(payment.submitted_at) <= #{dateTo} + """; + + @Select(""" + + """) + List listPayments(@Param("userId") long userId, + @Param("roleCode") String roleCode, + @Param("tab") String tab, + @Param("companyId") String companyId, + @Param("projectId") String projectId, + @Param("supplierId") String supplierId, + @Param("status") String status, + @Param("keyword") String keyword, + @Param("riskCode") String riskCode, + @Param("dateFrom") LocalDate dateFrom, + @Param("dateTo") LocalDate dateTo, + @Param("orderBy") String orderBy, + @Param("limit") int limit, + @Param("offset") int offset); + + @Select(""" + + """) + long countPayments(@Param("userId") long userId, + @Param("roleCode") String roleCode, + @Param("tab") String tab, + @Param("companyId") String companyId, + @Param("projectId") String projectId, + @Param("supplierId") String supplierId, + @Param("status") String status, + @Param("keyword") String keyword, + @Param("riskCode") String riskCode, + @Param("dateFrom") LocalDate dateFrom, + @Param("dateTo") LocalDate dateTo); + + @Select(""" + + """) + PaymentRow findPayment(@Param("userId") long userId, + @Param("roleCode") String roleCode, + @Param("publicId") String publicId); + + @Select(""" + SELECT id, public_id, company_id, project_id, contract_id, payable_id, supplier_id, + bank_account_version_id, source_document_id, source_version_id, attachment_rule_parameter_id, + attachment_rule_version_no, CAST(attachment_rule_snapshot_json AS CHAR) AS attachment_rule_snapshot_json, + original_payment_id, + repayment_sequence, requested_amount, approved_amount, frozen_amount, paid_amount, + currency, requested_date, purpose, status, submitted_by, approved_by, + version, created_by + FROM fin_payment_request WHERE id = #{id} FOR UPDATE + """) + PaymentLock lockPayment(long id); + + @Select(""" + SELECT id, public_id, business_no, source_document_id, source_version_id, status + FROM fin_payment_request + WHERE source_document_id = #{sourceDocumentId} + FOR SHARE + """) + PaymentProjectionRow findPaymentProjectionBySourceDocument(long sourceDocumentId); + + @Select("SELECT id, public_id, business_no AS code, name FROM md_company WHERE public_id = #{publicId} AND status = 'ACTIVE'") + EntityRef findCompany(String publicId); + + @Select(""" + SELECT id, public_id, business_no AS code, name, company_id, currency, status + FROM md_project WHERE public_id = #{publicId} AND status = 'ACTIVE' + """) + ProjectRef findProject(String publicId); + + @Select(""" + SELECT id, public_id, business_no AS code, name, company_id, project_id, counterparty_id, + original_amount, approved_change_amount, settlement_amount, currency, status + FROM md_contract WHERE public_id = #{publicId} AND status = 'ACTIVE' + """) + ContractRef findContract(String publicId); + + @Select(""" + SELECT id, public_id, business_no AS code, name + FROM md_counterparty WHERE public_id = #{publicId} AND status = 'ACTIVE' + """) + EntityRef findSupplier(String publicId); + + @Select(""" + SELECT payable.id, payable.public_id, payable.business_no, payable.company_id, payable.project_id, + payable.contract_id, payable.counterparty_id AS supplier_id, payable.cost_category_id, + category.code AS cost_category_code, category.name AS cost_category_name, + payable.amount, payable.paid_amount, payable.status + FROM fin_payable payable + LEFT JOIN md_cost_category category ON category.id = payable.cost_category_id + WHERE payable.public_id = #{publicId} AND payable.status IN ('CONFIRMED', 'PART_PAID') + """) + PayableRef findPayable(String publicId); + + @Select(""" + SELECT id, public_id, counterparty_id AS supplier_id, account_name, bank_name, + masked_account_no, version_no, status, valid_from, valid_to + FROM md_bank_account_version + WHERE public_id = #{publicId} AND status = 'ACTIVE' + AND valid_from <= UTC_TIMESTAMP(3) + AND (valid_to IS NULL OR valid_to >= UTC_TIMESTAMP(3)) + """) + BankAccountRef findBankAccount(String publicId); + + @Select(""" + SELECT id, public_id, counterparty_id AS supplier_id, account_name, bank_name, + masked_account_no, version_no, status, valid_from, valid_to + FROM md_bank_account_version WHERE id = #{id} + """) + BankAccountRef findBankAccountById(long id); + + @Select(""" + SELECT id, public_id, counterparty_id AS supplier_id, account_name, bank_name, + masked_account_no, version_no, status, valid_from, valid_to + FROM md_bank_account_version WHERE id = #{id} FOR UPDATE + """) + BankAccountRef lockBankAccountById(long id); + + @Select(""" + SELECT id, public_id, business_no AS code, name, company_id, project_id, counterparty_id, + original_amount, approved_change_amount, settlement_amount, currency, status + FROM md_contract WHERE id = #{id} FOR UPDATE + """) + ContractRef lockContract(long id); + + @Select(""" + SELECT payable.id, payable.public_id, payable.business_no, payable.company_id, payable.project_id, + payable.contract_id, payable.counterparty_id AS supplier_id, payable.cost_category_id, + category.code AS cost_category_code, category.name AS cost_category_name, + payable.amount, payable.paid_amount, payable.status + FROM fin_payable payable + LEFT JOIN md_cost_category category ON category.id = payable.cost_category_id + WHERE payable.id = #{id} FOR UPDATE + """) + PayableRef lockPayable(long id); + + @Select(""" + SELECT document.id, document.public_id, document.company_id, document.project_id, + document.form_type, document.status, document.current_version_id, + EXISTS ( + SELECT 1 FROM wf_instance instance + WHERE instance.source_version_id = document.current_version_id + AND instance.status = 'APPROVED' + ) AS current_version_approved + FROM src_source_document document + WHERE document.id = #{id} + FOR SHARE + """) + SourceRef findSourceById(long id); + + @Select(""" + SELECT document.id, document.public_id, document.company_id, document.project_id, + document.form_type, document.status, document.current_version_id, + EXISTS ( + SELECT 1 FROM wf_instance instance + WHERE instance.source_version_id = document.current_version_id + AND instance.status = 'APPROVED' + ) AS current_version_approved + FROM src_source_document document + WHERE document.public_id = #{publicId} + FOR SHARE + """) + SourceRef findSource(String publicId); + + @Select(""" + SELECT id, public_id, version_no, CAST(value_json AS CHAR) AS value_json + FROM sys_parameter_version + WHERE parameter_group = 'PAYMENT_ATTACHMENT_MATRIX' + AND status = 'ACTIVE' + AND effective_at IS NOT NULL + AND effective_at <= UTC_TIMESTAMP(3) + ORDER BY version_no DESC + LIMIT 1 + """) + AttachmentRuleParameterRow findActiveAttachmentRule(); + + @Select(""" + SELECT payment.attachment_rule_parameter_id AS parameter_id, + parameter.public_id AS parameter_public_id, + payment.attachment_rule_version_no AS version_no, + CAST(payment.attachment_rule_snapshot_json AS CHAR) AS snapshot_json + FROM fin_payment_request payment + LEFT JOIN sys_parameter_version parameter ON parameter.id = payment.attachment_rule_parameter_id + WHERE payment.id = #{paymentId} + """) + AttachmentRuleSnapshotRow findAttachmentRuleSnapshot(long paymentId); + + @Select("SELECT id, public_id, original_name, scan_status FROM file_object WHERE public_id = #{publicId}") + FileRef findFile(String publicId); + + /** + * Resolves a user-supplied payment file together with the only associations + * that make it safe to reuse. The booleans are computed in SQL so the + * service never relies on a caller-provided payment/company relationship. + */ + @Select(""" + SELECT file.id, file.public_id, file.original_name, file.scan_status, + (file.uploaded_by = #{actorPublicId}) AS uploaded_by_actor, + EXISTS ( + SELECT 1 FROM fin_payment_attachment existing_attachment + WHERE existing_attachment.payment_id = #{paymentId} + AND existing_attachment.file_id = file.id + AND existing_attachment.active = TRUE + ) AS attached_to_payment, + EXISTS ( + SELECT 1 FROM file_link link + WHERE link.file_id = file.id AND link.active = TRUE + AND link.archive_status IN ('ACTIVE', 'ARCHIVED', 'FROZEN') + AND (link.company_public_id = #{companyPublicId} + OR link.project_public_id = #{projectPublicId}) + ) AS linked_to_target + FROM file_object file + WHERE file.public_id = #{publicId} + """) + PaymentFileRef findFileForPayment(@Param("publicId") String publicId, + @Param("actorPublicId") String actorPublicId, + @Param("paymentId") long paymentId, + @Param("companyPublicId") String companyPublicId, + @Param("projectPublicId") String projectPublicId); + + @Select(""" + SELECT id, project_id, currency, confirmed_receipt, confirmed_deduction, + approved_adjustment, frozen_amount, approved_unpaid, paid_amount, version + FROM project_fund_control + WHERE project_id = #{projectId} AND currency = #{currency} + FOR UPDATE + """) + FundControlRow lockFundControl(@Param("projectId") long projectId, @Param("currency") String currency); + + @Insert(""" + INSERT IGNORE INTO project_fund_control (project_id, currency) + VALUES (#{projectId}, #{currency}) + """) + int ensureFundControl(@Param("projectId") long projectId, @Param("currency") String currency); + + @Select(""" + SELECT + COALESCE((SELECT SUM(CASE result.result_type + WHEN 'SUCCESS' THEN result.amount + WHEN 'REFUND' THEN -result.amount + ELSE 0 END) + FROM fin_payment_result result + JOIN fin_payment_request payment ON payment.id = result.payment_id + WHERE payment.contract_id = #{contractId} AND result.status = 'VERIFIED'), 0) + + COALESCE((SELECT SUM(payment.frozen_amount) + FROM fin_payment_request payment + WHERE payment.contract_id = #{contractId} + AND payment.id <> #{paymentId} + AND payment.status IN ('OFFLINE_PENDING', 'RESULT_RECORDED', 'PART_PAID')), 0) + """) + BigDecimal contractCommittedAmount(@Param("contractId") long contractId, @Param("paymentId") long paymentId); + + @Select(""" + SELECT payable.paid_amount + COALESCE(SUM(payment.frozen_amount), 0) + FROM fin_payable payable + LEFT JOIN fin_payment_request payment + ON payment.payable_id = payable.id + AND payment.id <> #{paymentId} + AND payment.status IN ('OFFLINE_PENDING', 'RESULT_RECORDED', 'PART_PAID') + WHERE payable.id = #{payableId} + GROUP BY payable.id, payable.paid_amount + """) + BigDecimal payableCommittedAmount(@Param("payableId") long payableId, + @Param("paymentId") long paymentId); + + @Select(""" + SELECT COUNT(*) FROM project_risk_flag + WHERE project_id = #{projectId} AND status = 'ACTIVE' AND blocking = TRUE + AND effective_from <= UTC_TIMESTAMP(3) + AND (effective_until IS NULL OR effective_until >= UTC_TIMESTAMP(3)) + """) + int countBlockingRisks(long projectId); + + @Select(""" + SELECT risk.id, risk.public_id, risk.risk_type, risk.reason, + risk.effective_from, risk.effective_until + FROM project_risk_flag risk + WHERE risk.project_id = #{projectId} AND risk.status = 'ACTIVE' AND risk.blocking = TRUE + AND risk.effective_from <= UTC_TIMESTAMP(3) + AND (risk.effective_until IS NULL OR risk.effective_until >= UTC_TIMESTAMP(3)) + ORDER BY risk.id + """) + List listBlockingRisks(long projectId); + + @Select(""" + SELECT COUNT(*) FROM fin_payment_request duplicate + WHERE duplicate.id <> #{paymentId} + AND duplicate.project_id = #{projectId} + AND duplicate.supplier_id = #{supplierId} + AND duplicate.requested_amount = #{amount} + AND duplicate.requested_date = #{requestedDate} + AND duplicate.purpose = #{purpose} + AND duplicate.status NOT IN ('REJECTED', 'FAILED', 'REFUNDED', 'VOID') + """) + int countDuplicates(@Param("paymentId") long paymentId, + @Param("projectId") long projectId, + @Param("supplierId") long supplierId, + @Param("amount") BigDecimal amount, + @Param("requestedDate") LocalDate requestedDate, + @Param("purpose") String purpose); + + @Select(""" + SELECT duplicate.id, duplicate.public_id, duplicate.requested_amount, + duplicate.requested_date, duplicate.purpose, duplicate.status + FROM fin_payment_request duplicate + WHERE duplicate.id <> #{paymentId} + AND duplicate.project_id = #{projectId} + AND duplicate.supplier_id = #{supplierId} + AND duplicate.requested_amount BETWEEN #{minimumAmount} AND #{maximumAmount} + AND duplicate.requested_date BETWEEN #{dateFrom} AND #{dateTo} + AND duplicate.status NOT IN ('REJECTED', 'FAILED', 'REFUNDED', 'VOID') + ORDER BY duplicate.requested_date, duplicate.id + """) + List listDuplicateCandidates( + @Param("paymentId") long paymentId, + @Param("projectId") long projectId, + @Param("supplierId") long supplierId, + @Param("minimumAmount") BigDecimal minimumAmount, + @Param("maximumAmount") BigDecimal maximumAmount, + @Param("dateFrom") LocalDate dateFrom, + @Param("dateTo") LocalDate dateTo); + + @Select("SELECT COUNT(*) FROM fin_payment_attachment WHERE payment_id = #{paymentId} AND active = TRUE") + int countAttachments(long paymentId); + + @Select(""" + SELECT COUNT(*) FROM fin_payment_attachment attachment + JOIN file_object file ON file.id = attachment.file_id + WHERE attachment.payment_id = #{paymentId} AND attachment.active = TRUE + AND file.scan_status <> 'AVAILABLE' + """) + int countUnsafeAttachments(long paymentId); + + @Select(""" + SELECT exception_item.public_id, exception_item.document_type, exception_item.document_name, + exception_item.version, + exception_item.reason, + exception_item.status, exception_item.requested_by, + requester.display_name AS requested_by_name, + exception_item.requested_at, reviewer.display_name AS reviewed_by_name, + exception_item.reviewed_at, exception_item.review_opinion, exception_item.consumed_at + FROM fin_payment_attachment_exception exception_item + JOIN iam_user requester ON requester.id = exception_item.requested_by + LEFT JOIN iam_user reviewer ON reviewer.id = exception_item.reviewed_by + WHERE exception_item.payment_id = #{paymentId} AND exception_item.active = TRUE + ORDER BY exception_item.id + """) + List listAttachmentExceptions(long paymentId); + + @Select(""" + SELECT COUNT(*) FROM fin_payment_attachment_exception + WHERE payment_id = #{paymentId} AND active = TRUE AND status = 'PENDING' + AND requested_by = #{actorId} + """) + int countPendingAttachmentExceptionsRequestedBy(@Param("paymentId") long paymentId, + @Param("actorId") long actorId); + + @Select(""" + SELECT exception_item.id, exception_item.public_id, exception_item.payment_id, + exception_item.document_type, exception_item.document_name, exception_item.version, exception_item.reason, + exception_item.status, exception_item.requested_by, exception_item.consumed_at + FROM fin_payment_attachment_exception exception_item + WHERE exception_item.public_id = #{publicId} AND exception_item.active = TRUE + FOR UPDATE + """) + AttachmentExceptionLock lockAttachmentException(String publicId); + + @Select(""" + SELECT exception_item.public_id, exception_item.control_code, + exception_item.finding_summary, exception_item.version, + exception_item.reason, exception_item.valid_until, exception_item.status, + exception_item.requested_by, requester.display_name AS requested_by_name, + exception_item.requested_at, reviewer.display_name AS reviewed_by_name, + exception_item.reviewed_at, exception_item.review_opinion, exception_item.consumed_at + FROM fin_payment_control_exception exception_item + JOIN iam_user requester ON requester.id = exception_item.requested_by + LEFT JOIN iam_user reviewer ON reviewer.id = exception_item.reviewed_by + WHERE exception_item.payment_id = #{paymentId} AND exception_item.active = TRUE + ORDER BY exception_item.id + """) + List listControlExceptions(long paymentId); + + @Select(""" + SELECT exception_item.id, exception_item.public_id, exception_item.payment_id, + exception_item.control_code, exception_item.finding_fingerprint, + exception_item.finding_summary, exception_item.version, + exception_item.reason, exception_item.valid_until, exception_item.status, + exception_item.requested_by, exception_item.reviewed_by, exception_item.consumed_at + FROM fin_payment_control_exception exception_item + WHERE exception_item.public_id = #{publicId} AND exception_item.active = TRUE + FOR UPDATE + """) + ControlExceptionLock lockControlException(String publicId); + + @Select(""" + SELECT exception_item.id, exception_item.public_id, exception_item.control_code, + exception_item.finding_fingerprint, exception_item.valid_until, + exception_item.reviewed_by + FROM fin_payment_control_exception exception_item + WHERE exception_item.payment_id = #{paymentId} AND exception_item.active = TRUE + AND exception_item.status = 'APPROVED' AND exception_item.consumed_at IS NULL + AND exception_item.valid_until >= #{asOfDate} + ORDER BY exception_item.id + FOR UPDATE + """) + List lockApprovedControlExceptions(@Param("paymentId") long paymentId, + @Param("asOfDate") LocalDate asOfDate); + + @Select(""" + SELECT COUNT(*) FROM fin_payment_control_exception + WHERE payment_id = #{paymentId} AND control_code = #{controlCode} AND active = TRUE + AND (status = 'PENDING' OR (status = 'APPROVED' AND consumed_at IS NULL + AND valid_until >= #{asOfDate})) + """) + int countActiveControlExceptions(@Param("paymentId") long paymentId, + @Param("controlCode") String controlCode, + @Param("asOfDate") LocalDate asOfDate); + + @Update(""" + UPDATE fin_payment_control_exception + SET active = FALSE, updated_at = UTC_TIMESTAMP(3) + WHERE payment_id = #{paymentId} AND control_code = #{controlCode} AND active = TRUE + AND (status = 'REJECTED' OR consumed_at IS NOT NULL OR valid_until < #{asOfDate}) + """) + int deactivateExpiredOrConsumedControlExceptions(@Param("paymentId") long paymentId, + @Param("controlCode") String controlCode, + @Param("asOfDate") LocalDate asOfDate); + + @Select(""" + SELECT COUNT(*) FROM fin_payment_control_exception + WHERE payment_id = #{paymentId} AND active = TRUE AND status = 'PENDING' + AND requested_by = #{actorId} + """) + int countPendingControlExceptionsRequestedBy(@Param("paymentId") long paymentId, + @Param("actorId") long actorId); + + @Insert(""" + INSERT INTO fin_payment_request ( + public_id, business_no, company_id, project_id, contract_id, payable_id, + supplier_id, bank_account_version_id, source_document_id, source_version_id, source_event_key, + attachment_rule_parameter_id, attachment_rule_version_no, attachment_rule_snapshot_json, + original_payment_id, repayment_sequence, requested_amount, approved_amount, + frozen_amount, currency, requested_date, purpose, status, created_by, updated_by + ) VALUES ( + #{publicId}, #{businessNo}, #{companyId}, #{projectId}, #{contractId}, #{payableId}, + #{supplierId}, #{bankAccountId}, #{sourceId}, #{sourceVersionId}, #{sourceEventKey}, + #{attachmentRuleParameterId}, #{attachmentRuleVersionNo}, CAST(#{attachmentRuleSnapshotJson} AS JSON), + #{originalPaymentId}, + #{repaymentSequence}, #{amount}, NULL, 0, #{currency}, #{requestedDate}, #{purpose}, + 'DRAFT', #{actorId}, #{actorId} + ) + """) + int insertPayment(@Param("publicId") String publicId, + @Param("businessNo") String businessNo, + @Param("companyId") long companyId, + @Param("projectId") long projectId, + @Param("contractId") long contractId, + @Param("payableId") Long payableId, + @Param("supplierId") long supplierId, + @Param("bankAccountId") long bankAccountId, + @Param("sourceId") Long sourceId, + @Param("sourceVersionId") Long sourceVersionId, + @Param("sourceEventKey") String sourceEventKey, + @Param("attachmentRuleParameterId") Long attachmentRuleParameterId, + @Param("attachmentRuleVersionNo") Integer attachmentRuleVersionNo, + @Param("attachmentRuleSnapshotJson") String attachmentRuleSnapshotJson, + @Param("originalPaymentId") Long originalPaymentId, + @Param("repaymentSequence") int repaymentSequence, + @Param("amount") BigDecimal amount, + @Param("currency") String currency, + @Param("requestedDate") LocalDate requestedDate, + @Param("purpose") String purpose, + @Param("actorId") long actorId); + + @Update(""" + UPDATE fin_payment_request + SET company_id = #{companyId}, project_id = #{projectId}, contract_id = #{contractId}, + payable_id = #{payableId}, supplier_id = #{supplierId}, + bank_account_version_id = #{bankAccountId}, source_document_id = #{sourceId}, + source_version_id = #{sourceVersionId}, + source_event_key = #{sourceEventKey}, + attachment_rule_parameter_id = #{attachmentRuleParameterId}, + attachment_rule_version_no = #{attachmentRuleVersionNo}, + attachment_rule_snapshot_json = CAST(#{attachmentRuleSnapshotJson} AS JSON), + requested_amount = #{amount}, currency = #{currency}, requested_date = #{requestedDate}, + purpose = #{purpose}, updated_by = #{actorId}, version = version + 1 + WHERE id = #{id} AND version = #{version} AND status IN ('DRAFT', 'RETURNED') + """) + int updatePayment(@Param("id") long id, + @Param("companyId") long companyId, + @Param("projectId") long projectId, + @Param("contractId") long contractId, + @Param("payableId") Long payableId, + @Param("supplierId") long supplierId, + @Param("bankAccountId") long bankAccountId, + @Param("sourceId") Long sourceId, + @Param("sourceVersionId") Long sourceVersionId, + @Param("sourceEventKey") String sourceEventKey, + @Param("attachmentRuleParameterId") Long attachmentRuleParameterId, + @Param("attachmentRuleVersionNo") Integer attachmentRuleVersionNo, + @Param("attachmentRuleSnapshotJson") String attachmentRuleSnapshotJson, + @Param("amount") BigDecimal amount, + @Param("currency") String currency, + @Param("requestedDate") LocalDate requestedDate, + @Param("purpose") String purpose, + @Param("actorId") long actorId, + @Param("version") long version); + + @Delete("DELETE FROM fin_payment_attachment WHERE payment_id = #{paymentId}") + int deleteAttachments(long paymentId); + + @Insert(""" + INSERT INTO fin_payment_attachment + (public_id, payment_id, file_id, document_type, uploaded_by) + VALUES (#{publicId}, #{paymentId}, #{fileId}, #{documentType}, #{actorId}) + """) + int insertAttachment(@Param("publicId") String publicId, + @Param("paymentId") long paymentId, + @Param("fileId") long fileId, + @Param("documentType") String documentType, + @Param("actorId") long actorId); + + @Update(""" + UPDATE fin_payment_attachment_exception + SET active = FALSE, updated_at = UTC_TIMESTAMP(3) + WHERE payment_id = #{paymentId} AND active = TRUE + """) + int deactivateAttachmentExceptions(long paymentId); + + @Select(""" + SELECT COALESCE(MAX(version_no), 0) + 1 + FROM fin_payment_attachment_exception + WHERE payment_id = #{paymentId} AND document_type = #{documentType} + """) + int nextAttachmentExceptionVersion(@Param("paymentId") long paymentId, + @Param("documentType") String documentType); + + @Insert(""" + INSERT INTO fin_payment_attachment_exception + (public_id, payment_id, document_type, document_name, version_no, reason, status, requested_by) + VALUES (#{publicId}, #{paymentId}, #{documentType}, #{documentName}, #{versionNo}, #{reason}, 'PENDING', #{actorId}) + """) + int insertAttachmentException(@Param("publicId") String publicId, + @Param("paymentId") long paymentId, + @Param("documentType") String documentType, + @Param("documentName") String documentName, + @Param("versionNo") int versionNo, + @Param("reason") String reason, + @Param("actorId") long actorId); + + @Insert(""" + INSERT INTO fin_payment_attachment (public_id, payment_id, file_id, document_type, uploaded_by) + SELECT REPLACE(UUID(), '-', ''), #{newPaymentId}, file_id, document_type, #{actorId} + FROM fin_payment_attachment WHERE payment_id = #{sourcePaymentId} AND active = TRUE + """) + int cloneAttachments(@Param("sourcePaymentId") long sourcePaymentId, + @Param("newPaymentId") long newPaymentId, + @Param("actorId") long actorId); + + @Update(""" + UPDATE fin_payment_request + SET attachment_rule_parameter_id = #{attachmentRuleParameterId}, + attachment_rule_version_no = #{attachmentRuleVersionNo}, + attachment_rule_snapshot_json = CAST(#{attachmentRuleSnapshotJson} AS JSON), + status = 'REVIEWING', submitted_by = #{actorId}, submitted_at = UTC_TIMESTAMP(3), + checked_by = NULL, checked_at = NULL, approved_by = NULL, approved_at = NULL, + returned_by = NULL, returned_at = NULL, return_reason = NULL, + updated_by = #{actorId}, version = version + 1 + WHERE id = #{id} AND version = #{version} AND status IN ('DRAFT', 'RETURNED') + """) + int submitPayment(@Param("id") long id, @Param("version") long version, + @Param("attachmentRuleParameterId") Long attachmentRuleParameterId, + @Param("attachmentRuleVersionNo") Integer attachmentRuleVersionNo, + @Param("attachmentRuleSnapshotJson") String attachmentRuleSnapshotJson, + @Param("actorId") long actorId); + + @Update(""" + UPDATE fin_payment_attachment_exception + SET status = #{status}, reviewed_by = #{actorId}, reviewed_at = UTC_TIMESTAMP(3), + review_opinion = #{opinion}, version = version + 1, updated_at = UTC_TIMESTAMP(3) + WHERE id = #{id} AND version = #{version} AND active = TRUE AND status = 'PENDING' + AND requested_by <> #{actorId} AND #{status} IN ('APPROVED', 'REJECTED') + """) + int reviewAttachmentException(@Param("id") long id, + @Param("version") long version, + @Param("status") String status, + @Param("opinion") String opinion, + @Param("actorId") long actorId); + + @Update(""" + UPDATE fin_payment_attachment_exception + SET status = 'REJECTED', reviewed_by = #{actorId}, reviewed_at = UTC_TIMESTAMP(3), + review_opinion = #{opinion}, version = version + 1, updated_at = UTC_TIMESTAMP(3) + WHERE payment_id = #{paymentId} AND active = TRUE AND status = 'PENDING' + AND requested_by <> #{actorId} + """) + int rejectPendingAttachmentExceptions(@Param("paymentId") long paymentId, + @Param("opinion") String opinion, + @Param("actorId") long actorId); + + @Update(""" + UPDATE fin_payment_attachment_exception + SET active = FALSE, updated_at = UTC_TIMESTAMP(3) + WHERE payment_id = #{paymentId} AND active = TRUE AND status <> 'PENDING' + """) + int deactivateReviewedAttachmentExceptions(long paymentId); + + @Select(""" + SELECT COUNT(*) + FROM fin_payment_attachment_exception + WHERE payment_id = #{paymentId} AND active = TRUE AND status = 'APPROVED' + AND consumed_at IS NULL + """) + int countConsumableApprovedAttachmentExceptions(long paymentId); + + @Update(""" + UPDATE fin_payment_attachment_exception + SET consumed_by = #{actorId}, consumed_at = UTC_TIMESTAMP(3), + version = version + 1, updated_at = UTC_TIMESTAMP(3) + WHERE payment_id = #{paymentId} AND active = TRUE AND status = 'APPROVED' + AND consumed_at IS NULL + """) + int consumeApprovedAttachmentExceptions(@Param("paymentId") long paymentId, + @Param("actorId") long actorId); + + @Select(""" + SELECT COALESCE(MAX(version_no), 0) + 1 + FROM fin_payment_control_exception + WHERE payment_id = #{paymentId} AND control_code = #{controlCode} + """) + int nextControlExceptionVersion(@Param("paymentId") long paymentId, + @Param("controlCode") String controlCode); + + @Insert(""" + INSERT INTO fin_payment_control_exception ( + public_id, payment_id, control_code, finding_fingerprint, finding_summary, + finding_snapshot_json, version_no, reason, valid_until, status, requested_by + ) VALUES ( + #{publicId}, #{paymentId}, #{controlCode}, #{findingFingerprint}, #{findingSummary}, + CAST(#{findingSnapshotJson} AS JSON), #{versionNo}, #{reason}, #{validUntil}, + 'PENDING', #{actorId} + ) + """) + int insertControlException(@Param("publicId") String publicId, + @Param("paymentId") long paymentId, + @Param("controlCode") String controlCode, + @Param("findingFingerprint") String findingFingerprint, + @Param("findingSummary") String findingSummary, + @Param("findingSnapshotJson") String findingSnapshotJson, + @Param("versionNo") int versionNo, + @Param("reason") String reason, + @Param("validUntil") LocalDate validUntil, + @Param("actorId") long actorId); + + @Update(""" + UPDATE fin_payment_control_exception + SET status = #{status}, reviewed_by = #{actorId}, reviewed_at = UTC_TIMESTAMP(3), + review_opinion = #{opinion}, version = version + 1, updated_at = UTC_TIMESTAMP(3) + WHERE id = #{id} AND version = #{version} AND active = TRUE AND status = 'PENDING' + AND requested_by <> #{actorId} AND #{status} IN ('APPROVED', 'REJECTED') + """) + int reviewControlException(@Param("id") long id, + @Param("version") long version, + @Param("status") String status, + @Param("opinion") String opinion, + @Param("actorId") long actorId); + + @Update(""" + UPDATE fin_payment_control_exception + SET status = 'REJECTED', reviewed_by = #{actorId}, reviewed_at = UTC_TIMESTAMP(3), + review_opinion = #{opinion}, version = version + 1, updated_at = UTC_TIMESTAMP(3) + WHERE payment_id = #{paymentId} AND active = TRUE AND status = 'PENDING' + AND requested_by <> #{actorId} + """) + int rejectPendingControlExceptions(@Param("paymentId") long paymentId, + @Param("opinion") String opinion, + @Param("actorId") long actorId); + + @Update(""" + UPDATE fin_payment_control_exception + SET active = FALSE, updated_at = UTC_TIMESTAMP(3) + WHERE payment_id = #{paymentId} AND active = TRUE AND status <> 'PENDING' + """) + int deactivateReviewedControlExceptions(long paymentId); + + @Update(""" + UPDATE fin_payment_control_exception + SET consumed_by = #{actorId}, consumed_at = UTC_TIMESTAMP(3), + version = version + 1, updated_at = UTC_TIMESTAMP(3) + WHERE id = #{id} AND active = TRUE AND status = 'APPROVED' + AND consumed_at IS NULL AND valid_until >= #{asOfDate} + """) + int consumeControlException(@Param("id") long id, @Param("actorId") long actorId, + @Param("asOfDate") LocalDate asOfDate); + + @Insert(""" + INSERT INTO fin_payment_check + (public_id, payment_id, check_code, result, message, checked_by) + VALUES (#{publicId}, #{paymentId}, #{code}, #{result}, #{message}, #{actorId}) + """) + int insertCheck(@Param("publicId") String publicId, + @Param("paymentId") long paymentId, + @Param("code") String code, + @Param("result") String result, + @Param("message") String message, + @Param("actorId") long actorId); + + @Update(""" + UPDATE fin_payment_request + SET status = 'OFFLINE_PENDING', approved_amount = requested_amount, + frozen_amount = requested_amount, check_snapshot_json = CAST(#{snapshotJson} AS JSON), + check_rule_version = #{ruleVersion}, checked_by = #{actorId}, + checked_at = UTC_TIMESTAMP(3), approved_by = #{actorId}, approved_at = UTC_TIMESTAMP(3), + updated_by = #{actorId}, version = version + 1 + WHERE id = #{id} AND version = #{version} AND status = 'REVIEWING' + """) + int approvePayment(@Param("id") long id, + @Param("version") long version, + @Param("snapshotJson") String snapshotJson, + @Param("ruleVersion") String ruleVersion, + @Param("actorId") long actorId); + + @Update(""" + UPDATE project_fund_control + SET frozen_amount = frozen_amount + #{amount}, version = version + 1 + WHERE id = #{id} AND version = #{version} + AND confirmed_receipt - confirmed_deduction + approved_adjustment + - paid_amount - frozen_amount - approved_unpaid >= #{amount} + """) + int reserveFunds(@Param("id") long id, @Param("version") long version, + @Param("amount") BigDecimal amount); + + @Update(""" + UPDATE fin_payment_request + SET status = 'RETURNED', returned_by = #{actorId}, returned_at = UTC_TIMESTAMP(3), + return_reason = #{reason}, updated_by = #{actorId}, version = version + 1 + WHERE id = #{id} AND version = #{version} AND status = 'REVIEWING' + """) + int returnPayment(@Param("id") long id, @Param("version") long version, + @Param("reason") String reason, @Param("actorId") long actorId); + + @Update(""" + UPDATE fin_payment_request SET status = 'VOID', updated_by = #{actorId}, version = version + 1 + WHERE id = #{id} AND version = #{version} AND status IN ('DRAFT', 'RETURNED', 'REJECTED') + """) + int voidPayment(@Param("id") long id, @Param("version") long version, + @Param("actorId") long actorId); + + @Insert(""" + INSERT INTO fin_payment_result ( + public_id, result_no, payment_id, recorded_from_status, result_type, paid_date, amount, + receipt_file_id, failure_reason, remark, recorded_by, status + ) VALUES ( + #{publicId}, #{resultNo}, #{paymentId}, #{recordedFromStatus}, #{resultType}, #{resultAt}, #{amount}, + #{firstFileId}, #{failureReason}, #{remark}, #{actorId}, 'PENDING_VERIFY' + ) + """) + int insertResult(@Param("publicId") String publicId, + @Param("resultNo") String resultNo, + @Param("paymentId") long paymentId, + @Param("recordedFromStatus") String recordedFromStatus, + @Param("resultType") String resultType, + @Param("resultAt") LocalDate resultAt, + @Param("amount") BigDecimal amount, + @Param("firstFileId") Long firstFileId, + @Param("failureReason") String failureReason, + @Param("remark") String remark, + @Param("actorId") long actorId); + + @Select("SELECT id FROM fin_payment_result WHERE public_id = #{publicId}") + Long resultId(String publicId); + + @Insert(""" + INSERT INTO fin_payment_result_file (public_id, payment_result_id, file_id, created_by) + VALUES (#{publicId}, #{resultId}, #{fileId}, #{actorId}) + """) + int insertResultFile(@Param("publicId") String publicId, + @Param("resultId") long resultId, + @Param("fileId") long fileId, + @Param("actorId") long actorId); + + @Update(""" + UPDATE fin_payment_request + SET status = 'RESULT_RECORDED', updated_by = #{actorId}, version = version + 1 + WHERE id = #{id} AND version = #{version} AND status IN ('OFFLINE_PENDING', 'PART_PAID', 'PAID') + """) + int markResultRecorded(@Param("id") long id, @Param("version") long version, + @Param("actorId") long actorId); + + @Select(""" + SELECT id, public_id, result_no, payment_id, recorded_from_status, result_type, paid_date, amount, + failure_reason, remark, recorded_by, recorded_at, verified_by, verified_at, + verification_opinion, returned_by, returned_at, return_opinion, status, version + FROM fin_payment_result + WHERE payment_id = #{paymentId} AND status = 'PENDING_VERIFY' + ORDER BY id DESC LIMIT 1 FOR UPDATE + """) + ResultLock lockPendingResult(long paymentId); + + @Update(""" + UPDATE fin_payment_result + SET status = 'VERIFIED', verified_by = #{actorId}, verified_at = UTC_TIMESTAMP(3), + verification_opinion = #{opinion}, version = version + 1 + WHERE id = #{id} AND version = #{version} AND status = 'PENDING_VERIFY' + AND recorded_by <> #{actorId} + """) + int verifyResult(@Param("id") long id, + @Param("version") long version, + @Param("opinion") String opinion, + @Param("actorId") long actorId); + + @Update(""" + UPDATE fin_payment_result + SET status = 'RETURNED', returned_by = #{actorId}, returned_at = UTC_TIMESTAMP(3), + return_opinion = #{opinion}, version = version + 1 + WHERE id = #{id} AND version = #{version} AND status = 'PENDING_VERIFY' + AND recorded_by <> #{actorId} + """) + int returnResult(@Param("id") long id, + @Param("version") long version, + @Param("opinion") String opinion, + @Param("actorId") long actorId); + + @Update(""" + UPDATE fin_payment_request + SET status = #{status}, updated_by = #{actorId}, version = version + 1 + WHERE id = #{id} AND version = #{version} AND status = 'RESULT_RECORDED' + AND #{status} IN ('OFFLINE_PENDING', 'PART_PAID', 'PAID') + """) + int restorePaymentAfterResultReturn(@Param("id") long id, + @Param("version") long version, + @Param("status") String status, + @Param("actorId") long actorId); + + @Update(""" + UPDATE fin_payment_request + SET status = #{status}, paid_amount = #{paidAmount}, frozen_amount = #{frozenAmount}, + updated_by = #{actorId}, version = version + 1 + WHERE id = #{id} AND version = #{version} AND status = 'RESULT_RECORDED' + AND #{paidAmount} >= 0 AND #{frozenAmount} >= 0 + AND #{paidAmount} + #{frozenAmount} <= COALESCE(approved_amount, requested_amount) + """) + int completePayment(@Param("id") long id, + @Param("version") long version, + @Param("status") String status, + @Param("paidAmount") BigDecimal paidAmount, + @Param("frozenAmount") BigDecimal frozenAmount, + @Param("actorId") long actorId); + + @Update(""" + UPDATE project_fund_control + SET frozen_amount = frozen_amount - #{amount}, paid_amount = paid_amount + #{amount}, + version = version + 1 + WHERE id = #{id} AND version = #{version} AND frozen_amount >= #{amount} + """) + int settleFundsPaid(@Param("id") long id, @Param("version") long version, + @Param("amount") BigDecimal amount); + + @Update(""" + UPDATE project_fund_control + SET frozen_amount = frozen_amount - #{amount}, version = version + 1 + WHERE id = #{id} AND version = #{version} AND frozen_amount >= #{amount} + """) + int releaseFrozenFunds(@Param("id") long id, @Param("version") long version, + @Param("amount") BigDecimal amount); + + @Update(""" + UPDATE project_fund_control + SET paid_amount = paid_amount - #{amount}, version = version + 1 + WHERE id = #{id} AND version = #{version} AND paid_amount >= #{amount} + """) + int reversePaidFunds(@Param("id") long id, @Param("version") long version, + @Param("amount") BigDecimal amount); + + @Update(""" + UPDATE project_fund_control + SET paid_amount = paid_amount - #{paidAmount}, + frozen_amount = frozen_amount - #{frozenAmount}, version = version + 1 + WHERE id = #{id} AND version = #{version} + AND paid_amount >= #{paidAmount} AND frozen_amount >= #{frozenAmount} + """) + int reversePaidAndReleaseFrozenFunds(@Param("id") long id, + @Param("version") long version, + @Param("paidAmount") BigDecimal paidAmount, + @Param("frozenAmount") BigDecimal frozenAmount); + + @Update(""" + UPDATE fin_payable + SET status = CASE WHEN paid_amount + #{amount} = amount THEN 'PAID' ELSE 'PART_PAID' END, + paid_amount = paid_amount + #{amount}, + updated_by = #{actorId}, version = version + 1 + WHERE id = #{id} AND amount - paid_amount >= #{amount} + AND status IN ('CONFIRMED', 'PART_PAID') + """) + int addPayablePaid(@Param("id") long id, @Param("amount") BigDecimal amount, + @Param("actorId") long actorId); + + @Update(""" + UPDATE fin_payable + SET status = CASE WHEN paid_amount - #{amount} = 0 THEN 'CONFIRMED' ELSE 'PART_PAID' END, + paid_amount = paid_amount - #{amount}, + updated_by = #{actorId}, version = version + 1 + WHERE id = #{id} AND paid_amount >= #{amount} AND status IN ('PART_PAID', 'PAID') + """) + int reversePayablePaid(@Param("id") long id, @Param("amount") BigDecimal amount, + @Param("actorId") long actorId); + + @Insert(""" + INSERT INTO fin_payment_refund + (public_id, payment_result_id, sequence_no, amount, reason, status, created_by) + VALUES (#{publicId}, #{resultId}, 1, #{amount}, #{reason}, 'VERIFIED', #{actorId}) + """) + int insertRefund(@Param("publicId") String publicId, + @Param("resultId") long resultId, + @Param("amount") BigDecimal amount, + @Param("reason") String reason, + @Param("actorId") long actorId); + + @Insert(""" + INSERT INTO acc_event ( + public_id, business_no, event_type, source_type, source_public_id, source_version, event_sequence, + company_id, project_id, counterparty_id, business_date, period, + amount, currency, status, created_by + ) VALUES ( + #{publicId}, #{businessNo}, 'PAYMENT', 'PAYMENT', #{sourcePublicId}, #{sourceVersion}, #{eventSequence}, + #{companyId}, #{projectId}, #{supplierId}, #{businessDate}, + DATE_FORMAT(#{businessDate}, '%Y-%m'), #{amount}, #{currency}, 'PENDING', #{actorId} + ) + """) + int insertAccountingEvent(@Param("publicId") String publicId, + @Param("businessNo") String businessNo, + @Param("sourcePublicId") String sourcePublicId, + @Param("sourceVersion") String sourceVersion, + @Param("eventSequence") int eventSequence, + @Param("companyId") long companyId, + @Param("projectId") long projectId, + @Param("supplierId") long supplierId, + @Param("businessDate") LocalDate businessDate, + @Param("amount") BigDecimal amount, + @Param("currency") String currency, + @Param("actorId") long actorId); + + @Insert(""" + INSERT INTO acc_event ( + public_id, business_no, event_type, source_type, source_public_id, source_version, event_sequence, + company_id, project_id, counterparty_id, business_date, period, + amount, currency, status, created_by + ) VALUES ( + #{publicId}, #{businessNo}, 'PAYMENT_REFUND', 'PAYMENT_REFUND', #{sourcePublicId}, #{sourceVersion}, #{eventSequence}, + #{companyId}, #{projectId}, #{supplierId}, #{businessDate}, + DATE_FORMAT(#{businessDate}, '%Y-%m'), #{amount}, #{currency}, 'PENDING', #{actorId} + ) + """) + int insertRefundAccountingEvent(@Param("publicId") String publicId, + @Param("businessNo") String businessNo, + @Param("sourcePublicId") String sourcePublicId, + @Param("sourceVersion") String sourceVersion, + @Param("eventSequence") int eventSequence, + @Param("companyId") long companyId, + @Param("projectId") long projectId, + @Param("supplierId") long supplierId, + @Param("businessDate") LocalDate businessDate, + @Param("amount") BigDecimal amount, + @Param("currency") String currency, + @Param("actorId") long actorId); + + @Select(""" + SELECT COALESCE(MAX(repayment_sequence), 0) + FROM fin_payment_request WHERE original_payment_id = #{originalPaymentId} + """) + int maxRepaymentSequence(long originalPaymentId); + + @Select(""" + SELECT COUNT(*) + FROM fin_payment_request + WHERE original_payment_id = #{originalPaymentId} + AND status IN ('DRAFT', 'REVIEWING', 'RETURNED', 'APPROVED', + 'OFFLINE_PENDING', 'RESULT_RECORDED', 'RESULT_VERIFIED', + 'PART_PAID', 'PAID') + """) + int countActiveRepayments(@Param("originalPaymentId") long originalPaymentId); + + @Select(""" + SELECT public_id, check_code, result, message, checked_at + FROM fin_payment_check WHERE payment_id = #{paymentId} + ORDER BY checked_at DESC, id DESC + """) + List listChecks(long paymentId); + + @Select(""" + SELECT result.id, result.public_id, result.result_no, result.payment_id, + result.recorded_from_status, result.result_type, result.paid_date, result.amount, + result.failure_reason, result.remark, + result.recorded_by, recorder.display_name AS recorded_by_name, result.recorded_at, + result.verified_by, verifier.display_name AS verified_by_name, result.verified_at, + result.verification_opinion, + result.returned_by, returner.display_name AS returned_by_name, result.returned_at, + result.return_opinion, result.status, result.version + FROM fin_payment_result result + JOIN iam_user recorder ON recorder.id = result.recorded_by + LEFT JOIN iam_user verifier ON verifier.id = result.verified_by + LEFT JOIN iam_user returner ON returner.id = result.returned_by + WHERE result.payment_id = #{paymentId} + ORDER BY result.id DESC + """) + List listResults(long paymentId); + + @Select(""" + SELECT file.public_id FROM fin_payment_result_file result_file + JOIN file_object file ON file.id = result_file.file_id + WHERE result_file.payment_result_id = #{resultId} + ORDER BY result_file.id + """) + List listResultFileIds(long resultId); + + @Select(""" + SELECT attachment.public_id, file.public_id AS file_public_id, file.original_name, + attachment.document_type, file.scan_status, attachment.created_at + FROM fin_payment_attachment attachment + JOIN file_object file ON file.id = attachment.file_id + WHERE attachment.payment_id = #{paymentId} AND attachment.active = TRUE + ORDER BY attachment.created_at, attachment.id + """) + List listAttachments(long paymentId); + + @Select(""" + + """) + List listCompanies(@Param("userId") long userId, @Param("roleCode") String roleCode); + + @Select(""" + + """) + List listProjects(@Param("userId") long userId, @Param("roleCode") String roleCode); + + @Select(""" + + """) + List listSuppliers(@Param("userId") long userId, @Param("roleCode") String roleCode); + + @Select(""" + + """) + List listContracts(@Param("userId") long userId, @Param("roleCode") String roleCode); + + @Select(""" + + """) + List listPayables(@Param("userId") long userId, @Param("roleCode") String roleCode); + + @Select(""" + + """) + List listBankAccounts(@Param("userId") long userId, + @Param("roleCode") String roleCode); + + record ReferenceRow(String publicId, String code, String name) { + } + + record ContractOptionRow(String publicId, String code, String name, String companyPublicId, + String projectPublicId, String supplierPublicId) { + } + + record EntityRef(long id, String publicId, String code, String name) { + } + + record ProjectRef(long id, String publicId, String code, String name, long companyId, + String currency, String status) { + } + + record ContractRef(long id, String publicId, String code, String name, long companyId, + long projectId, long counterpartyId, BigDecimal originalAmount, + BigDecimal approvedChangeAmount, BigDecimal settlementAmount, + String currency, String status) { + } + + record PayableRef(long id, String publicId, String businessNo, long companyId, + long projectId, long contractId, long supplierId, Long costCategoryId, + String costCategoryCode, String costCategoryName, BigDecimal amount, + BigDecimal paidAmount, String status) { + } + + record BankAccountRef(long id, String publicId, long supplierId, String accountName, + String bankName, String maskedAccountNo, int versionNo, String status, + LocalDateTime validFrom, LocalDateTime validTo) { + } + + record SourceRef(long id, String publicId, long companyId, Long projectId, String formType, + String status, Long currentVersionId, boolean currentVersionApproved) { + } + + record AttachmentRuleParameterRow(long id, String publicId, int versionNo, String valueJson) { + } + + record AttachmentRuleSnapshotRow(Long parameterId, String parameterPublicId, Integer versionNo, + String snapshotJson) { + } + + record FileRef(long id, String publicId, String originalName, String scanStatus) { + } + + record PaymentFileRef(long id, String publicId, String originalName, String scanStatus, + boolean uploadedByActor, boolean attachedToPayment, boolean linkedToTarget) { + } + + record FundControlRow(long id, long projectId, String currency, BigDecimal confirmedReceipt, + BigDecimal confirmedDeduction, BigDecimal approvedAdjustment, + BigDecimal frozenAmount, BigDecimal approvedUnpaid, BigDecimal paidAmount, + long version) { + } + + record PaymentLock(long id, String publicId, long companyId, long projectId, long contractId, + Long payableId, long supplierId, long bankAccountVersionId, Long sourceDocumentId, + Long sourceVersionId, + Long attachmentRuleParameterId, Integer attachmentRuleVersionNo, + String attachmentRuleSnapshotJson, + Long originalPaymentId, int repaymentSequence, BigDecimal requestedAmount, + BigDecimal approvedAmount, BigDecimal frozenAmount, BigDecimal paidAmount, String currency, + LocalDate requestedDate, String purpose, String status, Long submittedBy, + Long approvedBy, long version, long createdBy) { + } + + record PaymentProjectionRow(long id, String publicId, String businessNo, + Long sourceDocumentId, Long sourceVersionId, String status) { + } + + record PaymentRow(long id, String publicId, String businessNo, + String companyPublicId, String companyCode, String companyName, long projectId, + String projectPublicId, String projectCode, String projectName, + String contractPublicId, String contractCode, String contractName, + String payablePublicId, String payableCode, + String supplierPublicId, String supplierCode, String supplierName, + String accountPublicId, String accountName, String bankName, + String maskedAccountNo, int accountVersionNo, + String sourceDocumentPublicId, String originalPaymentPublicId, + int repaymentSequence, BigDecimal requestedAmount, BigDecimal approvedAmount, + BigDecimal frozenAmount, BigDecimal paidAmount, String currency, LocalDate requestedDate, + String purpose, String status, long version, long createdBy, Long submittedBy, + String submittedByName, String approvedByName, + Long latestResultRecordedBy, String resultRecordedByName, String resultVerifiedByName, + LocalDateTime createdAt, LocalDateTime updatedAt) { + } + + record CheckRow(String publicId, String checkCode, String result, String message, + LocalDateTime checkedAt) { + } + + record ResultRow(long id, String publicId, String resultNo, long paymentId, + String recordedFromStatus, String resultType, LocalDate paidDate, BigDecimal amount, + String failureReason, String remark, Long recordedBy, + String recordedByName, LocalDateTime recordedAt, Long verifiedBy, + String verifiedByName, LocalDateTime verifiedAt, + String verificationOpinion, Long returnedBy, String returnedByName, + LocalDateTime returnedAt, String returnOpinion, String status, long version) { + } + + record ResultLock(long id, String publicId, String resultNo, long paymentId, + String recordedFromStatus, String resultType, LocalDate paidDate, BigDecimal amount, + String failureReason, String remark, long recordedBy, + LocalDateTime recordedAt, Long verifiedBy, LocalDateTime verifiedAt, + String verificationOpinion, Long returnedBy, LocalDateTime returnedAt, + String returnOpinion, String status, long version) { + } + + record AttachmentRow(String publicId, String filePublicId, String originalName, + String documentType, String scanStatus, LocalDateTime createdAt) { + } + + record AttachmentExceptionRow(String publicId, String documentType, String documentName, long version, + String reason, String status, long requestedBy, + String requestedByName, LocalDateTime requestedAt, + String reviewedByName, LocalDateTime reviewedAt, + String reviewOpinion, LocalDateTime consumedAt) { + } + + record AttachmentExceptionLock(long id, String publicId, long paymentId, String documentType, + String documentName, + long version, String reason, String status, long requestedBy, + LocalDateTime consumedAt) { + } + + record DuplicateCandidateRow(long id, String publicId, BigDecimal requestedAmount, + LocalDate requestedDate, String purpose, String status) { + } + + record BlockingRiskRow(long id, String publicId, String riskType, String reason, + LocalDateTime effectiveFrom, LocalDateTime effectiveUntil) { + } + + record ControlExceptionRow(String publicId, String controlCode, String findingSummary, + long version, String reason, LocalDate validUntil, String status, + long requestedBy, String requestedByName, LocalDateTime requestedAt, + String reviewedByName, LocalDateTime reviewedAt, + String reviewOpinion, LocalDateTime consumedAt) { + } + + record ControlExceptionLock(long id, String publicId, long paymentId, String controlCode, + String findingFingerprint, String findingSummary, long version, + String reason, LocalDate validUntil, String status, + long requestedBy, Long reviewedBy, LocalDateTime consumedAt) { + } + + record ApprovedControlExceptionRow(long id, String publicId, String controlCode, + String findingFingerprint, LocalDate validUntil, + long reviewedBy) { + } + + record PayableOptionRow(String publicId, String businessNo, String projectPublicId, + String contractPublicId, String supplierPublicId, String costCategoryCode, + String costCategoryName, BigDecimal amount, BigDecimal paidAmount, + BigDecimal remainingAmount) { + } + + record BankAccountOptionRow(String publicId, String supplierPublicId, String accountName, + String bankName, String maskedAccountNo, int versionNo) { + } +} diff --git a/backend/src/main/java/com/kaidi/finance/project/api/ProjectController.java b/backend/src/main/java/com/kaidi/finance/project/api/ProjectController.java new file mode 100644 index 0000000..a9c8a6f --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/project/api/ProjectController.java @@ -0,0 +1,152 @@ +package com.kaidi.finance.project.api; + +import com.kaidi.finance.project.application.ProjectApplicationService; +import com.kaidi.finance.project.application.ProjectImportApplicationService; +import com.kaidi.finance.project.api.ProjectImportContracts.ProjectImportRequest; +import com.kaidi.finance.project.api.ProjectImportContracts.VersionCommandRequest; +import com.kaidi.finance.project.api.ProjectImportViews.ProjectImportSummaryView; +import com.kaidi.finance.shared.api.ApiResponse; +import com.kaidi.finance.shared.api.PageResult; +import com.kaidi.finance.shared.idempotency.IdempotencyApplicationService; +import com.fasterxml.jackson.core.type.TypeReference; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import java.time.LocalDate; +import java.util.List; +import io.swagger.v3.oas.annotations.Operation; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/projects") +public class ProjectController { + + private final ProjectApplicationService service; + private final ProjectImportApplicationService importService; + private final IdempotencyApplicationService idempotencyService; + + public ProjectController(ProjectApplicationService service, ProjectImportApplicationService importService, + IdempotencyApplicationService idempotencyService) { + this.service = service; + this.importService = importService; + this.idempotencyService = idempotencyService; + } + + @PostMapping + @PreAuthorize("@authorizationService.hasPermission('project:project:create')") + public ApiResponse create(@Valid @RequestBody ProjectCreateRequest request) { + return ApiResponse.ok(service.create(request)); + } + + @GetMapping + @PreAuthorize("@authorizationService.hasPermission('project:project:view')") + public ApiResponse> list( + @RequestParam(required = false) String companyId, + @RequestParam(required = false) String keyword, + @RequestParam(required = false) String ownerId, + @RequestParam(required = false) String customerId, + @RequestParam(required = false) String stage, + @RequestParam(required = false) String financeStatus, + @RequestParam(required = false) String archiveStatus, + @RequestParam(required = false) String status, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDateFrom, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDateTo, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDateFrom, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDateTo, + @RequestParam(defaultValue = "updatedAt,desc") String sort, + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int size) { + PageResult result = service.list(companyId, keyword, ownerId, customerId, + stage, financeStatus, archiveStatus, status, startDateFrom, startDateTo, + endDateFrom, endDateTo, sort, page, size); + return ApiResponse.ok(result.items(), result.meta()); + } + + @PostMapping("/exports") + @PreAuthorize("@authorizationService.hasPermission('project:project:export')") + public ApiResponse export(@Valid @RequestBody(required = false) ProjectExportRequest request) { + return ApiResponse.ok(service.export(request)); + } + + @PostMapping("/imports") + @PreAuthorize("@authorizationService.hasPermission('project:project:create')") + public ApiResponse importProjects( + @RequestHeader("Idempotency-Key") String idempotencyKey, + @Valid @RequestBody ProjectImportRequest request, HttpServletRequest httpRequest) { + return ApiResponse.ok(idempotencyService.execute(idempotencyKey, "POST", httpRequest.getRequestURI(), request, + new TypeReference() { }, () -> importService.create(request))); + } + + @GetMapping("/imports/{publicId}") + @PreAuthorize("@authorizationService.hasPermission('project:project:create')") + public ApiResponse getImport(@PathVariable String publicId) { + return ApiResponse.ok(importService.get(publicId)); + } + + @PostMapping("/imports/{publicId}/confirm") + @PreAuthorize("@authorizationService.hasPermission('project:project:create')") + public ApiResponse> confirmImport( + @PathVariable String publicId, @RequestHeader("Idempotency-Key") String idempotencyKey, + @Valid @RequestBody VersionCommandRequest request, HttpServletRequest httpRequest) { + return ApiResponse.ok(idempotencyService.execute(idempotencyKey, "POST", httpRequest.getRequestURI(), request, + new TypeReference>() { }, + () -> importService.confirm(publicId, request.version()))); + } + + @GetMapping("/{publicId}") + @PreAuthorize("@authorizationService.hasPermission('project:project:view')") + public ApiResponse get(@PathVariable String publicId) { + return ApiResponse.ok(service.get(publicId)); + } + + @GetMapping("/references") + @PreAuthorize("@authorizationService.hasPermission('project:project:view')") + public ApiResponse references() { + return ApiResponse.ok(service.references()); + } + + @PatchMapping("/{publicId}") + @PreAuthorize("@authorizationService.hasPermission('project:project:edit')") + public ApiResponse update(@PathVariable String publicId, + @Valid @RequestBody ProjectUpdateRequest request) { + return ApiResponse.ok(service.update(publicId, request)); + } + + @PostMapping("/{publicId}/finance-complete") + @Operation(operationId = "completeProjectFinance", summary = "确认项目财务资料完整") + @PreAuthorize("@authorizationService.hasPermission('project:project:finance-complete')") + public ApiResponse completeFinance( + @PathVariable String publicId, @Valid @RequestBody VersionCommandRequest request, + @RequestHeader(value = "Idempotency-Key", required = false) String key, + HttpServletRequest httpRequest) { + return ApiResponse.ok(idempotencyService.execute(key, httpRequest.getMethod(), httpRequest.getRequestURI(), + request, new TypeReference() { }, + () -> service.completeFinance(publicId, request))); + } + + @PostMapping("/{publicId}/risk-flags") + @PreAuthorize("@authorizationService.hasPermission('project:risk-flag:create')") + public ApiResponse createRiskFlag( + @PathVariable String publicId, + @Valid @RequestBody ProjectRiskFlagCreateRequest request) { + return ApiResponse.ok(service.createRiskFlag(publicId, request)); + } + + @PostMapping("/{publicId}/risk-flags/{riskPublicId}/resolve") + @PreAuthorize("@authorizationService.hasPermission('project:risk-flag:resolve')") + public ApiResponse resolveRiskFlag( + @PathVariable String publicId, + @PathVariable String riskPublicId, + @Valid @RequestBody ProjectRiskFlagResolveRequest request) { + return ApiResponse.ok(service.resolveRiskFlag(publicId, riskPublicId, request)); + } +} diff --git a/backend/src/main/java/com/kaidi/finance/project/api/ProjectCreateRequest.java b/backend/src/main/java/com/kaidi/finance/project/api/ProjectCreateRequest.java new file mode 100644 index 0000000..e151298 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/project/api/ProjectCreateRequest.java @@ -0,0 +1,19 @@ +package com.kaidi.finance.project.api; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import java.time.LocalDate; + +public record ProjectCreateRequest( + @NotBlank @Size(max = 64) String businessNo, + @NotBlank @Size(max = 200) String name, + @Size(max = 1000) String description, + @NotBlank @Pattern(regexp = "[0-9A-HJKMNP-TV-Z]{26}") String companyId, + @Pattern(regexp = "[0-9A-HJKMNP-TV-Z]{26}") String customerId, + @NotBlank @Pattern(regexp = "[0-9A-HJKMNP-TV-Z]{26}") String ownerId, + LocalDate startDate, + LocalDate endDate, + @NotBlank @Pattern(regexp = "[A-Za-z]{3}") String currency +) { +} diff --git a/backend/src/main/java/com/kaidi/finance/project/api/ProjectDetailView.java b/backend/src/main/java/com/kaidi/finance/project/api/ProjectDetailView.java new file mode 100644 index 0000000..edc3f84 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/project/api/ProjectDetailView.java @@ -0,0 +1,31 @@ +package com.kaidi.finance.project.api; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Set; + +public record ProjectDetailView( + String publicId, + String businessNo, + String name, + String description, + ProjectReferenceView company, + ProjectReferenceView customer, + ProjectReferenceView owner, + LocalDate startDate, + LocalDate endDate, + String currency, + String stage, + String financeStatus, + String archiveStatus, + String status, + long version, + LocalDateTime createdAt, + LocalDateTime updatedAt, + ProjectOverviewView overview, + List riskFlags, + List timeline, + Set allowedActions +) { +} diff --git a/backend/src/main/java/com/kaidi/finance/project/api/ProjectExportRequest.java b/backend/src/main/java/com/kaidi/finance/project/api/ProjectExportRequest.java new file mode 100644 index 0000000..51abc59 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/project/api/ProjectExportRequest.java @@ -0,0 +1,23 @@ +package com.kaidi.finance.project.api; + +import jakarta.validation.constraints.Size; +import java.time.LocalDate; + +/** Current-filter project list export. The service applies the same scope and masking as GET /projects. */ +public record ProjectExportRequest( + @Size(max = 26) String companyId, + @Size(max = 100) String keyword, + @Size(max = 26) String ownerId, + @Size(max = 26) String customerId, + @Size(max = 32) String stage, + @Size(max = 32) String financeStatus, + @Size(max = 32) String archiveStatus, + @Size(max = 32) String status, + LocalDate startDateFrom, + LocalDate startDateTo, + LocalDate endDateFrom, + LocalDate endDateTo, + @Size(max = 40) String sort, + @Size(max = 20) String[] columns +) { +} diff --git a/backend/src/main/java/com/kaidi/finance/project/api/ProjectExportView.java b/backend/src/main/java/com/kaidi/finance/project/api/ProjectExportView.java new file mode 100644 index 0000000..e106d77 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/project/api/ProjectExportView.java @@ -0,0 +1,14 @@ +package com.kaidi.finance.project.api; + +import java.util.List; + +public record ProjectExportView( + String exportId, + int rowCount, + String sha256, + String filterHash, + List columns, + String fileName, + String content +) { +} diff --git a/backend/src/main/java/com/kaidi/finance/project/api/ProjectImportContracts.java b/backend/src/main/java/com/kaidi/finance/project/api/ProjectImportContracts.java new file mode 100644 index 0000000..0c8eabf --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/project/api/ProjectImportContracts.java @@ -0,0 +1,42 @@ +package com.kaidi.finance.project.api; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import java.time.LocalDate; +import java.util.List; + +public final class ProjectImportContracts { + + private ProjectImportContracts() { + } + + public record ProjectImportRowRequest( + @NotNull @Positive Integer rowNo, + @NotBlank @Size(max = 64) String businessNo, + @NotBlank @Size(max = 200) String name, + @Size(max = 1000) String description, + @NotBlank @Size(max = 26) String companyId, + @Size(max = 26) String customerId, + @NotBlank @Size(max = 26) String ownerId, + LocalDate startDate, + LocalDate endDate, + @NotBlank @Pattern(regexp = "[A-Za-z]{3}") String currency + ) { + } + + public record ProjectImportRequest( + @NotBlank @Size(max = 26) String sourceFileId, + @NotBlank @Pattern(regexp = "[0-9a-fA-F]{64}") String fileSha256, + // Kept as an optional compatibility field for older clients. The + // server always parses sourceFileId and never trusts this copy. + @Size(max = 10_000) List<@Valid ProjectImportRowRequest> rows + ) { + } + + public record VersionCommandRequest(@NotNull Long version) { + } +} diff --git a/backend/src/main/java/com/kaidi/finance/project/api/ProjectImportViews.java b/backend/src/main/java/com/kaidi/finance/project/api/ProjectImportViews.java new file mode 100644 index 0000000..36f535d --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/project/api/ProjectImportViews.java @@ -0,0 +1,29 @@ +package com.kaidi.finance.project.api; + +import java.time.LocalDateTime; +import java.util.List; + +public final class ProjectImportViews { + + private ProjectImportViews() { + } + + public record ProjectImportIssueView(int rowNo, String fieldCode, String message) { + } + + public record ProjectImportSummaryView( + String publicId, + String batchNo, + String sourceFileId, + String fileSha256, + int totalRows, + int validRows, + int errorRows, + String status, + long version, + LocalDateTime createdAt, + LocalDateTime updatedAt, + List issues + ) { + } +} diff --git a/backend/src/main/java/com/kaidi/finance/project/api/ProjectListItemView.java b/backend/src/main/java/com/kaidi/finance/project/api/ProjectListItemView.java new file mode 100644 index 0000000..ea44f81 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/project/api/ProjectListItemView.java @@ -0,0 +1,30 @@ +package com.kaidi.finance.project.api; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.Set; + +public record ProjectListItemView( + String publicId, + String businessNo, + String name, + ProjectReferenceView company, + ProjectReferenceView customer, + ProjectReferenceView owner, + String contractAmount, + String receivedAmount, + String paidAmount, + String currency, + String stage, + String financeStatus, + String archiveStatus, + String status, + int pendingTaskCount, + int documentCompleteness, + LocalDate startDate, + LocalDate endDate, + long version, + LocalDateTime updatedAt, + Set allowedActions +) { +} diff --git a/backend/src/main/java/com/kaidi/finance/project/api/ProjectOverviewView.java b/backend/src/main/java/com/kaidi/finance/project/api/ProjectOverviewView.java new file mode 100644 index 0000000..381bf80 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/project/api/ProjectOverviewView.java @@ -0,0 +1,22 @@ +package com.kaidi.finance.project.api; + +public record ProjectOverviewView( + String contractAmount, + String receivedAmount, + String paidAmount, + String availableBalance, + int sourceFormCount, + int contractCount, + int receiptCount, + int invoiceCount, + int paymentCount, + int progressCount, + int peopleCount, + int accountingCount, + int voucherCount, + int pendingTaskCount, + int fileCount, + int activeRiskCount, + int documentCompleteness +) { +} diff --git a/backend/src/main/java/com/kaidi/finance/project/api/ProjectReferenceOptionsView.java b/backend/src/main/java/com/kaidi/finance/project/api/ProjectReferenceOptionsView.java new file mode 100644 index 0000000..3ed4c0a --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/project/api/ProjectReferenceOptionsView.java @@ -0,0 +1,10 @@ +package com.kaidi.finance.project.api; + +import java.util.List; + +public record ProjectReferenceOptionsView( + List companies, + List customers, + List owners +) { +} diff --git a/backend/src/main/java/com/kaidi/finance/project/api/ProjectReferenceView.java b/backend/src/main/java/com/kaidi/finance/project/api/ProjectReferenceView.java new file mode 100644 index 0000000..85370d7 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/project/api/ProjectReferenceView.java @@ -0,0 +1,4 @@ +package com.kaidi.finance.project.api; + +public record ProjectReferenceView(String publicId, String name) { +} diff --git a/backend/src/main/java/com/kaidi/finance/project/api/ProjectRiskFlagCreateRequest.java b/backend/src/main/java/com/kaidi/finance/project/api/ProjectRiskFlagCreateRequest.java new file mode 100644 index 0000000..324c564 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/project/api/ProjectRiskFlagCreateRequest.java @@ -0,0 +1,15 @@ +package com.kaidi.finance.project.api; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.time.OffsetDateTime; + +public record ProjectRiskFlagCreateRequest( + @NotBlank @Size(max = 32) String riskType, + @NotBlank @Size(max = 1000) String reason, + OffsetDateTime effectiveFrom, + OffsetDateTime effectiveUntil, + @NotNull Boolean blocking +) { +} diff --git a/backend/src/main/java/com/kaidi/finance/project/api/ProjectRiskFlagResolveRequest.java b/backend/src/main/java/com/kaidi/finance/project/api/ProjectRiskFlagResolveRequest.java new file mode 100644 index 0000000..14c4a56 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/project/api/ProjectRiskFlagResolveRequest.java @@ -0,0 +1,12 @@ +package com.kaidi.finance.project.api; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.PositiveOrZero; +import jakarta.validation.constraints.Size; + +public record ProjectRiskFlagResolveRequest( + @NotNull @PositiveOrZero Long version, + @NotBlank @Size(max = 1000) String reason +) { +} diff --git a/backend/src/main/java/com/kaidi/finance/project/api/ProjectRiskFlagView.java b/backend/src/main/java/com/kaidi/finance/project/api/ProjectRiskFlagView.java new file mode 100644 index 0000000..c6cb6e1 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/project/api/ProjectRiskFlagView.java @@ -0,0 +1,22 @@ +package com.kaidi.finance.project.api; + +import java.time.LocalDateTime; +import java.util.Set; + +public record ProjectRiskFlagView( + String publicId, + String riskType, + String reason, + LocalDateTime effectiveFrom, + LocalDateTime effectiveUntil, + boolean blocking, + String status, + long version, + String createdByName, + LocalDateTime createdAt, + String resolvedReason, + String resolvedByName, + LocalDateTime resolvedAt, + Set allowedActions +) { +} diff --git a/backend/src/main/java/com/kaidi/finance/project/api/ProjectSubresourceController.java b/backend/src/main/java/com/kaidi/finance/project/api/ProjectSubresourceController.java new file mode 100644 index 0000000..f820042 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/project/api/ProjectSubresourceController.java @@ -0,0 +1,334 @@ +package com.kaidi.finance.project.api; + +import com.kaidi.finance.archive.api.ArchiveViews.ArchiveFileView; +import com.kaidi.finance.archive.api.ArchiveViews.PackageView; +import com.kaidi.finance.archive.application.ArchiveApplicationService; +import com.kaidi.finance.accounting.api.AccountingViews.AccountingEventView; +import com.kaidi.finance.accounting.api.AccountingViews.VoucherView; +import com.kaidi.finance.accounting.application.AccountingApplicationService; +import com.kaidi.finance.operations.api.ContractCostViews.ContractLedgerPageView; +import com.kaidi.finance.operations.application.ContractCostApplicationService; +import com.kaidi.finance.payment.api.PaymentViews.PaymentView; +import com.kaidi.finance.payment.application.PaymentApplicationService; +import com.kaidi.finance.project.application.ProjectApplicationService; +import com.kaidi.finance.project.domain.ProjectRecord; +import com.kaidi.finance.receivable.api.ReceivableViews.InvoiceView; +import com.kaidi.finance.receivable.api.ReceivableViews.ReceivableView; +import com.kaidi.finance.receivable.api.ReceivableViews.ReceiptView; +import com.kaidi.finance.receivable.application.ReceivableApplicationService; +import com.kaidi.finance.shared.api.ApiResponse; +import com.kaidi.finance.shared.api.BusinessException; +import com.kaidi.finance.shared.api.ErrorCode; +import com.kaidi.finance.shared.api.PageResult; +import com.kaidi.finance.source.api.SourceContracts.SourceFormSummaryView; +import com.kaidi.finance.source.application.SourceFormApplicationService; +import io.swagger.v3.oas.annotations.Operation; +import java.time.LocalDate; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.HttpStatus; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * Read-only PAGE-08 project drill-down resources. + * + * Each method first checks the project visibility scope, then delegates the + * actual list query to the owning bounded context. This keeps the financial, + * source, accounting and archive permission checks in their original modules + * instead of widening GET /projects/{id} into an unbounded cross-domain DTO. + */ +@RestController +@RequestMapping("/api/v1/projects/{projectPublicId}") +public class ProjectSubresourceController { + + private final ProjectApplicationService projectService; + private final SourceFormApplicationService sourceService; + private final ContractCostApplicationService contractService; + private final ReceivableApplicationService receivableService; + private final PaymentApplicationService paymentService; + private final AccountingApplicationService accountingService; + private final ArchiveApplicationService archiveService; + + public ProjectSubresourceController(ProjectApplicationService projectService, + SourceFormApplicationService sourceService, + ContractCostApplicationService contractService, + ReceivableApplicationService receivableService, + PaymentApplicationService paymentService, + AccountingApplicationService accountingService, + ArchiveApplicationService archiveService) { + this.projectService = projectService; + this.sourceService = sourceService; + this.contractService = contractService; + this.receivableService = receivableService; + this.paymentService = paymentService; + this.accountingService = accountingService; + this.archiveService = archiveService; + } + + @GetMapping("/forms") + @Operation(operationId = "getProjectForms", summary = "查询项目来源表单") + @PreAuthorize("@authorizationService.hasPermission('source:form:view')") + public ApiResponse> forms( + @PathVariable("projectPublicId") String projectPublicId, + @RequestParam(required = false) String formType, + @RequestParam(required = false) String status, + @RequestParam(required = false) String sourceSystem, + @RequestParam(required = false) Boolean createdByMe, + @RequestParam(required = false) String keyword, + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int size) { + String projectId = readableProjectId(projectPublicId, "source:form:view"); + return page(sourceService.listForms(formType, projectId, status, sourceSystem, createdByMe, + keyword, page, size)); + } + + @GetMapping("/contracts") + @Operation(operationId = "getProjectContracts", summary = "查询项目合同与成本") + @PreAuthorize("@authorizationService.hasPermission('contractcost:ledger:view')") + public ApiResponse contracts( + @PathVariable("projectPublicId") String projectPublicId, + @RequestParam(required = false) String counterpartyId, + @RequestParam(required = false) String keyword, + @RequestParam(required = false) String status, + @RequestParam(required = false) String costCategoryId, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate businessDateFrom, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate businessDateTo, + @RequestParam(defaultValue = "updatedAt,desc") String sort, + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int size) { + String projectId = readableProjectId(projectPublicId, "contractcost:ledger:view"); + ContractCostApplicationService.LedgerPage result = contractService.list(null, projectId, + counterpartyId, keyword, status, costCategoryId, businessDateFrom, businessDateTo, sort, page, size); + return ApiResponse.ok(new ContractLedgerPageView(result.page().items(), result.summary()), result.page().meta()); + } + + @GetMapping("/receipts") + @Operation(operationId = "getProjectReceipts", summary = "查询项目应收、收款与发票") + @PreAuthorize("@authorizationService.hasPermission('receivable:receipt:view') or " + + "@authorizationService.hasPermission('receivable:invoice:view')") + public ApiResponse receipts( + @PathVariable("projectPublicId") String projectPublicId, + @RequestParam(defaultValue = "receipts") String view, + @RequestParam(required = false) String counterpartyId, + @RequestParam(required = false) String keyword, + @RequestParam(required = false) String status, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate dateFrom, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate dateTo, + @RequestParam(defaultValue = "updatedAt,desc") String sort, + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int size) { + String normalizedView = normalized(view); + if (!Set.of("receipts", "receivables", "invoices").contains(normalizedView)) { + throw invalidView("view 参数必须是 receipts、receivables 或 invoices"); + } + String projectId = readableProjectId(projectPublicId, + "invoices".equals(normalizedView) ? "receivable:invoice:view" : "receivable:receipt:view"); + return switch (normalizedView) { + case "receivables" -> page(receivableService.listReceivables(null, projectId, counterpartyId, + keyword, status, dateFrom, dateTo, sort, page, size)); + case "invoices" -> page(receivableService.listInvoices(null, projectId, counterpartyId, + keyword, status, dateFrom, dateTo, sort, page, size)); + case "receipts" -> page(receivableService.listReceipts(null, projectId, counterpartyId, + keyword, status, dateFrom, dateTo, sort, page, size)); + default -> throw new IllegalStateException("validated receipt view"); + }; + } + + /** Explicit alias for clients that model invoices as a separate tab resource. */ + @GetMapping("/invoices") + @Operation(operationId = "getProjectInvoices", summary = "查询项目发票") + @PreAuthorize("@authorizationService.hasPermission('receivable:invoice:view')") + public ApiResponse> invoices( + @PathVariable("projectPublicId") String projectPublicId, + @RequestParam(required = false) String counterpartyId, + @RequestParam(required = false) String keyword, + @RequestParam(required = false) String status, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate dateFrom, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate dateTo, + @RequestParam(defaultValue = "updatedAt,desc") String sort, + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int size) { + String projectId = readableProjectId(projectPublicId, "receivable:invoice:view"); + return page(receivableService.listInvoices(null, projectId, counterpartyId, keyword, status, + dateFrom, dateTo, sort, page, size)); + } + + @GetMapping("/payments") + @Operation(operationId = "getProjectPayments", summary = "查询项目付款") + @PreAuthorize("@authorizationService.hasPermission('payment:request:view')") + public ApiResponse> payments( + @PathVariable("projectPublicId") String projectPublicId, + @RequestParam(required = false) String tab, + @RequestParam(required = false) String supplierId, + @RequestParam(required = false) String status, + @RequestParam(required = false) String keyword, + @RequestParam(required = false) String riskCode, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate dateFrom, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate dateTo, + @RequestParam(defaultValue = "updatedAt,desc") String sort, + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int size) { + String projectId = readableProjectId(projectPublicId, "payment:request:view"); + return page(paymentService.listPayments(tab, null, projectId, supplierId, status, keyword, riskCode, + dateFrom, dateTo, sort, page, size)); + } + + @GetMapping("/progress") + @Operation(operationId = "getProjectProgress", summary = "查询项目履约与验收进度") + @PreAuthorize("@authorizationService.hasPermission('source:form:view')") + public ApiResponse> progress( + @PathVariable("projectPublicId") String projectPublicId, + @RequestParam(required = false) String view, + @RequestParam(required = false) String formType, + @RequestParam(required = false) String status, + @RequestParam(required = false) String keyword, + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int size) { + String projectId = readableProjectId(projectPublicId, "source:form:view"); + String effectiveFormType = progressFormType(view, formType); + return page(sourceService.listForms(effectiveFormType, projectId, status, null, null, keyword, page, size)); + } + + @GetMapping("/people") + @Operation(operationId = "getProjectPeople", summary = "查询项目人员") + @PreAuthorize("@authorizationService.hasPermission('source:form:view')") + public ApiResponse> people( + @PathVariable("projectPublicId") String projectPublicId, + @RequestParam(required = false) String view, + @RequestParam(required = false) String formType, + @RequestParam(required = false) String status, + @RequestParam(required = false) String keyword, + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int size) { + String projectId = readableProjectId(projectPublicId, "source:form:view"); + String effectiveFormType = peopleFormType(view, formType); + return page(sourceService.listForms(effectiveFormType, projectId, status, null, null, keyword, page, size)); + } + + @GetMapping("/accounting") + @Operation(operationId = "getProjectAccounting", summary = "查询项目记账准备") + @PreAuthorize("@authorizationService.hasPermission('accounting:event:view') or " + + "@authorizationService.hasPermission('accounting:voucher:view')") + public ApiResponse accounting( + @PathVariable("projectPublicId") String projectPublicId, + @RequestParam(defaultValue = "events") String view, + @RequestParam(required = false) String period, + @RequestParam(required = false) String eventType, + @RequestParam(required = false) String status, + @RequestParam(required = false) String externalVoucherNo, + @RequestParam(required = false) String keyword, + @RequestParam(required = false) String tab, + @RequestParam(defaultValue = "updatedAt,desc") String sort, + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int size) { + String normalizedView = normalized(view); + if (!Set.of("events", "vouchers").contains(normalizedView)) { + throw invalidView("view 参数必须是 events 或 vouchers"); + } + String projectId = readableProjectId(projectPublicId, + "vouchers".equals(normalizedView) ? "accounting:voucher:view" : "accounting:event:view"); + if ("vouchers".equals(normalizedView)) { + return page(accountingService.listProjectLifecycleVouchers(tab, period, projectId, eventType, status, + externalVoucherNo, keyword, sort, page, size)); + } + return page(accountingService.listProjectLifecycleEvents( + period, projectId, eventType, keyword, sort, page, size)); + } + + @GetMapping("/archives") + @Operation(operationId = "getProjectArchives", summary = "查询项目资料与档案") + @PreAuthorize("@authorizationService.hasPermission('archive:package:view') or " + + "@authorizationService.hasPermission('archive:file:view')") + public ApiResponse archives( + @PathVariable("projectPublicId") String projectPublicId, + @RequestParam(defaultValue = "packages") String view, + @RequestParam(required = false) String status, + @RequestParam(required = false) String completeness, + @RequestParam(required = false) Boolean frozen, + @RequestParam(required = false) String keyword, + @RequestParam(required = false) String formType, + @RequestParam(required = false) String fileName, + @RequestParam(required = false) String originalType, + @RequestParam(required = false) String sort, + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int size) { + String normalizedView = normalized(view); + if (!Set.of("packages", "files").contains(normalizedView)) { + throw invalidView("view 参数必须是 packages 或 files"); + } + String projectId = readableProjectId(projectPublicId, + "files".equals(normalizedView) ? "archive:file:view" : "archive:package:view"); + if ("files".equals(normalizedView)) { + return page(archiveService.listFiles(null, projectId, null, null, formType, fileName, null, + null, null, originalType, status, null, + sort == null || sort.isBlank() ? "uploadedAt,desc" : sort, page, size)); + } + // Project detail is a historical drill-down. Include archived and + // frozen revisions while the archive workbench keeps its prepare view. + return page(archiveService.listPackages("all", null, projectId, status, completeness, frozen, + keyword, sort == null || sort.isBlank() ? "updatedAt,desc" : sort, page, size)); + } + + @GetMapping("/timeline") + @Operation(operationId = "getProjectTimeline", summary = "查询项目操作时间线") + @PreAuthorize("@authorizationService.hasPermission('project:project:view')") + public ApiResponse> timeline( + @PathVariable("projectPublicId") String projectPublicId, + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int size) { + return page(projectService.timeline(projectPublicId, page, size)); + } + + @GetMapping("/risk-flags") + @Operation(operationId = "getProjectRiskFlags", summary = "查询项目风险标记") + @PreAuthorize("@authorizationService.hasPermission('project:risk-flag:view')") + public ApiResponse> riskFlags( + @PathVariable("projectPublicId") String projectPublicId, + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int size) { + return page(projectService.riskFlags(projectPublicId, page, size)); + } + + private String progressFormType(String view, String formType) { + if (formType != null && !formType.isBlank()) return formType; + return switch (normalized(view)) { + case "", "progress" -> "OA-10"; + case "acceptance" -> "OA-12"; + default -> throw invalidView("进度 view 参数必须是 progress 或 acceptance"); + }; + } + + private String peopleFormType(String view, String formType) { + if (formType != null && !formType.isBlank()) return formType; + return switch (normalized(view)) { + case "", "people", "changes" -> "OA-14"; + case "team" -> "OA-09"; + case "certificate-fees" -> "OA-13"; + default -> throw invalidView("人员 view 参数必须是 people、team、changes 或 certificate-fees"); + }; + } + + private String readableProjectId(String rawProjectId, String permissionCode) { + ProjectRecord project = projectService.requireReadableFor(rawProjectId, permissionCode); + return project.getPublicId(); + } + + private String normalized(String value) { + return value == null ? "" : value.trim().toLowerCase(Locale.ROOT); + } + + private BusinessException invalidView(String message) { + return new BusinessException(HttpStatus.BAD_REQUEST, ErrorCode.QUERY_PARAMETER_INVALID, message); + } + + private ApiResponse> page(PageResult result) { + return ApiResponse.ok(result.items(), result.meta()); + } +} diff --git a/backend/src/main/java/com/kaidi/finance/project/api/ProjectTimelineView.java b/backend/src/main/java/com/kaidi/finance/project/api/ProjectTimelineView.java new file mode 100644 index 0000000..37bd17c --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/project/api/ProjectTimelineView.java @@ -0,0 +1,7 @@ +package com.kaidi.finance.project.api; + +import java.time.LocalDateTime; + +public record ProjectTimelineView(String publicId, String actionCode, String resultCode, String reason, + String username, String activeRole, LocalDateTime occurredAt) { +} diff --git a/backend/src/main/java/com/kaidi/finance/project/api/ProjectUpdateRequest.java b/backend/src/main/java/com/kaidi/finance/project/api/ProjectUpdateRequest.java new file mode 100644 index 0000000..5673ae6 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/project/api/ProjectUpdateRequest.java @@ -0,0 +1,19 @@ +package com.kaidi.finance.project.api; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.PositiveOrZero; +import jakarta.validation.constraints.Size; +import java.time.LocalDate; + +public record ProjectUpdateRequest( + @NotBlank @Size(max = 200) String name, + @Size(max = 1000) String description, + @Pattern(regexp = "[0-9A-HJKMNP-TV-Z]{26}") String customerId, + @NotBlank @Pattern(regexp = "[0-9A-HJKMNP-TV-Z]{26}") String ownerId, + LocalDate startDate, + LocalDate endDate, + @NotNull @PositiveOrZero Long version +) { +} diff --git a/backend/src/main/java/com/kaidi/finance/project/application/ProjectApplicationService.java b/backend/src/main/java/com/kaidi/finance/project/application/ProjectApplicationService.java new file mode 100644 index 0000000..f14f6ce --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/project/application/ProjectApplicationService.java @@ -0,0 +1,827 @@ +package com.kaidi.finance.project.application; + +import com.kaidi.finance.iam.domain.FinancePrincipal; +import com.kaidi.finance.project.api.ProjectCreateRequest; +import com.kaidi.finance.project.api.ProjectDetailView; +import com.kaidi.finance.project.api.ProjectImportContracts.VersionCommandRequest; +import com.kaidi.finance.project.api.ProjectListItemView; +import com.kaidi.finance.project.api.ProjectOverviewView; +import com.kaidi.finance.project.api.ProjectReferenceView; +import com.kaidi.finance.project.api.ProjectReferenceOptionsView; +import com.kaidi.finance.project.api.ProjectRiskFlagCreateRequest; +import com.kaidi.finance.project.api.ProjectRiskFlagResolveRequest; +import com.kaidi.finance.project.api.ProjectRiskFlagView; +import com.kaidi.finance.project.api.ProjectTimelineView; +import com.kaidi.finance.project.api.ProjectUpdateRequest; +import com.kaidi.finance.project.domain.ProjectRecord; +import com.kaidi.finance.project.domain.ProjectRiskFlagRecord; +import com.kaidi.finance.project.infrastructure.ProjectMapper; +import com.kaidi.finance.shared.api.BusinessException; +import com.kaidi.finance.shared.api.ErrorCode; +import com.kaidi.finance.shared.api.PageResult; +import com.kaidi.finance.shared.audit.AuditService; +import com.kaidi.finance.shared.id.UlidGenerator; +import com.kaidi.finance.shared.security.AuthorizationService; +import com.kaidi.finance.shared.security.IdentityContext; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.text.Normalizer; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.Locale; +import java.util.Map; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Set; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class ProjectApplicationService { + + private static final String ULID_PATTERN = "[0-9A-HJKMNP-TV-Z]{26}"; + private static final int EXPORT_ROW_LIMIT = 50_000; + private static final List DEFAULT_EXPORT_COLUMNS = List.of( + "businessNo", "name", "company", "owner", "contractAmount", "receivedAmount", "paidAmount", + "stage", "pendingTaskCount", "documentCompleteness", "status" + ); + private static final Set EXPORT_COLUMNS = Set.of( + "publicId", "businessNo", "name", "company", "customer", "owner", "contractAmount", "receivedAmount", + "paidAmount", "currency", "stage", "financeStatus", "archiveStatus", "status", "pendingTaskCount", + "documentCompleteness", "startDate", "endDate", "version", "updatedAt" + ); + private static final Set PROJECT_STATUSES = Set.of("DRAFT", "ACTIVE", "SUSPENDED", "CLOSED", "ARCHIVED"); + private static final Set FINANCE_CONFIRMABLE_PROJECT_STATUSES = + Set.of("DRAFT", "ACTIVE", "SUSPENDED", "CLOSED"); + private static final Set PROJECT_STAGES = Set.of( + "INITIATION", "CONTRACTING", "EXECUTION", "ACCEPTANCE", "SETTLEMENT", "CLOSED" + ); + private static final Set FINANCE_STATUSES = Set.of("PENDING", "IN_PROGRESS", "COMPLETED", "BLOCKED"); + private static final Set ARCHIVE_STATUSES = Set.of( + "NOT_READY", "PREPARING", "CHECKING", "ARCHIVED", "RETURNED", "FROZEN" + ); + private static final Set RISK_TYPES = Set.of("LITIGATION", "PENALTY", "COMPLIANCE", "OTHER"); + private static final Map SORT_COLUMNS = Map.of( + "updatedAt", "project.updated_at", + "businessNo", "project.business_no", + "name", "project.name", + "contractAmount", "contract_amount", + "status", "project.status" + ); + + private final ProjectMapper mapper; + private final IdentityContext identityContext; + private final AuthorizationService authorizationService; + private final AuditService auditService; + private final UlidGenerator ulidGenerator; + private final ObjectMapper objectMapper; + + public ProjectApplicationService(ProjectMapper mapper, IdentityContext identityContext, + AuthorizationService authorizationService, AuditService auditService, + UlidGenerator ulidGenerator, ObjectMapper objectMapper) { + this.mapper = mapper; + this.identityContext = identityContext; + this.authorizationService = authorizationService; + this.auditService = auditService; + this.ulidGenerator = ulidGenerator; + this.objectMapper = objectMapper; + } + + @Transactional + public ProjectDetailView create(ProjectCreateRequest request) { + String companyPublicId = normalizePublicId(request.companyId(), "所属公司不存在或未生效"); + authorizationService.requireScope("project:project:create", companyPublicId, null); + validateDates(request.startDate(), request.endDate()); + Long companyId = mapper.findActiveCompanyId(companyPublicId); + if (companyId == null) { + throw validation("所属公司不存在或未生效"); + } + Long customerId = resolveCustomerId(request.customerId()); + Long ownerId = resolveOwnerId(request.ownerId()); + FinancePrincipal actor = identityContext.requirePrincipal(); + ProjectRecord project = new ProjectRecord(); + project.setPublicId(ulidGenerator.next()); + project.setCompanyId(companyId); + project.setBusinessNo(normalizeCode(request.businessNo())); + project.setName(request.name().trim()); + project.setDescription(nullableTrim(request.description(), 1000)); + project.setCustomerId(customerId); + project.setOwnerUserId(ownerId); + project.setStartDate(request.startDate()); + project.setEndDate(request.endDate()); + project.setCurrency(request.currency().trim().toUpperCase(Locale.ROOT)); + project.setCreatedBy(actor.userId()); + project.setUpdatedBy(actor.userId()); + try { + mapper.insertProject(project); + mapper.insertFundControl(project.getId(), project.getCurrency()); + } catch (DuplicateKeyException exception) { + throw new BusinessException(HttpStatus.CONFLICT, ErrorCode.DUPLICATE_RESOURCE, + "同一公司下的项目编号已存在"); + } + ProjectRecord stored = requireProject(project.getPublicId()); + ProjectDetailView view = detailView(stored); + auditService.recordScoped(stored.getCompanyPublicId(), stored.getPublicId(), + "PROJECT_CREATE", "PROJECT", stored.getPublicId(), "SUCCESS", null, null, view); + return view; + } + + @Transactional(readOnly = true) + public PageResult list(String companyId, String keyword, String ownerId, + String customerId, String stage, String financeStatus, + String archiveStatus, String status, + LocalDate startDateFrom, LocalDate startDateTo, + LocalDate endDateFrom, LocalDate endDateTo, + String sort, int page, int size) { + authorizationService.requirePermission("project:project:view"); + Page pageRequest = page(page, size); + String safeCompany = nullablePublicId(companyId); + String safeOwner = nullablePublicId(ownerId); + String safeCustomer = nullablePublicId(customerId); + String safeKeyword = nullableTrim(keyword, 100); + String safeStage = optionalEnum(stage, PROJECT_STAGES, "项目阶段"); + String safeFinanceStatus = optionalEnum(financeStatus, FINANCE_STATUSES, "财务状态"); + String safeArchiveStatus = optionalEnum(archiveStatus, ARCHIVE_STATUSES, "归档状态"); + String safeStatus = optionalEnum(status, PROJECT_STATUSES, "项目状态"); + validateRange(startDateFrom, startDateTo, "开始日期"); + validateRange(endDateFrom, endDateTo, "结束日期"); + String orderBy = orderBy(sort); + FinancePrincipal actor = identityContext.requirePrincipal(); + String roleCode = identityContext.requireActiveRole(); + long total = mapper.countProjects(actor.userId(), roleCode, safeCompany, safeKeyword, safeOwner, + safeCustomer, safeStage, safeFinanceStatus, safeArchiveStatus, safeStatus, + startDateFrom, startDateTo, endDateFrom, endDateTo); + var items = mapper.listProjects(actor.userId(), roleCode, safeCompany, safeKeyword, safeOwner, + safeCustomer, safeStage, safeFinanceStatus, safeArchiveStatus, safeStatus, + startDateFrom, startDateTo, endDateFrom, endDateTo, orderBy, + pageRequest.size(), pageRequest.offset()) + .stream().map(this::listView).toList(); + return new PageResult<>(items, total, pageRequest.page(), pageRequest.size()); + } + + /** + * Exports the same filtered, scope-limited and field-masked project list + * that the UI displays. A bounded page loop avoids introducing a second SQL + * projection that could drift from GET /projects semantics. + */ + @Transactional + public com.kaidi.finance.project.api.ProjectExportView export( + com.kaidi.finance.project.api.ProjectExportRequest request) { + authorizationService.requirePermission("project:project:export"); + var safe = request == null + ? new com.kaidi.finance.project.api.ProjectExportRequest(null, null, null, null, null, null, null, null, + null, null, null, null, null, null) + : request; + List columns = exportColumns(safe.columns()); + String filterJson = exportFilterJson(safe); + String filterHash = sha256(filterJson); + PageResult first = list(safe.companyId(), safe.keyword(), safe.ownerId(), safe.customerId(), + safe.stage(), safe.financeStatus(), safe.archiveStatus(), safe.status(), safe.startDateFrom(), + safe.startDateTo(), safe.endDateFrom(), safe.endDateTo(), safe.sort(), 1, 100); + if (first.totalElements() > EXPORT_ROW_LIMIT) { + throw validation("导出结果超过 50000 行,请缩小筛选范围"); + } + List rows = exportableRows(first.items()); + int page = 2; + while (!first.items().isEmpty() && rows.size() < first.totalElements()) { + PageResult next = list(safe.companyId(), safe.keyword(), safe.ownerId(), safe.customerId(), + safe.stage(), safe.financeStatus(), safe.archiveStatus(), safe.status(), safe.startDateFrom(), + safe.startDateTo(), safe.endDateFrom(), safe.endDateTo(), safe.sort(), page++, 100); + if (next.items().isEmpty()) { + break; + } + rows.addAll(exportableRows(next.items())); + first = next; + } + String content = "\uFEFF" + projectCsv(columns, rows); + String contentSha256 = sha256(content); + String exportId = ulidGenerator.next(); + FinancePrincipal actor = identityContext.requirePrincipal(); + String identityCode = identityContext.requireActiveRole(); + mapper.insertExportLog(exportId, com.kaidi.finance.shared.infrastructure.RequestContext.requestId(), + actor.publicId(), identityCode, filterJson, json(columns), rows.size(), contentSha256); + auditService.record("PROJECT_EXPORT", "PROJECT", null, "SUCCESS", filterHash, + Map.of("rowCount", rows.size()), Map.of("exportId", exportId, "sha256", contentSha256)); + return new com.kaidi.finance.project.api.ProjectExportView(exportId, rows.size(), contentSha256, filterHash, + columns, "projects.csv", content); + } + + /** + * GET /projects is governed by the view grant. An export must additionally + * honor the export grant's own data range: roles may be allowed to see a + * company while being allowed to download only selected projects. + */ + private List exportableRows(List rows) { + return rows.stream() + .filter(row -> authorizationService.hasScope("project:project:export", row.company().publicId(), row.publicId())) + .toList(); + } + + @Transactional(readOnly = true) + public ProjectDetailView get(String publicId) { + ProjectRecord project = requireReadable(publicId); + return detailView(project); + } + + /** + * Resolves a project and enforces the common PAGE-08 project visibility gate. + * Domain sub-resource queries call this before delegating to their owning + * module so that a caller can never use a project URL to probe another + * company's records. + */ + @Transactional(readOnly = true) + public ProjectRecord requireReadable(String publicId) { + ProjectRecord project = requireProject(publicId); + authorizationService.requireScope("project:project:view", project.getCompanyPublicId(), project.getPublicId()); + return project; + } + + /** + * Applies the PAGE-08 project shell gate together with the bounded + * context's own read grant. A project URL is an object lookup, not a + * broad list filter, so a missing domain range must be rejected instead + * of being represented as an indistinguishable empty tab. + */ + @Transactional(readOnly = true) + public ProjectRecord requireReadableFor(String publicId, String permissionCode) { + ProjectRecord project = requireReadable(publicId); + authorizationService.requireScope(permissionCode, project.getCompanyPublicId(), project.getPublicId()); + return project; + } + + @Transactional(readOnly = true) + public PageResult timeline(String publicId, int page, int size) { + ProjectRecord project = requireReadable(publicId); + Page request = page(page, size); + long total = mapper.countTimeline(project.getPublicId()); + var items = mapper.listTimelinePage(project.getPublicId(), request.size(), request.offset()).stream() + .map(item -> new ProjectTimelineView(item.publicId(), item.actionCode(), item.resultCode(), item.reason(), + item.username(), item.activeRole(), item.occurredAt())) + .toList(); + return new PageResult<>(items, total, request.page(), request.size()); + } + + @Transactional(readOnly = true) + public PageResult riskFlags(String publicId, int page, int size) { + ProjectRecord project = requireReadable(publicId); + authorizationService.requireScope("project:risk-flag:view", project.getCompanyPublicId(), project.getPublicId()); + Page request = page(page, size); + long total = mapper.countRiskFlags(project.getId()); + var items = mapper.listRiskFlagsPage(project.getId(), request.size(), request.offset()).stream() + .map(item -> riskView(item, project)) + .toList(); + return new PageResult<>(items, total, request.page(), request.size()); + } + + @Transactional(readOnly = true) + public ProjectReferenceOptionsView references() { + authorizationService.requirePermission("project:project:view"); + FinancePrincipal actor = identityContext.requirePrincipal(); + String roleCode = identityContext.requireActiveRole(); + return new ProjectReferenceOptionsView( + mapper.listVisibleCompanies(actor.userId(), roleCode), + mapper.listActiveCustomers(actor.userId(), roleCode), + mapper.listEnabledOwners(actor.userId(), roleCode) + ); + } + + @Transactional + public ProjectDetailView update(String publicId, ProjectUpdateRequest request) { + ProjectRecord current = requireProject(publicId); + authorizationService.requireScope("project:project:edit", current.getCompanyPublicId(), current.getPublicId()); + requireVersion(current.getVersion(), request.version()); + requireState(current.getStatus(), Set.of("DRAFT")); + validateDates(request.startDate(), request.endDate()); + ProjectDetailView before = detailView(current); + current.setName(request.name().trim()); + current.setDescription(nullableTrim(request.description(), 1000)); + current.setCustomerId(resolveCustomerId(request.customerId())); + current.setOwnerUserId(resolveOwnerId(request.ownerId())); + current.setStartDate(request.startDate()); + current.setEndDate(request.endDate()); + current.setUpdatedBy(identityContext.requirePrincipal().userId()); + if (mapper.updateProject(current) != 1) { + throw conflict(); + } + ProjectRecord afterProject = requireProject(publicId); + ProjectDetailView after = detailView(afterProject); + auditService.recordScoped(afterProject.getCompanyPublicId(), afterProject.getPublicId(), + "PROJECT_UPDATE", "PROJECT", afterProject.getPublicId(), "SUCCESS", null, before, after); + return after; + } + + /** + * Marks the project's finance work as complete. This is an explicit + * finance command rather than a writable list/detail field so the archive + * workflow has a durable, audited predecessor state. + */ + @Transactional + public ProjectDetailView completeFinance(String publicId, VersionCommandRequest request) { + ProjectRecord current = requireProject(publicId); + authorizationService.requireScope("project:project:finance-complete", + current.getCompanyPublicId(), current.getPublicId()); + requireVersion(current.getVersion(), request.version()); + if (!FINANCE_CONFIRMABLE_PROJECT_STATUSES.contains(current.getStatus())) { + throw state("当前项目状态不允许财务确认可归档"); + } + requireState(current.getFinanceStatus(), Set.of("PENDING", "IN_PROGRESS", "BLOCKED")); + FinancePrincipal actor = identityContext.requirePrincipal(); + if (mapper.completeFinance(current.getId(), request.version(), actor.userId()) != 1) { + throw conflict(); + } + ProjectRecord afterProject = requireProject(publicId); + ProjectDetailView after = detailView(afterProject); + auditService.recordScoped(afterProject.getCompanyPublicId(), afterProject.getPublicId(), + "PROJECT_FINANCE_CONFIRM", "PROJECT", afterProject.getPublicId(), "SUCCESS", + "财务确认可归档", detailView(current), after); + return after; + } + + /** + * Materializes the project created by an approved OA-01 source form. + * The workflow transaction owns authorization and submitter/reviewer separation. + */ + @Transactional + public void activateFromApprovedSource(String projectPublicId, String sourcePublicId) { + ProjectRecord current = requireProject(projectPublicId); + if ("ACTIVE".equals(current.getStatus())) { + return; + } + requireState(current.getStatus(), Set.of("DRAFT")); + long actorId = identityContext.requirePrincipal().userId(); + if (mapper.activateProject(current.getId(), current.getVersion(), actorId) != 1) { + throw conflict(); + } + ProjectRecord activated = requireProject(projectPublicId); + ProjectDetailView after = detailView(activated); + auditService.recordScoped(activated.getCompanyPublicId(), activated.getPublicId(), + "PROJECT_ACTIVATE_FROM_OA01", "PROJECT", activated.getPublicId(), + "SUCCESS", sourcePublicId, detailView(current), after); + } + + @Transactional + public ProjectRiskFlagView createRiskFlag(String projectPublicId, ProjectRiskFlagCreateRequest request) { + ProjectRecord project = requireProject(projectPublicId); + authorizationService.requireScope("project:risk-flag:create", project.getCompanyPublicId(), project.getPublicId()); + if (Set.of("CLOSED", "ARCHIVED").contains(project.getStatus())) { + throw state("已关闭或归档项目不能新增风险标记"); + } + String riskType = requiredRequestEnum(request.riskType(), RISK_TYPES, "风险类型"); + LocalDateTime effectiveFrom = utc(request.effectiveFrom(), LocalDateTime.now(ZoneOffset.UTC)); + LocalDateTime effectiveUntil = request.effectiveUntil() == null ? null : utc(request.effectiveUntil(), null); + if (effectiveUntil != null && effectiveUntil.isBefore(effectiveFrom)) { + throw validation("风险结束时间不能早于开始时间"); + } + if (mapper.countOverlappingRisk(project.getId(), riskType, effectiveFrom, effectiveUntil) > 0) { + throw new BusinessException(HttpStatus.CONFLICT, ErrorCode.DUPLICATE_RESOURCE, + "该项目已存在同类型且生效期重叠的风险标记"); + } + FinancePrincipal actor = identityContext.requirePrincipal(); + ProjectRiskFlagRecord risk = new ProjectRiskFlagRecord(); + risk.setPublicId(ulidGenerator.next()); + risk.setProjectId(project.getId()); + risk.setRiskType(riskType); + risk.setReason(request.reason().trim()); + risk.setEffectiveFrom(effectiveFrom); + risk.setEffectiveUntil(effectiveUntil); + risk.setBlocking(request.blocking()); + risk.setCreatedBy(actor.userId()); + risk.setUpdatedBy(actor.userId()); + mapper.insertRiskFlag(risk); + ProjectRiskFlagView view = riskView(requireRisk(risk.getPublicId()), project); + auditService.recordScoped(project.getCompanyPublicId(), project.getPublicId(), + "PROJECT_RISK_FLAG_CREATE", "PROJECT", project.getPublicId(), "SUCCESS", + request.reason().trim(), null, view); + return view; + } + + @Transactional + public ProjectRiskFlagView resolveRiskFlag(String projectPublicId, String riskPublicId, + ProjectRiskFlagResolveRequest request) { + ProjectRecord project = requireProject(projectPublicId); + authorizationService.requireScope("project:risk-flag:resolve", project.getCompanyPublicId(), project.getPublicId()); + ProjectRiskFlagRecord risk = requireRisk(riskPublicId); + if (risk.getProjectId() != project.getId()) { + throw notFound(); + } + requireVersion(risk.getVersion(), request.version()); + requireState(risk.getStatus(), Set.of("ACTIVE")); + ProjectRiskFlagView before = riskView(risk, project); + if (mapper.resolveRiskFlag(risk.getId(), request.version(), request.reason().trim(), + identityContext.requirePrincipal().userId()) != 1) { + throw conflict(); + } + ProjectRiskFlagView after = riskView(requireRisk(riskPublicId), project); + auditService.recordScoped(project.getCompanyPublicId(), project.getPublicId(), + "PROJECT_RISK_FLAG_RESOLVE", "PROJECT", project.getPublicId(), "SUCCESS", + request.reason().trim(), before, after); + return after; + } + + private ProjectDetailView detailView(ProjectRecord project) { + Set actions = projectActions(project); + boolean canViewRisks = authorizationService.hasScope( + "project:risk-flag:view", project.getCompanyPublicId(), project.getPublicId()); + var risks = canViewRisks + ? mapper.listRiskFlags(project.getId()).stream().map(risk -> riskView(risk, project)).toList() + : java.util.List.of(); + var timeline = mapper.listTimeline(project.getPublicId()).stream() + .map(item -> new ProjectTimelineView(item.publicId(), item.actionCode(), item.resultCode(), item.reason(), + item.username(), item.activeRole(), item.occurredAt())) + .toList(); + return new ProjectDetailView( + project.getPublicId(), project.getBusinessNo(), project.getName(), project.getDescription(), + reference(project.getCompanyPublicId(), project.getCompanyName()), + reference(project.getCustomerPublicId(), project.getCustomerName()), + reference(project.getOwnerPublicId(), project.getOwnerName()), + project.getStartDate(), project.getEndDate(), project.getCurrency(), project.getStage(), + project.getFinanceStatus(), project.getArchiveStatus(), project.getStatus(), project.getVersion(), + project.getCreatedAt(), project.getUpdatedAt(), + new ProjectOverviewView( + visibleMoney("contractcost:ledger:view", project, project.getContractAmount()), + visibleMoney("receivable:receipt:view", project, project.getConfirmedReceipt()), + visibleMoney("payment:request:view", project, project.getPaidAmount()), + visibleMoney("payment:request:view", project, project.getAvailableBalance()), + visibleCount("source:form:view", project, project.getSourceFormCount()), + visibleCount("contractcost:ledger:view", project, project.getContractCount()), + visibleCount("receivable:receipt:view", project, project.getReceiptCount()), + visibleCount("receivable:invoice:view", project, project.getInvoiceCount()), + visibleCount("payment:request:view", project, project.getPaymentCount()), + visibleCount("source:form:view", project, project.getProgressCount()), + visibleCount("source:form:view", project, project.getPeopleCount()), + visibleCount("accounting:event:view", project, project.getAccountingCount()), + visibleCount("accounting:voucher:view", project, project.getVoucherCount()), + visibleCount("source:form:view", project, project.getPendingTaskCount()), + visibleCount("archive:file:view", project, project.getFileCount()), + canViewRisks ? project.getActiveRiskCount() : 0, + visibleCount("archive:package:view", project, project.getDocumentCompleteness()) == 0 + ? 0 : project.getDocumentCompleteness()), + risks, timeline, actions + ); + } + + private String visibleMoney(String permission, ProjectRecord project, BigDecimal value) { + return authorizationService.hasScope(permission, project.getCompanyPublicId(), project.getPublicId()) + ? money(value) : money(BigDecimal.ZERO); + } + + private int visibleCount(String permission, ProjectRecord project, int value) { + return authorizationService.hasScope(permission, project.getCompanyPublicId(), project.getPublicId()) + ? value : 0; + } + + private ProjectListItemView listView(ProjectRecord project) { + return new ProjectListItemView( + project.getPublicId(), project.getBusinessNo(), project.getName(), + reference(project.getCompanyPublicId(), project.getCompanyName()), + reference(project.getCustomerPublicId(), project.getCustomerName()), + reference(project.getOwnerPublicId(), project.getOwnerName()), + visibleMoney("contractcost:ledger:view", project, project.getContractAmount()), + visibleMoney("receivable:receipt:view", project, project.getConfirmedReceipt()), + visibleMoney("payment:request:view", project, project.getPaidAmount()), + project.getCurrency(), project.getStage(), project.getFinanceStatus(), project.getArchiveStatus(), + project.getStatus(), + visibleCount("source:form:view", project, project.getPendingTaskCount()), + visibleCount("archive:package:view", project, project.getDocumentCompleteness()), + project.getStartDate(), project.getEndDate(), project.getVersion(), project.getUpdatedAt(), + projectActions(project) + ); + } + + private ProjectRiskFlagView riskView(ProjectRiskFlagRecord risk, ProjectRecord project) { + Set actions = new LinkedHashSet<>(); + if ("ACTIVE".equals(risk.getStatus()) + && authorizationService.hasScope("project:risk-flag:resolve", project.getCompanyPublicId(), project.getPublicId())) { + actions.add("RESOLVE"); + } + return new ProjectRiskFlagView(risk.getPublicId(), risk.getRiskType(), risk.getReason(), + risk.getEffectiveFrom(), risk.getEffectiveUntil(), risk.isBlocking(), risk.getStatus(), risk.getVersion(), + risk.getCreatedByName(), risk.getCreatedAt(), risk.getResolvedReason(), risk.getResolvedByName(), + risk.getResolvedAt(), Set.copyOf(actions)); + } + + private Set projectActions(ProjectRecord project) { + Set actions = new LinkedHashSet<>(); + if ("DRAFT".equals(project.getStatus()) + && authorizationService.hasScope("project:project:edit", project.getCompanyPublicId(), project.getPublicId())) { + actions.add("EDIT"); + } + if (FINANCE_CONFIRMABLE_PROJECT_STATUSES.contains(project.getStatus()) + && Set.of("PENDING", "IN_PROGRESS", "BLOCKED").contains(project.getFinanceStatus()) + && authorizationService.hasScope("project:project:finance-complete", + project.getCompanyPublicId(), project.getPublicId())) { + actions.add("CONFIRM_FINANCE"); + } + if (!Set.of("CLOSED", "ARCHIVED").contains(project.getStatus()) + && authorizationService.hasScope("project:risk-flag:create", project.getCompanyPublicId(), project.getPublicId())) { + actions.add("CREATE_RISK_FLAG"); + } + if (authorizationService.hasScope("project:project:export", project.getCompanyPublicId(), project.getPublicId())) { + actions.add("EXPORT"); + } + return Set.copyOf(actions); + } + + private List exportColumns(String[] requested) { + if (requested == null || requested.length == 0) { + return DEFAULT_EXPORT_COLUMNS; + } + LinkedHashSet selected = new LinkedHashSet<>(); + for (String value : requested) { + String column = value == null ? "" : value.trim(); + if (!EXPORT_COLUMNS.contains(column)) { + throw query("导出列不在允许范围内"); + } + selected.add(column); + } + if (selected.isEmpty()) { + throw query("至少选择一列导出字段"); + } + return List.copyOf(selected); + } + + private String exportFilterJson(com.kaidi.finance.project.api.ProjectExportRequest request) { + Map filter = new LinkedHashMap<>(); + filter.put("companyId", request.companyId()); + filter.put("keyword", request.keyword()); + filter.put("ownerId", request.ownerId()); + filter.put("customerId", request.customerId()); + filter.put("stage", request.stage()); + filter.put("financeStatus", request.financeStatus()); + filter.put("archiveStatus", request.archiveStatus()); + filter.put("status", request.status()); + filter.put("startDateFrom", request.startDateFrom()); + filter.put("startDateTo", request.startDateTo()); + filter.put("endDateFrom", request.endDateFrom()); + filter.put("endDateTo", request.endDateTo()); + filter.put("sort", request.sort() == null || request.sort().isBlank() ? "updatedAt,desc" : request.sort()); + return json(filter); + } + + private String projectCsv(List columns, List rows) { + StringBuilder output = new StringBuilder(); + output.append(columns.stream().map(ProjectApplicationService::csvCell) + .reduce((left, right) -> left + "," + right).orElse("")).append('\n'); + for (ProjectListItemView row : rows) { + List values = columns.stream().map(column -> projectColumn(row, column)).toList(); + output.append(values.stream().map(ProjectApplicationService::csvCell) + .reduce((left, right) -> left + "," + right).orElse("")).append('\n'); + } + return output.toString(); + } + + private String projectColumn(ProjectListItemView row, String column) { + return switch (column) { + case "publicId" -> row.publicId(); + case "businessNo" -> row.businessNo(); + case "name" -> row.name(); + case "company" -> referenceName(row.company()); + case "customer" -> referenceName(row.customer()); + case "owner" -> referenceName(row.owner()); + case "contractAmount" -> row.contractAmount(); + case "receivedAmount" -> row.receivedAmount(); + case "paidAmount" -> row.paidAmount(); + case "currency" -> row.currency(); + case "stage" -> row.stage(); + case "financeStatus" -> row.financeStatus(); + case "archiveStatus" -> row.archiveStatus(); + case "status" -> row.status(); + case "pendingTaskCount" -> Integer.toString(row.pendingTaskCount()); + case "documentCompleteness" -> Integer.toString(row.documentCompleteness()); + case "startDate" -> String.valueOf(row.startDate()); + case "endDate" -> String.valueOf(row.endDate()); + case "version" -> Long.toString(row.version()); + case "updatedAt" -> String.valueOf(row.updatedAt()); + default -> ""; + }; + } + + private String referenceName(ProjectReferenceView reference) { + return reference == null ? "" : reference.name(); + } + + private static String csvCell(String value) { + String safe = value == null || "null".equals(value) ? "" : value; + // Prevent spreadsheet applications from interpreting exported business data as formulas. + if (isSpreadsheetFormula(safe)) { + safe = "'" + safe; + } + return "\"" + safe.replace("\"", "\"\"") + "\""; + } + + private static boolean isSpreadsheetFormula(String value) { + int index = 0; + while (index < value.length() && Character.isWhitespace(value.charAt(index))) { + index++; + } + return index < value.length() && "=+-@".indexOf(value.charAt(index)) >= 0; + } + + private String json(Object value) { + try { + return objectMapper.writeValueAsString(value); + } catch (JsonProcessingException exception) { + throw new IllegalStateException("项目导出元数据无法序列化", exception); + } + } + + private static String sha256(String value) { + try { + return java.util.HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(value.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 is not available", exception); + } + } + + private ProjectReferenceView reference(String publicId, String name) { + return publicId == null ? null : new ProjectReferenceView(publicId, name); + } + + private ProjectRecord requireProject(String publicId) { + String normalized = normalizePublicId(publicId, "项目不存在或已不可用"); + ProjectRecord project = mapper.findProject(normalized); + if (project == null) { + throw notFound(); + } + return project; + } + + private ProjectRiskFlagRecord requireRisk(String publicId) { + String normalized = normalizePublicId(publicId, "风险标记不存在或已不可用"); + ProjectRiskFlagRecord risk = mapper.findRiskFlag(normalized); + if (risk == null) { + throw notFound(); + } + return risk; + } + + private Long resolveCustomerId(String publicId) { + if (publicId == null || publicId.isBlank()) { + return null; + } + Long id = mapper.findActiveCustomerId(normalizePublicId(publicId, "客户不存在或未生效")); + if (id == null) { + throw validation("客户不存在、未生效或不是客户类型"); + } + return id; + } + + private Long resolveOwnerId(String publicId) { + Long id = mapper.findEnabledOwnerId(normalizePublicId(publicId, "项目负责人不存在或已停用")); + if (id == null) { + throw validation("项目负责人不存在、已停用或没有项目管理身份"); + } + return id; + } + + private String normalizeCode(String value) { + return Normalizer.normalize(value.trim(), Normalizer.Form.NFKC).toUpperCase(Locale.ROOT); + } + + private String normalizePublicId(String value, String message) { + String normalized = value == null ? null : value.trim().toUpperCase(Locale.ROOT); + if (normalized == null || !normalized.matches(ULID_PATTERN)) { + throw new BusinessException(HttpStatus.NOT_FOUND, ErrorCode.RESOURCE_NOT_FOUND, message); + } + return normalized; + } + + private String nullablePublicId(String value) { + if (value == null || value.isBlank()) { + return null; + } + String normalized = value.trim().toUpperCase(Locale.ROOT); + if (!normalized.matches(ULID_PATTERN)) { + throw query("筛选条件中的业务 ID 格式不正确"); + } + return normalized; + } + + private String nullableTrim(String value, int maxLength) { + if (value == null || value.isBlank()) { + return null; + } + String trimmed = value.trim(); + if (trimmed.length() > maxLength) { + throw query("查询或字段内容超过长度限制"); + } + return trimmed; + } + + private String optionalEnum(String value, Set allowed, String label) { + if (value == null || value.isBlank()) { + return null; + } + return requiredQueryEnum(value, allowed, label); + } + + private String requiredQueryEnum(String value, Set allowed, String label) { + String normalized = value.trim().toUpperCase(Locale.ROOT); + if (!allowed.contains(normalized)) { + throw query(label + "不在允许范围内"); + } + return normalized; + } + + private String requiredRequestEnum(String value, Set allowed, String label) { + String normalized = value == null ? "" : value.trim().toUpperCase(Locale.ROOT); + if (!allowed.contains(normalized)) { + throw validation(label + "不在允许范围内"); + } + return normalized; + } + + private String orderBy(String sort) { + String value = sort == null || sort.isBlank() ? "updatedAt,desc" : sort.trim(); + String[] parts = value.split(",", -1); + if (parts.length != 2 || !SORT_COLUMNS.containsKey(parts[0])) { + throw query("排序字段不在允许范围内"); + } + String direction = parts[1].toUpperCase(Locale.ROOT); + if (!Set.of("ASC", "DESC").contains(direction)) { + throw query("排序方向只允许 asc 或 desc"); + } + return SORT_COLUMNS.get(parts[0]) + " " + direction; + } + + private Page page(int page, int size) { + if (page < 1 || !Set.of(20, 50, 100).contains(size)) { + throw query("页码从 1 开始,每页只允许 20、50 或 100 条"); + } + long offset = (long) (page - 1) * size; + if (offset > Integer.MAX_VALUE) { + throw query("页码超出允许范围"); + } + return new Page(page, size, (int) offset); + } + + private void validateDates(LocalDate startDate, LocalDate endDate) { + if (startDate != null && endDate != null && endDate.isBefore(startDate)) { + throw validation("项目结束日期不能早于开始日期"); + } + } + + private void validateRange(LocalDate from, LocalDate to, String label) { + if (from != null && to != null && to.isBefore(from)) { + throw query(label + "范围的结束日期不能早于开始日期"); + } + } + + private LocalDateTime utc(OffsetDateTime value, LocalDateTime defaultValue) { + return value == null ? defaultValue : value.withOffsetSameInstant(ZoneOffset.UTC).toLocalDateTime(); + } + + private String money(BigDecimal value) { + return (value == null ? BigDecimal.ZERO : value).setScale(2, RoundingMode.UNNECESSARY).toPlainString(); + } + + private void requireVersion(long current, long requested) { + if (current != requested) { + throw conflict(); + } + } + + private void requireState(String current, Set allowed) { + if (!allowed.contains(current)) { + throw state("当前状态不允许执行该操作"); + } + } + + private BusinessException query(String message) { + return new BusinessException(HttpStatus.BAD_REQUEST, ErrorCode.QUERY_PARAMETER_INVALID, message); + } + + private BusinessException validation(String message) { + return new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.VALIDATION_FAILED, message); + } + + private BusinessException state(String message) { + return new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.INVALID_STATE_TRANSITION, message); + } + + private BusinessException conflict() { + return new BusinessException(HttpStatus.CONFLICT, ErrorCode.VERSION_CONFLICT, + "数据已被其他人更新,请重新加载后再操作"); + } + + private BusinessException notFound() { + return new BusinessException(HttpStatus.NOT_FOUND, ErrorCode.RESOURCE_NOT_FOUND, "项目或风险标记不存在"); + } + + private record Page(int page, int size, int offset) { + } +} diff --git a/backend/src/main/java/com/kaidi/finance/project/application/ProjectImportApplicationService.java b/backend/src/main/java/com/kaidi/finance/project/application/ProjectImportApplicationService.java new file mode 100644 index 0000000..ba2fd52 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/project/application/ProjectImportApplicationService.java @@ -0,0 +1,470 @@ +package com.kaidi.finance.project.application; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.kaidi.finance.iam.domain.FinancePrincipal; +import com.kaidi.finance.project.api.ProjectDetailView; +import com.kaidi.finance.project.api.ProjectCreateRequest; +import com.kaidi.finance.project.api.ProjectImportContracts.ProjectImportRequest; +import com.kaidi.finance.project.api.ProjectImportContracts.ProjectImportRowRequest; +import com.kaidi.finance.project.api.ProjectImportViews.ProjectImportIssueView; +import com.kaidi.finance.project.api.ProjectImportViews.ProjectImportSummaryView; +import com.kaidi.finance.project.domain.ProjectImportRecords.ImportBatchRecord; +import com.kaidi.finance.project.domain.ProjectImportRecords.ImportBatchWrite; +import com.kaidi.finance.project.domain.ProjectImportRecords.ImportRowRecord; +import com.kaidi.finance.project.domain.ProjectImportRecords.ImportRowWrite; +import com.kaidi.finance.project.domain.ProjectRecord; +import com.kaidi.finance.project.infrastructure.ProjectImportMapper; +import com.kaidi.finance.project.infrastructure.ProjectMapper; +import com.kaidi.finance.shared.api.BusinessException; +import com.kaidi.finance.shared.api.ErrorCode; +import com.kaidi.finance.shared.audit.AuditService; +import com.kaidi.finance.shared.file.FileApplicationService; +import com.kaidi.finance.shared.file.FileObjectRecord; +import com.kaidi.finance.shared.id.UlidGenerator; +import com.kaidi.finance.shared.security.AuthorizationService; +import com.kaidi.finance.shared.security.IdentityContext; +import java.text.Normalizer; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.LocalDate; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.HexFormat; +import java.util.regex.Pattern; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** Two-phase project CSV import: validate/stage first, then atomically create projects. */ +@Service +public class ProjectImportApplicationService { + + private static final long MAX_IMPORT_FILE_BYTES = 50L * 1024 * 1024; + + private static final Pattern PUBLIC_ID = Pattern.compile("[0-9A-HJKMNP-TV-Z]{26}"); + private static final Pattern CURRENCY = Pattern.compile("[A-Z]{3}"); + + private final ProjectImportMapper mapper; + private final ProjectMapper projectMapper; + private final ProjectApplicationService projectService; + private final FileApplicationService fileService; + private final IdentityContext identityContext; + private final AuthorizationService authorizationService; + private final AuditService auditService; + private final UlidGenerator ulidGenerator; + private final ObjectMapper objectMapper; + private final ProjectImportCsvParser csvParser = new ProjectImportCsvParser(); + + public ProjectImportApplicationService(ProjectImportMapper mapper, ProjectMapper projectMapper, + ProjectApplicationService projectService, + FileApplicationService fileService, IdentityContext identityContext, + AuthorizationService authorizationService, AuditService auditService, + UlidGenerator ulidGenerator, ObjectMapper objectMapper) { + this.mapper = mapper; + this.projectMapper = projectMapper; + this.projectService = projectService; + this.fileService = fileService; + this.identityContext = identityContext; + this.authorizationService = authorizationService; + this.auditService = auditService; + this.ulidGenerator = ulidGenerator; + this.objectMapper = objectMapper; + } + + @Transactional + public ProjectImportSummaryView create(ProjectImportRequest request) { + authorizationService.requirePermission("project:project:create"); + FinancePrincipal actor = identityContext.requirePrincipal(); + FileObjectRecord file = requireFile(request.sourceFileId(), request.fileSha256(), actor); + if (mapper.findActiveBatchBySourceFileId(file.getId()) != null) { + throw duplicate("同一导入文件已经创建过有效批次"); + } + byte[] fileBytes = fileService.readAvailable(file.getPublicId()); + verifyContentHash(file, fileBytes); + ProjectImportCsvParser.ParsedCsv parsed; + try { + parsed = csvParser.parse(fileBytes); + } catch (ProjectImportCsvParser.CsvException exception) { + throw validation(exception.getMessage()); + } + List allIssues = new ArrayList<>(); + Set rowNumbers = new HashSet<>(); + Set businessKeys = new HashSet<>(); + List validations = new ArrayList<>(); + for (ProjectImportCsvParser.ParsedRow parsedRow : parsed.rows()) { + ProjectImportRowRequest sourceRow = parsedRow.request(); + List issues = new ArrayList<>(); + for (ProjectImportCsvParser.ParseIssue parseIssue : parsedRow.issues()) { + issues.add(issue(parsedRow.rowNo(), parseIssue.fieldCode(), parseIssue.message())); + } + if (sourceRow.rowNo() == null || sourceRow.rowNo() < 1 || !rowNumbers.add(sourceRow.rowNo())) { + issues.add(issue(sourceRow.rowNo() == null ? 0 : sourceRow.rowNo(), null, "行号必须为大于 0 的唯一整数")); + } + String companyId = canonicalReference(sourceRow.companyId(), ReferenceKind.COMPANY, + sourceRow.rowNo(), true, issues); + String customerId = canonicalReference(sourceRow.customerId(), ReferenceKind.CUSTOMER, + sourceRow.rowNo(), false, issues); + String ownerId = canonicalReference(sourceRow.ownerId(), ReferenceKind.OWNER, + sourceRow.rowNo(), true, issues); + ProjectImportRowRequest row = new ProjectImportRowRequest(sourceRow.rowNo(), sourceRow.businessNo(), + sourceRow.name(), sourceRow.description(), companyId, customerId, ownerId, + sourceRow.startDate(), sourceRow.endDate(), sourceRow.currency()); + String businessNo = normalizeCode(row.businessNo()); + if (companyId == null) { + issues.add(issue(row.rowNo(), "companyId", "公司标识格式无效")); + } + if (businessNo.isBlank()) { + issues.add(issue(row.rowNo(), "businessNo", "项目编号不能为空")); + } + if (companyId != null && !businessKeys.add(companyId + "\u0000" + businessNo)) { + issues.add(issue(row.rowNo(), "businessNo", "导入文件内存在相同公司和项目编号")); + } + NormalizedRow normalized = normalizeRow(row, companyId, businessNo, issues); + if (issues.isEmpty()) { + validateReferences(normalized, row.rowNo(), issues); + } + allIssues.addAll(issues); + validations.add(new RowValidation(normalized, issues)); + } + int validRows = (int) validations.stream().filter(item -> item.issues().isEmpty()).count(); + int errorRows = validations.size() - validRows; + String publicId = ulidGenerator.next(); + ImportBatchWrite batch = new ImportBatchWrite(); + batch.setPublicId(publicId); + batch.setBatchNo("PIMP-" + java.time.LocalDate.now(ZoneOffset.UTC).toString().replace("-", "") + + "-" + publicId.substring(18)); + batch.setSourceFileId(file.getId()); + batch.setFileSha256(file.getSha256()); + batch.setTotalRows(validations.size()); + batch.setValidRows(validRows); + batch.setErrorRows(errorRows); + batch.setValidationJson(json(allIssues)); + batch.setStatus(errorRows == 0 ? "READY" : "INVALID"); + batch.setActorId(actor.userId()); + try { + mapper.insertBatch(batch); + } catch (DuplicateKeyException exception) { + throw duplicate("同一导入文件已经创建过有效批次"); + } + for (RowValidation validation : validations) { + ImportRowWrite row = new ImportRowWrite(); + row.setBatchId(batch.getId()); + row.setRowNo(validation.row().rowNo()); + row.setPayloadJson(json(payload(validation.row()))); + row.setValidationJson(json(validation.issues())); + row.setStatus(validation.issues().isEmpty() ? "VALID" : "INVALID"); + mapper.insertRow(row); + } + ProjectImportSummaryView result = summary(mapper.findBatch(publicId)); + auditService.record("PROJECT_IMPORT_CREATE", "PROJECT_IMPORT", publicId, "SUCCESS", null, null, result); + return result; + } + + @Transactional(readOnly = true) + public ProjectImportSummaryView get(String publicId) { + authorizationService.requirePermission("project:project:create"); + ImportBatchRecord batch = requireBatch(publicId); + requireOwner(batch); + return summary(batch); + } + + @Transactional + public List confirm(String publicId, long expectedVersion) { + authorizationService.requirePermission("project:project:create"); + FinancePrincipal actor = identityContext.requirePrincipal(); + ImportBatchRecord batch = mapper.findBatchForUpdate(publicId); + if (batch == null) throw notFound(); + requireOwner(batch); + if (batch.version() != expectedVersion) throw conflict(); + if (!"READY".equals(batch.status())) throw invalidState("只有校验通过的导入批次可以确认"); + FileObjectRecord sourceFile = requireFile(batch.sourceFilePublicId(), batch.fileSha256(), actor); + verifyContentHash(sourceFile, fileService.readAvailable(sourceFile.getPublicId())); + List rows = mapper.listRows(batch.id()); + if (rows.isEmpty() || rows.stream().anyMatch(row -> !"VALID".equals(row.status()))) { + throw invalidState("导入批次包含未通过校验的行"); + } + Set businessKeys = new LinkedHashSet<>(); + List created = new ArrayList<>(); + try { + for (ImportRowRecord row : rows) { + NormalizedRow normalized = fromPayload(readJson(row.payloadJson())); + List issues = new ArrayList<>(); + validateReferences(normalized, row.rowNo(), issues); + if (!issues.isEmpty()) throw validation(issues.get(0).message()); + String key = normalized.companyId() + "\u0000" + normalized.businessNo(); + if (!businessKeys.add(key)) throw duplicate("导入批次中存在重复项目编号"); + Long companyId = projectMapper.findActiveCompanyId(normalized.companyId()); + if (companyId == null || mapper.countExistingBusinessNo(companyId, normalized.businessNo()) > 0) { + throw duplicate("同一公司下的项目编号已存在: " + normalized.businessNo()); + } + created.add(projectService.create(new ProjectCreateRequest( + normalized.businessNo(), normalized.name(), normalized.description(), normalized.companyId(), + normalized.customerId(), normalized.ownerId(), normalized.startDate(), normalized.endDate(), + normalized.currency()))); + } + } catch (DuplicateKeyException exception) { + throw duplicate("同一公司下的项目编号已存在"); + } + if (mapper.markRowsConfirmed(batch.id()) != rows.size() + || mapper.markBatchConfirmed(batch.id(), expectedVersion, actor.userId()) != 1) { + throw conflict(); + } + ImportBatchRecord confirmedBatch = mapper.findBatch(publicId); + auditService.record("PROJECT_IMPORT_CONFIRM", "PROJECT_IMPORT", publicId, "SUCCESS", null, null, + java.util.Map.of("batch", summary(confirmedBatch), "projectIds", + created.stream().map(ProjectDetailView::publicId).toList())); + return created; + } + + private void validateReferences(NormalizedRow row, int rowNo, List issues) { + if (row.companyId() == null) return; + if (!authorizationService.hasScope("project:project:create", row.companyId(), null)) { + issues.add(issue(rowNo, "companyId", "当前身份无权在该公司创建项目")); + } + Long companyId = projectMapper.findActiveCompanyId(row.companyId()); + if (companyId == null) { + issues.add(issue(rowNo, "companyId", "公司不存在、未生效或不在当前范围")); + return; + } + if (row.customerId() != null && projectMapper.findActiveCustomerId(row.customerId()) == null) { + issues.add(issue(rowNo, "customerId", "客户不存在、未生效或不是客户类型")); + } + if (projectMapper.findEnabledOwnerId(row.ownerId()) == null) { + issues.add(issue(rowNo, "ownerId", "项目负责人不存在、已停用或没有项目管理身份")); + } + if (mapper.countExistingBusinessNo(companyId, row.businessNo()) > 0) { + issues.add(issue(rowNo, "businessNo", "同一公司下的项目编号已存在")); + } + } + + private NormalizedRow normalizeRow(ProjectImportRowRequest row, String companyId, String businessNo, + List issues) { + String name = row.name() == null ? "" : row.name().trim(); + if (name.isBlank()) issues.add(issue(row.rowNo(), "name", "项目名称不能为空")); + String description = row.description() == null || row.description().isBlank() ? null : row.description().trim(); + if (businessNo.length() > 64) { + issues.add(issue(row.rowNo(), "businessNo", "项目编号最多 64 个字符")); + } + if (name.length() > 200) { + issues.add(issue(row.rowNo(), "name", "项目名称最多 200 个字符")); + } + if (description != null && description.length() > 1000) { + issues.add(issue(row.rowNo(), "description", "项目说明最多 1000 个字符")); + } + if (row.startDate() != null && row.endDate() != null && row.endDate().isBefore(row.startDate())) { + issues.add(issue(row.rowNo(), "endDate", "项目结束日期不能早于开始日期")); + } + String currency = row.currency() == null ? "" : row.currency().trim().toUpperCase(Locale.ROOT); + if (!CURRENCY.matcher(currency).matches()) issues.add(issue(row.rowNo(), "currency", "币种必须为 3 位字母")); + String customerId = normalizeId(row.customerId()); + String ownerId = normalizeId(row.ownerId()); + if (row.customerId() != null && customerId == null) { + issues.add(issue(row.rowNo(), "customerId", "客户标识格式无效")); + } + if (ownerId == null) issues.add(issue(row.rowNo(), "ownerId", "负责人标识格式无效")); + return new NormalizedRow(row.rowNo(), businessNo, name, description, companyId, customerId, ownerId, + row.startDate(), row.endDate(), currency); + } + + private ObjectNode payload(NormalizedRow row) { + ObjectNode payload = objectMapper.createObjectNode(); + payload.put("rowNo", row.rowNo()); + payload.put("businessNo", row.businessNo()); + payload.put("name", row.name()); + if (row.description() == null) payload.putNull("description"); else payload.put("description", row.description()); + payload.put("companyId", row.companyId()); + if (row.customerId() == null) payload.putNull("customerId"); else payload.put("customerId", row.customerId()); + payload.put("ownerId", row.ownerId()); + if (row.startDate() == null) payload.putNull("startDate"); else payload.put("startDate", row.startDate().toString()); + if (row.endDate() == null) payload.putNull("endDate"); else payload.put("endDate", row.endDate().toString()); + payload.put("currency", row.currency()); + return payload; + } + + private NormalizedRow fromPayload(JsonNode payload) { + return new NormalizedRow(payload.path("rowNo").asInt(), payload.path("businessNo").asText(), + payload.path("name").asText(), nullableText(payload, "description"), payload.path("companyId").asText(), + nullableText(payload, "customerId"), payload.path("ownerId").asText(), nullableDate(payload, "startDate"), + nullableDate(payload, "endDate"), payload.path("currency").asText().toUpperCase(Locale.ROOT)); + } + + private LocalDate nullableDate(JsonNode node, String field) { + String value = nullableText(node, field); + return value == null ? null : LocalDate.parse(value); + } + + private String nullableText(JsonNode node, String field) { + return node.hasNonNull(field) && !node.path(field).asText().isBlank() ? node.path(field).asText() : null; + } + + private FileObjectRecord requireFile(String publicId, String sha256, FinancePrincipal actor) { + if (publicId == null || !PUBLIC_ID.matcher(publicId.trim().toUpperCase(Locale.ROOT)).matches()) { + throw validation("导入文件标识格式无效"); + } + FileObjectRecord file = fileService.require(publicId.trim().toUpperCase(Locale.ROOT)); + if (!"csv".equalsIgnoreCase(file.getExtension())) { + throw new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.FILE_TYPE_NOT_ALLOWED, + "项目导入目前只支持 CSV 文件"); + } + if (file.getSizeBytes() > MAX_IMPORT_FILE_BYTES) { + throw validation("项目导入文件不得超过 50MB"); + } + if (!actor.publicId().equals(file.getUploadedBy()) && !identityContext.isSystemAdministrator()) { + throw new BusinessException(HttpStatus.FORBIDDEN, ErrorCode.PERMISSION_DENIED, "只能使用当前账号上传的导入文件"); + } + if (!"AVAILABLE".equals(file.getScanStatus())) { + throw new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.FILE_REJECTED, "导入文件未通过安全检查"); + } + if (sha256 == null || !file.getSha256().equalsIgnoreCase(sha256.trim())) { + throw validation("文件摘要与服务端记录不一致"); + } + return file; + } + + private void verifyContentHash(FileObjectRecord file, byte[] bytes) { + String actual = sha256(bytes); + if (file.getSha256() == null || !file.getSha256().equalsIgnoreCase(actual)) { + throw new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.VALIDATION_FAILED, + "文件内容摘要与存储记录不一致,文件可能已被替换"); + } + } + + private String sha256(byte[] bytes) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 is not available", exception); + } + } + + private String canonicalReference(String value, ReferenceKind kind, Integer rowNo, boolean required, + List issues) { + if (value == null || value.isBlank()) { + if (required) issues.add(issue(rowNo, kind.fieldCode, kind.label + "不能为空")); + return null; + } + String trimmed = value.trim(); + String id = normalizeId(trimmed); + if (id != null) return id; + List matches = switch (kind) { + case COMPANY -> projectMapper.findActiveCompaniesByName(trimmed); + case CUSTOMER -> projectMapper.findActiveCustomersByName(trimmed); + case OWNER -> projectMapper.findEnabledOwnersByName(trimmed); + }; + if (matches.size() == 1) return matches.get(0).publicId(); + if (matches.isEmpty()) { + issues.add(issue(rowNo, kind.fieldCode, kind.label + "不存在,请填写公共 ID 或唯一名称")); + } else { + issues.add(issue(rowNo, kind.fieldCode, kind.label + "存在重名,请填写 26 位公共 ID")); + } + return null; + } + + private enum ReferenceKind { + COMPANY("companyId", "公司"), CUSTOMER("customerId", "客户"), OWNER("ownerId", "负责人"); + + private final String fieldCode; + private final String label; + + ReferenceKind(String fieldCode, String label) { + this.fieldCode = fieldCode; + this.label = label; + } + } + + private ImportBatchRecord requireBatch(String publicId) { + ImportBatchRecord batch = mapper.findBatch(publicId); + if (batch == null) throw notFound(); + return batch; + } + + private void requireOwner(ImportBatchRecord batch) { + if (batch.createdBy() != identityContext.requirePrincipal().userId() + && !identityContext.isSystemAdministrator()) { + throw new BusinessException(HttpStatus.FORBIDDEN, ErrorCode.PERMISSION_DENIED, "只能查看当前账号创建的导入批次"); + } + } + + private ProjectImportSummaryView summary(ImportBatchRecord batch) { + return new ProjectImportSummaryView(batch.publicId(), batch.batchNo(), batch.sourceFilePublicId(), batch.fileSha256(), + batch.totalRows(), batch.validRows(), batch.errorRows(), batch.status(), batch.version(), batch.createdAt(), + batch.updatedAt(), issues(batch.validationJson())); + } + + private List issues(String value) { + if (value == null || value.isBlank()) return List.of(); + try { + return objectMapper.readerForListOf(ProjectImportIssueView.class).readValue(value); + } catch (JsonProcessingException exception) { + return List.of(new ProjectImportIssueView(0, null, "导入批次校验摘要损坏")); + } + } + + private JsonNode readJson(String value) { + try { + return objectMapper.readTree(value); + } catch (JsonProcessingException exception) { + throw new IllegalStateException("导入批次数据损坏", exception); + } + } + + private String json(Object value) { + try { + return objectMapper.writeValueAsString(value); + } catch (JsonProcessingException exception) { + throw new IllegalStateException("导入数据无法序列化", exception); + } + } + + private String normalizeId(String value) { + if (value == null || value.isBlank()) return null; + String normalized = value.trim().toUpperCase(Locale.ROOT); + return PUBLIC_ID.matcher(normalized).matches() ? normalized : null; + } + + private String normalizeCode(String value) { + return value == null ? "" : Normalizer.normalize(value.trim(), Normalizer.Form.NFKC).toUpperCase(Locale.ROOT); + } + + private ProjectImportIssueView issue(Integer rowNo, String field, String message) { + return new ProjectImportIssueView(rowNo == null ? 0 : rowNo, field, message); + } + + private BusinessException validation(String message) { + return new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.VALIDATION_FAILED, message); + } + + private BusinessException duplicate(String message) { + return new BusinessException(HttpStatus.CONFLICT, ErrorCode.DUPLICATE_RESOURCE, message); + } + + private BusinessException conflict() { + return new BusinessException(HttpStatus.CONFLICT, ErrorCode.VERSION_CONFLICT, "导入批次已被其他人处理,请重新加载"); + } + + private BusinessException invalidState(String message) { + return new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.INVALID_STATE_TRANSITION, message); + } + + private BusinessException notFound() { + return new BusinessException(HttpStatus.NOT_FOUND, ErrorCode.RESOURCE_NOT_FOUND, "导入批次不存在或已失效"); + } + + private record NormalizedRow(int rowNo, String businessNo, String name, String description, String companyId, + String customerId, String ownerId, LocalDate startDate, LocalDate endDate, + String currency) { + } + + private record RowValidation(NormalizedRow row, List issues) { + } +} diff --git a/backend/src/main/java/com/kaidi/finance/project/application/ProjectImportCsvParser.java b/backend/src/main/java/com/kaidi/finance/project/application/ProjectImportCsvParser.java new file mode 100644 index 0000000..a9ce13b --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/project/application/ProjectImportCsvParser.java @@ -0,0 +1,236 @@ +package com.kaidi.finance.project.application; + +import com.kaidi.finance.project.api.ProjectImportContracts.ProjectImportRowRequest; +import java.nio.ByteBuffer; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.CodingErrorAction; +import java.nio.charset.StandardCharsets; +import java.time.LocalDate; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +/** + * Small RFC-4180 compatible parser for the fixed PAGE-07 project template. + * It intentionally has no dependency on a spreadsheet engine: project import + * is CSV-only in the first delivery and the server, not the browser, is the + * source of truth for the staged rows. + */ +final class ProjectImportCsvParser { + + private static final int MAX_ROWS = 10_000; + private static final int MAX_COLUMNS = 9; + private static final Map HEADER_ALIASES = aliases(); + + ParsedCsv parse(byte[] bytes) { + final String text; + try { + text = StandardCharsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + .decode(ByteBuffer.wrap(bytes)) + .toString(); + // UTF-8 BOM is permitted in exports from Excel and the browser + // template. A BOM anywhere else remains ordinary data. + } catch (CharacterCodingException exception) { + throw new CsvException("文件不是有效的 UTF-8 CSV"); + } + String content = text.startsWith("\uFEFF") ? text.substring(1) : text; + List records = records(content); + if (records.size() < 2) { + throw new CsvException("CSV 至少需要一行表头和一行数据"); + } + Map columns = header(records.get(0)); + List rows = new ArrayList<>(); + for (int index = 1; index < records.size(); index++) { + Record record = records.get(index); + if (record.cells().stream().allMatch(value -> value == null || value.trim().isEmpty())) { + continue; + } + if (rows.size() >= MAX_ROWS) { + throw new CsvException("单次最多导入 10,000 行,请拆分文件"); + } + List issues = new ArrayList<>(); + if (record.cells().size() > MAX_COLUMNS) { + issues.add(new ParseIssue(null, "列数超过模板定义")); + } + String businessNo = value(record, columns, "businessNo"); + String name = value(record, columns, "name"); + String description = nullable(value(record, columns, "description")); + String companyId = value(record, columns, "companyId"); + String customerId = nullable(value(record, columns, "customerId")); + String ownerId = value(record, columns, "ownerId"); + LocalDate startDate = date(record, columns, "startDate", issues); + LocalDate endDate = date(record, columns, "endDate", issues); + String currency = value(record, columns, "currency"); + rows.add(new ParsedRow(record.rowNo(), new ProjectImportRowRequest( + record.rowNo(), businessNo, name, description, companyId, customerId, ownerId, + startDate, endDate, currency), issues)); + } + if (rows.isEmpty()) throw new CsvException("CSV 没有可导入的数据行"); + return new ParsedCsv(rows); + } + + private Map header(Record record) { + Map columns = new HashMap<>(); + Set seen = new HashSet<>(); + for (int index = 0; index < record.cells().size(); index++) { + String raw = record.cells().get(index) == null ? "" : record.cells().get(index).trim(); + String canonical = HEADER_ALIASES.get(raw); + if (canonical == null) throw new CsvException("CSV 表头包含未知列: " + raw); + if (!seen.add(canonical)) throw new CsvException("CSV 表头存在重复列: " + raw); + columns.put(canonical, index); + } + for (String required : List.of("businessNo", "name", "companyId", "ownerId", "currency")) { + if (!columns.containsKey(required)) throw new CsvException("CSV 缺少必填列: " + required); + } + return columns; + } + + private String value(Record record, Map columns, String name) { + Integer index = columns.get(name); + if (index == null || index >= record.cells().size()) return ""; + return record.cells().get(index) == null ? "" : record.cells().get(index).trim(); + } + + private String nullable(String value) { + return value == null || value.isBlank() ? null : value; + } + + private LocalDate date(Record record, Map columns, String name, List issues) { + String raw = nullable(value(record, columns, name)); + if (raw == null) return null; + try { + return LocalDate.parse(raw); + } catch (DateTimeParseException exception) { + issues.add(new ParseIssue(name, "日期必须使用 YYYY-MM-DD 格式")); + return null; + } + } + + private List records(String text) { + List result = new ArrayList<>(); + List row = new ArrayList<>(); + StringBuilder cell = new StringBuilder(); + boolean quoted = false; + boolean fieldStarted = false; + boolean afterQuote = false; + int physicalLine = 1; + int rowStart = 1; + for (int index = 0; index < text.length(); index++) { + char current = text.charAt(index); + char next = index + 1 < text.length() ? text.charAt(index + 1) : 0; + if (quoted) { + if (current == '"' && next == '"') { + cell.append('"'); + index++; + } else if (current == '"') { + quoted = false; + afterQuote = true; + } else { + if (current == '\r') { + physicalLine++; + if (next == '\n') index++; + cell.append('\n'); + } else { + if (current == '\n') physicalLine++; + cell.append(current); + } + } + continue; + } + if (afterQuote) { + if (current == ',') { + row.add(cell.toString()); + cell.setLength(0); + fieldStarted = false; + afterQuote = false; + } else if (current == '\r' || current == '\n') { + if (current == '\r' && next == '\n') index++; + physicalLine++; + finishRecord(result, row, cell, rowStart, physicalLine); + row = new ArrayList<>(); + rowStart = physicalLine; + fieldStarted = false; + afterQuote = false; + } else if (!Character.isWhitespace(current)) { + throw new CsvException("CSV 第 " + rowStart + " 行的引号后存在无效字符"); + } + continue; + } + if (current == '"' && !fieldStarted && cell.length() == 0) { + quoted = true; + fieldStarted = true; + } else if (current == ',') { + row.add(cell.toString()); + cell.setLength(0); + fieldStarted = false; + } else if (current == '\r' || current == '\n') { + if (current == '\r' && next == '\n') index++; + physicalLine++; + finishRecord(result, row, cell, rowStart, physicalLine); + row = new ArrayList<>(); + rowStart = physicalLine; + fieldStarted = false; + } else { + fieldStarted = true; + cell.append(current); + } + } + if (quoted) throw new CsvException("CSV 第 " + rowStart + " 行存在未闭合的双引号"); + if (cell.length() > 0 || !row.isEmpty() || fieldStarted) { + finishRecord(result, row, cell, rowStart, physicalLine); + } + return result; + } + + private void finishRecord(List result, List row, StringBuilder cell, + int rowStart, int physicalLine) { + row.add(cell.toString()); + cell.setLength(0); + if (row.stream().anyMatch(value -> value != null && !value.trim().isEmpty())) { + result.add(new Record(rowStart, List.copyOf(row))); + } + } + + private static Map aliases() { + Map map = new HashMap<>(); + put(map, "businessNo", "项目编号", "businessNo"); + put(map, "name", "项目名称", "name"); + put(map, "description", "项目说明", "description"); + put(map, "companyId", "公司", "公司名称或ID", "公司名称或公共ID", "companyId"); + put(map, "customerId", "客户", "客户名称或ID", "客户名称或公共ID", "customerId"); + put(map, "ownerId", "负责人", "负责人名称或ID", "负责人名称或公共ID", "ownerId"); + put(map, "startDate", "开始日期", "startDate"); + put(map, "endDate", "结束日期", "endDate"); + put(map, "currency", "币种", "currency"); + return Map.copyOf(map); + } + + private static void put(Map map, String canonical, String... names) { + for (String name : names) map.put(name, canonical); + } + + record ParsedCsv(List rows) { + } + + record ParsedRow(int rowNo, ProjectImportRowRequest request, List issues) { + } + + record ParseIssue(String fieldCode, String message) { + } + + private record Record(int rowNo, List cells) { + } + + static final class CsvException extends RuntimeException { + CsvException(String message) { + super(message); + } + } +} diff --git a/backend/src/main/java/com/kaidi/finance/project/domain/ProjectImportRecords.java b/backend/src/main/java/com/kaidi/finance/project/domain/ProjectImportRecords.java new file mode 100644 index 0000000..82ecbeb --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/project/domain/ProjectImportRecords.java @@ -0,0 +1,95 @@ +package com.kaidi.finance.project.domain; + +import java.time.LocalDateTime; + +public final class ProjectImportRecords { + + private ProjectImportRecords() { + } + + public record ImportBatchRecord( + long id, + String publicId, + String batchNo, + String sourceFilePublicId, + String fileSha256, + int totalRows, + int validRows, + int errorRows, + String validationJson, + String status, + long version, + long createdBy, + LocalDateTime createdAt, + LocalDateTime updatedAt + ) { + } + + public record ImportRowRecord( + long id, + int rowNo, + String payloadJson, + String validationJson, + String status + ) { + } + + public static final class ImportBatchWrite { + private long id; + private String publicId; + private String batchNo; + private long sourceFileId; + private String fileSha256; + private int totalRows; + private int validRows; + private int errorRows; + private String validationJson; + private String status; + private long actorId; + + public long getId() { return id; } + public void setId(long id) { this.id = id; } + public String getPublicId() { return publicId; } + public void setPublicId(String publicId) { this.publicId = publicId; } + public String getBatchNo() { return batchNo; } + public void setBatchNo(String batchNo) { this.batchNo = batchNo; } + public long getSourceFileId() { return sourceFileId; } + public void setSourceFileId(long sourceFileId) { this.sourceFileId = sourceFileId; } + public String getFileSha256() { return fileSha256; } + public void setFileSha256(String fileSha256) { this.fileSha256 = fileSha256; } + public int getTotalRows() { return totalRows; } + public void setTotalRows(int totalRows) { this.totalRows = totalRows; } + public int getValidRows() { return validRows; } + public void setValidRows(int validRows) { this.validRows = validRows; } + public int getErrorRows() { return errorRows; } + public void setErrorRows(int errorRows) { this.errorRows = errorRows; } + public String getValidationJson() { return validationJson; } + public void setValidationJson(String validationJson) { this.validationJson = validationJson; } + public String getStatus() { return status; } + public void setStatus(String status) { this.status = status; } + public long getActorId() { return actorId; } + public void setActorId(long actorId) { this.actorId = actorId; } + } + + public static final class ImportRowWrite { + private long id; + private long batchId; + private int rowNo; + private String payloadJson; + private String validationJson; + private String status; + + public long getId() { return id; } + public void setId(long id) { this.id = id; } + public long getBatchId() { return batchId; } + public void setBatchId(long batchId) { this.batchId = batchId; } + public int getRowNo() { return rowNo; } + public void setRowNo(int rowNo) { this.rowNo = rowNo; } + public String getPayloadJson() { return payloadJson; } + public void setPayloadJson(String payloadJson) { this.payloadJson = payloadJson; } + public String getValidationJson() { return validationJson; } + public void setValidationJson(String validationJson) { this.validationJson = validationJson; } + public String getStatus() { return status; } + public void setStatus(String status) { this.status = status; } + } +} diff --git a/backend/src/main/java/com/kaidi/finance/project/domain/ProjectRecord.java b/backend/src/main/java/com/kaidi/finance/project/domain/ProjectRecord.java new file mode 100644 index 0000000..d97ed5b --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/project/domain/ProjectRecord.java @@ -0,0 +1,138 @@ +package com.kaidi.finance.project.domain; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + +public class ProjectRecord { + private long id; + private String publicId; + private long companyId; + private String companyPublicId; + private String companyName; + private String businessNo; + private String name; + private String description; + private Long customerId; + private String customerPublicId; + private String customerName; + private long ownerUserId; + private String ownerPublicId; + private String ownerName; + private LocalDate startDate; + private LocalDate endDate; + private String currency; + private String stage; + private String financeStatus; + private String archiveStatus; + private String status; + private long version; + private long createdBy; + private long updatedBy; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + private BigDecimal contractAmount; + private BigDecimal confirmedReceipt; + private BigDecimal paidAmount; + private BigDecimal availableBalance; + private int pendingTaskCount; + private int sourceFormCount; + private int contractCount; + private int receiptCount; + private int invoiceCount; + private int paymentCount; + private int progressCount; + private int peopleCount; + private int accountingCount; + private int voucherCount; + private int fileCount; + private int activeRiskCount; + private int documentCompleteness; + + public long getId() { return id; } + public void setId(long id) { this.id = id; } + public String getPublicId() { return publicId; } + public void setPublicId(String publicId) { this.publicId = publicId; } + public long getCompanyId() { return companyId; } + public void setCompanyId(long companyId) { this.companyId = companyId; } + public String getCompanyPublicId() { return companyPublicId; } + public void setCompanyPublicId(String companyPublicId) { this.companyPublicId = companyPublicId; } + public String getCompanyName() { return companyName; } + public void setCompanyName(String companyName) { this.companyName = companyName; } + public String getBusinessNo() { return businessNo; } + public void setBusinessNo(String businessNo) { this.businessNo = businessNo; } + public String getName() { return name; } + public void setName(String name) { this.name = name; } + public String getDescription() { return description; } + public void setDescription(String description) { this.description = description; } + public Long getCustomerId() { return customerId; } + public void setCustomerId(Long customerId) { this.customerId = customerId; } + public String getCustomerPublicId() { return customerPublicId; } + public void setCustomerPublicId(String customerPublicId) { this.customerPublicId = customerPublicId; } + public String getCustomerName() { return customerName; } + public void setCustomerName(String customerName) { this.customerName = customerName; } + public long getOwnerUserId() { return ownerUserId; } + public void setOwnerUserId(long ownerUserId) { this.ownerUserId = ownerUserId; } + public String getOwnerPublicId() { return ownerPublicId; } + public void setOwnerPublicId(String ownerPublicId) { this.ownerPublicId = ownerPublicId; } + public String getOwnerName() { return ownerName; } + public void setOwnerName(String ownerName) { this.ownerName = ownerName; } + public LocalDate getStartDate() { return startDate; } + public void setStartDate(LocalDate startDate) { this.startDate = startDate; } + public LocalDate getEndDate() { return endDate; } + public void setEndDate(LocalDate endDate) { this.endDate = endDate; } + public String getCurrency() { return currency; } + public void setCurrency(String currency) { this.currency = currency; } + public String getStage() { return stage; } + public void setStage(String stage) { this.stage = stage; } + public String getFinanceStatus() { return financeStatus; } + public void setFinanceStatus(String financeStatus) { this.financeStatus = financeStatus; } + public String getArchiveStatus() { return archiveStatus; } + public void setArchiveStatus(String archiveStatus) { this.archiveStatus = archiveStatus; } + public String getStatus() { return status; } + public void setStatus(String status) { this.status = status; } + public long getVersion() { return version; } + public void setVersion(long version) { this.version = version; } + public long getCreatedBy() { return createdBy; } + public void setCreatedBy(long createdBy) { this.createdBy = createdBy; } + public long getUpdatedBy() { return updatedBy; } + public void setUpdatedBy(long updatedBy) { this.updatedBy = updatedBy; } + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } + public LocalDateTime getUpdatedAt() { return updatedAt; } + public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; } + public BigDecimal getContractAmount() { return contractAmount; } + public void setContractAmount(BigDecimal contractAmount) { this.contractAmount = contractAmount; } + public BigDecimal getConfirmedReceipt() { return confirmedReceipt; } + public void setConfirmedReceipt(BigDecimal confirmedReceipt) { this.confirmedReceipt = confirmedReceipt; } + public BigDecimal getPaidAmount() { return paidAmount; } + public void setPaidAmount(BigDecimal paidAmount) { this.paidAmount = paidAmount; } + public BigDecimal getAvailableBalance() { return availableBalance; } + public void setAvailableBalance(BigDecimal availableBalance) { this.availableBalance = availableBalance; } + public int getPendingTaskCount() { return pendingTaskCount; } + public void setPendingTaskCount(int pendingTaskCount) { this.pendingTaskCount = pendingTaskCount; } + public int getSourceFormCount() { return sourceFormCount; } + public void setSourceFormCount(int sourceFormCount) { this.sourceFormCount = sourceFormCount; } + public int getContractCount() { return contractCount; } + public void setContractCount(int contractCount) { this.contractCount = contractCount; } + public int getReceiptCount() { return receiptCount; } + public void setReceiptCount(int receiptCount) { this.receiptCount = receiptCount; } + public int getInvoiceCount() { return invoiceCount; } + public void setInvoiceCount(int invoiceCount) { this.invoiceCount = invoiceCount; } + public int getPaymentCount() { return paymentCount; } + public void setPaymentCount(int paymentCount) { this.paymentCount = paymentCount; } + public int getProgressCount() { return progressCount; } + public void setProgressCount(int progressCount) { this.progressCount = progressCount; } + public int getPeopleCount() { return peopleCount; } + public void setPeopleCount(int peopleCount) { this.peopleCount = peopleCount; } + public int getAccountingCount() { return accountingCount; } + public void setAccountingCount(int accountingCount) { this.accountingCount = accountingCount; } + public int getVoucherCount() { return voucherCount; } + public void setVoucherCount(int voucherCount) { this.voucherCount = voucherCount; } + public int getFileCount() { return fileCount; } + public void setFileCount(int fileCount) { this.fileCount = fileCount; } + public int getActiveRiskCount() { return activeRiskCount; } + public void setActiveRiskCount(int activeRiskCount) { this.activeRiskCount = activeRiskCount; } + public int getDocumentCompleteness() { return documentCompleteness; } + public void setDocumentCompleteness(int documentCompleteness) { this.documentCompleteness = documentCompleteness; } +} diff --git a/backend/src/main/java/com/kaidi/finance/project/domain/ProjectRiskFlagRecord.java b/backend/src/main/java/com/kaidi/finance/project/domain/ProjectRiskFlagRecord.java new file mode 100644 index 0000000..f904742 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/project/domain/ProjectRiskFlagRecord.java @@ -0,0 +1,64 @@ +package com.kaidi.finance.project.domain; + +import java.time.LocalDateTime; + +public class ProjectRiskFlagRecord { + private long id; + private String publicId; + private long projectId; + private String riskType; + private String reason; + private LocalDateTime effectiveFrom; + private LocalDateTime effectiveUntil; + private boolean blocking; + private String status; + private long version; + private String resolvedReason; + private Long resolvedBy; + private String resolvedByName; + private LocalDateTime resolvedAt; + private long createdBy; + private String createdByName; + private long updatedBy; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + public long getId() { return id; } + public void setId(long id) { this.id = id; } + public String getPublicId() { return publicId; } + public void setPublicId(String publicId) { this.publicId = publicId; } + public long getProjectId() { return projectId; } + public void setProjectId(long projectId) { this.projectId = projectId; } + public String getRiskType() { return riskType; } + public void setRiskType(String riskType) { this.riskType = riskType; } + public String getReason() { return reason; } + public void setReason(String reason) { this.reason = reason; } + public LocalDateTime getEffectiveFrom() { return effectiveFrom; } + public void setEffectiveFrom(LocalDateTime effectiveFrom) { this.effectiveFrom = effectiveFrom; } + public LocalDateTime getEffectiveUntil() { return effectiveUntil; } + public void setEffectiveUntil(LocalDateTime effectiveUntil) { this.effectiveUntil = effectiveUntil; } + public boolean isBlocking() { return blocking; } + public void setBlocking(boolean blocking) { this.blocking = blocking; } + public String getStatus() { return status; } + public void setStatus(String status) { this.status = status; } + public long getVersion() { return version; } + public void setVersion(long version) { this.version = version; } + public String getResolvedReason() { return resolvedReason; } + public void setResolvedReason(String resolvedReason) { this.resolvedReason = resolvedReason; } + public Long getResolvedBy() { return resolvedBy; } + public void setResolvedBy(Long resolvedBy) { this.resolvedBy = resolvedBy; } + public String getResolvedByName() { return resolvedByName; } + public void setResolvedByName(String resolvedByName) { this.resolvedByName = resolvedByName; } + public LocalDateTime getResolvedAt() { return resolvedAt; } + public void setResolvedAt(LocalDateTime resolvedAt) { this.resolvedAt = resolvedAt; } + public long getCreatedBy() { return createdBy; } + public void setCreatedBy(long createdBy) { this.createdBy = createdBy; } + public String getCreatedByName() { return createdByName; } + public void setCreatedByName(String createdByName) { this.createdByName = createdByName; } + public long getUpdatedBy() { return updatedBy; } + public void setUpdatedBy(long updatedBy) { this.updatedBy = updatedBy; } + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } + public LocalDateTime getUpdatedAt() { return updatedAt; } + public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; } +} diff --git a/backend/src/main/java/com/kaidi/finance/project/domain/ProjectTimelineRecord.java b/backend/src/main/java/com/kaidi/finance/project/domain/ProjectTimelineRecord.java new file mode 100644 index 0000000..dde536f --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/project/domain/ProjectTimelineRecord.java @@ -0,0 +1,7 @@ +package com.kaidi.finance.project.domain; + +import java.time.LocalDateTime; + +public record ProjectTimelineRecord(String publicId, String actionCode, String resultCode, String reason, + String username, String activeRole, LocalDateTime occurredAt) { +} diff --git a/backend/src/main/java/com/kaidi/finance/project/infrastructure/ProjectImportMapper.java b/backend/src/main/java/com/kaidi/finance/project/infrastructure/ProjectImportMapper.java new file mode 100644 index 0000000..cc183c5 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/project/infrastructure/ProjectImportMapper.java @@ -0,0 +1,111 @@ +package com.kaidi.finance.project.infrastructure; + +import com.kaidi.finance.project.domain.ProjectImportRecords.ImportBatchRecord; +import com.kaidi.finance.project.domain.ProjectImportRecords.ImportBatchWrite; +import com.kaidi.finance.project.domain.ProjectImportRecords.ImportRowRecord; +import com.kaidi.finance.project.domain.ProjectImportRecords.ImportRowWrite; +import java.util.List; +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Options; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +@org.apache.ibatis.annotations.Mapper +public interface ProjectImportMapper { + + @Insert(""" + INSERT INTO project_import_batch ( + public_id, batch_no, source_file_id, file_sha256, total_rows, valid_rows, + error_rows, validation_json, status, created_by, updated_by + ) VALUES ( + #{publicId}, #{batchNo}, #{sourceFileId}, #{fileSha256}, #{totalRows}, #{validRows}, + #{errorRows}, CAST(#{validationJson} AS JSON), #{status}, #{actorId}, #{actorId} + ) + """) + @Options(useGeneratedKeys = true, keyProperty = "id") + int insertBatch(ImportBatchWrite batch); + + @Insert(""" + INSERT INTO project_import_row ( + batch_id, row_no, payload_json, validation_json, status + ) VALUES ( + #{batchId}, #{rowNo}, CAST(#{payloadJson} AS JSON), CAST(#{validationJson} AS JSON), #{status} + ) + """) + int insertRow(ImportRowWrite row); + + @Select(""" + SELECT batch.id, batch.public_id, batch.batch_no, + file.public_id AS source_file_public_id, batch.file_sha256, + batch.total_rows, batch.valid_rows, batch.error_rows, + CAST(batch.validation_json AS CHAR) AS validation_json, + batch.status, batch.version, batch.created_by, + batch.created_at, batch.updated_at + FROM project_import_batch batch + JOIN file_object file ON file.id = batch.source_file_id + WHERE batch.source_file_id = #{sourceFileId} + AND batch.status IN ('VALIDATING', 'INVALID', 'READY', 'CONFIRMED') + ORDER BY batch.id DESC + LIMIT 1 + """) + ImportBatchRecord findActiveBatchBySourceFileId(long sourceFileId); + + @Select(""" + SELECT batch.id, batch.public_id, batch.batch_no, + file.public_id AS source_file_public_id, batch.file_sha256, + batch.total_rows, batch.valid_rows, batch.error_rows, + CAST(batch.validation_json AS CHAR) AS validation_json, + batch.status, batch.version, batch.created_by, + batch.created_at, batch.updated_at + FROM project_import_batch batch + JOIN file_object file ON file.id = batch.source_file_id + WHERE batch.public_id = #{publicId} + """) + ImportBatchRecord findBatch(String publicId); + + @Select(""" + SELECT batch.id, batch.public_id, batch.batch_no, + file.public_id AS source_file_public_id, batch.file_sha256, + batch.total_rows, batch.valid_rows, batch.error_rows, + CAST(batch.validation_json AS CHAR) AS validation_json, + batch.status, batch.version, batch.created_by, + batch.created_at, batch.updated_at + FROM project_import_batch batch + JOIN file_object file ON file.id = batch.source_file_id + WHERE batch.public_id = #{publicId} + FOR UPDATE + """) + ImportBatchRecord findBatchForUpdate(String publicId); + + @Select(""" + SELECT id, row_no, CAST(payload_json AS CHAR) AS payload_json, + CAST(validation_json AS CHAR) AS validation_json, status + FROM project_import_row + WHERE batch_id = #{batchId} + ORDER BY row_no + """) + List listRows(long batchId); + + @Update(""" + UPDATE project_import_row + SET status = 'CONFIRMED' + WHERE batch_id = #{batchId} AND status = 'VALID' + """) + int markRowsConfirmed(long batchId); + + @Update(""" + UPDATE project_import_batch + SET status = 'CONFIRMED', version = version + 1, updated_by = #{actorId} + WHERE id = #{id} AND version = #{expectedVersion} AND status = 'READY' + """) + int markBatchConfirmed(@Param("id") long id, @Param("expectedVersion") long expectedVersion, + @Param("actorId") long actorId); + + @Select(""" + SELECT COUNT(*) + FROM md_project project + WHERE project.company_id = #{companyId} AND project.business_no = #{businessNo} + """) + int countExistingBusinessNo(@Param("companyId") long companyId, @Param("businessNo") String businessNo); +} diff --git a/backend/src/main/java/com/kaidi/finance/project/infrastructure/ProjectMapper.java b/backend/src/main/java/com/kaidi/finance/project/infrastructure/ProjectMapper.java new file mode 100644 index 0000000..437f88c --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/project/infrastructure/ProjectMapper.java @@ -0,0 +1,716 @@ +package com.kaidi.finance.project.infrastructure; + +import com.kaidi.finance.project.domain.ProjectRecord; +import com.kaidi.finance.project.domain.ProjectRiskFlagRecord; +import com.kaidi.finance.project.domain.ProjectTimelineRecord; +import com.kaidi.finance.project.api.ProjectReferenceView; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Options; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +@org.apache.ibatis.annotations.Mapper +public interface ProjectMapper { + + String PROJECT_COLUMNS = """ + project.id AS id, + project.public_id AS public_id, + project.company_id AS company_id, + company.public_id AS company_public_id, + company.name AS company_name, + project.business_no AS business_no, + project.name AS name, + project.description AS description, + project.customer_id AS customer_id, + customer.public_id AS customer_public_id, + customer.name AS customer_name, + project.owner_user_id AS owner_user_id, + owner.public_id AS owner_public_id, + owner.display_name AS owner_name, + project.start_date AS start_date, + project.end_date AS end_date, + project.currency AS currency, + project.stage AS stage, + project.finance_status AS finance_status, + project.archive_status AS archive_status, + project.status AS status, + project.version AS version, + project.created_by AS created_by, + project.updated_by AS updated_by, + project.created_at AS created_at, + project.updated_at AS updated_at, + COALESCE(contract_summary.contract_amount, 0.00) AS contract_amount, + COALESCE(fund.confirmed_receipt, 0.00) AS confirmed_receipt, + COALESCE(fund.paid_amount, 0.00) AS paid_amount, + COALESCE(fund.confirmed_receipt, 0.00) + - COALESCE(fund.confirmed_deduction, 0.00) + + COALESCE(fund.approved_adjustment, 0.00) + - COALESCE(fund.frozen_amount, 0.00) + - COALESCE(fund.approved_unpaid, 0.00) + - COALESCE(fund.paid_amount, 0.00) AS available_balance, + COALESCE(task_summary.pending_task_count, 0) AS pending_task_count, + COALESCE(source_summary.source_form_count, 0) AS source_form_count, + COALESCE(contract_summary.contract_count, 0) AS contract_count, + COALESCE(receipt_summary.receipt_count, 0) AS receipt_count, + COALESCE(invoice_summary.invoice_count, 0) AS invoice_count, + COALESCE(payment_summary.payment_count, 0) AS payment_count, + COALESCE(source_summary.progress_count, 0) AS progress_count, + COALESCE(source_summary.people_count, 0) AS people_count, + COALESCE(accounting_summary.accounting_count, 0) AS accounting_count, + COALESCE(voucher_summary.voucher_count, 0) AS voucher_count, + COALESCE(file_summary.file_count, 0) AS file_count, + COALESCE(risk_summary.active_risk_count, 0) AS active_risk_count, + CAST(ROUND(COALESCE(archive_summary.completeness, 0), 0) AS UNSIGNED) AS document_completeness + """; + + String PROJECT_JOINS = """ + JOIN md_company company ON company.id = project.company_id + LEFT JOIN md_counterparty customer ON customer.id = project.customer_id + JOIN iam_user owner ON owner.id = project.owner_user_id + LEFT JOIN project_fund_control fund + ON fund.project_id = project.id AND fund.currency = project.currency + LEFT JOIN ( + SELECT contract.project_id, + COUNT(*) AS contract_count, + SUM(COALESCE(contract.settlement_amount, + contract.original_amount + contract.approved_change_amount)) AS contract_amount + FROM md_contract contract + WHERE contract.status NOT IN ('DISABLED', 'VOID') + GROUP BY contract.project_id + ) contract_summary ON contract_summary.project_id = project.id + LEFT JOIN ( + SELECT source.project_id, + COUNT(*) AS source_form_count, + SUM(CASE WHEN source.form_type IN ('OA-10', 'OA-12') THEN 1 ELSE 0 END) AS progress_count, + SUM(CASE WHEN source.form_type IN ('OA-09', 'OA-13', 'OA-14') THEN 1 ELSE 0 END) AS people_count + FROM src_source_document source + WHERE source.project_id IS NOT NULL AND source.status <> 'VOID' + GROUP BY source.project_id + ) source_summary ON source_summary.project_id = project.id + LEFT JOIN ( + SELECT receipt_projects.project_id, COUNT(DISTINCT receipt_projects.receipt_id) AS receipt_count + FROM ( + SELECT receipt.id AS receipt_id, receipt.project_id + FROM fin_receipt receipt + WHERE receipt.project_id IS NOT NULL AND receipt.status <> 'VOID' + UNION ALL + SELECT allocation.receipt_id, allocation.project_id + FROM fin_receipt_allocation allocation + JOIN fin_receipt receipt ON receipt.id = allocation.receipt_id + WHERE receipt.status <> 'VOID' + ) receipt_projects + GROUP BY receipt_projects.project_id + ) receipt_summary ON receipt_summary.project_id = project.id + LEFT JOIN ( + SELECT invoice.project_id, COUNT(*) AS invoice_count + FROM fin_invoice invoice + WHERE invoice.status <> 'VOID' + GROUP BY invoice.project_id + ) invoice_summary ON invoice_summary.project_id = project.id + LEFT JOIN ( + SELECT payment.project_id, COUNT(*) AS payment_count + FROM fin_payment_request payment + WHERE payment.status <> 'VOID' + GROUP BY payment.project_id + ) payment_summary ON payment_summary.project_id = project.id + LEFT JOIN ( + SELECT event.project_id, COUNT(*) AS accounting_count + FROM acc_event event + WHERE event.project_id IS NOT NULL AND event.status <> 'VOID' + GROUP BY event.project_id + ) accounting_summary ON accounting_summary.project_id = project.id + LEFT JOIN ( + SELECT voucher.project_id, COUNT(*) AS voucher_count + FROM acc_voucher voucher + WHERE voucher.project_id IS NOT NULL AND voucher.status <> 'VOID' + GROUP BY voucher.project_id + ) voucher_summary ON voucher_summary.project_id = project.id + LEFT JOIN ( + SELECT current_package.project_id, current_package.completeness + FROM arc_package current_package + WHERE NOT EXISTS ( + SELECT 1 + FROM arc_package newer_package + WHERE newer_package.project_id = current_package.project_id + AND (newer_package.package_version > current_package.package_version + OR (newer_package.package_version = current_package.package_version + AND newer_package.id > current_package.id)) + ) + ) archive_summary ON archive_summary.project_id = project.id + LEFT JOIN ( + SELECT source.project_id, COUNT(*) AS pending_task_count + FROM wf_task task + JOIN wf_instance instance ON instance.id = task.instance_id + JOIN src_document_version document_version ON document_version.id = instance.source_version_id + JOIN src_source_document source ON source.id = document_version.source_document_id + WHERE task.status = 'PENDING' + AND instance.status IN ('RUNNING', 'RETURNED') + AND source.project_id IS NOT NULL + GROUP BY source.project_id + ) task_summary ON task_summary.project_id = project.id + LEFT JOIN ( + SELECT link.project_public_id, COUNT(*) AS file_count + FROM file_link link + JOIN file_object file ON file.id = link.file_id + WHERE link.project_public_id IS NOT NULL + AND link.active = TRUE + AND link.archive_status <> 'SUPERSEDED' + AND file.scan_status = 'AVAILABLE' + GROUP BY link.project_public_id + ) file_summary ON file_summary.project_public_id = project.public_id + LEFT JOIN ( + SELECT risk.project_id, COUNT(*) AS active_risk_count + FROM project_risk_flag risk + WHERE risk.status = 'ACTIVE' + AND risk.effective_from <= UTC_TIMESTAMP(3) + AND (risk.effective_until IS NULL OR risk.effective_until >= UTC_TIMESTAMP(3)) + GROUP BY risk.project_id + ) risk_summary ON risk_summary.project_id = project.id + """; + + @Insert(""" + INSERT INTO md_project ( + public_id, company_id, business_no, name, description, customer_id, owner_user_id, + start_date, end_date, currency, stage, finance_status, archive_status, status, + created_by, updated_by + ) VALUES ( + #{publicId}, #{companyId}, #{businessNo}, #{name}, #{description}, #{customerId}, #{ownerUserId}, + #{startDate}, #{endDate}, #{currency}, 'INITIATION', 'PENDING', 'NOT_READY', 'DRAFT', + #{createdBy}, #{updatedBy} + ) + """) + @Options(useGeneratedKeys = true, keyProperty = "id") + int insertProject(ProjectRecord project); + + @Insert(""" + INSERT INTO project_fund_control (project_id, currency) + VALUES (#{projectId}, #{currency}) + """) + int insertFundControl(@Param("projectId") long projectId, @Param("currency") String currency); + + @Insert(""" + INSERT INTO export_log ( + public_id, request_id, actor_public_id, identity_code, resource_type, + filter_json, columns_json, row_count, sha256 + ) VALUES ( + #{publicId}, #{requestId}, #{actorPublicId}, #{identityCode}, 'PROJECT_LIST', + CAST(#{filterJson} AS JSON), CAST(#{columnsJson} AS JSON), #{rowCount}, #{sha256} + ) + """) + int insertExportLog(@Param("publicId") String publicId, @Param("requestId") String requestId, + @Param("actorPublicId") String actorPublicId, @Param("identityCode") String identityCode, + @Param("filterJson") String filterJson, @Param("columnsJson") String columnsJson, + @Param("rowCount") int rowCount, @Param("sha256") String sha256); + + @Select(""" + + """) + ProjectRecord findProject(String publicId); + + @Select(""" + + """) + List listProjects(@Param("userId") long userId, + @Param("roleCode") String roleCode, + @Param("companyPublicId") String companyPublicId, + @Param("keyword") String keyword, + @Param("ownerPublicId") String ownerPublicId, + @Param("customerPublicId") String customerPublicId, + @Param("stage") String stage, + @Param("financeStatus") String financeStatus, + @Param("archiveStatus") String archiveStatus, + @Param("status") String status, + @Param("startDateFrom") LocalDate startDateFrom, + @Param("startDateTo") LocalDate startDateTo, + @Param("endDateFrom") LocalDate endDateFrom, + @Param("endDateTo") LocalDate endDateTo, + @Param("orderBy") String orderBy, + @Param("limit") int limit, + @Param("offset") int offset); + + @Select(""" + + """) + long countProjects(@Param("userId") long userId, + @Param("roleCode") String roleCode, + @Param("companyPublicId") String companyPublicId, + @Param("keyword") String keyword, + @Param("ownerPublicId") String ownerPublicId, + @Param("customerPublicId") String customerPublicId, + @Param("stage") String stage, + @Param("financeStatus") String financeStatus, + @Param("archiveStatus") String archiveStatus, + @Param("status") String status, + @Param("startDateFrom") LocalDate startDateFrom, + @Param("startDateTo") LocalDate startDateTo, + @Param("endDateFrom") LocalDate endDateFrom, + @Param("endDateTo") LocalDate endDateTo); + + @Update(""" + UPDATE md_project + SET name = #{name}, description = #{description}, customer_id = #{customerId}, + owner_user_id = #{ownerUserId}, start_date = #{startDate}, end_date = #{endDate}, + updated_by = #{updatedBy}, version = version + 1 + WHERE id = #{id} AND version = #{version} AND status = 'DRAFT' + """) + int updateProject(ProjectRecord project); + + @Update(""" + UPDATE md_project + SET finance_status = 'COMPLETED', updated_by = #{actorId}, version = version + 1 + WHERE id = #{id} AND version = #{version} + AND finance_status IN ('PENDING', 'IN_PROGRESS', 'BLOCKED') + AND status IN ('DRAFT', 'ACTIVE', 'SUSPENDED', 'CLOSED') + """) + int completeFinance(@Param("id") long id, @Param("version") long version, + @Param("actorId") long actorId); + + @Update(""" + UPDATE md_project + SET status = 'ACTIVE', updated_by = #{updatedBy}, version = version + 1 + WHERE id = #{id} AND version = #{version} AND status = 'DRAFT' + """) + int activateProject(@Param("id") long id, @Param("version") long version, + @Param("updatedBy") long updatedBy); + + @Select(""" + SELECT id FROM md_company + WHERE public_id = #{publicId} AND status = 'ACTIVE' + """) + Long findActiveCompanyId(String publicId); + + @Select(""" + SELECT public_id AS publicId, name + FROM md_company + WHERE status = 'ACTIVE' AND name = #{name} + ORDER BY public_id + """) + List findActiveCompaniesByName(String name); + + @Select(""" + SELECT id FROM md_counterparty + WHERE public_id = #{publicId} AND status = 'ACTIVE' + AND counterparty_type IN ('CUSTOMER', 'BOTH') + """) + Long findActiveCustomerId(String publicId); + + @Select(""" + SELECT public_id AS publicId, name + FROM md_counterparty + WHERE status = 'ACTIVE' AND counterparty_type IN ('CUSTOMER', 'BOTH') + AND name = #{name} + ORDER BY public_id + """) + List findActiveCustomersByName(String name); + + @Select(""" + SELECT user_account.id + FROM iam_user user_account + JOIN iam_user_role user_role ON user_role.user_id = user_account.id + JOIN iam_role role ON role.id = user_role.role_id + WHERE user_account.public_id = #{publicId} + AND user_account.enabled = TRUE + AND role.enabled = TRUE + AND role.code = 'PROJECT_MANAGER' + LIMIT 1 + """) + Long findEnabledOwnerId(String publicId); + + @Select(""" + SELECT DISTINCT user_account.public_id AS publicId, user_account.display_name AS name + FROM iam_user user_account + JOIN iam_user_role user_role ON user_role.user_id = user_account.id + JOIN iam_role role ON role.id = user_role.role_id + WHERE user_account.enabled = TRUE AND role.enabled = TRUE + AND role.code = 'PROJECT_MANAGER' AND user_account.display_name = #{name} + ORDER BY user_account.public_id + """) + List findEnabledOwnersByName(String name); + + @Select(""" + + """) + List listVisibleCompanies(@Param("userId") long userId, + @Param("roleCode") String roleCode); + + @Select(""" + + """) + List listActiveCustomers(@Param("userId") long userId, + @Param("roleCode") String roleCode); + + @Select(""" + + """) + List listEnabledOwners(@Param("userId") long userId, + @Param("roleCode") String roleCode); + + @Select(""" + + """) + int countOverlappingRisk(@Param("projectId") long projectId, + @Param("riskType") String riskType, + @Param("effectiveFrom") LocalDateTime effectiveFrom, + @Param("effectiveUntil") LocalDateTime effectiveUntil); + + @Insert(""" + INSERT INTO project_risk_flag ( + public_id, project_id, risk_type, reason, effective_from, effective_until, + blocking, status, created_by, updated_by + ) VALUES ( + #{publicId}, #{projectId}, #{riskType}, #{reason}, #{effectiveFrom}, #{effectiveUntil}, + #{blocking}, 'ACTIVE', #{createdBy}, #{updatedBy} + ) + """) + @Options(useGeneratedKeys = true, keyProperty = "id") + int insertRiskFlag(ProjectRiskFlagRecord riskFlag); + + @Select(""" + + """) + ProjectRiskFlagRecord findRiskFlag(String publicId); + + @Select(""" + + """) + List listRiskFlags(long projectId); + + @Select(""" + + """) + List listRiskFlagsPage(@Param("projectId") long projectId, + @Param("limit") int limit, + @Param("offset") int offset); + + @Select("SELECT COUNT(*) FROM project_risk_flag WHERE project_id = #{projectId}") + long countRiskFlags(@Param("projectId") long projectId); + + @Update(""" + + """) + int resolveRiskFlag(@Param("id") long id, @Param("version") long version, + @Param("reason") String reason, @Param("actorId") long actorId); + + @Select(""" + SELECT public_id AS publicId, action_code AS actionCode, result_code AS resultCode, + reason, username, active_role AS activeRole, created_at AS occurredAt + FROM audit_log + WHERE (project_public_id = #{projectPublicId} + OR (object_type = 'PROJECT' AND object_public_id = #{projectPublicId})) + ORDER BY created_at DESC, id DESC + LIMIT 100 + """) + List listTimeline(String projectPublicId); + + @Select(""" + SELECT public_id AS publicId, action_code AS actionCode, result_code AS resultCode, + reason, username, active_role AS activeRole, created_at AS occurredAt + FROM audit_log + WHERE (project_public_id = #{projectPublicId} + OR (object_type = 'PROJECT' AND object_public_id = #{projectPublicId})) + ORDER BY created_at DESC, id DESC + LIMIT #{limit} OFFSET #{offset} + """) + List listTimelinePage(@Param("projectPublicId") String projectPublicId, + @Param("limit") int limit, + @Param("offset") int offset); + + @Select(""" + SELECT COUNT(*) FROM audit_log + WHERE (project_public_id = #{projectPublicId} + OR (object_type = 'PROJECT' AND object_public_id = #{projectPublicId})) + """) + long countTimeline(@Param("projectPublicId") String projectPublicId); +} diff --git a/backend/src/main/java/com/kaidi/finance/receivable/api/ReceivableContracts.java b/backend/src/main/java/com/kaidi/finance/receivable/api/ReceivableContracts.java new file mode 100644 index 0000000..9b16b1b --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/receivable/api/ReceivableContracts.java @@ -0,0 +1,109 @@ +package com.kaidi.finance.receivable.api; + +import jakarta.validation.constraints.DecimalMax; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; + +public final class ReceivableContracts { + + private ReceivableContracts() { + } + + public record ReceiptCreateRequest( + @NotBlank @Size(max = 26) String companyId, + @Size(max = 26) String projectId, + @Size(max = 26) String customerId, + @NotNull LocalDate receiptDate, + @NotNull @DecimalMin(value = "0.01") BigDecimal amount, + @NotBlank @Pattern(regexp = "[A-Z]{3}") String currency, + @NotBlank @Size(max = 200) String payerName, + @Size(max = 100) String referenceNo + ) { + } + + public record ReceiptUpdateRequest( + @NotBlank @Size(max = 26) String companyId, + @Size(max = 26) String projectId, + @Size(max = 26) String customerId, + @NotNull LocalDate receiptDate, + @NotNull @DecimalMin(value = "0.01") BigDecimal amount, + @NotBlank @Pattern(regexp = "[A-Z]{3}") String currency, + @NotBlank @Size(max = 200) String payerName, + @Size(max = 100) String referenceNo, + @NotNull Long version + ) { + } + + public record VersionCommandRequest(@NotNull Long version) { + } + + public record ReceiptAllocationRequest( + @NotBlank @Size(max = 26) String receivableId, + @NotNull @DecimalMin(value = "0.01") BigDecimal amount, + @Size(max = 500) String note, + @NotNull Long version + ) { + } + + public record InvoiceCreateRequest( + @NotBlank @Size(max = 26) String companyId, + @NotBlank @Size(max = 26) String projectId, + @Size(max = 26) String contractId, + @NotBlank @Size(max = 26) String counterpartyId, + @NotBlank @Pattern(regexp = "SPECIAL|NORMAL|RECEIPT") String invoiceType, + @NotNull @DecimalMin(value = "0.01") BigDecimal amount, + @NotNull @DecimalMin("0") @DecimalMax("1") BigDecimal taxRate, + @NotNull LocalDate requestedDate + ) { + } + + public record InvoiceUpdateRequest( + @NotBlank @Size(max = 26) String companyId, + @NotBlank @Size(max = 26) String projectId, + @Size(max = 26) String contractId, + @NotBlank @Size(max = 26) String counterpartyId, + @NotBlank @Pattern(regexp = "SPECIAL|NORMAL|RECEIPT") String invoiceType, + @NotNull @DecimalMin(value = "0.01") BigDecimal amount, + @NotNull @DecimalMin("0") @DecimalMax("1") BigDecimal taxRate, + @NotNull LocalDate requestedDate, + @NotNull Long version + ) { + } + + public record InvoiceReviewRequest( + @NotNull Long version, + @NotBlank @Pattern(regexp = "APPROVE|RETURN|REJECT") String decision, + @NotBlank @Size(max = 1000) String opinion, + @Size(max = 32) List<@NotBlank @Size(max = 64) String> returnPoints + ) { + } + + public record InvoiceResultRequest( + @NotNull Long version, + @NotBlank @Size(max = 100) String invoiceNo, + @NotNull LocalDate issuedDate, + @Size(max = 26) String resultFileId + ) { + } + + public record ReasonCommandRequest( + @NotNull Long version, + @NotBlank @Size(max = 1000) String reason + ) { + } + + public record RedLetterRequest( + @NotNull Long version, + @NotBlank @Size(max = 100) String invoiceNo, + @NotNull LocalDate issuedDate, + @Size(max = 26) String resultFileId, + @NotBlank @Size(max = 1000) String reason + ) { + } +} diff --git a/backend/src/main/java/com/kaidi/finance/receivable/api/ReceivableController.java b/backend/src/main/java/com/kaidi/finance/receivable/api/ReceivableController.java new file mode 100644 index 0000000..e16e015 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/receivable/api/ReceivableController.java @@ -0,0 +1,287 @@ +package com.kaidi.finance.receivable.api; + +import com.fasterxml.jackson.core.type.TypeReference; + +import com.kaidi.finance.receivable.api.ReceivableContracts.InvoiceCreateRequest; +import com.kaidi.finance.receivable.api.ReceivableContracts.InvoiceResultRequest; +import com.kaidi.finance.receivable.api.ReceivableContracts.InvoiceReviewRequest; +import com.kaidi.finance.receivable.api.ReceivableContracts.InvoiceUpdateRequest; +import com.kaidi.finance.receivable.api.ReceivableContracts.ReceiptAllocationRequest; +import com.kaidi.finance.receivable.api.ReceivableContracts.ReceiptCreateRequest; +import com.kaidi.finance.receivable.api.ReceivableContracts.ReceiptUpdateRequest; +import com.kaidi.finance.receivable.api.ReceivableContracts.RedLetterRequest; +import com.kaidi.finance.receivable.api.ReceivableContracts.ReasonCommandRequest; +import com.kaidi.finance.receivable.api.ReceivableContracts.VersionCommandRequest; +import com.kaidi.finance.receivable.api.ReceivableViews.InvoiceView; +import com.kaidi.finance.receivable.api.ReceivableViews.ReceivableView; +import com.kaidi.finance.receivable.api.ReceivableViews.ReceiptDetailView; +import com.kaidi.finance.receivable.api.ReceivableViews.ReceiptView; +import com.kaidi.finance.receivable.api.ReceivableViews.ReferenceOptionsView; +import com.kaidi.finance.receivable.application.ReceivableApplicationService; +import com.kaidi.finance.shared.api.ApiResponse; +import com.kaidi.finance.shared.api.PageResult; +import com.kaidi.finance.shared.idempotency.IdempotencyApplicationService; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import java.time.LocalDate; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.ContentDisposition; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1") +public class ReceivableController { + + private final ReceivableApplicationService service; + private final IdempotencyApplicationService idempotencyService; + + public ReceivableController(ReceivableApplicationService service, + IdempotencyApplicationService idempotencyService) { + this.service = service; + this.idempotencyService = idempotencyService; + } + + @GetMapping("/receivables/references") + @PreAuthorize("@authorizationService.hasPermission('receivable:receipt:view')") + public ApiResponse references() { + return ApiResponse.ok(service.references()); + } + + @GetMapping("/receivables") + @PreAuthorize("@authorizationService.hasPermission('receivable:receipt:view')") + public ApiResponse> receivables( + @RequestParam(required = false) String companyId, + @RequestParam(required = false) String projectId, + @RequestParam(required = false) String counterpartyId, + @RequestParam(required = false) String keyword, + @RequestParam(required = false) String status, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate dateFrom, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate dateTo, + @RequestParam(defaultValue = "updatedAt,desc") String sort, + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int size) { + PageResult result = service.listReceivables(companyId, projectId, counterpartyId, + keyword, status, dateFrom, dateTo, sort, page, size); + return ApiResponse.ok(result.items(), result.meta()); + } + + @GetMapping("/receipts") + @PreAuthorize("@authorizationService.hasPermission('receivable:receipt:view')") + public ApiResponse> receipts( + @RequestParam(required = false) String companyId, + @RequestParam(required = false) String projectId, + @RequestParam(required = false) String counterpartyId, + @RequestParam(required = false) String keyword, + @RequestParam(required = false) String status, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate dateFrom, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate dateTo, + @RequestParam(defaultValue = "updatedAt,desc") String sort, + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int size) { + PageResult result = service.listReceipts(companyId, projectId, counterpartyId, keyword, status, + dateFrom, dateTo, sort, page, size); + return ApiResponse.ok(result.items(), result.meta()); + } + + @PostMapping("/receipts") + @PreAuthorize("@authorizationService.hasPermission('receivable:receipt:create')") + public ApiResponse createReceipt(@Valid @RequestBody ReceiptCreateRequest request, + @RequestHeader(value = "Idempotency-Key", required = false) String key, + HttpServletRequest httpRequest) { + return ApiResponse.ok(command(key, httpRequest, request, new TypeReference() { }, + () -> service.createReceipt(request))); + } + + @GetMapping("/receipts/{publicId}") + @PreAuthorize("@authorizationService.hasPermission('receivable:receipt:view')") + public ApiResponse receipt(@PathVariable String publicId) { + return ApiResponse.ok(service.getReceipt(publicId)); + } + + @PatchMapping("/receipts/{publicId}") + @PreAuthorize("@authorizationService.hasPermission('receivable:receipt:edit')") + public ApiResponse updateReceipt(@PathVariable String publicId, + @Valid @RequestBody ReceiptUpdateRequest request, + @RequestHeader(value = "Idempotency-Key", required = false) String key, + HttpServletRequest httpRequest) { + return ApiResponse.ok(command(key, httpRequest, request, new TypeReference() { }, + () -> service.updateReceipt(publicId, request))); + } + + @PostMapping("/receipts/{publicId}/confirm") + @PreAuthorize("@authorizationService.hasPermission('receivable:receipt:confirm')") + public ApiResponse confirmReceipt(@PathVariable String publicId, + @Valid @RequestBody VersionCommandRequest request, + @RequestHeader(value = "Idempotency-Key", required = false) String key, + HttpServletRequest httpRequest) { + return ApiResponse.ok(command(key, httpRequest, request, new TypeReference() { }, + () -> service.confirmReceipt(publicId, request.version()))); + } + + @PostMapping("/receipts/{publicId}/allocations") + @PreAuthorize("@authorizationService.hasPermission('receivable:receipt:allocate')") + public ApiResponse allocateReceipt(@PathVariable String publicId, + @Valid @RequestBody ReceiptAllocationRequest request, + @RequestHeader(value = "Idempotency-Key", required = false) String key, + HttpServletRequest httpRequest) { + return ApiResponse.ok(command(key, httpRequest, request, new TypeReference() { }, + () -> service.allocateReceipt(publicId, request))); + } + + @PostMapping("/receipts/{publicId}/void") + @PreAuthorize("@authorizationService.hasPermission('receivable:receipt:void')") + public ApiResponse voidReceipt(@PathVariable String publicId, + @Valid @RequestBody ReasonCommandRequest request, + @RequestHeader(value = "Idempotency-Key", required = false) String key, + HttpServletRequest httpRequest) { + return ApiResponse.ok(command(key, httpRequest, request, new TypeReference() { }, + () -> service.voidReceipt(publicId, request))); + } + + @GetMapping(value = "/receipts/export", produces = "text/csv") + @PreAuthorize("@authorizationService.hasPermission('receivable:receipt:export')") + public ResponseEntity exportReceipts( + @RequestParam(required = false) String companyId, + @RequestParam(required = false) String projectId, + @RequestParam(required = false) String counterpartyId, + @RequestParam(required = false) String keyword, + @RequestParam(required = false) String status, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate dateFrom, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate dateTo, + @RequestParam(defaultValue = "updatedAt,desc") String sort) { + return csv("receipts.csv", service.exportReceipts(companyId, projectId, counterpartyId, keyword, status, + dateFrom, dateTo, sort)); + } + + @GetMapping("/invoices") + @PreAuthorize("@authorizationService.hasPermission('receivable:invoice:view')") + public ApiResponse> invoices( + @RequestParam(required = false) String companyId, + @RequestParam(required = false) String projectId, + @RequestParam(required = false) String counterpartyId, + @RequestParam(required = false) String keyword, + @RequestParam(required = false) String status, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate dateFrom, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate dateTo, + @RequestParam(defaultValue = "updatedAt,desc") String sort, + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int size) { + PageResult result = service.listInvoices(companyId, projectId, counterpartyId, keyword, status, + dateFrom, dateTo, sort, page, size); + return ApiResponse.ok(result.items(), result.meta()); + } + + @PostMapping("/invoices") + @PreAuthorize("@authorizationService.hasPermission('receivable:invoice:create')") + public ApiResponse createInvoice(@Valid @RequestBody InvoiceCreateRequest request, + @RequestHeader(value = "Idempotency-Key", required = false) String key, + HttpServletRequest httpRequest) { + return ApiResponse.ok(command(key, httpRequest, request, new TypeReference() { }, + () -> service.createInvoice(request))); + } + + @GetMapping("/invoices/{publicId}") + @PreAuthorize("@authorizationService.hasPermission('receivable:invoice:view')") + public ApiResponse invoice(@PathVariable String publicId) { + return ApiResponse.ok(service.getInvoice(publicId)); + } + + @PatchMapping("/invoices/{publicId}") + @PreAuthorize("@authorizationService.hasPermission('receivable:invoice:edit')") + public ApiResponse updateInvoice(@PathVariable String publicId, + @Valid @RequestBody InvoiceUpdateRequest request, + @RequestHeader(value = "Idempotency-Key", required = false) String key, + HttpServletRequest httpRequest) { + return ApiResponse.ok(command(key, httpRequest, request, new TypeReference() { }, + () -> service.updateInvoice(publicId, request))); + } + + @PostMapping("/invoices/{publicId}/submit") + @PreAuthorize("@authorizationService.hasPermission('receivable:invoice:submit')") + public ApiResponse submitInvoice(@PathVariable String publicId, + @Valid @RequestBody VersionCommandRequest request, + @RequestHeader(value = "Idempotency-Key", required = false) String key, + HttpServletRequest httpRequest) { + return ApiResponse.ok(command(key, httpRequest, request, new TypeReference() { }, + () -> service.submitInvoice(publicId, request.version()))); + } + + @PostMapping("/invoices/{publicId}/review") + @PreAuthorize("@authorizationService.hasPermission('receivable:invoice:review')") + public ApiResponse reviewInvoice(@PathVariable String publicId, + @Valid @RequestBody InvoiceReviewRequest request, + @RequestHeader(value = "Idempotency-Key", required = false) String key, + HttpServletRequest httpRequest) { + return ApiResponse.ok(command(key, httpRequest, request, new TypeReference() { }, + () -> service.reviewInvoice(publicId, request))); + } + + @PostMapping("/invoices/{publicId}/record-result") + @PreAuthorize("@authorizationService.hasPermission('receivable:invoice:record-result')") + public ApiResponse recordInvoiceResult(@PathVariable String publicId, + @Valid @RequestBody InvoiceResultRequest request, + @RequestHeader(value = "Idempotency-Key", required = false) String key, + HttpServletRequest httpRequest) { + return ApiResponse.ok(command(key, httpRequest, request, new TypeReference() { }, + () -> service.recordInvoiceResult(publicId, request))); + } + + @PostMapping("/invoices/{publicId}/void") + @PreAuthorize("@authorizationService.hasPermission('receivable:invoice:void')") + public ApiResponse voidInvoice(@PathVariable String publicId, + @Valid @RequestBody ReasonCommandRequest request, + @RequestHeader(value = "Idempotency-Key", required = false) String key, + HttpServletRequest httpRequest) { + return ApiResponse.ok(command(key, httpRequest, request, new TypeReference() { }, + () -> service.voidInvoice(publicId, request))); + } + + @PostMapping("/invoices/{publicId}/red-letter") + @PreAuthorize("@authorizationService.hasPermission('receivable:invoice:red-letter')") + public ApiResponse redLetterInvoice(@PathVariable String publicId, + @Valid @RequestBody RedLetterRequest request, + @RequestHeader(value = "Idempotency-Key", required = false) String key, + HttpServletRequest httpRequest) { + return ApiResponse.ok(command(key, httpRequest, request, new TypeReference() { }, + () -> service.redLetterInvoice(publicId, request))); + } + + @GetMapping(value = "/invoices/export", produces = "text/csv") + @PreAuthorize("@authorizationService.hasPermission('receivable:invoice:export')") + public ResponseEntity exportInvoices( + @RequestParam(required = false) String companyId, + @RequestParam(required = false) String projectId, + @RequestParam(required = false) String counterpartyId, + @RequestParam(required = false) String keyword, + @RequestParam(required = false) String status, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate dateFrom, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate dateTo, + @RequestParam(defaultValue = "updatedAt,desc") String sort) { + return csv("invoices.csv", service.exportInvoices(companyId, projectId, counterpartyId, keyword, status, + dateFrom, dateTo, sort)); + } + + private static ResponseEntity csv(String filename, byte[] data) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(new MediaType("text", "csv", java.nio.charset.StandardCharsets.UTF_8)); + headers.setContentDisposition(ContentDisposition.attachment().filename(filename).build()); + return ResponseEntity.ok().headers(headers).body(data); + } + + private T command(String key, HttpServletRequest request, Object body, TypeReference type, + java.util.function.Supplier action) { + return idempotencyService.execute(key, request.getMethod(), request.getRequestURI(), body, type, action); + } +} diff --git a/backend/src/main/java/com/kaidi/finance/receivable/api/ReceivableViews.java b/backend/src/main/java/com/kaidi/finance/receivable/api/ReceivableViews.java new file mode 100644 index 0000000..9df01e7 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/receivable/api/ReceivableViews.java @@ -0,0 +1,129 @@ +package com.kaidi.finance.receivable.api; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +public final class ReceivableViews { + + private ReceivableViews() { + } + + public record ReferenceView(String publicId, String code, String name) { + } + + public record ReferenceOptionsView(List companies, + List projects, + List counterparties, + List contracts, + List receivables) { + } + + public record ReceivableOptionView(String publicId, + String businessNo, + ReferenceView project, + ReferenceView counterparty, + String amount, + String outstandingAmount, + String currency) { + } + + public record ReceiptView( + String publicId, + String businessNo, + ReferenceView company, + ReferenceView project, + ReferenceView customer, + LocalDate receiptDate, + String amount, + String allocatedAmount, + String unallocatedAmount, + String currency, + String payerName, + String referenceNo, + String status, + String confirmedByName, + LocalDateTime confirmedAt, + String voidReason, + long version, + LocalDateTime createdAt, + LocalDateTime updatedAt, + List allowedActions + ) { + } + + public record ReceiptAllocationView( + String publicId, + int sequenceNo, + ReferenceView receivable, + ReferenceView project, + ReferenceView contract, + String amount, + String receivableAmount, + String receivableOutstandingAmount, + String note, + String createdByName, + LocalDateTime createdAt + ) { + } + + public record ReceiptDetailView(ReceiptView receipt, List allocations) { + } + + public record ReceivableView( + String publicId, + String businessNo, + ReferenceView company, + ReferenceView project, + ReferenceView contract, + ReferenceView counterparty, + LocalDate businessDate, + String amount, + String settledAmount, + String outstandingAmount, + String currency, + String status, + String sourceInvoiceId, + long version, + LocalDateTime createdAt, + LocalDateTime updatedAt + ) { + } + + public record InvoiceView( + String publicId, + String businessNo, + String sourceDocumentId, + String sourceVersionId, + ReferenceView company, + ReferenceView project, + ReferenceView contract, + ReferenceView counterparty, + String invoiceType, + String amount, + String currency, + String taxRate, + LocalDate requestedDate, + String invoiceNo, + LocalDate issuedDate, + String resultFileId, + String originalInvoiceId, + String status, + String submittedByName, + String reviewedByName, + String resultRecordedByName, + LocalDateTime submittedAt, + LocalDateTime reviewedAt, + String reviewOpinion, + LocalDateTime resultRecordedAt, + String terminationReason, + String contractLimit, + String contractCommittedAmount, + String contractAvailableAmount, + long version, + LocalDateTime createdAt, + LocalDateTime updatedAt, + List allowedActions + ) { + } +} diff --git a/backend/src/main/java/com/kaidi/finance/receivable/application/ApprovedInvoiceProjectionService.java b/backend/src/main/java/com/kaidi/finance/receivable/application/ApprovedInvoiceProjectionService.java new file mode 100644 index 0000000..99e47aa --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/receivable/application/ApprovedInvoiceProjectionService.java @@ -0,0 +1,253 @@ +package com.kaidi.finance.receivable.application; + +import com.kaidi.finance.receivable.infrastructure.ReceivableMapper; +import com.kaidi.finance.receivable.infrastructure.ReceivableMapper.ApprovedInvoiceReferences; +import com.kaidi.finance.receivable.infrastructure.ReceivableMapper.ContractControl; +import com.kaidi.finance.receivable.infrastructure.ReceivableMapper.InvoiceProjection; +import com.kaidi.finance.receivable.infrastructure.ReceivableMapper.InvoiceReservation; +import com.kaidi.finance.shared.api.BusinessException; +import com.kaidi.finance.shared.api.ErrorCode; +import com.kaidi.finance.shared.audit.AuditService; +import com.kaidi.finance.shared.id.UlidGenerator; +import java.math.BigDecimal; +import java.text.Normalizer; +import java.time.LocalDate; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class ApprovedInvoiceProjectionService { + + private static final Set INVOICE_TYPES = Set.of("SPECIAL", "NORMAL", "RECEIPT"); + + private final ReceivableMapper mapper; + private final UlidGenerator ulidGenerator; + private final AuditService auditService; + + public ApprovedInvoiceProjectionService( + ReceivableMapper mapper, + UlidGenerator ulidGenerator, + AuditService auditService + ) { + this.mapper = mapper; + this.ulidGenerator = ulidGenerator; + this.auditService = auditService; + } + + @Transactional(propagation = Propagation.MANDATORY) + public void project(ApprovedInvoiceCommand command) { + validate(command); + InvoiceProjection existing = mapper.findInvoiceBySourceDocument(command.sourceDocumentId()); + if (existing != null) { + if (existing.sourceVersionId() == command.sourceVersionId()) return; + if (!"RETURNED".equals(existing.status())) { + throw invalidState("来源单据已有未退回的开票候选,不能用新版本替换"); + } + } + + ApprovedInvoiceReferences references = mapper.findReferencesForApprovedInvoice( + command.companyId(), + command.projectId(), + command.counterpartyPublicId(), + command.contractPublicId() + ); + if (references == null) { + throw validation("contractId", "公司、项目、客户或合同不存在、未生效,或归属关系不一致"); + } + ContractControl contract = mapper.lockContractControl(references.contractId()); + BigDecimal reservedInvoiceAmount = currentReservedInvoiceAmount(references.contractId()); + validateContractSnapshot(command, contract, reservedInvoiceAmount); + if (command.createdBy() == command.reviewedBy()) { + throw new BusinessException( + HttpStatus.UNPROCESSABLE_ENTITY, + ErrorCode.SOD_VIOLATION, + "开票来源申请人与财务终审人必须为不同人员" + ); + } + validateCustomerSnapshot(command, references); + + String invoicePublicId = existing == null ? ulidGenerator.next() : existing.publicId(); + try { + if (existing == null) { + mapper.insertProjectedInvoice( + invoicePublicId, + "INV-" + invoicePublicId, + command.companyId(), + references.projectId(), + references.contractId(), + references.counterpartyId(), + command.sourceDocumentId(), + command.sourceVersionId(), + command.invoiceType(), + command.amount(), + references.currency(), + command.taxRate(), + command.requestedDate(), + command.createdBy(), + command.reviewedBy() + ); + } else if (mapper.replaceReturnedProjectedInvoice( + existing.id(), + command.sourceDocumentId(), + command.sourceVersionId(), + command.companyId(), + references.projectId(), + references.contractId(), + references.counterpartyId(), + command.invoiceType(), + command.amount(), + references.currency(), + command.taxRate(), + command.requestedDate(), + command.reviewedBy(), + existing.version() + ) != 1) { + throw invalidState("退回的开票候选已变化,请刷新后重试"); + } + } catch (DuplicateKeyException exception) { + throw new BusinessException( + HttpStatus.CONFLICT, + ErrorCode.DUPLICATE_RESOURCE, + "该来源单据已生成开票候选" + ); + } + auditService.recordScoped( + references.companyPublicId(), + references.projectPublicId(), + existing == null ? "SOURCE_OA08_INVOICE_PROJECT" : "SOURCE_OA08_INVOICE_REPROJECT", + "INVOICE", + invoicePublicId, + "SUCCESS", + command.opinion(), + null, + Map.of( + "sourceDocumentId", command.sourceDocumentPublicId(), + "status", "DRAFT", + "projectionMode", existing == null ? "CREATED" : "REPLACED_RETURNED", + "contractId", references.contractPublicId(), + "counterpartyId", references.counterpartyPublicId(), + "contractAmount", command.contractAmount().toPlainString(), + "invoicedAmount", command.invoicedAmount().toPlainString(), + "reservedInvoiceAmountBefore", reservedInvoiceAmount.toPlainString(), + "amount", command.amount().toPlainString(), + "currency", references.currency() + ) + ); + } + + private void validate(ApprovedInvoiceCommand command) { + if (command == null || command.companyId() <= 0 || command.projectId() <= 0 + || command.sourceDocumentId() <= 0 || command.sourceVersionId() <= 0 + || command.createdBy() <= 0 || command.reviewedBy() <= 0 + || blank(command.sourceDocumentPublicId()) || blank(command.counterpartyPublicId()) + || blank(command.contractPublicId()) || blank(command.customerName()) + || blank(command.invoiceTitle()) || blank(command.taxNo()) || blank(command.invoiceType()) + || command.requestedDate() == null || command.amount() == null || command.amount().signum() <= 0 + || command.amount().scale() != 2 || command.taxRate() == null + || command.contractAmount() == null || command.contractAmount().signum() < 0 + || command.contractAmount().scale() != 2 || command.invoicedAmount() == null + || command.invoicedAmount().signum() < 0 || command.invoicedAmount().scale() != 2 + || command.taxRate().signum() < 0 || command.taxRate().compareTo(BigDecimal.ONE) > 0 + || command.taxRate().scale() > 6 || !INVOICE_TYPES.contains(command.invoiceType())) { + throw validation("invoiceAmount", "开票候选投影参数无效"); + } + } + + private void validateCustomerSnapshot(ApprovedInvoiceCommand command, ApprovedInvoiceReferences references) { + String masterName = normalized(references.counterpartyName()); + if (!masterName.equals(normalized(command.customerName())) + || !masterName.equals(normalized(command.invoiceTitle()))) { + throw validation("customerName", "客户名称或开票抬头与所选生效客户不一致"); + } + if (blank(references.counterpartyTaxNo()) + || !normalized(references.counterpartyTaxNo()).equals(normalized(command.taxNo()))) { + throw validation("invoiceHeader", "纳税人识别号与所选生效客户不一致"); + } + } + + private BigDecimal currentReservedInvoiceAmount(long contractId) { + return mapper.lockInvoiceReservations(contractId).stream() + .map(this::signedInvoiceAmount) + .reduce(BigDecimal.ZERO, BigDecimal::add); + } + + private BigDecimal signedInvoiceAmount(InvoiceReservation reservation) { + return "RED_LETTER".equals(reservation.invoiceType()) + ? reservation.amount().negate() + : reservation.amount(); + } + + private void validateContractSnapshot(ApprovedInvoiceCommand command, ContractControl contract, + BigDecimal reservedInvoiceAmount) { + if (contract == null || contract.status() == null || !"ACTIVE".equals(contract.status())) { + throw validation("contractId", "合同不存在、未生效或当前状态不允许开票"); + } + BigDecimal limit = contract.settlementAmount() == null + ? contract.originalAmount().add(contract.approvedChangeAmount()) + : contract.settlementAmount(); + if (command.invoicedAmount().compareTo(contract.committedInvoiceAmount()) != 0) { + throw validation("invoicedAmount", "已开票金额快照与当前合同台账不一致,请刷新来源表单"); + } + if (command.contractAmount().compareTo(limit) != 0) { + throw validation("contractAmount", "合同金额快照与当前合同额度不一致,请刷新来源表单"); + } + if (command.invoicedAmount().add(command.amount()).compareTo(command.contractAmount()) > 0 + || reservedInvoiceAmount.add(command.amount()).compareTo(limit) > 0) { + throw new BusinessException( + HttpStatus.UNPROCESSABLE_ENTITY, + ErrorCode.CONTRACT_LIMIT_EXCEEDED, + "本次开票与在途开票候选合计会超过合同可开票额度" + ); + } + } + + private String normalized(String value) { + return Normalizer.normalize(value, Normalizer.Form.NFKC).strip().toUpperCase(Locale.ROOT); + } + + private boolean blank(String value) { + return value == null || value.isBlank(); + } + + private BusinessException validation(String field, String message) { + return new BusinessException( + HttpStatus.UNPROCESSABLE_ENTITY, + ErrorCode.VALIDATION_FAILED, + "审批来源不能生成开票候选", + Map.of(field, message) + ); + } + + private BusinessException invalidState(String message) { + return new BusinessException(HttpStatus.CONFLICT, ErrorCode.INVALID_STATE_TRANSITION, message); + } + + public record ApprovedInvoiceCommand( + long companyId, + long projectId, + long sourceDocumentId, + String sourceDocumentPublicId, + long sourceVersionId, + String counterpartyPublicId, + String contractPublicId, + BigDecimal contractAmount, + BigDecimal invoicedAmount, + String customerName, + String invoiceTitle, + String taxNo, + String invoiceType, + BigDecimal amount, + BigDecimal taxRate, + LocalDate requestedDate, + long createdBy, + long reviewedBy, + String opinion + ) { + } +} diff --git a/backend/src/main/java/com/kaidi/finance/receivable/application/ApprovedReceiptProjectionService.java b/backend/src/main/java/com/kaidi/finance/receivable/application/ApprovedReceiptProjectionService.java new file mode 100644 index 0000000..7e00312 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/receivable/application/ApprovedReceiptProjectionService.java @@ -0,0 +1,193 @@ +package com.kaidi.finance.receivable.application; + +import com.kaidi.finance.fund.application.FundLedgerApplicationService; +import com.kaidi.finance.fund.application.FundLedgerApplicationService.FundLedgerCommand; +import com.kaidi.finance.receivable.infrastructure.ReceivableMapper; +import com.kaidi.finance.receivable.infrastructure.ReceivableMapper.ApprovedReceiptProject; +import com.kaidi.finance.receivable.infrastructure.ReceivableMapper.ReceiptProjection; +import com.kaidi.finance.shared.api.BusinessException; +import com.kaidi.finance.shared.api.ErrorCode; +import com.kaidi.finance.shared.audit.AuditService; +import com.kaidi.finance.shared.id.UlidGenerator; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.Map; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class ApprovedReceiptProjectionService { + + private static final BigDecimal ZERO = BigDecimal.ZERO.setScale(2); + + private final ReceivableMapper mapper; + private final FundLedgerApplicationService fundLedgerService; + private final UlidGenerator ulidGenerator; + private final AuditService auditService; + + public ApprovedReceiptProjectionService( + ReceivableMapper mapper, + FundLedgerApplicationService fundLedgerService, + UlidGenerator ulidGenerator, + AuditService auditService + ) { + this.mapper = mapper; + this.fundLedgerService = fundLedgerService; + this.ulidGenerator = ulidGenerator; + this.auditService = auditService; + } + + @Transactional(propagation = Propagation.MANDATORY) + public void project(ApprovedReceiptCommand command) { + validate(command); + ReceiptProjection existing = mapper.findReceiptBySourceDocument(command.sourceDocumentId()); + if (existing != null) { + if (existing.sourceVersionId() != command.sourceVersionId()) { + throw invalidState("来源单据已有其他版本生成的收款记录"); + } + return; + } + + ApprovedReceiptProject project = mapper.findProjectForApprovedReceipt( + command.projectId(), command.companyId() + ); + if (project == null) { + throw validation("projectId", "公司或项目不存在、未生效或归属关系已变化"); + } + if (command.createdBy() == command.confirmedBy()) { + throw new BusinessException( + HttpStatus.UNPROCESSABLE_ENTITY, + ErrorCode.SOD_VIOLATION, + "收款来源申请人与财务确认人必须为不同人员" + ); + } + + String receiptPublicId = ulidGenerator.next(); + String receiptBusinessNo = "RCPT-" + receiptPublicId; + try { + mapper.insertProjectedReceipt( + receiptPublicId, + receiptBusinessNo, + command.companyId(), + project.id(), + project.customerId(), + command.sourceDocumentId(), + command.sourceVersionId(), + command.receiptDate(), + command.amount(), + project.currency(), + command.payerName(), + command.createdBy(), + command.confirmedBy() + ); + } catch (DuplicateKeyException exception) { + throw new BusinessException( + HttpStatus.CONFLICT, + ErrorCode.DUPLICATE_RESOURCE, + "该来源单据已生成收款记录" + ); + } + + mapper.ensureFundControl(project.id(), project.currency()); + if (mapper.adjustConfirmedReceipt(project.id(), project.currency(), command.amount()) != 1) { + throw conflict(); + } + fundLedgerService.append(new FundLedgerCommand( + project.id(), + project.currency(), + "RECEIPT_CONFIRM:" + receiptPublicId, + "RECEIPT_CONFIRMED", + "RECEIPT", + receiptPublicId, + receiptBusinessNo, + "OA-05 财务终审确认项目收款", + command.amount(), + command.amount(), + ZERO, + ZERO, + ZERO, + ZERO, + ZERO, + command.confirmedBy() + )); + if (mapper.insertAccountingEvent( + ulidGenerator.next(), + "EVT-" + receiptPublicId, + "RECEIPT", + receiptPublicId, + "RECEIPT-" + receiptPublicId, + 1, + command.companyId(), + project.id(), + project.customerId(), + command.receiptDate(), + command.amount(), + project.currency(), + command.confirmedBy() + ) != 1) { + throw conflict(); + } + auditService.recordScoped( + project.companyPublicId(), + project.publicId(), + "SOURCE_OA05_RECEIPT_PROJECT", + "RECEIPT", + receiptPublicId, + "SUCCESS", + command.opinion(), + null, + Map.of( + "sourceDocumentId", command.sourceDocumentPublicId(), + "status", "CONFIRMED", + "amount", command.amount().toPlainString(), + "currency", project.currency() + ) + ); + } + + private void validate(ApprovedReceiptCommand command) { + if (command == null || command.companyId() <= 0 || command.projectId() <= 0 + || command.sourceDocumentId() <= 0 || command.sourceVersionId() <= 0 + || command.createdBy() <= 0 || command.confirmedBy() <= 0 + || command.sourceDocumentPublicId() == null || command.sourceDocumentPublicId().isBlank() + || command.receiptDate() == null || command.amount() == null || command.amount().signum() <= 0 + || command.amount().scale() != 2 || command.payerName() == null || command.payerName().isBlank()) { + throw validation("receiptAmount", "收款投影参数无效"); + } + } + + private BusinessException validation(String field, String message) { + return new BusinessException( + HttpStatus.UNPROCESSABLE_ENTITY, + ErrorCode.VALIDATION_FAILED, + "审批来源不能生成收款记录", + Map.of(field, message) + ); + } + + private BusinessException invalidState(String message) { + return new BusinessException(HttpStatus.CONFLICT, ErrorCode.INVALID_STATE_TRANSITION, message); + } + + private BusinessException conflict() { + return new BusinessException(HttpStatus.CONFLICT, ErrorCode.CONCURRENT_MODIFICATION, "收款投影并发冲突,请重试"); + } + + public record ApprovedReceiptCommand( + long companyId, + long projectId, + long sourceDocumentId, + String sourceDocumentPublicId, + long sourceVersionId, + LocalDate receiptDate, + BigDecimal amount, + String payerName, + long createdBy, + long confirmedBy, + String opinion + ) { + } +} diff --git a/backend/src/main/java/com/kaidi/finance/receivable/application/ReceivableApplicationService.java b/backend/src/main/java/com/kaidi/finance/receivable/application/ReceivableApplicationService.java new file mode 100644 index 0000000..6315220 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/receivable/application/ReceivableApplicationService.java @@ -0,0 +1,950 @@ +package com.kaidi.finance.receivable.application; + +import com.kaidi.finance.fund.application.FundLedgerApplicationService; +import com.kaidi.finance.fund.application.FundLedgerApplicationService.FundLedgerCommand; +import com.kaidi.finance.iam.domain.FinancePrincipal; +import com.kaidi.finance.receivable.api.ReceivableContracts.InvoiceCreateRequest; +import com.kaidi.finance.receivable.api.ReceivableContracts.InvoiceResultRequest; +import com.kaidi.finance.receivable.api.ReceivableContracts.InvoiceReviewRequest; +import com.kaidi.finance.receivable.api.ReceivableContracts.InvoiceUpdateRequest; +import com.kaidi.finance.receivable.api.ReceivableContracts.ReceiptAllocationRequest; +import com.kaidi.finance.receivable.api.ReceivableContracts.ReceiptCreateRequest; +import com.kaidi.finance.receivable.api.ReceivableContracts.ReceiptUpdateRequest; +import com.kaidi.finance.receivable.api.ReceivableContracts.RedLetterRequest; +import com.kaidi.finance.receivable.api.ReceivableContracts.ReasonCommandRequest; +import com.kaidi.finance.receivable.api.ReceivableViews.InvoiceView; +import com.kaidi.finance.receivable.api.ReceivableViews.ReceivableOptionView; +import com.kaidi.finance.receivable.api.ReceivableViews.ReceivableView; +import com.kaidi.finance.receivable.api.ReceivableViews.ReceiptAllocationView; +import com.kaidi.finance.receivable.api.ReceivableViews.ReceiptDetailView; +import com.kaidi.finance.receivable.api.ReceivableViews.ReceiptView; +import com.kaidi.finance.receivable.api.ReceivableViews.ReferenceOptionsView; +import com.kaidi.finance.receivable.api.ReceivableViews.ReferenceView; +import com.kaidi.finance.receivable.infrastructure.ReceivableMapper; +import com.kaidi.finance.receivable.infrastructure.ReceivableMapper.AllocationRow; +import com.kaidi.finance.receivable.infrastructure.ReceivableMapper.ContractRef; +import com.kaidi.finance.receivable.infrastructure.ReceivableMapper.ContractControl; +import com.kaidi.finance.receivable.infrastructure.ReceivableMapper.EntityRef; +import com.kaidi.finance.receivable.infrastructure.ReceivableMapper.FileRef; +import com.kaidi.finance.receivable.infrastructure.ReceivableMapper.InvoiceRow; +import com.kaidi.finance.receivable.infrastructure.ReceivableMapper.ProjectRef; +import com.kaidi.finance.receivable.infrastructure.ReceivableMapper.ReceivableLock; +import com.kaidi.finance.receivable.infrastructure.ReceivableMapper.ReceivableRow; +import com.kaidi.finance.receivable.infrastructure.ReceivableMapper.ReceiptLock; +import com.kaidi.finance.receivable.infrastructure.ReceivableMapper.ReceiptRow; +import com.kaidi.finance.shared.api.BusinessException; +import com.kaidi.finance.shared.api.ErrorCode; +import com.kaidi.finance.shared.api.PageResult; +import com.kaidi.finance.shared.audit.AuditService; +import com.kaidi.finance.shared.id.UlidGenerator; +import com.kaidi.finance.shared.security.AuthorizationService; +import com.kaidi.finance.shared.security.IdentityContext; +import com.kaidi.finance.source.application.SourceFormApplicationService; +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.regex.Pattern; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class ReceivableApplicationService { + + private static final Pattern PUBLIC_ID = Pattern.compile("[0-9A-HJKMNP-TV-Z]{26}"); + private static final Set RECEIPT_STATUSES = Set.of( + "DRAFT", "CONFIRMED", "PARTIALLY_ALLOCATED", "ALLOCATED", "VOID"); + private static final Set INVOICE_STATUSES = Set.of( + "DRAFT", "REVIEWING", "RETURNED", "REJECTED", "APPROVED", "RESULT_PENDING", + "ISSUED", "VOID", "RED_LETTERED"); + private static final Set RECEIVABLE_STATUSES = Set.of( + "OPEN", "PARTIALLY_SETTLED", "SETTLED", "VOID"); + + private final ReceivableMapper mapper; + private final IdentityContext identityContext; + private final AuthorizationService authorizationService; + private final AuditService auditService; + private final UlidGenerator ulidGenerator; + private final FundLedgerApplicationService fundLedgerService; + private final SourceFormApplicationService sourceFormService; + + public ReceivableApplicationService(ReceivableMapper mapper, + IdentityContext identityContext, + AuthorizationService authorizationService, + AuditService auditService, + UlidGenerator ulidGenerator, + FundLedgerApplicationService fundLedgerService, + SourceFormApplicationService sourceFormService) { + this.mapper = mapper; + this.identityContext = identityContext; + this.authorizationService = authorizationService; + this.auditService = auditService; + this.ulidGenerator = ulidGenerator; + this.fundLedgerService = fundLedgerService; + this.sourceFormService = sourceFormService; + } + + @Transactional(readOnly = true) + public PageResult listReceipts(String companyId, String projectId, String counterpartyId, + String keyword, + String status, LocalDate dateFrom, LocalDate dateTo, + String sort, int page, int size) { + authorizationService.requirePermission("receivable:receipt:view"); + requireOptionalId(companyId, "公司编号"); + requireOptionalId(projectId, "项目编号"); + requireOptionalId(counterpartyId, "客户编号"); + requireStatus(status, RECEIPT_STATUSES); + Page request = page(page, size); + Actor actor = actor(); + String orderBy = receiptSort(sort); + String safeKeyword = clean(keyword, 100); + long total = mapper.countReceipts(actor.userId(), actor.roleCode(), "receivable:receipt:view", companyId, projectId, counterpartyId, + safeKeyword, blank(status), dateFrom, dateTo); + List items = mapper.listReceipts(actor.userId(), actor.roleCode(), "receivable:receipt:view", companyId, projectId, + counterpartyId, + safeKeyword, blank(status), dateFrom, dateTo, orderBy, request.size(), request.offset()) + .stream().map(this::receiptView).toList(); + return new PageResult<>(items, total, request.page(), request.size()); + } + + @Transactional(readOnly = true) + public ReceiptDetailView getReceipt(String publicId) { + authorizationService.requirePermission("receivable:receipt:view"); + ReceiptRow row = receipt(publicId); + return new ReceiptDetailView(receiptView(row), mapper.listAllocations(row.id()).stream() + .map(this::allocationView).toList()); + } + + @Transactional(readOnly = true) + public PageResult listReceivables(String companyId, String projectId, + String counterpartyId, String keyword, + String status, LocalDate dateFrom, LocalDate dateTo, + String sort, int page, int size) { + authorizationService.requirePermission("receivable:receipt:view"); + requireOptionalId(companyId, "公司编号"); + requireOptionalId(projectId, "项目编号"); + requireOptionalId(counterpartyId, "客户编号"); + requireStatus(status, RECEIVABLE_STATUSES); + Page request = page(page, size); + Actor actor = actor(); + String safeKeyword = clean(keyword, 100); + long total = mapper.countReceivables(actor.userId(), actor.roleCode(), "receivable:receipt:view", companyId, projectId, + counterpartyId, safeKeyword, blank(status), dateFrom, dateTo); + List items = mapper.listReceivables(actor.userId(), actor.roleCode(), "receivable:receipt:view", companyId, + projectId, counterpartyId, safeKeyword, blank(status), dateFrom, dateTo, + receivableSort(sort), request.size(), request.offset()).stream().map(this::receivableView).toList(); + return new PageResult<>(items, total, request.page(), request.size()); + } + + @Transactional(readOnly = true) + public PageResult listInvoices(String companyId, String projectId, String counterpartyId, + String keyword, + String status, LocalDate dateFrom, LocalDate dateTo, + String sort, int page, int size) { + authorizationService.requirePermission("receivable:invoice:view"); + requireOptionalId(companyId, "公司编号"); + requireOptionalId(projectId, "项目编号"); + requireOptionalId(counterpartyId, "开票对象编号"); + requireStatus(status, INVOICE_STATUSES); + Page request = page(page, size); + Actor actor = actor(); + String orderBy = invoiceSort(sort); + String safeKeyword = clean(keyword, 100); + long total = mapper.countInvoices(actor.userId(), actor.roleCode(), "receivable:invoice:view", companyId, projectId, counterpartyId, + safeKeyword, blank(status), dateFrom, dateTo); + List items = mapper.listInvoices(actor.userId(), actor.roleCode(), "receivable:invoice:view", companyId, projectId, + counterpartyId, + safeKeyword, blank(status), dateFrom, dateTo, orderBy, request.size(), request.offset()) + .stream().map(this::invoiceView).toList(); + return new PageResult<>(items, total, request.page(), request.size()); + } + + @Transactional(readOnly = true) + public InvoiceView getInvoice(String publicId) { + authorizationService.requirePermission("receivable:invoice:view"); + return invoiceView(invoice(publicId)); + } + + @Transactional(readOnly = true) + public ReferenceOptionsView references() { + authorizationService.requirePermission("receivable:receipt:view"); + Actor actor = actor(); + return new ReferenceOptionsView( + mapper.listCompanies(actor.userId(), actor.roleCode(), "receivable:receipt:view").stream().map(this::reference).toList(), + mapper.listProjects(actor.userId(), actor.roleCode(), "receivable:receipt:view").stream().map(this::reference).toList(), + mapper.listCounterparties().stream().map(this::reference).toList(), + mapper.listContracts(actor.userId(), actor.roleCode(), "receivable:invoice:view").stream().map(this::reference).toList(), + mapper.listOpenReceivables(actor.userId(), actor.roleCode(), "receivable:receipt:view").stream() + .map(this::receivableOption).toList() + ); + } + + @Transactional + public ReceiptView createReceipt(ReceiptCreateRequest request) { + authorizationService.requirePermission("receivable:receipt:create"); + Actor actor = actor(); + RefBundle refs = receiptRefs(request.companyId(), request.projectId(), request.customerId(), null, + "receivable:receipt:create"); + authorizationService.requireScope("receivable:receipt:create", refs.company().publicId(), + refs.project() == null ? null : refs.project().publicId(), scaledMoney(request.amount())); + String publicId = ulidGenerator.next(); + try { + mapper.insertReceipt(publicId, businessNo("RCPT", publicId), refs.company().id(), + refs.project() == null ? null : refs.project().id(), refs.counterpartyId(), request.receiptDate(), + scaledMoney(request.amount()), request.currency(), cleanRequired(request.payerName(), 200), + clean(request.referenceNo(), 100), actor.userId()); + } catch (DuplicateKeyException exception) { + throw duplicate("同一公司下的银行流水号已经登记,请核对是否重复收款"); + } + ReceiptView result = receiptView(receipt(publicId)); + auditService.recordScoped(refs.company().publicId(), refs.project() == null ? null : refs.project().publicId(), + "RECEIPT_CREATE", "RECEIPT", publicId, "SUCCESS", null, null, result); + return result; + } + + @Transactional + public ReceiptView updateReceipt(String publicId, ReceiptUpdateRequest request) { + authorizationService.requirePermission("receivable:receipt:edit"); + Actor actor = actor(); + ReceiptRow current = receipt(publicId); + authorizationService.requireScope("receivable:receipt:edit", current.companyPublicId(), + current.projectPublicId(), current.amount()); + requireOwnerOrFinance(current.createdBy(), actor); + requireVersion(current.version(), request.version()); + if (!"DRAFT".equals(current.status())) throw transition("只有草稿收款可以编辑"); + if (current.allocatedAmount().signum() > 0 && scaledMoney(request.amount()).compareTo(current.allocatedAmount()) < 0) { + throw validation("收款金额不能小于已分配金额"); + } + RefBundle refs = receiptRefs(request.companyId(), request.projectId(), request.customerId(), null, + "receivable:receipt:edit"); + authorizationService.requireScope("receivable:receipt:edit", refs.company().publicId(), + refs.project() == null ? null : refs.project().publicId(), scaledMoney(request.amount())); + try { + if (mapper.updateReceipt(current.id(), refs.company().id(), refs.project() == null ? null : refs.project().id(), + refs.counterpartyId(), request.receiptDate(), scaledMoney(request.amount()), request.currency(), + cleanRequired(request.payerName(), 200), clean(request.referenceNo(), 100), actor.userId(), request.version()) != 1) { + throw conflict(); + } + } catch (DuplicateKeyException exception) { + throw duplicate("同一公司下的银行流水号已经登记,请核对是否重复收款"); + } + ReceiptView result = receiptView(receipt(publicId)); + auditService.recordScoped(refs.company().publicId(), refs.project() == null ? null : refs.project().publicId(), + "RECEIPT_UPDATE", "RECEIPT", publicId, "SUCCESS", null, receiptView(current), result); + return result; + } + + @Transactional + public ReceiptView confirmReceipt(String publicId, long expectedVersion) { + authorizationService.requirePermission("receivable:receipt:confirm"); + Actor actor = actor(); + ReceiptRow current = receipt(publicId); + authorizationService.requireScope("receivable:receipt:confirm", current.companyPublicId(), + current.projectPublicId(), current.amount()); + if (current.createdBy() == actor.userId()) { + throw sod("收款登记人与确认人必须为不同人员"); + } + requireVersion(current.version(), expectedVersion); + if (!Set.of("DRAFT").contains(current.status())) { + throw transition("只有草稿收款可以确认"); + } + if (mapper.confirmReceipt(current.id(), expectedVersion, actor.userId()) != 1) throw conflict(); + EntityRef company = mapper.findCompany(current.companyPublicId()); + EntityRef customer = current.customerPublicId() == null ? null : mapper.findCounterparty(current.customerPublicId()); + ProjectRef project = current.projectPublicId() == null ? null : mapper.findProject(current.projectPublicId()); + if (project != null) { + adjustConfirmedReceipt(project, current.currency(), current.amount()); + appendFundEntry(project.id(), current.currency(), "RECEIPT_CONFIRM:" + current.publicId(), + "RECEIPT_CONFIRMED", current.publicId(), current.businessNo(), "确认项目收款", + current.amount(), current.amount(), actor.userId()); + } + mapper.insertAccountingEvent(ulidGenerator.next(), businessNo("EVT", publicId), "RECEIPT", publicId, + "RECEIPT-" + publicId, 1, + company.id(), project == null ? null : project.id(), customer == null ? null : customer.id(), + current.receiptDate(), current.amount(), current.currency(), actor.userId()); + ReceiptView result = receiptView(receipt(publicId)); + auditService.recordScoped(current.companyPublicId(), current.projectPublicId(), + "RECEIPT_CONFIRM", "RECEIPT", publicId, "SUCCESS", null, receiptView(current), result); + return result; + } + + @Transactional + public ReceiptDetailView allocateReceipt(String publicId, ReceiptAllocationRequest request) { + authorizationService.requirePermission("receivable:receipt:allocate"); + Actor actor = actor(); + ReceiptRow current = receipt(publicId); + ReceiptLock lockedReceipt = mapper.lockReceipt(current.id()); + if (lockedReceipt == null) throw notFound("收款记录不存在"); + requireVersion(lockedReceipt.version(), request.version()); + if (!Set.of("CONFIRMED", "PARTIALLY_ALLOCATED").contains(lockedReceipt.status())) { + throw transition("只有已确认且尚未分配完的收款可以核销"); + } + ReceivableLock receivable = mapper.lockReceivable(publicId(request.receivableId())); + if (receivable == null) throw notFound("应收记录不存在"); + if (!current.companyPublicId().equals(receivable.companyPublicId())) { + throw validation("收款与应收不属于同一公司"); + } + if (current.projectPublicId() != null && !current.projectPublicId().equals(receivable.projectPublicId())) { + throw validation("已指定项目的收款只能核销同一项目应收"); + } + if (current.customerPublicId() != null + && !current.customerPublicId().equals(receivable.counterpartyPublicId())) { + throw validation("收款客户与应收客户不一致"); + } + if (!current.currency().equals(receivable.currency())) throw validation("收款与应收币种不一致"); + if (!Set.of("OPEN", "PARTIALLY_SETTLED").contains(receivable.status())) { + throw transition("该应收已经结清或作废"); + } + BigDecimal amount = scaledMoney(request.amount()); + authorizationService.requireScope("receivable:receipt:allocate", current.companyPublicId(), + receivable.projectPublicId(), amount); + BigDecimal receiptRemaining = lockedReceipt.amount().subtract(lockedReceipt.allocatedAmount()); + if (amount.compareTo(receiptRemaining) > 0) throw validation("分配金额超过收款未分配余额"); + if (amount.compareTo(receivable.outstandingAmount()) > 0) throw validation("分配金额超过应收未收余额"); + int sequenceNo = mapper.nextAllocationSequence(current.id()); + if (mapper.settleReceivable(receivable.id(), receivable.version(), amount, actor.userId()) != 1) { + throw conflict(); + } + if (mapper.addAllocation(current.id(), request.version(), amount, actor.userId()) != 1) throw conflict(); + try { + mapper.insertAllocation(ulidGenerator.next(), current.id(), receivable.id(), sequenceNo, + receivable.projectId(), receivable.contractId(), amount, + clean(request.note(), 500), actor.userId()); + } catch (DuplicateKeyException exception) { + throw duplicate("本次应收核销序号已存在,请刷新后重试"); + } + // A receipt without a project is counted only once it is allocated to + // a concrete project. Receipts already carrying a project were added + // at confirmation and must not be double-counted here. + if (current.projectPublicId() == null) { + adjustConfirmedReceipt(receivable.projectId(), current.currency(), amount); + appendFundEntry(receivable.projectId(), current.currency(), + "RECEIPT_ALLOCATE:" + current.publicId() + ":" + sequenceNo, + "RECEIPT_ALLOCATED_TO_PROJECT", current.publicId(), current.businessNo(), + "暂收款核销到项目", amount, amount, actor.userId()); + } + ReceiptDetailView result = getReceipt(publicId); + auditService.recordScoped(current.companyPublicId(), + current.projectPublicId() == null ? receivable.projectPublicId() : current.projectPublicId(), + "RECEIPT_ALLOCATE", "RECEIPT", publicId, "SUCCESS", null, receiptView(current), result); + return result; + } + + @Transactional + public ReceiptView voidReceipt(String publicId, ReasonCommandRequest request) { + authorizationService.requirePermission("receivable:receipt:void"); + Actor actor = actor(); + ReceiptRow current = receipt(publicId); + authorizationService.requireScope("receivable:receipt:void", current.companyPublicId(), + current.projectPublicId(), current.amount()); + ReceiptLock locked = mapper.lockReceipt(current.id()); + if (locked == null) throw notFound("收款记录不存在"); + requireVersion(locked.version(), request.version()); + if (current.sourceDocumentId() != null) { + throw transition("来源审批生成的收款必须通过来源更正流程处理,不能直接作废"); + } + if (!Set.of("DRAFT", "CONFIRMED").contains(locked.status()) || locked.allocatedAmount().signum() > 0) { + throw transition("只有未分配的草稿或已确认收款可以作废"); + } + if ("CONFIRMED".equals(locked.status())) voidPendingAccountingEvent(publicId, "RECEIPT"); + if (mapper.voidReceipt(locked.id(), request.version(), cleanRequired(request.reason(), 1000), + actor.userId()) != 1) throw conflict(); + if ("CONFIRMED".equals(locked.status()) && current.projectPublicId() != null) { + ProjectRef project = mapper.findProject(current.projectPublicId()); + if (project == null) throw notFound("项目不存在或未生效"); + adjustConfirmedReceipt(project, current.currency(), current.amount().negate()); + appendFundEntry(project.id(), current.currency(), "RECEIPT_VOID:" + current.publicId(), + "RECEIPT_VOIDED", current.publicId(), current.businessNo(), "作废已确认项目收款", + current.amount(), current.amount().negate(), actor.userId()); + } + ReceiptView result = receiptView(receipt(publicId)); + auditService.recordScoped(current.companyPublicId(), current.projectPublicId(), + "RECEIPT_VOID", "RECEIPT", publicId, "SUCCESS", request.reason(), receiptView(current), result); + return result; + } + + @Transactional + public InvoiceView createInvoice(InvoiceCreateRequest request) { + authorizationService.requirePermission("receivable:invoice:create"); + Actor actor = actor(); + RefBundle refs = receiptRefs(request.companyId(), request.projectId(), request.counterpartyId(), request.contractId(), + "receivable:invoice:create"); + if (refs.project() == null) throw notFound("项目不存在"); + if (refs.counterpartyId() == null) throw notFound("开票对象不存在"); + requireContractCounterparty(refs); + authorizationService.requireScope("receivable:invoice:create", refs.company().publicId(), + refs.project().publicId(), scaledMoney(request.amount())); + String publicId = ulidGenerator.next(); + try { + mapper.insertInvoice(publicId, businessNo("INV", publicId), refs.company().id(), refs.project().id(), + refs.contract() == null ? null : refs.contract().id(), refs.counterpartyId(), request.invoiceType(), + scaledMoney(request.amount()), refs.project().currency(), rate(request.taxRate()), + request.requestedDate(), actor.userId()); + } catch (DuplicateKeyException exception) { + throw duplicate("开票申请业务编号已经存在"); + } + InvoiceView result = invoiceView(invoice(publicId)); + auditService.recordScoped(refs.company().publicId(), refs.project().publicId(), + "INVOICE_CREATE", "INVOICE", publicId, "SUCCESS", null, null, result); + return result; + } + + @Transactional + public InvoiceView updateInvoice(String publicId, InvoiceUpdateRequest request) { + authorizationService.requirePermission("receivable:invoice:edit"); + Actor actor = actor(); + InvoiceRow current = invoice(publicId); + authorizationService.requireScope("receivable:invoice:edit", current.companyPublicId(), + current.projectPublicId(), current.amount()); + requireOwnerOrFinance(current.createdBy(), actor); + requireVersion(current.version(), request.version()); + if (!Set.of("DRAFT", "RETURNED").contains(current.status())) { + throw transition("只有草稿或已退回的开票申请可以编辑"); + } + if (current.sourceDocumentId() != null) { + throw transition("来源审批生成的开票候选必须通过来源更正流程处理,不能直接编辑"); + } + RefBundle refs = receiptRefs(request.companyId(), request.projectId(), request.counterpartyId(), request.contractId(), + "receivable:invoice:edit"); + if (refs.project() == null || refs.counterpartyId() == null) throw notFound("项目或开票对象不存在"); + requireContractCounterparty(refs); + authorizationService.requireScope("receivable:invoice:edit", refs.company().publicId(), + refs.project().publicId(), scaledMoney(request.amount())); + if (mapper.updateInvoice(current.id(), refs.company().id(), refs.project().id(), + refs.contract() == null ? null : refs.contract().id(), refs.counterpartyId(), request.invoiceType(), + scaledMoney(request.amount()), refs.project().currency(), rate(request.taxRate()), + request.requestedDate(), actor.userId(), request.version()) != 1) { + throw conflict(); + } + InvoiceView result = invoiceView(invoice(publicId)); + auditService.recordScoped(refs.company().publicId(), refs.project().publicId(), + "INVOICE_UPDATE", "INVOICE", publicId, "SUCCESS", null, invoiceView(current), result); + return result; + } + + @Transactional + public InvoiceView submitInvoice(String publicId, long expectedVersion) { + authorizationService.requirePermission("receivable:invoice:submit"); + Actor actor = actor(); + InvoiceRow current = invoice(publicId); + authorizationService.requireScope("receivable:invoice:submit", current.companyPublicId(), current.projectPublicId(), current.amount()); + requireVersion(current.version(), expectedVersion); + if (!Set.of("DRAFT", "RETURNED").contains(current.status())) throw transition("当前状态不能提交开票申请"); + if (current.sourceDocumentId() != null && "RETURNED".equals(current.status())) { + throw transition("来源审批生成的开票候选退回后,必须先更正来源表单并重新审批"); + } + if (mapper.submitInvoice(current.id(), expectedVersion, actor.userId()) != 1) throw conflict(); + InvoiceView result = invoiceView(invoice(publicId)); + auditService.recordScoped(current.companyPublicId(), current.projectPublicId(), + "INVOICE_SUBMIT", "INVOICE", publicId, "SUCCESS", null, invoiceView(current), result); + return result; + } + + @Transactional + public InvoiceView reviewInvoice(String publicId, InvoiceReviewRequest request) { + authorizationService.requirePermission("receivable:invoice:review"); + Actor actor = actor(); + InvoiceRow current = invoice(publicId); + authorizationService.requireScope("receivable:invoice:review", current.companyPublicId(), current.projectPublicId(), current.amount()); + requireVersion(current.version(), request.version()); + if (!"REVIEWING".equals(current.status())) throw transition("只有复核中的开票申请可以审核"); + if (current.submittedBy() != null && current.submittedBy() == actor.userId()) throw sod("开票申请提交人与复核人必须为不同人员"); + if (current.sourceDocumentId() != null && "REJECT".equals(request.decision())) { + throw transition("来源审批生成的开票候选不能直接驳回,请退回来源表单更正"); + } + if (current.sourceDocumentId() != null && "RETURN".equals(request.decision()) + && (request.returnPoints() == null || request.returnPoints().isEmpty())) { + throw new BusinessException( + HttpStatus.UNPROCESSABLE_ENTITY, + ErrorCode.VALIDATION_FAILED, + "来源开票候选退回时必须选择需要更正的字段" + ); + } + String status = switch (request.decision()) { + case "APPROVE" -> "RESULT_PENDING"; + case "RETURN" -> "RETURNED"; + case "REJECT" -> "REJECTED"; + default -> throw validation("审核决定无效"); + }; + if ("APPROVE".equals(request.decision())) ensureInvoiceWithinContractLimit(current, current.amount()); + String opinion = cleanRequired(request.opinion(), 1000); + if (mapper.reviewInvoice(current.id(), request.version(), status, + opinion, actor.userId()) != 1) throw conflict(); + if (current.sourceDocumentId() != null && "RETURN".equals(request.decision())) { + if (current.sourceVersionId() == null) { + throw transition("来源开票候选缺少来源版本,不能退回更正"); + } + sourceFormService.returnApprovedOa08ForCorrection( + current.sourceDocumentId(), + current.sourceVersionId(), + current.publicId(), + request.returnPoints(), + opinion + ); + } + InvoiceView result = invoiceView(invoice(publicId)); + auditService.recordScoped(current.companyPublicId(), current.projectPublicId(), + "INVOICE_REVIEW", "INVOICE", publicId, "SUCCESS", opinion, invoiceView(current), result); + return result; + } + + @Transactional + public InvoiceView recordInvoiceResult(String publicId, InvoiceResultRequest request) { + authorizationService.requirePermission("receivable:invoice:record-result"); + Actor actor = actor(); + InvoiceRow current = invoice(publicId); + authorizationService.requireScope("receivable:invoice:record-result", current.companyPublicId(), current.projectPublicId(), current.amount()); + requireVersion(current.version(), request.version()); + if (!Set.of("RESULT_PENDING", "APPROVED").contains(current.status())) throw transition("当前状态不能登记开票结果"); + ensureInvoiceWithinContractLimit(current, BigDecimal.ZERO); + Long fileId = checkedFileId(request.resultFileId()); + String invoiceNo = cleanRequired(request.invoiceNo(), 100); + try { + if (mapper.recordInvoiceResult(current.id(), request.version(), invoiceNo, + request.issuedDate(), fileId, actor.userId()) != 1) throw conflict(); + } catch (DuplicateKeyException exception) { + throw duplicate("同一公司下的发票号码已经登记,请核对开票结果"); + } + String receivableId = ulidGenerator.next(); + try { + mapper.insertReceivable(receivableId, businessNo("AR", receivableId), current.id(), + current.companyId(), current.projectId(), current.contractId(), current.counterpartyId(), + request.issuedDate(), current.amount(), current.currency(), actor.userId()); + } catch (DuplicateKeyException exception) { + throw duplicate("该发票已经生成应收记录"); + } + mapper.insertAccountingEvent(ulidGenerator.next(), businessNo("EVT", publicId), "INVOICE", publicId, + current.sourceVersionPublicId() == null ? "INVOICE-" + publicId : current.sourceVersionPublicId(), 1, + current.companyId(), current.projectId(), current.counterpartyId(), request.issuedDate(), + current.amount(), current.currency(), actor.userId()); + InvoiceView result = invoiceView(invoice(publicId)); + auditService.recordScoped(current.companyPublicId(), current.projectPublicId(), + "INVOICE_RECORD_RESULT", "INVOICE", publicId, "SUCCESS", null, invoiceView(current), result); + return result; + } + + @Transactional + public InvoiceView voidInvoice(String publicId, ReasonCommandRequest request) { + authorizationService.requirePermission("receivable:invoice:void"); + Actor actor = actor(); + InvoiceRow current = invoice(publicId); + authorizationService.requireScope("receivable:invoice:void", current.companyPublicId(), current.projectPublicId(), current.amount()); + requireVersion(current.version(), request.version()); + if (!Set.of("DRAFT", "RETURNED", "REJECTED", "RESULT_PENDING", "APPROVED", "ISSUED") + .contains(current.status())) throw transition("当前状态不能作废发票"); + if (current.sourceDocumentId() != null) { + throw transition("来源审批生成的开票候选必须通过来源更正流程处理,不能直接作废"); + } + if (current.contractId() != null) mapper.lockContractControl(current.contractId()); + if ("ISSUED".equals(current.status())) { + ReceivableLock receivable = mapper.lockReceivableByInvoice(current.id()); + if (receivable != null) { + if (!"OPEN".equals(receivable.status()) + || receivable.outstandingAmount().compareTo(receivable.amount()) != 0) { + throw transition("发票对应应收已经发生核销,不能直接作废"); + } + if (mapper.voidUnsettledReceivable(receivable.id(), receivable.version(), actor.userId()) != 1) { + throw conflict(); + } + } + voidPendingAccountingEvent(publicId, "INVOICE"); + } + if (mapper.voidInvoice(current.id(), request.version(), cleanRequired(request.reason(), 1000), + actor.userId()) != 1) throw conflict(); + InvoiceView result = invoiceView(invoice(publicId)); + auditService.recordScoped(current.companyPublicId(), current.projectPublicId(), + "INVOICE_VOID", "INVOICE", publicId, "SUCCESS", request.reason(), invoiceView(current), result); + return result; + } + + @Transactional + public InvoiceView redLetterInvoice(String publicId, RedLetterRequest request) { + authorizationService.requirePermission("receivable:invoice:red-letter"); + Actor actor = actor(); + InvoiceRow current = invoice(publicId); + authorizationService.requireScope("receivable:invoice:red-letter", current.companyPublicId(), current.projectPublicId(), current.amount()); + requireVersion(current.version(), request.version()); + if (!"ISSUED".equals(current.status())) throw transition("只有已开具发票可以红冲"); + if (current.contractId() != null) mapper.lockContractControl(current.contractId()); + ReceivableLock receivable = mapper.lockReceivableByInvoice(current.id()); + if (receivable != null && (!"OPEN".equals(receivable.status()) + || receivable.outstandingAmount().compareTo(receivable.amount()) != 0)) { + throw transition("发票对应应收已经发生核销,请先完成收款冲销再登记红冲"); + } + Long fileId = checkedFileId(request.resultFileId()); + String newId = ulidGenerator.next(); + voidPendingAccountingEvent(publicId, "INVOICE"); + if (receivable != null + && mapper.voidUnsettledReceivable(receivable.id(), receivable.version(), actor.userId()) != 1) { + throw conflict(); + } + String reason = cleanRequired(request.reason(), 1000); + if (mapper.markRedLettered(current.id(), request.version(), reason, actor.userId()) != 1) throw conflict(); + try { + mapper.insertRedLetter(newId, businessNo("INV-RED", newId), current.id(), + cleanRequired(request.invoiceNo(), 100), request.issuedDate(), fileId, reason, actor.userId()); + } catch (DuplicateKeyException exception) { + throw duplicate("同一公司下的红字发票号码已经登记"); + } + InvoiceView result = invoiceView(invoice(newId)); + auditService.recordScoped(current.companyPublicId(), current.projectPublicId(), + "INVOICE_RED_LETTER", "INVOICE", publicId, "SUCCESS", request.reason(), invoiceView(current), result); + return result; + } + + @Transactional(readOnly = true) + public byte[] exportReceipts(String companyId, String projectId, String counterpartyId, + String keyword, String status, + LocalDate dateFrom, LocalDate dateTo, String sort) { + authorizationService.requirePermission("receivable:receipt:export"); + Actor actor = actor(); + String safeCompanyId = blank(companyId); + String safeProjectId = blank(projectId); + String safeCounterpartyId = blank(counterpartyId); + requireOptionalId(safeCompanyId, "公司编号"); + requireOptionalId(safeProjectId, "项目编号"); + requireOptionalId(safeCounterpartyId, "客户编号"); + requireStatus(status, RECEIPT_STATUSES); + List rows = mapper.listReceipts(actor.userId(), actor.roleCode(), "receivable:receipt:export", safeCompanyId, safeProjectId, + safeCounterpartyId, + clean(keyword, 100), blank(status), dateFrom, dateTo, receiptSort(sort), 10000, 0) + .stream().map(this::receiptView).toList(); + StringBuilder csv = new StringBuilder("\uFEFF收款编号,公司,项目,付款方,收款日期,金额,已分配,状态\n"); + for (ReceiptView row : rows) { + csv.append(csv(row.businessNo())).append(',').append(csv(name(row.company()))) + .append(',').append(csv(name(row.project()))).append(',').append(csv(row.payerName())) + .append(',').append(row.receiptDate()).append(',').append(row.amount()) + .append(',').append(row.allocatedAmount()).append(',').append(row.status()).append('\n'); + } + auditService.recordScoped(safeCompanyId, safeProjectId, "RECEIPT_EXPORT", "RECEIPT", null, "SUCCESS", null, null, + MapSummary.of("count", rows.size())); + return csv.toString().getBytes(StandardCharsets.UTF_8); + } + + @Transactional(readOnly = true) + public byte[] exportInvoices(String companyId, String projectId, String counterpartyId, + String keyword, String status, + LocalDate dateFrom, LocalDate dateTo, String sort) { + authorizationService.requirePermission("receivable:invoice:export"); + Actor actor = actor(); + String safeCompanyId = blank(companyId); + String safeProjectId = blank(projectId); + String safeCounterpartyId = blank(counterpartyId); + requireOptionalId(safeCompanyId, "公司编号"); + requireOptionalId(safeProjectId, "项目编号"); + requireOptionalId(safeCounterpartyId, "开票对象编号"); + requireStatus(status, INVOICE_STATUSES); + List rows = mapper.listInvoices(actor.userId(), actor.roleCode(), "receivable:invoice:export", safeCompanyId, safeProjectId, + safeCounterpartyId, + clean(keyword, 100), blank(status), dateFrom, dateTo, invoiceSort(sort), 10000, 0) + .stream().map(this::invoiceView).toList(); + StringBuilder csv = new StringBuilder("\uFEFF开票编号,公司,项目,开票对象,申请日期,金额,发票号码,状态\n"); + for (InvoiceView row : rows) { + csv.append(csv(row.businessNo())).append(',').append(csv(name(row.company()))) + .append(',').append(csv(name(row.project()))).append(',').append(csv(name(row.counterparty()))) + .append(',').append(row.requestedDate()).append(',').append(row.amount()) + .append(',').append(csv(row.invoiceNo())).append(',').append(row.status()).append('\n'); + } + auditService.recordScoped(safeCompanyId, safeProjectId, "INVOICE_EXPORT", "INVOICE", null, "SUCCESS", null, null, + MapSummary.of("count", rows.size())); + return csv.toString().getBytes(StandardCharsets.UTF_8); + } + + private ReceiptRow receipt(String publicId) { + String id = publicId(publicId); + Actor actor = actor(); + ReceiptRow row = mapper.findReceipt(actor.userId(), actor.roleCode(), "receivable:receipt:view", id); + if (row == null) throw notFound("收款记录不存在或不在当前数据范围"); + return row; + } + + private InvoiceRow invoice(String publicId) { + String id = publicId(publicId); + Actor actor = actor(); + InvoiceRow row = mapper.findInvoice(actor.userId(), actor.roleCode(), "receivable:invoice:view", id); + if (row == null) throw notFound("发票记录不存在或不在当前数据范围"); + return row; + } + + private RefBundle receiptRefs(String companyId, String projectId, String counterpartyId, + String contractId, String permission) { + EntityRef company = mapper.findCompany(publicId(companyId)); + if (company == null) throw notFound("公司不存在或未生效"); + ProjectRef project = projectId == null || projectId.isBlank() ? null : mapper.findProject(publicId(projectId)); + if (projectId != null && project == null) throw notFound("项目不存在或未生效"); + if (project != null && project.companyId() != company.id()) throw validation("项目不属于所选公司"); + EntityRef counterparty = counterpartyId == null || counterpartyId.isBlank() + ? null : mapper.findCounterparty(publicId(counterpartyId)); + if (counterpartyId != null && counterparty == null) throw notFound("往来单位不存在或未生效"); + ContractRef contract = contractId == null || contractId.isBlank() ? null : mapper.findContract(publicId(contractId)); + if (contractId != null && contract == null) throw notFound("合同不存在或未生效"); + if (contract != null && (project == null || contract.projectId() != project.id() || contract.companyId() != company.id())) { + throw validation("合同不属于所选公司和项目"); + } + if (contract != null && project != null && !contract.currency().equals(project.currency())) { + throw validation("合同币种与项目币种不一致"); + } + authorizationService.requireScope(permission, company.publicId(), project == null ? null : project.publicId()); + return new RefBundle(company, project, counterparty == null ? null : counterparty.id(), contract); + } + + private void requireContractCounterparty(RefBundle refs) { + if (refs.contract() != null && refs.counterpartyId() != null + && refs.contract().counterpartyId() != refs.counterpartyId()) { + throw validation("开票对象与所选合同往来单位不一致"); + } + } + + private void ensureInvoiceWithinContractLimit(InvoiceRow invoice, BigDecimal additionalAmount) { + if (invoice.contractId() == null) return; + ContractControl contract = mapper.lockContractControl(invoice.contractId()); + if (contract == null || !Set.of("ACTIVE", "SETTLED").contains(contract.status())) { + throw transition("合同不存在或当前状态不允许开票"); + } + BigDecimal limit = contract.settlementAmount() == null + ? contract.originalAmount().add(contract.approvedChangeAmount()) + : contract.settlementAmount(); + if (contract.committedInvoiceAmount().add(additionalAmount).compareTo(limit) > 0) { + throw contractLimitExceeded("本次开票会超过合同可开票额度"); + } + } + + private void voidPendingAccountingEvent(String sourcePublicId, String sourceType) { + String status = mapper.findAccountingEventStatus(sourcePublicId, sourceType); + if (status == null) return; + if (!"PENDING".equals(status)) { + throw transition("该业务已经进入记账处理,需通过记账更正流程处理"); + } + if (mapper.voidAccountingEvent(sourcePublicId, sourceType) != 1) throw conflict(); + } + + private void adjustConfirmedReceipt(ProjectRef project, String currency, BigDecimal delta) { + adjustConfirmedReceipt(project.id(), currency, delta); + } + + private void adjustConfirmedReceipt(long projectId, String currency, BigDecimal delta) { + mapper.ensureFundControl(projectId, currency); + if (mapper.adjustConfirmedReceipt(projectId, currency, delta) != 1) { + throw conflict(); + } + } + + private void appendFundEntry(long projectId, String currency, String sourceEventKey, String entryType, + String sourcePublicId, String sourceBusinessNo, String description, + BigDecimal eventAmount, BigDecimal confirmedReceiptDelta, long actorId) { + BigDecimal empty = BigDecimal.ZERO.setScale(2); + fundLedgerService.append(new FundLedgerCommand(projectId, currency, sourceEventKey, entryType, + "RECEIPT", sourcePublicId, sourceBusinessNo, description, eventAmount, + confirmedReceiptDelta, empty, empty, empty, empty, empty, actorId)); + } + + private Long checkedFileId(String publicId) { + if (publicId == null || publicId.isBlank()) return null; + FileRef file = mapper.findFile(publicId(publicId)); + if (file == null) throw notFound("结果文件不存在"); + if (!Set.of("AVAILABLE", "CLEAN", "NOT_CONFIGURED").contains(file.scanStatus())) { + throw validation("结果文件尚未通过文件检查"); + } + return file.id(); + } + + private ReceiptView receiptView(ReceiptRow row) { + Actor actor = actor(); + List actions = new ArrayList<>(); + if ("DRAFT".equals(row.status()) && canEdit(row.createdBy(), actor) + && authorizationService.hasScope("receivable:receipt:edit", row.companyPublicId(), + row.projectPublicId(), row.amount())) actions.add("EDIT"); + if ("DRAFT".equals(row.status()) && row.createdBy() != actor.userId() + && authorizationService.hasScope("receivable:receipt:confirm", row.companyPublicId(), + row.projectPublicId(), row.amount())) actions.add("CONFIRM"); + if (Set.of("CONFIRMED", "PARTIALLY_ALLOCATED").contains(row.status()) + && authorizationService.hasScope("receivable:receipt:allocate", row.companyPublicId(), + row.projectPublicId(), row.unallocatedAmount())) actions.add("ALLOCATE"); + if (row.sourceDocumentId() == null + && Set.of("DRAFT", "CONFIRMED").contains(row.status()) && row.allocatedAmount().signum() == 0 + && authorizationService.hasScope("receivable:receipt:void", row.companyPublicId(), + row.projectPublicId(), row.amount())) actions.add("VOID_RECEIPT"); + return new ReceiptView(row.publicId(), row.businessNo(), ref(row.companyPublicId(), row.companyCode(), row.companyName()), + ref(row.projectPublicId(), row.projectCode(), row.projectName()), + ref(row.customerPublicId(), row.customerCode(), row.customerName()), row.receiptDate(), moneyText(row.amount()), + moneyText(row.allocatedAmount()), moneyText(row.unallocatedAmount()), row.currency(), row.payerName(), row.referenceNo(), + row.status(), row.confirmedByName(), row.confirmedAt(), row.voidReason(), row.version(), row.createdAt(), + row.updatedAt(), actions); + } + + private ReceiptAllocationView allocationView(AllocationRow row) { + return new ReceiptAllocationView(row.publicId(), row.sequenceNo() == null ? 0 : row.sequenceNo(), + ref(row.receivablePublicId(), null, row.receivableBusinessNo()), + ref(row.projectPublicId(), row.projectCode(), row.projectName()), + ref(row.contractPublicId(), row.contractCode(), row.contractName()), moneyText(row.amount()), + moneyText(row.receivableAmount()), moneyText(row.receivableOutstandingAmount()), row.note(), + row.createdByName(), row.createdAt()); + } + + private ReceivableView receivableView(ReceivableRow row) { + return new ReceivableView(row.publicId(), row.businessNo(), + ref(row.companyPublicId(), row.companyCode(), row.companyName()), + ref(row.projectPublicId(), row.projectCode(), row.projectName()), + ref(row.contractPublicId(), row.contractCode(), row.contractName()), + ref(row.counterpartyPublicId(), row.counterpartyCode(), row.counterpartyName()), + row.businessDate(), moneyText(row.amount()), moneyText(row.settledAmount()), + moneyText(row.outstandingAmount()), row.currency(), row.status(), row.sourceInvoicePublicId(), + row.version(), row.createdAt(), row.updatedAt()); + } + + private ReceivableOptionView receivableOption(ReceivableRow row) { + return new ReceivableOptionView(row.publicId(), row.businessNo(), + ref(row.projectPublicId(), row.projectCode(), row.projectName()), + ref(row.counterpartyPublicId(), row.counterpartyCode(), row.counterpartyName()), + moneyText(row.amount()), moneyText(row.outstandingAmount()), row.currency()); + } + + private InvoiceView invoiceView(InvoiceRow row) { + Actor actor = actor(); + List actions = new ArrayList<>(); + if (row.sourceDocumentId() == null + && Set.of("DRAFT", "RETURNED").contains(row.status()) && canEdit(row.createdBy(), actor) + && authorizationService.hasScope("receivable:invoice:edit", row.companyPublicId(), + row.projectPublicId(), row.amount())) actions.add("EDIT"); + if (("DRAFT".equals(row.status()) || row.sourceDocumentId() == null && "RETURNED".equals(row.status())) + && authorizationService.hasScope("receivable:invoice:submit", row.companyPublicId(), + row.projectPublicId(), row.amount())) actions.add("SUBMIT"); + if ("REVIEWING".equals(row.status()) + && authorizationService.hasScope("receivable:invoice:review", row.companyPublicId(), + row.projectPublicId(), row.amount()) + && (row.submittedBy() == null || row.submittedBy() != actor.userId())) actions.add("REVIEW"); + if (Set.of("RESULT_PENDING", "APPROVED").contains(row.status()) + && authorizationService.hasScope("receivable:invoice:record-result", row.companyPublicId(), + row.projectPublicId(), row.amount())) actions.add("RECORD_RESULT"); + if ("ISSUED".equals(row.status()) + && authorizationService.hasScope("receivable:invoice:red-letter", row.companyPublicId(), + row.projectPublicId(), row.amount())) actions.add("RED_LETTER"); + if (row.sourceDocumentId() == null + && Set.of("DRAFT", "RETURNED", "REJECTED", "RESULT_PENDING", "APPROVED", "ISSUED") + .contains(row.status()) + && authorizationService.hasScope("receivable:invoice:void", row.companyPublicId(), + row.projectPublicId(), row.amount())) actions.add("VOID"); + return new InvoiceView(row.publicId(), row.businessNo(), row.sourceDocumentPublicId(), + row.sourceVersionPublicId(), ref(row.companyPublicId(), row.companyCode(), row.companyName()), + ref(row.projectPublicId(), row.projectCode(), row.projectName()), + ref(row.contractPublicId(), row.contractCode(), row.contractName()), + ref(row.counterpartyPublicId(), row.counterpartyCode(), row.counterpartyName()), row.invoiceType(), + moneyText(row.amount()), row.currency(), decimal(row.taxRate()), row.requestedDate(), row.invoiceNo(), row.issuedDate(), + row.resultFilePublicId(), row.originalInvoicePublicId(), row.status(), row.submittedByName(), + row.reviewedByName(), row.resultRecordedByName(), row.submittedAt(), row.reviewedAt(), + row.reviewOpinion(), row.resultRecordedAt(), row.terminationReason(), moneyText(row.contractLimit()), + moneyText(row.contractCommittedAmount()), moneyText(row.contractAvailableAmount()), row.version(), + row.createdAt(), row.updatedAt(), actions); + } + + private ReferenceView reference(ReceivableMapper.ReferenceRow row) { + return ref(row.publicId(), row.code(), row.name()); + } + + private ReferenceView ref(String id, String code, String name) { + return id == null ? null : new ReferenceView(id, code, name); + } + + private String name(ReferenceView ref) { return ref == null ? "" : ref.name(); } + private String businessNo(String prefix, String publicId) { return prefix + "-" + publicId; } + private String publicId(String value) { + if (value == null || !PUBLIC_ID.matcher(value.trim()).matches()) throw validation("公开编号格式无效"); + return value.trim(); + } + private String clean(String value, int max) { + if (value == null || value.isBlank()) return null; + String result = value.trim(); + if (result.length() > max) throw validation("文本长度超过限制"); + return result; + } + private String cleanRequired(String value, int max) { + String result = clean(value, max); + if (result == null) throw validation("必填文本不能为空"); + return result; + } + private String blank(String value) { return value == null || value.isBlank() ? null : value; } + private BigDecimal scaledMoney(BigDecimal value) { return value == null ? null : value.setScale(2); } + private String moneyText(BigDecimal value) { return value == null ? null : value.setScale(2).toPlainString(); } + private BigDecimal rate(BigDecimal value) { return value == null ? null : value.setScale(6); } + private String decimal(BigDecimal value) { return value == null ? null : value.stripTrailingZeros().toPlainString(); } + private String csv(String value) { + if (value == null) return ""; + return "\"" + value.replace("\"", "\"\"") + "\""; + } + private String receiptSort(String sort) { + if (sort == null || sort.isBlank()) return "receipt.updated_at DESC"; + String value = sort.trim(); + String[] parts = value.split(",", 2); + String column = switch (parts[0]) { + case "receiptDate" -> "receipt.receipt_date"; + case "updatedAt" -> "receipt.updated_at"; + default -> throw validation("不支持的收款排序字段"); + }; + String direction = parts.length == 2 && "asc".equalsIgnoreCase(parts[1]) ? "ASC" : + parts.length == 2 && "desc".equalsIgnoreCase(parts[1]) ? "DESC" : null; + if (direction == null) throw validation("排序方向必须为 asc 或 desc"); + return column + " " + direction; + } + private String invoiceSort(String sort) { + if (sort == null || sort.isBlank()) return "invoice.updated_at DESC"; + String value = sort.trim(); + String[] parts = value.split(",", 2); + String column = switch (parts[0]) { + case "requestedDate" -> "invoice.requested_date"; + case "updatedAt" -> "invoice.updated_at"; + case "amount" -> "invoice.amount"; + default -> throw validation("不支持的发票排序字段"); + }; + String direction = parts.length == 2 && "asc".equalsIgnoreCase(parts[1]) ? "ASC" : + parts.length == 2 && "desc".equalsIgnoreCase(parts[1]) ? "DESC" : null; + if (direction == null) throw validation("排序方向必须为 asc 或 desc"); + return column + " " + direction; + } + private String receivableSort(String sort) { + if (sort == null || sort.isBlank()) return "receivable.updated_at DESC"; + String[] parts = sort.trim().split(",", 2); + String column = switch (parts[0]) { + case "businessDate" -> "receivable.business_date"; + case "amount" -> "receivable.amount"; + case "status" -> "receivable.status"; + case "updatedAt" -> "receivable.updated_at"; + default -> throw validation("不支持的应收排序字段"); + }; + String direction = parts.length == 2 && "asc".equalsIgnoreCase(parts[1]) ? "ASC" : + parts.length == 2 && "desc".equalsIgnoreCase(parts[1]) ? "DESC" : null; + if (direction == null) throw validation("排序方向必须为 asc 或 desc"); + return column + " " + direction; + } + private void requireOptionalId(String value, String label) { if (value != null && !value.isBlank()) publicId(value); } + private void requireStatus(String value, Set allowed) { if (value != null && !value.isBlank() && !allowed.contains(value)) throw validation("状态筛选值无效"); } + private void requireVersion(long actual, long expected) { if (actual != expected) throw conflict(); } + private void requireOwnerOrFinance(long ownerId, Actor actor) { + if (!canEdit(ownerId, actor)) throw new BusinessException(HttpStatus.FORBIDDEN, ErrorCode.PERMISSION_DENIED, "只能编辑本人草稿"); + } + private boolean canEdit(long ownerId, Actor actor) { + return ownerId == actor.userId() || "FINANCE_MANAGER".equals(actor.roleCode()) + || "SYSTEM_ADMIN".equals(actor.roleCode()); + } + private Actor actor() { FinancePrincipal principal = identityContext.requirePrincipal(); return new Actor(principal.userId(), identityContext.requireActiveRole()); } + private Page page(int page, int size) { if (page < 1 || !Set.of(20, 50, 100).contains(size)) throw validation("分页参数无效"); return new Page(page, size); } + private BusinessException validation(String message) { return new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.VALIDATION_FAILED, message); } + private BusinessException notFound(String message) { return new BusinessException(HttpStatus.NOT_FOUND, ErrorCode.RESOURCE_NOT_FOUND, message); } + private BusinessException conflict() { return new BusinessException(HttpStatus.CONFLICT, ErrorCode.VERSION_CONFLICT, "记录已被其他人更新,请刷新后重试"); } + private BusinessException duplicate(String message) { + return new BusinessException(HttpStatus.CONFLICT, ErrorCode.DUPLICATE_RESOURCE, message); + } + private BusinessException transition(String message) { return new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.INVALID_STATE_TRANSITION, message); } + private BusinessException sod(String message) { return new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.SOD_VIOLATION, message); } + private BusinessException contractLimitExceeded(String message) { + return new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.CONTRACT_LIMIT_EXCEEDED, message); + } + + private record Actor(long userId, String roleCode) { } + private record Page(int page, int size) { int offset() { return Math.toIntExact(Math.min((long) (page - 1) * size, Integer.MAX_VALUE)); } } + private record RefBundle(EntityRef company, ProjectRef project, Long counterpartyId, ContractRef contract) { } + private record MapSummary(int count) { static MapSummary of(String ignored, int count) { return new MapSummary(count); } } +} diff --git a/backend/src/main/java/com/kaidi/finance/receivable/infrastructure/ReceivableMapper.java b/backend/src/main/java/com/kaidi/finance/receivable/infrastructure/ReceivableMapper.java new file mode 100644 index 0000000..cbb1a88 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/receivable/infrastructure/ReceivableMapper.java @@ -0,0 +1,1147 @@ +package com.kaidi.finance.receivable.infrastructure; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +@org.apache.ibatis.annotations.Mapper +public interface ReceivableMapper { + + String RECEIPT_SCOPE = """ + EXISTS ( + SELECT 1 FROM iam_scope scope + JOIN iam_role role ON role.id = scope.role_id + JOIN iam_permission permission ON permission.id = scope.permission_id + WHERE scope.user_id = #{userId} AND role.code = #{roleCode} + AND permission.code = #{permissionCode} + AND scope.status = 'ACTIVE' + AND scope.valid_from <= UTC_TIMESTAMP(3) + AND (scope.valid_to IS NULL OR scope.valid_to >= UTC_TIMESTAMP(3)) + AND ( + scope.scope_type = 'GLOBAL' + OR (scope.scope_type = 'COMPANY' AND scope.company_public_id = company.public_id) + OR (scope.scope_type = 'PROJECT' AND scope.project_public_id = project.public_id) + ) + ) + """; + + String INVOICE_SCOPE = """ + EXISTS ( + SELECT 1 FROM iam_scope scope + JOIN iam_role role ON role.id = scope.role_id + JOIN iam_permission permission ON permission.id = scope.permission_id + WHERE scope.user_id = #{userId} AND role.code = #{roleCode} + AND permission.code = #{permissionCode} + AND scope.status = 'ACTIVE' + AND scope.valid_from <= UTC_TIMESTAMP(3) + AND (scope.valid_to IS NULL OR scope.valid_to >= UTC_TIMESTAMP(3)) + AND ( + scope.scope_type = 'GLOBAL' + OR (scope.scope_type = 'COMPANY' AND scope.company_public_id = company.public_id) + OR (scope.scope_type = 'PROJECT' AND scope.project_public_id = project.public_id) + ) + ) + """; + + String RECEIVABLE_SCOPE = RECEIPT_SCOPE; + + String RECEIPT_SELECT = """ + SELECT receipt.id, receipt.public_id, receipt.business_no, receipt.source_document_id, + company.public_id AS company_public_id, company.business_no AS company_code, + company.name AS company_name, + project.public_id AS project_public_id, project.business_no AS project_code, + project.name AS project_name, + customer.public_id AS customer_public_id, customer.business_no AS customer_code, + customer.name AS customer_name, + receipt.receipt_date, receipt.amount, receipt.allocated_amount, + receipt.amount - receipt.allocated_amount AS unallocated_amount, + receipt.currency, receipt.payer_name, receipt.reference_no, receipt.status, + receipt.confirmed_by, confirmer.display_name AS confirmed_by_name, + receipt.confirmed_at, receipt.void_reason, receipt.version, receipt.created_by, + receipt.created_at, receipt.updated_at + FROM fin_receipt receipt + JOIN md_company company ON company.id = receipt.company_id + LEFT JOIN md_project project ON project.id = receipt.project_id + LEFT JOIN md_counterparty customer ON customer.id = receipt.customer_id + LEFT JOIN iam_user confirmer ON confirmer.id = receipt.confirmed_by + """; + + String INVOICE_SELECT = """ + SELECT invoice.id, invoice.source_document_id, invoice.source_version_id, + source_document.public_id AS source_document_public_id, + source_version.public_id AS source_version_public_id, + invoice.company_id, invoice.project_id, invoice.contract_id, + invoice.counterparty_id, invoice.public_id, invoice.business_no, + company.public_id AS company_public_id, company.business_no AS company_code, + company.name AS company_name, + project.public_id AS project_public_id, project.business_no AS project_code, + project.name AS project_name, + contract.public_id AS contract_public_id, contract.business_no AS contract_code, + contract.name AS contract_name, + counterparty.public_id AS counterparty_public_id, + counterparty.business_no AS counterparty_code, counterparty.name AS counterparty_name, + invoice.invoice_type, invoice.amount, invoice.currency, invoice.tax_rate, invoice.requested_date, + invoice.invoice_no, invoice.issued_date, result_file.public_id AS result_file_public_id, + original.public_id AS original_invoice_public_id, invoice.status, + invoice.submitted_by, submitter.display_name AS submitted_by_name, invoice.submitted_at, + invoice.reviewed_by, reviewer.display_name AS reviewed_by_name, invoice.reviewed_at, + invoice.review_opinion, recorder.display_name AS result_recorded_by_name, + invoice.result_recorded_at, invoice.termination_reason, + COALESCE(contract.settlement_amount, + contract.original_amount + contract.approved_change_amount) AS contract_limit, + CASE WHEN contract.id IS NULL THEN NULL ELSE COALESCE(( + SELECT SUM(CASE WHEN committed.invoice_type = 'RED_LETTER' + THEN -committed.amount ELSE committed.amount END) + FROM fin_invoice committed + WHERE committed.contract_id = contract.id + AND committed.status IN ('RESULT_PENDING', 'APPROVED', 'ISSUED', 'RED_LETTERED') + ), 0) END AS contract_committed_amount, + CASE WHEN contract.id IS NULL THEN NULL ELSE GREATEST( + COALESCE(contract.settlement_amount, + contract.original_amount + contract.approved_change_amount) + - COALESCE(( + SELECT SUM(CASE WHEN committed.invoice_type = 'RED_LETTER' + THEN -committed.amount ELSE committed.amount END) + FROM fin_invoice committed + WHERE committed.contract_id = contract.id + AND committed.status IN ('RESULT_PENDING', 'APPROVED', 'ISSUED', 'RED_LETTERED') + ), 0), 0 + ) END AS contract_available_amount, + invoice.version, invoice.created_by, invoice.created_at, invoice.updated_at + FROM fin_invoice invoice + JOIN md_company company ON company.id = invoice.company_id + JOIN md_project project ON project.id = invoice.project_id + LEFT JOIN md_contract contract ON contract.id = invoice.contract_id + JOIN md_counterparty counterparty ON counterparty.id = invoice.counterparty_id + LEFT JOIN file_object result_file ON result_file.id = invoice.result_file_id + LEFT JOIN fin_invoice original ON original.id = invoice.original_invoice_id + LEFT JOIN iam_user submitter ON submitter.id = invoice.submitted_by + LEFT JOIN iam_user reviewer ON reviewer.id = invoice.reviewed_by + LEFT JOIN iam_user recorder ON recorder.id = invoice.result_recorded_by + LEFT JOIN src_source_document source_document ON source_document.id = invoice.source_document_id + LEFT JOIN src_document_version source_version ON source_version.id = invoice.source_version_id + """; + + String RECEIVABLE_SELECT = """ + SELECT receivable.id, receivable.public_id, receivable.business_no, + company.public_id AS company_public_id, company.business_no AS company_code, + company.name AS company_name, + project.public_id AS project_public_id, project.business_no AS project_code, + project.name AS project_name, + contract.public_id AS contract_public_id, contract.business_no AS contract_code, + contract.name AS contract_name, + counterparty.public_id AS counterparty_public_id, + counterparty.business_no AS counterparty_code, counterparty.name AS counterparty_name, + receivable.business_date, receivable.amount, + receivable.amount - receivable.outstanding_amount AS settled_amount, + receivable.outstanding_amount, receivable.currency, receivable.status, + source_invoice.public_id AS source_invoice_public_id, + receivable.version, receivable.created_at, receivable.updated_at + FROM fin_receivable receivable + JOIN md_company company ON company.id = receivable.company_id + JOIN md_project project ON project.id = receivable.project_id + LEFT JOIN md_contract contract ON contract.id = receivable.contract_id + JOIN md_counterparty counterparty ON counterparty.id = receivable.counterparty_id + LEFT JOIN fin_invoice source_invoice ON source_invoice.id = receivable.source_invoice_id + """; + + String RECEIPT_FILTERS = """ + AND company.public_id = #{companyId} + AND project.public_id = #{projectId} + AND customer.public_id = #{counterpartyId} + + AND (receipt.business_no LIKE CONCAT('%', #{keyword}, '%') + OR receipt.payer_name LIKE CONCAT('%', #{keyword}, '%') + OR receipt.reference_no LIKE CONCAT('%', #{keyword}, '%') + OR project.name LIKE CONCAT('%', #{keyword}, '%')) + + AND receipt.status = #{status} + AND receipt.receipt_date >= #{dateFrom} + AND receipt.receipt_date <= #{dateTo} + """; + + String INVOICE_FILTERS = """ + AND company.public_id = #{companyId} + AND project.public_id = #{projectId} + AND counterparty.public_id = #{counterpartyId} + + AND (invoice.business_no LIKE CONCAT('%', #{keyword}, '%') + OR invoice.invoice_no LIKE CONCAT('%', #{keyword}, '%') + OR counterparty.name LIKE CONCAT('%', #{keyword}, '%') + OR project.name LIKE CONCAT('%', #{keyword}, '%')) + + AND invoice.status = #{status} + AND invoice.requested_date >= #{dateFrom} + AND invoice.requested_date <= #{dateTo} + """; + + String RECEIVABLE_FILTERS = """ + AND company.public_id = #{companyId} + AND project.public_id = #{projectId} + AND counterparty.public_id = #{counterpartyId} + + AND (receivable.business_no LIKE CONCAT('%', #{keyword}, '%') + OR source_invoice.business_no LIKE CONCAT('%', #{keyword}, '%') + OR counterparty.name LIKE CONCAT('%', #{keyword}, '%') + OR project.name LIKE CONCAT('%', #{keyword}, '%')) + + AND receivable.status = #{status} + AND receivable.business_date >= #{dateFrom} + AND receivable.business_date <= #{dateTo} + """; + + @Select(""" + + """) + List listReceipts(@Param("userId") long userId, + @Param("roleCode") String roleCode, + @Param("permissionCode") String permissionCode, + @Param("companyId") String companyId, + @Param("projectId") String projectId, + @Param("counterpartyId") String counterpartyId, + @Param("keyword") String keyword, + @Param("status") String status, + @Param("dateFrom") LocalDate dateFrom, + @Param("dateTo") LocalDate dateTo, + @Param("orderBy") String orderBy, + @Param("limit") int limit, + @Param("offset") int offset); + + @Select(""" + + """) + long countReceipts(@Param("userId") long userId, + @Param("roleCode") String roleCode, + @Param("permissionCode") String permissionCode, + @Param("companyId") String companyId, + @Param("projectId") String projectId, + @Param("counterpartyId") String counterpartyId, + @Param("keyword") String keyword, + @Param("status") String status, + @Param("dateFrom") LocalDate dateFrom, + @Param("dateTo") LocalDate dateTo); + + @Select(""" + + """) + ReceiptRow findReceipt(@Param("userId") long userId, + @Param("roleCode") String roleCode, + @Param("permissionCode") String permissionCode, + @Param("publicId") String publicId); + + @Select(""" + SELECT receipt.id, receipt.amount, receipt.allocated_amount, receipt.status, + receipt.version, receipt.created_by + FROM fin_receipt receipt WHERE receipt.id = #{id} FOR UPDATE + """) + ReceiptLock lockReceipt(long id); + + @Select(""" + SELECT allocation.public_id, allocation.sequence_no, + receivable.public_id AS receivable_public_id, + receivable.business_no AS receivable_business_no, + project.public_id AS project_public_id, project.business_no AS project_code, + project.name AS project_name, + contract.public_id AS contract_public_id, contract.business_no AS contract_code, + contract.name AS contract_name, + allocation.amount, receivable.amount AS receivable_amount, + receivable.outstanding_amount AS receivable_outstanding_amount, + allocation.note, creator.display_name AS created_by_name, + allocation.created_at + FROM fin_receipt_allocation allocation + LEFT JOIN fin_receivable receivable ON receivable.id = allocation.receivable_id + JOIN md_project project ON project.id = allocation.project_id + LEFT JOIN md_contract contract ON contract.id = allocation.contract_id + JOIN iam_user creator ON creator.id = allocation.created_by + WHERE allocation.receipt_id = #{receiptId} + ORDER BY allocation.created_at DESC, allocation.public_id DESC + """) + List listAllocations(long receiptId); + + @Select(""" + + """) + List listReceivables(@Param("userId") long userId, + @Param("roleCode") String roleCode, + @Param("permissionCode") String permissionCode, + @Param("companyId") String companyId, + @Param("projectId") String projectId, + @Param("counterpartyId") String counterpartyId, + @Param("keyword") String keyword, + @Param("status") String status, + @Param("dateFrom") LocalDate dateFrom, + @Param("dateTo") LocalDate dateTo, + @Param("orderBy") String orderBy, + @Param("limit") int limit, + @Param("offset") int offset); + + @Select(""" + + """) + long countReceivables(@Param("userId") long userId, + @Param("roleCode") String roleCode, + @Param("permissionCode") String permissionCode, + @Param("companyId") String companyId, + @Param("projectId") String projectId, + @Param("counterpartyId") String counterpartyId, + @Param("keyword") String keyword, + @Param("status") String status, + @Param("dateFrom") LocalDate dateFrom, + @Param("dateTo") LocalDate dateTo); + + @Select(""" + + """) + List listOpenReceivables(@Param("userId") long userId, + @Param("roleCode") String roleCode, + @Param("permissionCode") String permissionCode); + + @Insert(""" + INSERT INTO fin_receipt ( + public_id, business_no, company_id, project_id, customer_id, receipt_date, + amount, allocated_amount, currency, payer_name, reference_no, status, + created_by, updated_by + ) VALUES ( + #{publicId}, #{businessNo}, #{companyId}, #{projectId}, #{customerId}, #{receiptDate}, + #{amount}, 0, #{currency}, #{payerName}, #{referenceNo}, 'DRAFT', #{actorId}, #{actorId} + ) + """) + int insertReceipt(@Param("publicId") String publicId, + @Param("businessNo") String businessNo, + @Param("companyId") long companyId, + @Param("projectId") Long projectId, + @Param("customerId") Long customerId, + @Param("receiptDate") LocalDate receiptDate, + @Param("amount") BigDecimal amount, + @Param("currency") String currency, + @Param("payerName") String payerName, + @Param("referenceNo") String referenceNo, + @Param("actorId") long actorId); + + @Select(""" + SELECT receipt.id, receipt.public_id, receipt.business_no, receipt.source_version_id + FROM fin_receipt receipt + WHERE receipt.source_document_id = #{sourceDocumentId} + """) + ReceiptProjection findReceiptBySourceDocument(@Param("sourceDocumentId") long sourceDocumentId); + + @Select(""" + SELECT project.id, project.public_id, project.company_id, + company.public_id AS company_public_id, project.customer_id, project.currency + FROM md_project project + JOIN md_company company ON company.id = project.company_id + JOIN md_counterparty customer + ON customer.id = project.customer_id + AND customer.status = 'ACTIVE' + AND customer.counterparty_type IN ('CUSTOMER', 'BOTH') + WHERE project.id = #{projectId} + AND project.company_id = #{companyId} + AND project.status = 'ACTIVE' + AND company.status = 'ACTIVE' + """) + ApprovedReceiptProject findProjectForApprovedReceipt(@Param("projectId") long projectId, + @Param("companyId") long companyId); + + @Insert(""" + INSERT INTO fin_receipt ( + public_id, business_no, company_id, project_id, customer_id, + source_document_id, source_version_id, receipt_date, amount, allocated_amount, + currency, payer_name, reference_no, status, confirmed_by, confirmed_at, + created_by, updated_by + ) VALUES ( + #{publicId}, #{businessNo}, #{companyId}, #{projectId}, #{customerId}, + #{sourceDocumentId}, #{sourceVersionId}, #{receiptDate}, #{amount}, 0, + #{currency}, #{payerName}, NULL, 'CONFIRMED', #{confirmedBy}, UTC_TIMESTAMP(3), + #{createdBy}, #{confirmedBy} + ) + """) + int insertProjectedReceipt(@Param("publicId") String publicId, + @Param("businessNo") String businessNo, + @Param("companyId") long companyId, + @Param("projectId") long projectId, + @Param("customerId") Long customerId, + @Param("sourceDocumentId") long sourceDocumentId, + @Param("sourceVersionId") long sourceVersionId, + @Param("receiptDate") LocalDate receiptDate, + @Param("amount") BigDecimal amount, + @Param("currency") String currency, + @Param("payerName") String payerName, + @Param("createdBy") long createdBy, + @Param("confirmedBy") long confirmedBy); + + @Update(""" + UPDATE fin_receipt + SET company_id = #{companyId}, project_id = #{projectId}, customer_id = #{customerId}, + receipt_date = #{receiptDate}, amount = #{amount}, currency = #{currency}, + payer_name = #{payerName}, reference_no = #{referenceNo}, + updated_by = #{actorId}, version = version + 1 + WHERE id = #{id} AND version = #{version} AND status = 'DRAFT' + """) + int updateReceipt(@Param("id") long id, + @Param("companyId") long companyId, + @Param("projectId") Long projectId, + @Param("customerId") Long customerId, + @Param("receiptDate") LocalDate receiptDate, + @Param("amount") BigDecimal amount, + @Param("currency") String currency, + @Param("payerName") String payerName, + @Param("referenceNo") String referenceNo, + @Param("actorId") long actorId, + @Param("version") long version); + + @Update(""" + UPDATE fin_receipt + SET status = 'CONFIRMED', confirmed_by = #{actorId}, confirmed_at = UTC_TIMESTAMP(3), + updated_by = #{actorId}, version = version + 1 + WHERE id = #{id} AND version = #{version} AND status = 'DRAFT' + """) + int confirmReceipt(@Param("id") long id, @Param("version") long version, + @Param("actorId") long actorId); + + /** + * Keeps the project fund-control projection in the same transaction as a + * receipt confirmation/allocation/void. A negative delta is used when an + * unallocated confirmed receipt is voided; the guard prevents the + * projection from ever becoming negative under concurrent requests. + */ + @Insert(""" + INSERT INTO project_fund_control (project_id, currency) + VALUES (#{projectId}, #{currency}) + ON DUPLICATE KEY UPDATE id = project_fund_control.id + """) + int ensureFundControl(@Param("projectId") long projectId, @Param("currency") String currency); + + @Update(""" + UPDATE project_fund_control + SET confirmed_receipt = confirmed_receipt + #{delta}, version = version + 1 + WHERE project_id = #{projectId} AND currency = #{currency} + AND confirmed_receipt + #{delta} >= 0 + """) + int adjustConfirmedReceipt(@Param("projectId") long projectId, + @Param("currency") String currency, + @Param("delta") BigDecimal delta); + + @Insert(""" + INSERT INTO fin_receipt_allocation + (public_id, receipt_id, receivable_id, sequence_no, project_id, contract_id, + amount, note, created_by) + VALUES (#{publicId}, #{receiptId}, #{receivableId}, #{sequenceNo}, #{projectId}, #{contractId}, + #{amount}, #{note}, #{actorId}) + """) + int insertAllocation(@Param("publicId") String publicId, + @Param("receiptId") long receiptId, + @Param("receivableId") long receivableId, + @Param("sequenceNo") int sequenceNo, + @Param("projectId") long projectId, + @Param("contractId") Long contractId, + @Param("amount") BigDecimal amount, + @Param("note") String note, + @Param("actorId") long actorId); + + @Update(""" + UPDATE fin_receipt + SET status = CASE WHEN allocated_amount + #{amount} = amount + THEN 'ALLOCATED' ELSE 'PARTIALLY_ALLOCATED' END, + allocated_amount = allocated_amount + #{amount}, + updated_by = #{actorId}, version = version + 1 + WHERE id = #{id} AND version = #{version} + AND status IN ('CONFIRMED', 'PARTIALLY_ALLOCATED') + AND allocated_amount + #{amount} <= amount + """) + int addAllocation(@Param("id") long id, @Param("version") long version, + @Param("amount") BigDecimal amount, @Param("actorId") long actorId); + + @Select(""" + SELECT COALESCE(MAX(sequence_no), 0) + 1 + FROM fin_receipt_allocation + WHERE receipt_id = #{receiptId} + """) + int nextAllocationSequence(long receiptId); + + @Update(""" + UPDATE fin_receipt + SET status = 'VOID', void_reason = #{reason}, updated_by = #{actorId}, version = version + 1 + WHERE id = #{id} AND version = #{version} + AND status IN ('DRAFT', 'CONFIRMED') AND allocated_amount = 0 + """) + int voidReceipt(@Param("id") long id, + @Param("version") long version, + @Param("reason") String reason, + @Param("actorId") long actorId); + + @Select(""" + SELECT receivable.id, receivable.public_id, receivable.business_no, + receivable.company_id, company.public_id AS company_public_id, + receivable.project_id, project.public_id AS project_public_id, + receivable.contract_id, contract.public_id AS contract_public_id, + receivable.counterparty_id, counterparty.public_id AS counterparty_public_id, + receivable.amount, receivable.outstanding_amount, receivable.currency, + receivable.status, receivable.version + FROM fin_receivable receivable + JOIN md_company company ON company.id = receivable.company_id + JOIN md_project project ON project.id = receivable.project_id + LEFT JOIN md_contract contract ON contract.id = receivable.contract_id + JOIN md_counterparty counterparty ON counterparty.id = receivable.counterparty_id + WHERE receivable.public_id = #{publicId} + FOR UPDATE + """) + ReceivableLock lockReceivable(String publicId); + + @Update(""" + UPDATE fin_receivable + SET status = CASE WHEN outstanding_amount - #{amount} = 0 + THEN 'SETTLED' ELSE 'PARTIALLY_SETTLED' END, + outstanding_amount = outstanding_amount - #{amount}, + updated_by = #{actorId}, version = version + 1 + WHERE id = #{id} AND version = #{version} + AND status IN ('OPEN', 'PARTIALLY_SETTLED') + AND outstanding_amount >= #{amount} + """) + int settleReceivable(@Param("id") long id, + @Param("version") long version, + @Param("amount") BigDecimal amount, + @Param("actorId") long actorId); + + @Insert(""" + INSERT INTO fin_receivable ( + public_id, business_no, source_invoice_id, company_id, project_id, contract_id, + counterparty_id, business_date, amount, outstanding_amount, currency, + status, created_by, updated_by + ) VALUES ( + #{publicId}, #{businessNo}, #{sourceInvoiceId}, #{companyId}, #{projectId}, #{contractId}, + #{counterpartyId}, #{businessDate}, #{amount}, #{amount}, #{currency}, + 'OPEN', #{actorId}, #{actorId} + ) + """) + int insertReceivable(@Param("publicId") String publicId, + @Param("businessNo") String businessNo, + @Param("sourceInvoiceId") long sourceInvoiceId, + @Param("companyId") long companyId, + @Param("projectId") long projectId, + @Param("contractId") Long contractId, + @Param("counterpartyId") long counterpartyId, + @Param("businessDate") LocalDate businessDate, + @Param("amount") BigDecimal amount, + @Param("currency") String currency, + @Param("actorId") long actorId); + + @Select(""" + SELECT receivable.id, receivable.public_id, receivable.business_no, + receivable.company_id, company.public_id AS company_public_id, + receivable.project_id, project.public_id AS project_public_id, + receivable.contract_id, contract.public_id AS contract_public_id, + receivable.counterparty_id, counterparty.public_id AS counterparty_public_id, + receivable.amount, receivable.outstanding_amount, receivable.currency, + receivable.status, receivable.version + FROM fin_receivable receivable + JOIN md_company company ON company.id = receivable.company_id + JOIN md_project project ON project.id = receivable.project_id + LEFT JOIN md_contract contract ON contract.id = receivable.contract_id + JOIN md_counterparty counterparty ON counterparty.id = receivable.counterparty_id + WHERE receivable.source_invoice_id = #{invoiceId} + FOR UPDATE + """) + ReceivableLock lockReceivableByInvoice(long invoiceId); + + @Update(""" + UPDATE fin_receivable + SET status = 'VOID', outstanding_amount = 0, updated_by = #{actorId}, version = version + 1 + WHERE id = #{id} AND version = #{version} AND status = 'OPEN' + AND outstanding_amount = amount + """) + int voidUnsettledReceivable(@Param("id") long id, + @Param("version") long version, + @Param("actorId") long actorId); + + @Select("SELECT status FROM acc_event WHERE source_public_id = #{sourcePublicId} AND source_type = #{sourceType}") + String findAccountingEventStatus(@Param("sourcePublicId") String sourcePublicId, + @Param("sourceType") String sourceType); + + @Update(""" + UPDATE acc_event SET status = 'VOID', version = version + 1 + WHERE source_public_id = #{sourcePublicId} AND source_type = #{sourceType} + AND status = 'PENDING' + """) + int voidAccountingEvent(@Param("sourcePublicId") String sourcePublicId, + @Param("sourceType") String sourceType); + + @Select(""" + + """) + List listInvoices(@Param("userId") long userId, + @Param("roleCode") String roleCode, + @Param("permissionCode") String permissionCode, + @Param("companyId") String companyId, + @Param("projectId") String projectId, + @Param("counterpartyId") String counterpartyId, + @Param("keyword") String keyword, + @Param("status") String status, + @Param("dateFrom") LocalDate dateFrom, + @Param("dateTo") LocalDate dateTo, + @Param("orderBy") String orderBy, + @Param("limit") int limit, + @Param("offset") int offset); + + @Select(""" + + """) + long countInvoices(@Param("userId") long userId, + @Param("roleCode") String roleCode, + @Param("permissionCode") String permissionCode, + @Param("companyId") String companyId, + @Param("projectId") String projectId, + @Param("counterpartyId") String counterpartyId, + @Param("keyword") String keyword, + @Param("status") String status, + @Param("dateFrom") LocalDate dateFrom, + @Param("dateTo") LocalDate dateTo); + + @Select(""" + + """) + InvoiceRow findInvoice(@Param("userId") long userId, + @Param("roleCode") String roleCode, + @Param("permissionCode") String permissionCode, + @Param("publicId") String publicId); + + @Select("SELECT id, status, version, created_by, submitted_by FROM fin_invoice WHERE id = #{id} FOR UPDATE") + InvoiceLock lockInvoice(long id); + + @Insert(""" + INSERT INTO fin_invoice ( + public_id, business_no, company_id, project_id, contract_id, counterparty_id, + invoice_type, amount, currency, tax_rate, requested_date, status, created_by, updated_by + ) VALUES ( + #{publicId}, #{businessNo}, #{companyId}, #{projectId}, #{contractId}, #{counterpartyId}, + #{invoiceType}, #{amount}, #{currency}, #{taxRate}, #{requestedDate}, 'DRAFT', #{actorId}, #{actorId} + ) + """) + int insertInvoice(@Param("publicId") String publicId, + @Param("businessNo") String businessNo, + @Param("companyId") long companyId, + @Param("projectId") long projectId, + @Param("contractId") Long contractId, + @Param("counterpartyId") long counterpartyId, + @Param("invoiceType") String invoiceType, + @Param("amount") BigDecimal amount, + @Param("currency") String currency, + @Param("taxRate") BigDecimal taxRate, + @Param("requestedDate") LocalDate requestedDate, + @Param("actorId") long actorId); + + @Select(""" + SELECT invoice.id, invoice.public_id, invoice.business_no, invoice.source_version_id, + invoice.status, invoice.version + FROM fin_invoice invoice + WHERE invoice.source_document_id = #{sourceDocumentId} + FOR UPDATE + """) + InvoiceProjection findInvoiceBySourceDocument(@Param("sourceDocumentId") long sourceDocumentId); + + @Select(""" + SELECT project.id AS project_id, project.public_id AS project_public_id, + company.public_id AS company_public_id, project.currency, + customer.id AS counterparty_id, customer.public_id AS counterparty_public_id, + customer.name AS counterparty_name, customer.normalized_tax_no AS counterparty_tax_no, + contract.id AS contract_id, contract.public_id AS contract_public_id + FROM md_project project + JOIN md_company company ON company.id = project.company_id + JOIN md_counterparty customer + ON customer.public_id = #{counterpartyPublicId} + AND customer.status = 'ACTIVE' + AND customer.counterparty_type IN ('CUSTOMER', 'BOTH') + JOIN md_contract contract + ON contract.public_id = #{contractPublicId} + AND contract.company_id = project.company_id + AND contract.project_id = project.id + AND contract.counterparty_id = customer.id + AND contract.currency = project.currency + AND contract.status = 'ACTIVE' + WHERE project.id = #{projectId} + AND project.company_id = #{companyId} + AND project.customer_id = customer.id + AND project.status = 'ACTIVE' + AND company.status = 'ACTIVE' + """) + ApprovedInvoiceReferences findReferencesForApprovedInvoice( + @Param("companyId") long companyId, + @Param("projectId") long projectId, + @Param("counterpartyPublicId") String counterpartyPublicId, + @Param("contractPublicId") String contractPublicId + ); + + @Insert(""" + INSERT INTO fin_invoice ( + public_id, business_no, company_id, project_id, contract_id, counterparty_id, + source_document_id, source_version_id, invoice_type, amount, currency, + tax_rate, requested_date, status, created_by, updated_by + ) VALUES ( + #{publicId}, #{businessNo}, #{companyId}, #{projectId}, #{contractId}, #{counterpartyId}, + #{sourceDocumentId}, #{sourceVersionId}, #{invoiceType}, #{amount}, #{currency}, + #{taxRate}, #{requestedDate}, 'DRAFT', #{createdBy}, #{updatedBy} + ) + """) + int insertProjectedInvoice(@Param("publicId") String publicId, + @Param("businessNo") String businessNo, + @Param("companyId") long companyId, + @Param("projectId") long projectId, + @Param("contractId") long contractId, + @Param("counterpartyId") long counterpartyId, + @Param("sourceDocumentId") long sourceDocumentId, + @Param("sourceVersionId") long sourceVersionId, + @Param("invoiceType") String invoiceType, + @Param("amount") BigDecimal amount, + @Param("currency") String currency, + @Param("taxRate") BigDecimal taxRate, + @Param("requestedDate") LocalDate requestedDate, + @Param("createdBy") long createdBy, + @Param("updatedBy") long updatedBy); + + @Update(""" + UPDATE fin_invoice + SET company_id = #{companyId}, project_id = #{projectId}, contract_id = #{contractId}, + counterparty_id = #{counterpartyId}, source_version_id = #{sourceVersionId}, + invoice_type = #{invoiceType}, amount = #{amount}, currency = #{currency}, + tax_rate = #{taxRate}, requested_date = #{requestedDate}, status = 'DRAFT', + submitted_by = NULL, submitted_at = NULL, reviewed_by = NULL, reviewed_at = NULL, + review_opinion = NULL, invoice_no = NULL, issued_date = NULL, result_file_id = NULL, + result_recorded_by = NULL, result_recorded_at = NULL, termination_reason = NULL, + updated_by = #{updatedBy}, version = version + 1 + WHERE id = #{id} AND source_document_id = #{sourceDocumentId} + AND version = #{version} AND status = 'RETURNED' + """) + int replaceReturnedProjectedInvoice(@Param("id") long id, + @Param("sourceDocumentId") long sourceDocumentId, + @Param("sourceVersionId") long sourceVersionId, + @Param("companyId") long companyId, + @Param("projectId") long projectId, + @Param("contractId") long contractId, + @Param("counterpartyId") long counterpartyId, + @Param("invoiceType") String invoiceType, + @Param("amount") BigDecimal amount, + @Param("currency") String currency, + @Param("taxRate") BigDecimal taxRate, + @Param("requestedDate") LocalDate requestedDate, + @Param("updatedBy") long updatedBy, + @Param("version") long version); + + @Update(""" + UPDATE fin_invoice + SET company_id = #{companyId}, project_id = #{projectId}, contract_id = #{contractId}, + counterparty_id = #{counterpartyId}, invoice_type = #{invoiceType}, amount = #{amount}, + currency = #{currency}, tax_rate = #{taxRate}, requested_date = #{requestedDate}, updated_by = #{actorId}, + version = version + 1 + WHERE id = #{id} AND version = #{version} AND status IN ('DRAFT', 'RETURNED') + """) + int updateInvoice(@Param("id") long id, + @Param("companyId") long companyId, + @Param("projectId") long projectId, + @Param("contractId") Long contractId, + @Param("counterpartyId") long counterpartyId, + @Param("invoiceType") String invoiceType, + @Param("amount") BigDecimal amount, + @Param("currency") String currency, + @Param("taxRate") BigDecimal taxRate, + @Param("requestedDate") LocalDate requestedDate, + @Param("actorId") long actorId, + @Param("version") long version); + + @Update(""" + UPDATE fin_invoice + SET status = 'REVIEWING', submitted_by = #{actorId}, submitted_at = UTC_TIMESTAMP(3), + reviewed_by = NULL, reviewed_at = NULL, review_opinion = NULL, + updated_by = #{actorId}, version = version + 1 + WHERE id = #{id} AND version = #{version} AND status IN ('DRAFT', 'RETURNED') + """) + int submitInvoice(@Param("id") long id, @Param("version") long version, + @Param("actorId") long actorId); + + @Update(""" + UPDATE fin_invoice + SET status = #{status}, reviewed_by = #{actorId}, reviewed_at = UTC_TIMESTAMP(3), + review_opinion = #{opinion}, updated_by = #{actorId}, version = version + 1 + WHERE id = #{id} AND version = #{version} AND status = 'REVIEWING' + """) + int reviewInvoice(@Param("id") long id, @Param("version") long version, + @Param("status") String status, @Param("opinion") String opinion, + @Param("actorId") long actorId); + + @Update(""" + UPDATE fin_invoice + SET invoice_no = #{invoiceNo}, issued_date = #{issuedDate}, result_file_id = #{resultFileId}, + result_recorded_by = #{actorId}, result_recorded_at = UTC_TIMESTAMP(3), + status = 'ISSUED', updated_by = #{actorId}, version = version + 1 + WHERE id = #{id} AND version = #{version} AND status IN ('RESULT_PENDING', 'APPROVED') + """) + int recordInvoiceResult(@Param("id") long id, @Param("version") long version, + @Param("invoiceNo") String invoiceNo, + @Param("issuedDate") LocalDate issuedDate, + @Param("resultFileId") Long resultFileId, + @Param("actorId") long actorId); + + @Update(""" + UPDATE fin_invoice + SET status = 'VOID', termination_reason = #{reason}, + updated_by = #{actorId}, version = version + 1 + WHERE id = #{id} AND version = #{version} + AND status IN ('DRAFT', 'RETURNED', 'REJECTED', 'RESULT_PENDING', 'APPROVED', 'ISSUED') + """) + int voidInvoice(@Param("id") long id, @Param("version") long version, + @Param("reason") String reason, + @Param("actorId") long actorId); + + @Insert(""" + INSERT INTO fin_invoice ( + public_id, business_no, company_id, project_id, contract_id, counterparty_id, + invoice_type, amount, currency, tax_rate, requested_date, invoice_no, issued_date, + result_file_id, original_invoice_id, status, reviewed_by, reviewed_at, + review_opinion, result_recorded_by, result_recorded_at, created_by, updated_by + ) SELECT + #{publicId}, #{businessNo}, original.company_id, original.project_id, original.contract_id, + original.counterparty_id, 'RED_LETTER', original.amount, original.currency, original.tax_rate, #{issuedDate}, + #{invoiceNo}, #{issuedDate}, #{resultFileId}, original.id, 'ISSUED', #{actorId}, UTC_TIMESTAMP(3), + #{reason}, #{actorId}, UTC_TIMESTAMP(3), #{actorId}, #{actorId} + FROM fin_invoice original WHERE original.id = #{originalId} + """) + int insertRedLetter(@Param("publicId") String publicId, + @Param("businessNo") String businessNo, + @Param("originalId") long originalId, + @Param("invoiceNo") String invoiceNo, + @Param("issuedDate") LocalDate issuedDate, + @Param("resultFileId") Long resultFileId, + @Param("reason") String reason, + @Param("actorId") long actorId); + + @Update(""" + UPDATE fin_invoice + SET status = 'RED_LETTERED', termination_reason = #{reason}, + updated_by = #{actorId}, version = version + 1 + WHERE id = #{id} AND version = #{version} AND status = 'ISSUED' + """) + int markRedLettered(@Param("id") long id, @Param("version") long version, + @Param("reason") String reason, + @Param("actorId") long actorId); + + @Insert(""" + INSERT IGNORE INTO acc_event ( + public_id, business_no, event_type, source_type, source_public_id, source_version, event_sequence, + company_id, project_id, counterparty_id, business_date, period, + amount, currency, status, created_by + ) VALUES ( + #{publicId}, #{businessNo}, #{eventType}, #{eventType}, #{sourcePublicId}, #{sourceVersion}, #{eventSequence}, + #{companyId}, #{projectId}, #{counterpartyId}, #{businessDate}, + DATE_FORMAT(#{businessDate}, '%Y-%m'), #{amount}, #{currency}, 'PENDING', #{actorId} + ) + """) + int insertAccountingEvent(@Param("publicId") String publicId, + @Param("businessNo") String businessNo, + @Param("eventType") String eventType, + @Param("sourcePublicId") String sourcePublicId, + @Param("sourceVersion") String sourceVersion, + @Param("eventSequence") int eventSequence, + @Param("companyId") long companyId, + @Param("projectId") Long projectId, + @Param("counterpartyId") Long counterpartyId, + @Param("businessDate") LocalDate businessDate, + @Param("amount") BigDecimal amount, + @Param("currency") String currency, + @Param("actorId") long actorId); + + @Select("SELECT id, public_id FROM md_company WHERE public_id = #{publicId} AND status = 'ACTIVE'") + EntityRef findCompany(String publicId); + + @Select(""" + SELECT project.id, project.public_id, project.company_id, company.public_id AS company_public_id, + project.currency + FROM md_project project JOIN md_company company ON company.id = project.company_id + WHERE project.public_id = #{publicId} AND project.status = 'ACTIVE' + """) + ProjectRef findProject(String publicId); + + @Select("SELECT id, public_id FROM md_counterparty WHERE public_id = #{publicId} AND status = 'ACTIVE'") + EntityRef findCounterparty(String publicId); + + @Select(""" + SELECT contract.id, contract.public_id, contract.company_id, contract.project_id, + contract.counterparty_id, contract.original_amount, contract.approved_change_amount, + contract.settlement_amount, contract.currency, contract.status + FROM md_contract contract WHERE contract.public_id = #{publicId} AND contract.status = 'ACTIVE' + """) + ContractRef findContract(String publicId); + + @Select(""" + SELECT contract.id, contract.public_id, contract.company_id, contract.project_id, + contract.counterparty_id, contract.original_amount, contract.approved_change_amount, + contract.settlement_amount, contract.status, + COALESCE(( + SELECT SUM(CASE WHEN invoice.invoice_type = 'RED_LETTER' + THEN -invoice.amount ELSE invoice.amount END) + FROM fin_invoice invoice + WHERE invoice.contract_id = contract.id + AND invoice.status IN ('RESULT_PENDING', 'APPROVED', 'ISSUED', 'RED_LETTERED') + ), 0) AS committed_invoice_amount, + COALESCE(( + SELECT SUM(CASE WHEN invoice.invoice_type = 'RED_LETTER' + THEN -invoice.amount ELSE invoice.amount END) + FROM fin_invoice invoice + WHERE invoice.contract_id = contract.id + AND invoice.status IN ( + 'DRAFT', 'REVIEWING', 'RESULT_PENDING', 'APPROVED', 'ISSUED', 'RED_LETTERED' + ) + ), 0) AS reserved_invoice_amount + FROM md_contract contract + WHERE contract.id = #{id} + FOR UPDATE + """) + ContractControl lockContractControl(long id); + + @Select(""" + SELECT invoice.invoice_type, invoice.amount + FROM fin_invoice invoice + WHERE invoice.contract_id = #{contractId} + AND invoice.status IN ( + 'DRAFT', 'REVIEWING', 'RESULT_PENDING', 'APPROVED', 'ISSUED', 'RED_LETTERED' + ) + ORDER BY invoice.id + FOR UPDATE + """) + List lockInvoiceReservations(@Param("contractId") long contractId); + + @Select("SELECT id, public_id, scan_status FROM file_object WHERE public_id = #{publicId}") + FileRef findFile(String publicId); + + @Select(""" + + """) + List listCompanies(@Param("userId") long userId, @Param("roleCode") String roleCode, + @Param("permissionCode") String permissionCode); + + @Select(""" + + """) + List listProjects(@Param("userId") long userId, @Param("roleCode") String roleCode, + @Param("permissionCode") String permissionCode); + + @Select(""" + SELECT counterparty.public_id, counterparty.business_no AS code, counterparty.name + FROM md_counterparty counterparty WHERE counterparty.status = 'ACTIVE' + ORDER BY counterparty.name, counterparty.public_id + """) + List listCounterparties(); + + @Select(""" + + """) + List listContracts(@Param("userId") long userId, @Param("roleCode") String roleCode, + @Param("permissionCode") String permissionCode); + + record ReceiptRow(long id, String publicId, String businessNo, Long sourceDocumentId, + String companyPublicId, String companyCode, String companyName, + String projectPublicId, String projectCode, String projectName, + String customerPublicId, String customerCode, String customerName, + LocalDate receiptDate, BigDecimal amount, BigDecimal allocatedAmount, + BigDecimal unallocatedAmount, String currency, String payerName, + String referenceNo, String status, Long confirmedBy, String confirmedByName, + LocalDateTime confirmedAt, String voidReason, long version, long createdBy, + LocalDateTime createdAt, LocalDateTime updatedAt) { + } + + record ReceiptLock(long id, BigDecimal amount, BigDecimal allocatedAmount, + String status, long version, long createdBy) { + } + + record ReceiptProjection(long id, String publicId, String businessNo, long sourceVersionId) { + } + + record ApprovedReceiptProject(long id, String publicId, long companyId, String companyPublicId, + long customerId, String currency) { + } + + record AllocationRow(String publicId, Integer sequenceNo, + String receivablePublicId, String receivableBusinessNo, + String projectPublicId, String projectCode, String projectName, + String contractPublicId, String contractCode, String contractName, + BigDecimal amount, BigDecimal receivableAmount, + BigDecimal receivableOutstandingAmount, String note, String createdByName, + LocalDateTime createdAt) { + } + + record ReceivableRow(long id, String publicId, String businessNo, + String companyPublicId, String companyCode, String companyName, + String projectPublicId, String projectCode, String projectName, + String contractPublicId, String contractCode, String contractName, + String counterpartyPublicId, String counterpartyCode, String counterpartyName, + LocalDate businessDate, BigDecimal amount, BigDecimal settledAmount, + BigDecimal outstandingAmount, String currency, String status, + String sourceInvoicePublicId, long version, + LocalDateTime createdAt, LocalDateTime updatedAt) { + } + + record ReceivableLock(long id, String publicId, String businessNo, + long companyId, String companyPublicId, + long projectId, String projectPublicId, + Long contractId, String contractPublicId, + long counterpartyId, String counterpartyPublicId, + BigDecimal amount, BigDecimal outstandingAmount, + String currency, String status, long version) { + } + + record InvoiceRow(long id, Long sourceDocumentId, Long sourceVersionId, + String sourceDocumentPublicId, String sourceVersionPublicId, + long companyId, long projectId, Long contractId, + long counterpartyId, String publicId, String businessNo, + String companyPublicId, String companyCode, String companyName, + String projectPublicId, String projectCode, String projectName, + String contractPublicId, String contractCode, String contractName, + String counterpartyPublicId, String counterpartyCode, String counterpartyName, + String invoiceType, BigDecimal amount, String currency, BigDecimal taxRate, + LocalDate requestedDate, String invoiceNo, LocalDate issuedDate, + String resultFilePublicId, String originalInvoicePublicId, String status, + Long submittedBy, String submittedByName, LocalDateTime submittedAt, + Long reviewedBy, String reviewedByName, LocalDateTime reviewedAt, + String reviewOpinion, String resultRecordedByName, + LocalDateTime resultRecordedAt, String terminationReason, + BigDecimal contractLimit, BigDecimal contractCommittedAmount, + BigDecimal contractAvailableAmount, long version, + long createdBy, LocalDateTime createdAt, LocalDateTime updatedAt) { + } + + record InvoiceLock(long id, String status, long version, long createdBy, Long submittedBy) { + } + + record InvoiceProjection(long id, String publicId, String businessNo, long sourceVersionId, + String status, long version) { + } + + record ApprovedInvoiceReferences( + long projectId, + String projectPublicId, + String companyPublicId, + String currency, + long counterpartyId, + String counterpartyPublicId, + String counterpartyName, + String counterpartyTaxNo, + long contractId, + String contractPublicId + ) { + } + + record EntityRef(long id, String publicId) { + } + + record ProjectRef(long id, String publicId, long companyId, String companyPublicId, String currency) { + } + + record ContractRef(long id, String publicId, long companyId, long projectId, + long counterpartyId, BigDecimal originalAmount, + BigDecimal approvedChangeAmount, BigDecimal settlementAmount, String currency, + String status) { + } + + record ContractControl(long id, String publicId, long companyId, long projectId, + long counterpartyId, BigDecimal originalAmount, + BigDecimal approvedChangeAmount, BigDecimal settlementAmount, + String status, BigDecimal committedInvoiceAmount, + BigDecimal reservedInvoiceAmount) { + } + + record InvoiceReservation(String invoiceType, BigDecimal amount) { + } + + record FileRef(long id, String publicId, String scanStatus) { + } + + record ReferenceRow(String publicId, String code, String name) { + } +} diff --git a/backend/src/main/java/com/kaidi/finance/reporting/api/ReportContracts.java b/backend/src/main/java/com/kaidi/finance/reporting/api/ReportContracts.java new file mode 100644 index 0000000..bf97b5f --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/reporting/api/ReportContracts.java @@ -0,0 +1,34 @@ +package com.kaidi.finance.reporting.api; + +import jakarta.validation.constraints.Size; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import java.time.LocalDate; + +public final class ReportContracts { + + private ReportContracts() { + } + + public record ReportExportPrepareRequest( + @Size(max = 26) String companyId, + @Size(max = 26) String projectId, + @Size(max = 26) String counterpartyId, + @Size(max = 100) String keyword, + @Size(max = 32) String status, + LocalDate dateFrom, + LocalDate dateTo, + String[] columns, + @NotBlank @Size(max = 64) String sort, + @NotBlank @Pattern(regexp = "[a-fA-F0-9]{64}") String expectedFilterHash, + @NotBlank @Size(max = 64) String expectedDefinitionVersion + ) { + } + + public record ReportExportConfirmRequest( + @NotBlank @Pattern(regexp = "[0-9A-HJKMNP-TV-Z]{26}") String exportId, + @NotNull Boolean maskingConfirmed + ) { + } +} diff --git a/backend/src/main/java/com/kaidi/finance/reporting/api/ReportController.java b/backend/src/main/java/com/kaidi/finance/reporting/api/ReportController.java new file mode 100644 index 0000000..2eb1399 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/reporting/api/ReportController.java @@ -0,0 +1,96 @@ +package com.kaidi.finance.reporting.api; + +import com.kaidi.finance.reporting.api.ReportContracts.ReportExportConfirmRequest; +import com.kaidi.finance.reporting.api.ReportContracts.ReportExportPrepareRequest; +import com.kaidi.finance.reporting.api.ReportViews.ReportDrilldownView; +import com.kaidi.finance.reporting.api.ReportViews.ReportExportView; +import com.kaidi.finance.reporting.api.ReportViews.ReportExportPreviewView; +import com.kaidi.finance.reporting.api.ReportViews.ReportRow; +import com.kaidi.finance.reporting.api.ReportViews.ReportView; +import com.kaidi.finance.reporting.application.ReportApplicationService; +import com.kaidi.finance.shared.api.ApiResponse; +import jakarta.validation.Valid; +import java.time.LocalDate; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.springframework.http.MediaType; +import org.springframework.security.access.prepost.PreAuthorize; +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; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/reports") +public class ReportController { + + private final ReportApplicationService service; + + public ReportController(ReportApplicationService service) { + this.service = service; + } + + @GetMapping("/{reportCode}") + @PreAuthorize("@authorizationService.hasPermission('report:finance:view')") + public ApiResponse> report(@PathVariable String reportCode, + @RequestParam(required = false) String companyId, + @RequestParam(required = false) String projectId, + @RequestParam(required = false) String counterpartyId, + @RequestParam(required = false) String keyword, + @RequestParam(required = false) String status, + @RequestParam(required = false) LocalDate dateFrom, + @RequestParam(required = false) LocalDate dateTo, + @RequestParam(required = false) String sort, + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int size) { + ReportView report = service.list(reportCode, companyId, projectId, counterpartyId, keyword, status, + dateFrom, dateTo, sort, page, size); + Map meta = new LinkedHashMap<>(); + meta.put("page", report.page()); + meta.put("size", report.size()); + meta.put("totalElements", report.totalElements()); + meta.put("totalPages", report.totalPages()); + meta.put("reportCode", report.reportCode()); + meta.put("definitionVersion", report.definitionVersion()); + meta.put("filterHash", report.filterHash()); + meta.put("columns", report.columns()); + meta.put("sort", report.sort()); + meta.put("summary", report.summary()); + meta.put("allowedActions", report.allowedActions()); + return ApiResponse.ok(report.rows(), meta); + } + + @GetMapping("/{reportCode}/{rowId}/drilldown") + @PreAuthorize("@authorizationService.hasPermission('report:finance:view')") + public ApiResponse drilldown(@PathVariable String reportCode, @PathVariable String rowId, + @RequestParam(required = false) String companyId, + @RequestParam(required = false) String projectId, + @RequestParam(required = false) String counterpartyId, + @RequestParam(required = false) String keyword, + @RequestParam(required = false) String status, + @RequestParam(required = false) LocalDate dateFrom, + @RequestParam(required = false) LocalDate dateTo) { + return ApiResponse.ok(service.drilldown(reportCode, rowId, companyId, projectId, counterpartyId, keyword, + status, dateFrom, dateTo)); + } + + @PostMapping(value = "/{reportCode}/exports/prepare", consumes = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("@authorizationService.hasPermission('report:finance:export')") + public ApiResponse prepareExport( + @PathVariable String reportCode, + @Valid @RequestBody(required = false) ReportExportPrepareRequest request) { + return ApiResponse.ok(service.prepareExport(reportCode, request)); + } + + @PostMapping(value = "/{reportCode}/exports/confirm", consumes = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("@authorizationService.hasPermission('report:finance:export')") + public ApiResponse confirmExport( + @PathVariable String reportCode, + @Valid @RequestBody(required = false) ReportExportConfirmRequest request) { + return ApiResponse.ok(service.confirmExport(reportCode, request)); + } +} diff --git a/backend/src/main/java/com/kaidi/finance/reporting/api/ReportViews.java b/backend/src/main/java/com/kaidi/finance/reporting/api/ReportViews.java new file mode 100644 index 0000000..a72a039 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/reporting/api/ReportViews.java @@ -0,0 +1,373 @@ +package com.kaidi.finance.reporting.api; + +import java.util.List; +import java.util.Map; + +public final class ReportViews { + + private ReportViews() { + } + + public sealed interface ReportRow permits ProjectLedgerRow, ContractExecutionRow, ReceiptInvoiceRow, + PaymentProgressRow, WorkflowDurationRow, ArchiveCompletenessRow { + + String rowId(); + } + + public record ProjectLedgerRow( + String rowId, + String businessNo, + String projectName, + String companyName, + String contractAmount, + String receiptAmount, + String costAmount, + String paymentAmount, + String financeStatus, + String archiveStatus, + long completedVoucherCount, + long pendingAccountingCount, + long fileCount, + String completeness, + String status + ) implements ReportRow { + } + + public record ContractExecutionRow( + String rowId, + String businessNo, + String projectName, + String name, + String currency, + String originalAmount, + String approvedChangeAmount, + String settlementAmount, + String invoicedAmount, + String pendingPayableAmount, + String payableAmount, + String paidAmount, + String availableAmount, + String status + ) implements ReportRow { + } + + public record ReceiptInvoiceRow( + String rowId, + String businessNo, + String projectName, + String recordType, + String businessDate, + String amount, + String cumulativeReceiptAmount, + String cumulativeInvoiceAmount, + String outstandingAmount, + String allocatedAmount, + String invoiceNo, + String invoiceType, + String status + ) implements ReportRow { + } + + public record PaymentProgressRow( + String rowId, + String businessNo, + String projectName, + String requestedDate, + String requestedAmount, + String approvedAmount, + String submittedAt, + String checkedAt, + String approvedAt, + String offlineResult, + String resultAmount, + String paidDate, + String transactionNo, + long receiptFileCount, + String accountingStatus, + String status + ) implements ReportRow { + } + + public record WorkflowDurationRow( + String rowId, + String businessNo, + String projectName, + String formType, + String nodeCode, + String arrivedAt, + String completedAt, + long durationSeconds, + long returnCount, + long pendingTaskCount, + String status + ) implements ReportRow { + } + + public record ArchiveCompletenessRow( + String rowId, + String businessNo, + String projectName, + int packageVersion, + String completeness, + long missingCount, + long pendingCount, + long notApplicableCount, + long presentCount, + long totalItemCount, + boolean frozen, + String status + ) implements ReportRow { + } + + public record ReportView(String reportCode, String definitionVersion, String filterHash, + List columns, String sort, List rows, Map summary, + long totalElements, int page, int size, int totalPages, + List allowedActions) { + } + + public record ReportDrilldownView( + String reportCode, + String rowId, + String definitionVersion, + String filterHash, + ReportSourceView source, + List groups, + List relatedRecords, + List sourceForms, + List approvals, + List attachments + ) { + } + + public sealed interface ReportSourceView permits ProjectLedgerSourceView, ContractExecutionSourceView, + ReceiptInvoiceSourceView, PaymentProgressSourceView, WorkflowDurationSourceView, + ArchiveCompletenessSourceView { + + String rowId(); + } + + public record ProjectLedgerSourceView( + String rowId, + String businessNo, + String projectName, + String companyPublicId, + String companyName, + String counterpartyPublicId, + String projectPublicId, + String stage, + String financeStatus, + String archiveStatus, + String currency, + String status, + String contractAmount, + String receiptAmount, + String costAmount, + String paymentAmount, + long completedVoucherCount, + long pendingAccountingCount, + long fileCount, + String completeness, + String latestPackageStatus, + String businessDate, + String updatedAt, + String metricAmount + ) implements ReportSourceView { + } + + public record ContractExecutionSourceView( + String rowId, + String businessNo, + String projectName, + String companyPublicId, + String companyName, + String counterpartyPublicId, + String projectPublicId, + String name, + String originalAmount, + String approvedChangeAmount, + String settlementAmount, + String currency, + String status, + String invoicedAmount, + String pendingPayableAmount, + String payableAmount, + String paidAmount, + String availableAmount, + String businessDate, + String updatedAt, + String metricAmount + ) implements ReportSourceView { + } + + public record ReceiptInvoiceSourceView( + String rowId, + String businessNo, + String projectName, + String companyPublicId, + String companyName, + String counterpartyPublicId, + String projectPublicId, + String recordType, + String businessDate, + String amount, + String currency, + String allocatedAmount, + String invoiceNo, + String invoiceType, + String cumulativeReceiptAmount, + String cumulativeInvoiceAmount, + String outstandingAmount, + String sourceDocumentId, + String status, + String updatedAt, + String metricAmount + ) implements ReportSourceView { + } + + public record PaymentProgressSourceView( + String rowId, + String businessNo, + String projectName, + String companyPublicId, + String companyName, + String counterpartyPublicId, + String projectPublicId, + String requestedDate, + String requestedAmount, + String approvedAmount, + String submittedAt, + String checkedAt, + String approvedAt, + String status, + String businessDate, + String currency, + String offlineResult, + String resultAmount, + String resultId, + String paidDate, + String transactionNo, + long receiptFileCount, + String accountingStatus, + String sourceDocumentId, + String updatedAt, + String metricAmount + ) implements ReportSourceView { + } + + public record WorkflowDurationSourceView( + String rowId, + String businessNo, + String projectName, + String companyPublicId, + String companyName, + String counterpartyPublicId, + String projectPublicId, + String currency, + String formType, + String sourceDocumentId, + String sourceVersionId, + String workflowInstanceId, + String nodeCode, + String status, + String arrivedAt, + String completedAt, + long durationSeconds, + long returnCount, + long pendingTaskCount, + String businessDate, + String updatedAt, + String metricAmount + ) implements ReportSourceView { + } + + public record ArchiveCompletenessSourceView( + String rowId, + String businessNo, + String projectName, + String companyPublicId, + String companyName, + String counterpartyPublicId, + String projectPublicId, + String currency, + String packageId, + int packageVersion, + String completeness, + long missingCount, + long pendingCount, + long notApplicableCount, + long presentCount, + long totalItemCount, + boolean frozen, + String status, + String businessDate, + String updatedAt, + String metricAmount + ) implements ReportSourceView { + } + + public record ReportMetricGroupView(String code, String label, String value) { + } + + public record ReportRelatedRecordView( + String relationType, + String objectType, + String publicId, + String businessNo, + String status, + String amount, + String businessDate, + String sourceDocumentId + ) { + } + + public record ReportSourceFormView( + String publicId, + String businessNo, + String formType, + String sourceSystem, + String sourceNo, + String status, + String sourceVersionId, + Integer versionNo, + String relationType, + String ownerType, + String ownerPublicId, + boolean exactVersion + ) { + } + + public record ReportApprovalEventView( + String publicId, + String sourceDocumentId, + String sourceVersionId, + String nodeCode, + String action, + String actorName, + String opinion, + String actedAt + ) { + } + + public record ReportAttachmentView( + String fileId, + String ownerType, + String ownerId, + String sourceDocumentId, + String documentType, + String displayName, + String scanStatus, + Integer versionNo + ) { + } + + public record ReportExportView(String exportId, String reportCode, String definitionVersion, + String filterHash, String status, int estimatedRows, int rowCount, String sha256, + String fileName, String content) { + } + + public record ReportExportPreviewView(String exportId, String reportCode, String definitionVersion, + String filterHash, String status, int estimatedRows, + List columns, String sort, String expiresAt, + String maskingNotice) { + } +} diff --git a/backend/src/main/java/com/kaidi/finance/reporting/application/ReportApplicationService.java b/backend/src/main/java/com/kaidi/finance/reporting/application/ReportApplicationService.java new file mode 100644 index 0000000..0eaa4f4 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/reporting/application/ReportApplicationService.java @@ -0,0 +1,866 @@ +package com.kaidi.finance.reporting.application; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.kaidi.finance.iam.domain.FinancePrincipal; +import com.kaidi.finance.reporting.api.ReportContracts.ReportExportConfirmRequest; +import com.kaidi.finance.reporting.api.ReportContracts.ReportExportPrepareRequest; +import com.kaidi.finance.reporting.api.ReportViews.ArchiveCompletenessRow; +import com.kaidi.finance.reporting.api.ReportViews.ArchiveCompletenessSourceView; +import com.kaidi.finance.reporting.api.ReportViews.ContractExecutionRow; +import com.kaidi.finance.reporting.api.ReportViews.ContractExecutionSourceView; +import com.kaidi.finance.reporting.api.ReportViews.PaymentProgressRow; +import com.kaidi.finance.reporting.api.ReportViews.PaymentProgressSourceView; +import com.kaidi.finance.reporting.api.ReportViews.ProjectLedgerRow; +import com.kaidi.finance.reporting.api.ReportViews.ProjectLedgerSourceView; +import com.kaidi.finance.reporting.api.ReportViews.ReportApprovalEventView; +import com.kaidi.finance.reporting.api.ReportViews.ReportAttachmentView; +import com.kaidi.finance.reporting.api.ReportViews.ReportDrilldownView; +import com.kaidi.finance.reporting.api.ReportViews.ReportExportView; +import com.kaidi.finance.reporting.api.ReportViews.ReportExportPreviewView; +import com.kaidi.finance.reporting.api.ReportViews.ReportMetricGroupView; +import com.kaidi.finance.reporting.api.ReportViews.ReportRelatedRecordView; +import com.kaidi.finance.reporting.api.ReportViews.ReportRow; +import com.kaidi.finance.reporting.api.ReportViews.ReportSourceView; +import com.kaidi.finance.reporting.api.ReportViews.ReportSourceFormView; +import com.kaidi.finance.reporting.api.ReportViews.ReportView; +import com.kaidi.finance.reporting.api.ReportViews.ReceiptInvoiceRow; +import com.kaidi.finance.reporting.api.ReportViews.ReceiptInvoiceSourceView; +import com.kaidi.finance.reporting.api.ReportViews.WorkflowDurationRow; +import com.kaidi.finance.reporting.api.ReportViews.WorkflowDurationSourceView; +import com.kaidi.finance.reporting.infrastructure.ReportMapper; +import com.kaidi.finance.reporting.infrastructure.ReportMapper.SummaryRow; +import com.kaidi.finance.reporting.infrastructure.ReportMapper.ExportRequestRow; +import com.kaidi.finance.shared.api.BusinessException; +import com.kaidi.finance.shared.api.ErrorCode; +import com.kaidi.finance.shared.audit.AuditService; +import com.kaidi.finance.shared.id.UlidGenerator; +import com.kaidi.finance.shared.infrastructure.RequestContext; +import com.kaidi.finance.shared.security.AuthorizationService; +import com.kaidi.finance.shared.security.IdentityContext; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.HashSet; +import java.util.Set; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Isolation; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class ReportApplicationService { + + public static final String DEFINITION_VERSION = "REPORT-DRAFT-V3"; + private static final String VIEW_PERMISSION = "report:finance:view"; + private static final String EXPORT_PERMISSION = "report:finance:export"; + private static final String FILE_VIEW_PERMISSION = "archive:file:view"; + private static final int EXPORT_ROW_LIMIT = 50_000; + private static final String DEFAULT_SORT = "updatedAt,desc;rowId,asc"; + private static final String MASKING_NOTICE = "按当前身份重新校验数据范围,并沿用页面脱敏规则。"; + private static final DateTimeFormatter SQL_TIMESTAMP_FORMATTER = + DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSxxx").withZone(ZoneOffset.UTC); + private static final Set REPORT_CODES = Set.of( + "project-ledger", "contract-execution", "receipt-invoice", "payment-progress", + "workflow-duration", "archive-completeness"); + private static final Map> DEFAULT_COLUMNS = Map.of( + "project-ledger", List.of("businessNo", "projectName", "companyName", "contractAmount", "receiptAmount", + "costAmount", "paymentAmount", "financeStatus", "archiveStatus", "completedVoucherCount", + "pendingAccountingCount", "fileCount", "completeness", "status"), + "contract-execution", List.of("businessNo", "projectName", "name", "currency", "originalAmount", + "approvedChangeAmount", "settlementAmount", "invoicedAmount", "pendingPayableAmount", + "payableAmount", "paidAmount", + "availableAmount", "status"), + "receipt-invoice", List.of("businessNo", "projectName", "recordType", "businessDate", "amount", + "cumulativeReceiptAmount", "cumulativeInvoiceAmount", "outstandingAmount", "allocatedAmount", + "invoiceNo", "invoiceType", "status"), + "payment-progress", List.of("businessNo", "projectName", "requestedDate", "requestedAmount", + "approvedAmount", "submittedAt", "checkedAt", "approvedAt", "offlineResult", "resultAmount", + "paidDate", "transactionNo", "receiptFileCount", "accountingStatus", "status"), + "workflow-duration", List.of("businessNo", "projectName", "formType", "nodeCode", "arrivedAt", + "completedAt", "durationSeconds", "returnCount", "pendingTaskCount", "status"), + "archive-completeness", List.of("businessNo", "projectName", "packageVersion", "completeness", + "missingCount", "pendingCount", "notApplicableCount", "presentCount", "totalItemCount", + "frozen", "status")); + private static final Map> SORT_COLUMNS = Map.of( + "project-ledger", sortColumns("businessNo", "projectName", "companyName", "contractAmount", + "receiptAmount", "costAmount", "paymentAmount", "financeStatus", "archiveStatus", + "completedVoucherCount", "pendingAccountingCount", "fileCount", "completeness", "status"), + "contract-execution", sortColumns("businessNo", "projectName", "name", "currency", "originalAmount", + "approvedChangeAmount", "settlementAmount", "invoicedAmount", "pendingPayableAmount", + "payableAmount", "paidAmount", "availableAmount", "status"), + "receipt-invoice", sortColumns("businessNo", "projectName", "recordType", "businessDate", "amount", + "cumulativeReceiptAmount", "cumulativeInvoiceAmount", "outstandingAmount", "allocatedAmount", + "invoiceNo", "invoiceType", "status"), + "payment-progress", sortColumns("businessNo", "projectName", "requestedDate", "requestedAmount", + "approvedAmount", "submittedAt", "checkedAt", "approvedAt", "offlineResult", "resultAmount", + "paidDate", "transactionNo", "receiptFileCount", "accountingStatus", "status"), + "workflow-duration", sortColumns("businessNo", "projectName", "formType", "nodeCode", "arrivedAt", + "completedAt", "durationSeconds", "returnCount", "pendingTaskCount", "status"), + "archive-completeness", sortColumns("businessNo", "projectName", "packageVersion", "completeness", + "missingCount", "pendingCount", "notApplicableCount", "presentCount", "totalItemCount", "frozen", + "status")); + private static final Map> STATUSES = Map.of( + "project-ledger", Set.of("DRAFT", "ACTIVE", "SUSPENDED", "CLOSED", "ARCHIVED"), + "contract-execution", Set.of("DRAFT", "REVIEWING", "RETURNED", "ACTIVE", "SETTLED", "DISABLED", "VOID"), + "receipt-invoice", Set.of("CONFIRMED", "PARTIALLY_ALLOCATED", "ALLOCATED", "ISSUED", "RED_LETTERED"), + "payment-progress", Set.of("DRAFT", "REVIEWING", "RETURNED", "REJECTED", "APPROVED", "OFFLINE_PENDING", + "RESULT_RECORDED", "RESULT_VERIFIED", "PART_PAID", "PAID", "FAILED", "REFUNDED", "VOID"), + "workflow-duration", Set.of("PENDING", "APPROVED", "RETURNED", "REJECTED", "CANCELLED"), + "archive-completeness", Set.of("NOT_CREATED", "PREPARING", "CHECKED", "REVIEWING", "RETURNED", + "ARCHIVED", "FROZEN")); + + private final ReportMapper mapper; + private final AuthorizationService authorizationService; + private final IdentityContext identityContext; + private final AuditService auditService; + private final UlidGenerator ulidGenerator; + private final ObjectMapper objectMapper; + private final ReportExportRequestStateService exportRequestStateService; + + public ReportApplicationService(ReportMapper mapper, AuthorizationService authorizationService, + IdentityContext identityContext, AuditService auditService, + UlidGenerator ulidGenerator, ObjectMapper objectMapper, + ReportExportRequestStateService exportRequestStateService) { + this.mapper = mapper; + this.authorizationService = authorizationService; + this.identityContext = identityContext; + this.auditService = auditService; + this.ulidGenerator = ulidGenerator; + this.objectMapper = objectMapper; + this.exportRequestStateService = exportRequestStateService; + } + + @Transactional(readOnly = true) + public ReportView list(String reportCode, String companyId, String projectId, String counterpartyId, + String keyword, String status, LocalDate dateFrom, LocalDate dateTo, + String requestedSort, int page, int size) { + authorizationService.requirePermission(VIEW_PERMISSION); + String code = normalizeReportCode(reportCode); + Page requestedPage = page(page, size); + Actor actor = actor(); + LocalDateTime scopeAsOf = LocalDateTime.now(ZoneOffset.UTC).truncatedTo(ChronoUnit.MILLIS); + String viewScopeFingerprint = authorizationService.activeScopeFingerprint(VIEW_PERMISSION, scopeAsOf); + Filter filter = filter(code, viewScopeFingerprint, actor, companyId, projectId, counterpartyId, keyword, + status, dateFrom, dateTo); + List columns = DEFAULT_COLUMNS.get(code); + String sort = normalizeSort(code, requestedSort); + List rows = mapper.list(code, VIEW_PERMISSION, actor.userId(), actor.roleCode(), scopeAsOf, + filter.companyId(), filter.projectId(), filter.counterpartyId(), filter.keyword(), filter.status(), + filter.dateFrom(), filter.dateTo(), requestedPage.size(), requestedPage.offset(), sortSql(code, sort)).stream() + .map(raw -> row(code, raw)).toList(); + long total = mapper.count(code, VIEW_PERMISSION, actor.userId(), actor.roleCode(), scopeAsOf, + filter.companyId(), filter.projectId(), filter.counterpartyId(), filter.keyword(), filter.status(), + filter.dateFrom(), filter.dateTo()); + SummaryRow summaryRow = mapper.summary(code, VIEW_PERMISSION, actor.userId(), actor.roleCode(), scopeAsOf, + filter.companyId(), filter.projectId(), filter.counterpartyId(), filter.keyword(), filter.status(), + filter.dateFrom(), filter.dateTo()); + requireUnambiguousCurrencySummary(code, summaryRow); + Map summary = new LinkedHashMap<>(); + summary.put("totalElements", Long.toString(total)); + summary.put("metricTotal", money(summaryRow == null ? null : summaryRow.metricTotal())); + summary.put("metricLabel", metricLabel(code)); + return new ReportView(code, DEFINITION_VERSION, filter.hash(), columns, sort, rows, summary, + total, requestedPage.page(), requestedPage.size(), totalPages(total, requestedPage.size()), + authorizationService.hasPermission(EXPORT_PERMISSION) ? List.of("EXPORT") : List.of()); + } + + @Transactional(readOnly = true) + public ReportDrilldownView drilldown(String reportCode, String rowId, String companyId, String projectId, + String counterpartyId, String keyword, String status, LocalDate dateFrom, + LocalDate dateTo) { + authorizationService.requirePermission(VIEW_PERMISSION); + String code = normalizeReportCode(reportCode); + String safeRowId = requiredId(rowId); + Actor actor = actor(); + LocalDateTime scopeAsOf = LocalDateTime.now(ZoneOffset.UTC).truncatedTo(ChronoUnit.MILLIS); + String viewScopeFingerprint = authorizationService.activeScopeFingerprint(VIEW_PERMISSION, scopeAsOf); + Filter filter = filter(code, viewScopeFingerprint, actor, companyId, projectId, counterpartyId, keyword, + status, dateFrom, dateTo); + Map source = mapper.findRow(code, VIEW_PERMISSION, safeRowId, actor.userId(), actor.roleCode(), + scopeAsOf, filter.companyId(), filter.projectId(), filter.counterpartyId(), filter.keyword(), + filter.status(), filter.dateFrom(), filter.dateTo()); + if (source == null || source.isEmpty()) throw notFound("报表明细不存在或不在当前数据范围"); + Map normalized = normalizeMap(source); + ReportSourceView sourceView = source(code, normalized); + List relatedRecords = mapper.listRelatedRecords(code, VIEW_PERMISSION, safeRowId, + actor.userId(), actor.roleCode(), scopeAsOf, filter.companyId(), filter.projectId(), + filter.counterpartyId(), filter.keyword(), filter.status(), filter.dateFrom(), filter.dateTo()).stream() + .map(this::relatedRecord).toList(); + List sourceForms = mapper.listSourceForms(code, VIEW_PERMISSION, safeRowId, + actor.userId(), actor.roleCode(), scopeAsOf, filter.companyId(), filter.projectId(), + filter.counterpartyId(), filter.keyword(), filter.status(), filter.dateFrom(), filter.dateTo()).stream() + .map(this::sourceForm).toList(); + List approvals = mapper.listApprovals(code, VIEW_PERMISSION, safeRowId, + actor.userId(), actor.roleCode(), scopeAsOf, filter.companyId(), filter.projectId(), + filter.counterpartyId(), filter.keyword(), filter.status(), filter.dateFrom(), filter.dateTo()).stream() + .map(this::approval).toList(); + List attachments = authorizationService.hasPermission(FILE_VIEW_PERMISSION) + ? mapper.listAttachments(code, VIEW_PERMISSION, FILE_VIEW_PERMISSION, safeRowId, + actor.userId(), actor.roleCode(), scopeAsOf, filter.companyId(), filter.projectId(), + filter.counterpartyId(), filter.keyword(), filter.status(), filter.dateFrom(), filter.dateTo()) + .stream().map(this::attachment).toList() + : List.of(); + return new ReportDrilldownView(code, safeRowId, DEFINITION_VERSION, filter.hash(), sourceView, + metricGroups(code, normalized), relatedRecords, sourceForms, approvals, attachments); + } + + @Transactional(isolation = Isolation.REPEATABLE_READ) + public ReportExportPreviewView prepareExport(String reportCode, ReportExportPrepareRequest request) { + authorizationService.requirePermission(EXPORT_PERMISSION); + if (request == null) throw validation("导出预检参数不能为空"); + String code = normalizeReportCode(reportCode); + Actor actor = actor(); + LocalDateTime scopeAsOf = LocalDateTime.now(ZoneOffset.UTC).truncatedTo(ChronoUnit.MILLIS); + String viewScopeFingerprint = authorizationService.activeScopeFingerprint(VIEW_PERMISSION, scopeAsOf); + String exportScopeFingerprint = authorizationService.activeScopeFingerprint(EXPORT_PERMISSION, scopeAsOf); + Filter filter = filter(code, viewScopeFingerprint, actor, request.companyId(), request.projectId(), + request.counterpartyId(), request.keyword(), request.status(), request.dateFrom(), request.dateTo()); + if (request.expectedDefinitionVersion() != null + && !DEFINITION_VERSION.equals(request.expectedDefinitionVersion())) { + throw snapshotChanged(); + } + if (request.expectedFilterHash() != null + && !filter.hash().equalsIgnoreCase(request.expectedFilterHash())) { + throw snapshotChanged(); + } + List columns = columns(code, request.columns()); + String sort = normalizeSort(code, request.sort()); + long total = mapper.count(code, EXPORT_PERMISSION, actor.userId(), actor.roleCode(), scopeAsOf, + filter.companyId(), filter.projectId(), filter.counterpartyId(), filter.keyword(), filter.status(), + filter.dateFrom(), filter.dateTo()); + if (total > EXPORT_ROW_LIMIT) throw validation("导出结果超过 50000 行,请缩小筛选范围"); + String exportId = ulidGenerator.next(); + LocalDateTime expiresAt = scopeAsOf.plusMinutes(10); + mapper.insertExportRequest(exportId, actor.publicId(), actor.roleCode(), exportScopeFingerprint, + viewScopeFingerprint, code, + DEFINITION_VERSION, filter.hash(), json(filter.values()), json(columns), sort, Math.toIntExact(total), + expiresAt); + auditService.recordScoped(filter.companyId(), filter.projectId(), "REPORT_EXPORT_PREPARE", "REPORT", code, + "SUCCESS", filter.hash(), Map.of("estimatedRows", total), Map.of("exportId", exportId)); + return new ReportExportPreviewView(exportId, code, DEFINITION_VERSION, filter.hash(), "PREPARED", + Math.toIntExact(total), columns, sort, expiresAt.toString(), + MASKING_NOTICE); + } + + @Transactional(isolation = Isolation.REPEATABLE_READ) + public ReportExportView confirmExport(String reportCode, ReportExportConfirmRequest request) { + authorizationService.requirePermission(EXPORT_PERMISSION); + if (request == null || request.exportId() == null || !Boolean.TRUE.equals(request.maskingConfirmed())) { + throw validation("导出前必须完成预检并确认当前筛选范围和脱敏规则"); + } + String code = normalizeReportCode(reportCode); + Actor actor = actor(); + if (exportRequestStateService.expireIfNeeded(request.exportId(), actor.publicId(), actor.roleCode(), code)) { + throw snapshotChanged(); + } + LocalDateTime scopeAsOf = LocalDateTime.now(ZoneOffset.UTC).truncatedTo(ChronoUnit.MILLIS); + String viewScopeFingerprint = authorizationService.activeScopeFingerprint(VIEW_PERMISSION, scopeAsOf); + String exportScopeFingerprint = authorizationService.activeScopeFingerprint(EXPORT_PERMISSION, scopeAsOf); + ExportRequestRow prepared = mapper.lockExportRequest(request.exportId()); + if (prepared == null || !actor.publicId().equals(prepared.actorPublicId()) + || !actor.roleCode().equals(prepared.identityCode()) || !code.equals(prepared.reportCode())) { + throw notFound("导出预检不存在或不属于当前身份"); + } + if (!"PREPARED".equals(prepared.status())) throw snapshotChanged(); + if (!DEFINITION_VERSION.equals(prepared.definitionVersion()) + || !exportScopeFingerprint.equalsIgnoreCase(prepared.scopeFingerprint()) + || !viewScopeFingerprint.equalsIgnoreCase(prepared.viewScopeFingerprint())) { + throw snapshotChanged(); + } + FrozenFilter frozen = frozenFilter(prepared.filterJson()); + Filter filter = filter(code, prepared.viewScopeFingerprint(), actor, frozen.companyId(), frozen.projectId(), + frozen.counterpartyId(), frozen.keyword(), frozen.status(), frozen.dateFrom(), frozen.dateTo()); + if (!filter.hash().equalsIgnoreCase(prepared.filterHash())) throw snapshotChanged(); + List columns = frozenColumns(code, prepared.columnsJson()); + String sort = frozenSort(code, prepared.sortCode()); + long total = mapper.count(code, EXPORT_PERMISSION, actor.userId(), actor.roleCode(), scopeAsOf, + filter.companyId(), filter.projectId(), filter.counterpartyId(), filter.keyword(), filter.status(), + filter.dateFrom(), filter.dateTo()); + if (total != prepared.estimatedRows()) throw snapshotChanged(); + List> rows = mapper.list(code, EXPORT_PERMISSION, actor.userId(), actor.roleCode(), + scopeAsOf, filter.companyId(), filter.projectId(), filter.counterpartyId(), filter.keyword(), + filter.status(), filter.dateFrom(), filter.dateTo(), EXPORT_ROW_LIMIT, 0, sortSql(code, sort)).stream() + .map(this::normalizeMap).toList(); + if (rows.size() != prepared.estimatedRows()) throw snapshotChanged(); + String content = "\uFEFF" + csv(columns, rows); + String contentSha256 = sha256(content); + String exportLogId = ulidGenerator.next(); + mapper.insertExportLog(exportLogId, RequestContext.requestId(), actor.publicId(), actor.roleCode(), + "REPORT_" + code.toUpperCase(Locale.ROOT).replace('-', '_'), json(filter.values()), json(columns), + rows.size(), contentSha256); + if (mapper.completeExportRequest(prepared.id(), exportLogId) != 1) throw snapshotChanged(); + auditService.recordScoped(filter.companyId(), filter.projectId(), "REPORT_EXPORT", "REPORT", code, "SUCCESS", filter.hash(), + Map.of("rowCount", rows.size()), Map.of("exportId", prepared.publicId(), "sha256", contentSha256)); + return new ReportExportView(prepared.publicId(), code, DEFINITION_VERSION, filter.hash(), "COMPLETED", + prepared.estimatedRows(), + rows.size(), contentSha256, + code + "-report.csv", content); + } + + private ReportRow row(String code, Map raw) { + Map value = normalizeMap(raw); + return switch (code) { + case "project-ledger" -> new ProjectLedgerRow( + nullableText(value.get("rowId")), nullableText(value.get("businessNo")), + nullableText(value.get("projectName")), nullableText(value.get("companyName")), + nullableText(value.get("contractAmount")), nullableText(value.get("receiptAmount")), + nullableText(value.get("costAmount")), nullableText(value.get("paymentAmount")), + nullableText(value.get("financeStatus")), nullableText(value.get("archiveStatus")), + longValue(value.get("completedVoucherCount")), longValue(value.get("pendingAccountingCount")), + longValue(value.get("fileCount")), nullableText(value.get("completeness")), + nullableText(value.get("status")) + ); + case "contract-execution" -> new ContractExecutionRow( + nullableText(value.get("rowId")), nullableText(value.get("businessNo")), + nullableText(value.get("projectName")), nullableText(value.get("name")), + nullableText(value.get("currency")), nullableText(value.get("originalAmount")), + nullableText(value.get("approvedChangeAmount")), nullableText(value.get("settlementAmount")), + nullableText(value.get("invoicedAmount")), nullableText(value.get("pendingPayableAmount")), + nullableText(value.get("payableAmount")), nullableText(value.get("paidAmount")), + nullableText(value.get("availableAmount")), nullableText(value.get("status")) + ); + case "receipt-invoice" -> new ReceiptInvoiceRow( + nullableText(value.get("rowId")), nullableText(value.get("businessNo")), + nullableText(value.get("projectName")), nullableText(value.get("recordType")), + nullableText(value.get("businessDate")), nullableText(value.get("amount")), + nullableText(value.get("cumulativeReceiptAmount")), + nullableText(value.get("cumulativeInvoiceAmount")), + nullableText(value.get("outstandingAmount")), nullableText(value.get("allocatedAmount")), + nullableText(value.get("invoiceNo")), nullableText(value.get("invoiceType")), + nullableText(value.get("status")) + ); + case "payment-progress" -> new PaymentProgressRow( + nullableText(value.get("rowId")), nullableText(value.get("businessNo")), + nullableText(value.get("projectName")), nullableText(value.get("requestedDate")), + nullableText(value.get("requestedAmount")), nullableText(value.get("approvedAmount")), + nullableText(value.get("submittedAt")), nullableText(value.get("checkedAt")), + nullableText(value.get("approvedAt")), nullableText(value.get("offlineResult")), + nullableText(value.get("resultAmount")), nullableText(value.get("paidDate")), + nullableText(value.get("transactionNo")), longValue(value.get("receiptFileCount")), + nullableText(value.get("accountingStatus")), nullableText(value.get("status")) + ); + case "workflow-duration" -> new WorkflowDurationRow( + nullableText(value.get("rowId")), nullableText(value.get("businessNo")), + nullableText(value.get("projectName")), nullableText(value.get("formType")), + nullableText(value.get("nodeCode")), nullableText(value.get("arrivedAt")), + nullableText(value.get("completedAt")), longValue(value.get("durationSeconds")), + longValue(value.get("returnCount")), longValue(value.get("pendingTaskCount")), + nullableText(value.get("status")) + ); + case "archive-completeness" -> new ArchiveCompletenessRow( + nullableText(value.get("rowId")), nullableText(value.get("businessNo")), + nullableText(value.get("projectName")), integerValue(value.get("packageVersion")), + nullableText(value.get("completeness")), longValue(value.get("missingCount")), + longValue(value.get("pendingCount")), longValue(value.get("notApplicableCount")), + longValue(value.get("presentCount")), longValue(value.get("totalItemCount")), + bool(value.get("frozen")), nullableText(value.get("status")) + ); + default -> throw new IllegalStateException("Validated report code is not mapped: " + code); + }; + } + + private ReportSourceView source(String code, Map value) { + return switch (code) { + case "project-ledger" -> new ProjectLedgerSourceView( + nullableText(value.get("rowId")), nullableText(value.get("businessNo")), + nullableText(value.get("projectName")), nullableText(value.get("companyPublicId")), + nullableText(value.get("companyName")), nullableText(value.get("counterpartyPublicId")), + nullableText(value.get("projectPublicId")), nullableText(value.get("stage")), + nullableText(value.get("financeStatus")), nullableText(value.get("archiveStatus")), + nullableText(value.get("currency")), nullableText(value.get("status")), + nullableText(value.get("contractAmount")), nullableText(value.get("receiptAmount")), + nullableText(value.get("costAmount")), nullableText(value.get("paymentAmount")), + longValue(value.get("completedVoucherCount")), longValue(value.get("pendingAccountingCount")), + longValue(value.get("fileCount")), nullableText(value.get("completeness")), + nullableText(value.get("latestPackageStatus")), nullableText(value.get("businessDate")), + nullableText(value.get("updatedAt")), nullableText(value.get("metricAmount")) + ); + case "contract-execution" -> new ContractExecutionSourceView( + nullableText(value.get("rowId")), nullableText(value.get("businessNo")), + nullableText(value.get("projectName")), nullableText(value.get("companyPublicId")), + nullableText(value.get("companyName")), nullableText(value.get("counterpartyPublicId")), + nullableText(value.get("projectPublicId")), nullableText(value.get("name")), + nullableText(value.get("originalAmount")), nullableText(value.get("approvedChangeAmount")), + nullableText(value.get("settlementAmount")), nullableText(value.get("currency")), + nullableText(value.get("status")), nullableText(value.get("invoicedAmount")), + nullableText(value.get("pendingPayableAmount")), nullableText(value.get("payableAmount")), + nullableText(value.get("paidAmount")), nullableText(value.get("availableAmount")), + nullableText(value.get("businessDate")), nullableText(value.get("updatedAt")), + nullableText(value.get("metricAmount")) + ); + case "receipt-invoice" -> new ReceiptInvoiceSourceView( + nullableText(value.get("rowId")), nullableText(value.get("businessNo")), + nullableText(value.get("projectName")), nullableText(value.get("companyPublicId")), + nullableText(value.get("companyName")), nullableText(value.get("counterpartyPublicId")), + nullableText(value.get("projectPublicId")), nullableText(value.get("recordType")), + nullableText(value.get("businessDate")), nullableText(value.get("amount")), + nullableText(value.get("currency")), nullableText(value.get("allocatedAmount")), + nullableText(value.get("invoiceNo")), nullableText(value.get("invoiceType")), + nullableText(value.get("cumulativeReceiptAmount")), + nullableText(value.get("cumulativeInvoiceAmount")), + nullableText(value.get("outstandingAmount")), nullableText(value.get("sourceDocumentId")), + nullableText(value.get("status")), nullableText(value.get("updatedAt")), + nullableText(value.get("metricAmount")) + ); + case "payment-progress" -> new PaymentProgressSourceView( + nullableText(value.get("rowId")), nullableText(value.get("businessNo")), + nullableText(value.get("projectName")), nullableText(value.get("companyPublicId")), + nullableText(value.get("companyName")), nullableText(value.get("counterpartyPublicId")), + nullableText(value.get("projectPublicId")), nullableText(value.get("requestedDate")), + nullableText(value.get("requestedAmount")), nullableText(value.get("approvedAmount")), + nullableText(value.get("submittedAt")), nullableText(value.get("checkedAt")), + nullableText(value.get("approvedAt")), nullableText(value.get("status")), + nullableText(value.get("businessDate")), nullableText(value.get("currency")), + nullableText(value.get("offlineResult")), nullableText(value.get("resultAmount")), + nullableText(value.get("resultId")), nullableText(value.get("paidDate")), + nullableText(value.get("transactionNo")), longValue(value.get("receiptFileCount")), + nullableText(value.get("accountingStatus")), nullableText(value.get("sourceDocumentId")), + nullableText(value.get("updatedAt")), nullableText(value.get("metricAmount")) + ); + case "workflow-duration" -> new WorkflowDurationSourceView( + nullableText(value.get("rowId")), nullableText(value.get("businessNo")), + nullableText(value.get("projectName")), nullableText(value.get("companyPublicId")), + nullableText(value.get("companyName")), nullableText(value.get("counterpartyPublicId")), + nullableText(value.get("projectPublicId")), nullableText(value.get("currency")), + nullableText(value.get("formType")), nullableText(value.get("sourceDocumentId")), + nullableText(value.get("sourceVersionId")), nullableText(value.get("workflowInstanceId")), + nullableText(value.get("nodeCode")), nullableText(value.get("status")), + nullableText(value.get("arrivedAt")), nullableText(value.get("completedAt")), + longValue(value.get("durationSeconds")), longValue(value.get("returnCount")), + longValue(value.get("pendingTaskCount")), nullableText(value.get("businessDate")), + nullableText(value.get("updatedAt")), nullableText(value.get("metricAmount")) + ); + case "archive-completeness" -> new ArchiveCompletenessSourceView( + nullableText(value.get("rowId")), nullableText(value.get("businessNo")), + nullableText(value.get("projectName")), nullableText(value.get("companyPublicId")), + nullableText(value.get("companyName")), nullableText(value.get("counterpartyPublicId")), + nullableText(value.get("projectPublicId")), nullableText(value.get("currency")), + nullableText(value.get("packageId")), integerValue(value.get("packageVersion")), + nullableText(value.get("completeness")), longValue(value.get("missingCount")), + longValue(value.get("pendingCount")), longValue(value.get("notApplicableCount")), + longValue(value.get("presentCount")), longValue(value.get("totalItemCount")), + bool(value.get("frozen")), nullableText(value.get("status")), + nullableText(value.get("businessDate")), nullableText(value.get("updatedAt")), + nullableText(value.get("metricAmount")) + ); + default -> throw new IllegalStateException("Validated report code is not mapped: " + code); + }; + } + + private Map normalizeMap(Map raw) { + Map result = new LinkedHashMap<>(); + raw.forEach((key, value) -> result.put(camel(key), scalarValue(value))); + return result; + } + + private List metricGroups(String code, Map source) { + List groups = new ArrayList<>(); + switch (code) { + case "project-ledger" -> { + groups.add(group("CONTRACT", "合同执行", source.get("contractAmount"))); + groups.add(group("RECEIPT", "收款", source.get("receiptAmount"))); + groups.add(group("COST", "成本", source.get("costAmount"))); + groups.add(group("PAYMENT", "实际付款", source.get("paymentAmount"))); + groups.add(group("ACCOUNTING", "已完成凭证", source.get("completedVoucherCount"))); + groups.add(group("ARCHIVE", "资料完整率", source.get("completeness"))); + } + case "contract-execution" -> { + groups.add(group("INVOICE", "净开票", source.get("invoicedAmount"))); + groups.add(group("PENDING_PAYABLE", "待确认应付", source.get("pendingPayableAmount"))); + groups.add(group("PAYABLE", "应付", source.get("payableAmount"))); + groups.add(group("PAID", "已付", source.get("paidAmount"))); + groups.add(group("AVAILABLE", "可付余额", source.get("availableAmount"))); + } + case "receipt-invoice" -> { + groups.add(group("CURRENT", "本次金额", source.get("amount"))); + groups.add(group("CUMULATIVE_RECEIPT", "累计收款", source.get("cumulativeReceiptAmount"))); + groups.add(group("CUMULATIVE_INVOICE", "累计净开票", source.get("cumulativeInvoiceAmount"))); + groups.add(group("OUTSTANDING", "未收金额", source.get("outstandingAmount"))); + } + case "payment-progress" -> { + groups.add(group("REQUESTED", "申请金额", source.get("requestedAmount"))); + groups.add(group("APPROVED", "批准金额", source.get("approvedAmount"))); + groups.add(group("PAYMENT_RESULT", "已复核线下结果", source.get("resultAmount"))); + groups.add(group("RECEIPT", "回单文件数", source.get("receiptFileCount"))); + } + case "workflow-duration" -> { + groups.add(group("DURATION", "节点耗时(秒)", source.get("durationSeconds"))); + groups.add(group("RETURN", "退回次数", source.get("returnCount"))); + groups.add(group("PENDING", "未处理任务", source.get("pendingTaskCount"))); + } + case "archive-completeness" -> { + groups.add(group("MISSING", "缺失", source.get("missingCount"))); + groups.add(group("PENDING", "待补", source.get("pendingCount"))); + groups.add(group("NOT_APPLICABLE", "不涉及", source.get("notApplicableCount"))); + groups.add(group("PRESENT", "已齐全", source.get("presentCount"))); + } + default -> { + } + } + return groups; + } + + private ReportRelatedRecordView relatedRecord(Map raw) { + Map value = normalizeMap(raw); + return new ReportRelatedRecordView(text(value.get("relationType")), text(value.get("objectType")), + text(value.get("publicId")), text(value.get("businessNo")), text(value.get("status")), + scalar(value.get("amount")), text(value.get("businessDate")), text(value.get("sourceDocumentId"))); + } + + private ReportSourceFormView sourceForm(Map raw) { + Map value = normalizeMap(raw); + return new ReportSourceFormView(text(value.get("publicId")), text(value.get("businessNo")), + text(value.get("formType")), text(value.get("sourceSystem")), text(value.get("sourceNo")), + text(value.get("status")), text(value.get("sourceVersionId")), integer(value.get("versionNo")), + text(value.get("relationType")), text(value.get("ownerType")), text(value.get("ownerPublicId")), + bool(value.get("exactVersion"))); + } + + private ReportApprovalEventView approval(Map raw) { + Map value = normalizeMap(raw); + return new ReportApprovalEventView(text(value.get("publicId")), text(value.get("sourceDocumentId")), + text(value.get("sourceVersionId")), text(value.get("nodeCode")), text(value.get("action")), + text(value.get("actorName")), text(value.get("opinion")), text(value.get("actedAt"))); + } + + private ReportAttachmentView attachment(Map raw) { + Map value = normalizeMap(raw); + return new ReportAttachmentView(text(value.get("fileId")), text(value.get("ownerType")), + text(value.get("ownerId")), text(value.get("sourceDocumentId")), text(value.get("documentType")), + text(value.get("displayName")), text(value.get("scanStatus")), integer(value.get("versionNo"))); + } + + private static String camel(String key) { + StringBuilder result = new StringBuilder(key.length()); + boolean upper = false; + for (char character : key.toCharArray()) { + if (character == '_') { + upper = true; + } else if (upper) { + result.append(Character.toUpperCase(character)); + upper = false; + } else { + result.append(character); + } + } + return result.toString(); + } + + private static Object scalarValue(Object value) { + return value instanceof BigDecimal decimal ? money(decimal) : value; + } + + private static ReportMetricGroupView group(String code, String label, Object value) { + return new ReportMetricGroupView(code, label, scalar(value)); + } + + private static List columns(String code, String[] requested) { + List defaults = DEFAULT_COLUMNS.get(code); + if (requested == null || requested.length == 0) return defaults; + Set allowed = Set.copyOf(defaults); + List result = Arrays.stream(requested).map(String::trim).filter(allowed::contains).distinct().toList(); + if (result.isEmpty()) throw validation("导出列不在当前报表白名单中"); + return result; + } + + private static String normalizeSort(String code, String requested) { + if (requested == null || requested.isBlank()) return DEFAULT_SORT; + Map allowed = SORT_COLUMNS.get(code); + List normalized = new ArrayList<>(); + Set fields = new HashSet<>(); + String[] terms = requested.trim().split(";", -1); + if (terms.length > 3) throw validation("排序字段不能超过 3 个"); + for (String term : terms) { + String[] parts = term.trim().split(",", -1); + if (parts.length != 2) throw validation("排序格式无效"); + String field = parts[0].trim(); + String direction = parts[1].trim().toLowerCase(Locale.ROOT); + if (!allowed.containsKey(field) && !"updatedAt".equals(field) && !"rowId".equals(field)) { + throw validation("当前报表不支持该排序字段"); + } + if (!"asc".equals(direction) && !"desc".equals(direction)) throw validation("排序方向无效"); + if (!fields.add(field)) throw validation("排序字段不能重复"); + if ("rowId".equals(field) && !"asc".equals(direction)) throw validation("rowId 只能升序排序"); + normalized.add(field + "," + direction); + } + if (!fields.contains("rowId")) normalized.add("rowId,asc"); + return String.join(";", normalized); + } + + private static String frozenSort(String code, String value) { + try { + return normalizeSort(code, value); + } catch (BusinessException exception) { + throw snapshotChanged(); + } + } + + private static String sortSql(String code, String sort) { + Map allowed = SORT_COLUMNS.get(code); + return Arrays.stream(sort.split(";")) + .map(term -> { + String[] parts = term.split(",", -1); + String field = parts[0]; + String column = "updatedAt".equals(field) ? "updated_at" + : "rowId".equals(field) ? "row_id" : allowed.get(field); + return "report." + column + " " + parts[1].toUpperCase(Locale.ROOT); + }) + .reduce((left, right) -> left + ", " + right) + .orElseThrow(() -> new IllegalStateException("Sort is required")); + } + + private static Map sortColumns(String... columns) { + Map result = new LinkedHashMap<>(); + for (String column : columns) result.put(column, snake(column)); + return Map.copyOf(result); + } + + private static String snake(String value) { + return value.replaceAll("([a-z])([A-Z])", "$1_$2").toLowerCase(Locale.ROOT); + } + + private FrozenFilter frozenFilter(String value) { + try { + Map raw = objectMapper.readValue(value, new TypeReference>() { }); + return new FrozenFilter(text(raw.get("companyId")), text(raw.get("projectId")), + text(raw.get("counterpartyId")), nullableText(raw.get("keyword")), nullableText(raw.get("status")), + localDate(raw.get("dateFrom")), localDate(raw.get("dateTo"))); + } catch (JsonProcessingException | IllegalArgumentException exception) { + throw snapshotChanged(); + } + } + + private List frozenColumns(String code, String value) { + try { + List requested = objectMapper.readValue(value, new TypeReference>() { }); + return columns(code, requested.toArray(String[]::new)); + } catch (JsonProcessingException exception) { + throw snapshotChanged(); + } + } + + private static String nullableText(Object value) { + if (value instanceof java.sql.Timestamp timestamp) { + return SQL_TIMESTAMP_FORMATTER.format(timestamp.toInstant()); + } + return value == null ? null : String.valueOf(value); + } + + private static LocalDate localDate(Object value) { + return value == null ? null : LocalDate.parse(String.valueOf(value)); + } + + private static String csv(List columns, List> rows) { + StringBuilder output = new StringBuilder(); + output.append(String.join(",", columns)).append('\n'); + for (Map row : rows) { + for (int index = 0; index < columns.size(); index++) { + if (index > 0) output.append(','); + Object value = row.get(columns.get(index)); + String cell = csvSafe(value == null ? "" : String.valueOf(value)); + output.append('"').append(cell.replace("\"", "\"\"")).append('"'); + } + output.append('\n'); + } + return output.toString(); + } + + private Filter filter(String reportCode, String scopeFingerprint, Actor actor, String companyId, String projectId, + String counterpartyId, + String keyword, String status, LocalDate dateFrom, LocalDate dateTo) { + String company = optionalId(companyId); + String project = optionalId(projectId); + String counterparty = optionalId(counterpartyId); + String text = clean(keyword, 100); + String safeStatus = clean(status, 32); + if (safeStatus != null) { + safeStatus = safeStatus.toUpperCase(Locale.ROOT); + if (!STATUSES.get(reportCode).contains(safeStatus)) throw validation("状态不适用于当前报表"); + } + LocalDate from = dateFrom; + LocalDate to = dateTo; + if (from == null && to == null) { + LocalDate today = LocalDate.now(ZoneOffset.UTC); + from = today.withDayOfMonth(1); + to = today.withDayOfMonth(today.lengthOfMonth()); + } + if (from != null && to != null && to.isBefore(from)) throw validation("日期范围无效"); + Map values = new LinkedHashMap<>(); + values.put("reportCode", reportCode); + values.put("definitionVersion", DEFINITION_VERSION); + values.put("actorPublicId", actor.publicId()); + values.put("roleCode", actor.roleCode()); + values.put("scopeFingerprint", scopeFingerprint); + values.put("companyId", company); + values.put("projectId", project); + values.put("counterpartyId", counterparty); + values.put("keyword", text); + values.put("status", safeStatus); + values.put("dateFrom", from); + values.put("dateTo", to); + return new Filter(company, project, counterparty, text, safeStatus, from, to, sha256(json(values)), values); + } + + private static String csvSafe(String value) { + String trimmed = value.stripLeading(); + return !trimmed.isEmpty() && "=+-@".indexOf(trimmed.charAt(0)) >= 0 ? "'" + value : value; + } + + private static BusinessException snapshotChanged() { + return new BusinessException(HttpStatus.CONFLICT, ErrorCode.VERSION_CONFLICT, + "报表口径、筛选或权限范围已变化,请刷新后重新导出"); + } + + private Actor actor() { + FinancePrincipal principal = identityContext.requirePrincipal(); + return new Actor(principal.userId(), principal.publicId(), identityContext.requireActiveRole()); + } + + private static String normalizeReportCode(String reportCode) { + String code = reportCode == null ? "" : reportCode.trim().toLowerCase(Locale.ROOT); + if (!REPORT_CODES.contains(code)) throw validation("不支持的报表编码"); + return code; + } + + private static String requiredId(String value) { + String result = optionalId(value); + if (result == null) throw validation("公开编号格式无效"); + return result; + } + + private static String optionalId(String value) { + String normalized = clean(value, 26); + if (normalized == null) return null; + if (!normalized.matches("[0-9A-HJKMNP-TV-Z]{26}")) throw validation("公开编号格式无效"); + return normalized; + } + + private static String clean(String value, int max) { + if (value == null || value.isBlank()) return null; + String result = value.trim(); + if (result.length() > max) throw validation("查询条件不能超过 " + max + " 个字符"); + return result; + } + + private String json(Object value) { + try { + return objectMapper.writeValueAsString(value); + } catch (JsonProcessingException exception) { + throw new IllegalStateException("Report filter cannot be serialized", exception); + } + } + + private static String sha256(String value) { + try { + return java.util.HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(value.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 is not available", exception); + } + } + + private static String money(BigDecimal value) { + return value == null ? "0.00" : value.setScale(2, RoundingMode.HALF_UP).toPlainString(); + } + + private static String scalar(Object value) { + if (value == null) return ""; + if (value instanceof BigDecimal decimal) return money(decimal); + return String.valueOf(value); + } + + private static String text(Object value) { + return value == null ? "" : String.valueOf(value); + } + + private static long longValue(Object value) { + if (value == null) return 0L; + return value instanceof Number number ? number.longValue() : Long.parseLong(String.valueOf(value)); + } + + private static int integerValue(Object value) { + if (value == null) return 0; + return value instanceof Number number ? number.intValue() : Integer.parseInt(String.valueOf(value)); + } + + private static Integer integer(Object value) { + if (value == null) return null; + return value instanceof Number number ? number.intValue() : Integer.valueOf(String.valueOf(value)); + } + + private static boolean bool(Object value) { + if (value instanceof Boolean booleanValue) return booleanValue; + if (value instanceof Number number) return number.intValue() != 0; + return Boolean.parseBoolean(String.valueOf(value)); + } + + private static String metricLabel(String code) { + return switch (code) { + case "project-ledger" -> "合同额合计"; + case "contract-execution" -> "合同执行额合计"; + case "receipt-invoice" -> "收款及净开票合计"; + case "payment-progress" -> "申请金额合计"; + case "workflow-duration" -> "节点耗时(秒)合计"; + case "archive-completeness" -> "平均完整率"; + default -> "指标合计"; + }; + } + + private static void requireUnambiguousCurrencySummary(String code, SummaryRow summary) { + if (Set.of("project-ledger", "contract-execution", "receipt-invoice", "payment-progress").contains(code) + && summary != null && (summary.currencyCount() > 1 + || (summary.currencies() != null && summary.currencies().contains("MIXED")))) { + throw validation("当前筛选包含多种币种,系统不会生成跨币种金额合计,请按币种缩小筛选范围"); + } + } + + private static int totalPages(long total, int size) { + return total == 0 ? 0 : (int) ((total + size - 1) / size); + } + + private static Page page(int page, int size) { + if (page < 1 || !Set.of(20, 50, 100).contains(size)) throw validation("分页参数无效"); + return new Page(page, size); + } + + private static BusinessException validation(String message) { + return new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.VALIDATION_FAILED, message); + } + + private static BusinessException notFound(String message) { + return new BusinessException(HttpStatus.NOT_FOUND, ErrorCode.RESOURCE_NOT_FOUND, message); + } + + private record Actor(long userId, String publicId, String roleCode) { + } + + private record Page(int page, int size) { + int offset() { + long value = (long) (page - 1) * size; + return value > Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) value; + } + } + + private record Filter(String companyId, String projectId, String counterpartyId, String keyword, String status, + LocalDate dateFrom, LocalDate dateTo, String hash, Map values) { + } + + private record FrozenFilter(String companyId, String projectId, String counterpartyId, String keyword, + String status, LocalDate dateFrom, LocalDate dateTo) { + } +} diff --git a/backend/src/main/java/com/kaidi/finance/reporting/application/ReportExportRequestStateService.java b/backend/src/main/java/com/kaidi/finance/reporting/application/ReportExportRequestStateService.java new file mode 100644 index 0000000..3961165 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/reporting/application/ReportExportRequestStateService.java @@ -0,0 +1,21 @@ +package com.kaidi.finance.reporting.application; + +import com.kaidi.finance.reporting.infrastructure.ReportMapper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class ReportExportRequestStateService { + + private final ReportMapper mapper; + + public ReportExportRequestStateService(ReportMapper mapper) { + this.mapper = mapper; + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public boolean expireIfNeeded(String publicId, String actorPublicId, String identityCode, String reportCode) { + return mapper.expireExportRequest(publicId, actorPublicId, identityCode, reportCode) == 1; + } +} diff --git a/backend/src/main/java/com/kaidi/finance/reporting/infrastructure/ReportMapper.java b/backend/src/main/java/com/kaidi/finance/reporting/infrastructure/ReportMapper.java new file mode 100644 index 0000000..8e9f0f9 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/reporting/infrastructure/ReportMapper.java @@ -0,0 +1,951 @@ +package com.kaidi.finance.reporting.infrastructure; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +@Mapper +public interface ReportMapper { + + String REPORT_SELECT = """ + + + SELECT project.public_id AS row_id, project.business_no, project.name AS project_name, + company.public_id AS company_public_id, company.name AS company_name, + customer.public_id AS counterparty_public_id, project.public_id AS project_public_id, + project.stage, project.finance_status, project.archive_status, + CASE WHEN EXISTS (SELECT 1 FROM md_contract contract + WHERE contract.project_id = project.id AND contract.status <> 'VOID' + AND contract.currency <> project.currency) + OR EXISTS (SELECT 1 FROM fin_receipt receipt + WHERE receipt.project_id = project.id + AND receipt.status IN ('CONFIRMED','PARTIALLY_ALLOCATED','ALLOCATED') + AND receipt.currency <> project.currency) + OR EXISTS (SELECT 1 FROM fin_payment_request payment + WHERE payment.project_id = project.id AND payment.status <> 'VOID' + AND payment.currency <> project.currency) + THEN 'MIXED' ELSE project.currency END AS currency, project.status, + COALESCE((SELECT SUM(contract.original_amount + contract.approved_change_amount) + FROM md_contract contract + WHERE contract.project_id = project.id AND contract.status <> 'VOID'), 0) AS contract_amount, + COALESCE((SELECT SUM(receipt.amount) FROM fin_receipt receipt + WHERE receipt.project_id = project.id + AND receipt.status IN ('CONFIRMED','PARTIALLY_ALLOCATED','ALLOCATED')), 0) AS receipt_amount, + COALESCE((SELECT SUM(payable.amount) FROM fin_payable payable + WHERE payable.project_id = project.id + AND payable.status IN ('CONFIRMED','PART_PAID','PAID')), 0) AS cost_amount, + COALESCE((SELECT SUM(CASE WHEN result.result_type = 'REFUND' THEN -result.amount + WHEN result.result_type = 'SUCCESS' THEN result.amount ELSE 0 END) + FROM fin_payment_result result + JOIN fin_payment_request payment ON payment.id = result.payment_id + WHERE payment.project_id = project.id AND result.status = 'VERIFIED'), 0) AS payment_amount, + COALESCE((SELECT COUNT(*) FROM acc_voucher voucher + WHERE voucher.project_id = project.id AND voucher.status = 'COMPLETED'), 0) AS completed_voucher_count, + COALESCE((SELECT COUNT(*) FROM acc_event event + WHERE event.project_id = project.id AND event.status IN ('PENDING','DRAFTED')), 0) AS pending_accounting_count, + COALESCE((SELECT COUNT(*) FROM file_link link + WHERE link.project_public_id = project.public_id + AND link.archive_status IN ('ACTIVE','ARCHIVED','FROZEN')), 0) AS file_count, + COALESCE((SELECT package.completeness FROM arc_package package + WHERE package.project_id = project.id + ORDER BY package.package_version DESC LIMIT 1), 0) AS completeness, + COALESCE((SELECT package.status FROM arc_package package + WHERE package.project_id = project.id + ORDER BY package.package_version DESC LIMIT 1), 'NOT_CREATED') AS latest_package_status, + DATE(GREATEST(project.updated_at, + COALESCE((SELECT MAX(contract.updated_at) FROM md_contract contract + WHERE contract.project_id = project.id), project.updated_at), + COALESCE((SELECT MAX(receipt.updated_at) FROM fin_receipt receipt + WHERE receipt.project_id = project.id), project.updated_at), + COALESCE((SELECT MAX(payment.updated_at) FROM fin_payment_request payment + WHERE payment.project_id = project.id), project.updated_at), + COALESCE((SELECT MAX(package.updated_at) FROM arc_package package + WHERE package.project_id = project.id), project.updated_at))) AS business_date, + project.updated_at AS updated_at, + COALESCE((SELECT SUM(contract.original_amount + contract.approved_change_amount) + FROM md_contract contract + WHERE contract.project_id = project.id AND contract.status <> 'VOID'), 0) AS metric_amount + FROM md_project project + JOIN md_company company ON company.id = project.company_id + LEFT JOIN md_counterparty customer ON customer.id = project.customer_id + WHERE EXISTS ( + SELECT 1 FROM iam_scope scope + JOIN iam_role role ON role.id = scope.role_id + JOIN iam_permission permission ON permission.id = scope.permission_id + WHERE scope.user_id = #{userId} AND role.code = #{roleCode} + AND permission.code = #{permissionCode} AND scope.status = 'ACTIVE' + AND scope.valid_from <= #{scopeAsOf} + AND (scope.valid_to IS NULL OR scope.valid_to >= #{scopeAsOf}) + AND (scope.scope_type = 'GLOBAL' + OR (scope.scope_type = 'COMPANY' AND scope.company_public_id = company.public_id) + OR (scope.scope_type = 'PROJECT' AND scope.project_public_id = project.public_id + AND (scope.company_public_id IS NULL + OR scope.company_public_id = company.public_id))) + ) + + + SELECT contract.public_id AS row_id, contract.business_no, project.name AS project_name, + company.public_id AS company_public_id, company.name AS company_name, + counterparty.public_id AS counterparty_public_id, project.public_id AS project_public_id, + contract.name, contract.original_amount, contract.approved_change_amount, + contract.settlement_amount, contract.currency, contract.status, + COALESCE((SELECT SUM(CASE WHEN invoice.invoice_type = 'RED_LETTER' + THEN -invoice.amount ELSE invoice.amount END) + FROM fin_invoice invoice + WHERE invoice.contract_id = contract.id + AND invoice.status IN ('ISSUED','RED_LETTERED')), 0) AS invoiced_amount, + COALESCE((SELECT SUM(payable.amount) FROM fin_payable payable + WHERE payable.contract_id = contract.id + AND payable.status = 'PENDING'), 0) AS pending_payable_amount, + COALESCE((SELECT SUM(payable.amount) FROM fin_payable payable + WHERE payable.contract_id = contract.id + AND payable.status IN ('CONFIRMED','PART_PAID','PAID')), 0) AS payable_amount, + COALESCE((SELECT SUM(payable.paid_amount) FROM fin_payable payable + WHERE payable.contract_id = contract.id AND payable.status <> 'VOID'), 0) AS paid_amount, + GREATEST(COALESCE(contract.settlement_amount, + contract.original_amount + contract.approved_change_amount) + - COALESCE((SELECT SUM(payable.paid_amount) FROM fin_payable payable + WHERE payable.contract_id = contract.id AND payable.status <> 'VOID'), 0), 0) + AS available_amount, + DATE(GREATEST(contract.updated_at, + COALESCE((SELECT MAX(invoice.updated_at) FROM fin_invoice invoice + WHERE invoice.contract_id = contract.id), contract.updated_at), + COALESCE((SELECT MAX(payable.updated_at) FROM fin_payable payable + WHERE payable.contract_id = contract.id), contract.updated_at), + COALESCE((SELECT MAX(payment.updated_at) FROM fin_payment_request payment + WHERE payment.contract_id = contract.id), contract.updated_at))) AS business_date, + contract.updated_at AS updated_at, + COALESCE(contract.settlement_amount, + contract.original_amount + contract.approved_change_amount) AS metric_amount + FROM md_contract contract + JOIN md_company company ON company.id = contract.company_id + JOIN md_project project ON project.id = contract.project_id + JOIN md_counterparty counterparty ON counterparty.id = contract.counterparty_id + WHERE EXISTS ( + SELECT 1 FROM iam_scope scope + JOIN iam_role role ON role.id = scope.role_id + JOIN iam_permission permission ON permission.id = scope.permission_id + WHERE scope.user_id = #{userId} AND role.code = #{roleCode} + AND permission.code = #{permissionCode} AND scope.status = 'ACTIVE' + AND scope.valid_from <= #{scopeAsOf} + AND (scope.valid_to IS NULL OR scope.valid_to >= #{scopeAsOf}) + AND (scope.scope_type = 'GLOBAL' + OR (scope.scope_type = 'COMPANY' AND scope.company_public_id = company.public_id) + OR (scope.scope_type = 'PROJECT' AND scope.project_public_id = project.public_id + AND (scope.company_public_id IS NULL + OR scope.company_public_id = company.public_id))) + ) + + + SELECT receipt.public_id AS row_id, receipt.business_no, project.name AS project_name, + company.public_id AS company_public_id, company.name AS company_name, + customer.public_id AS counterparty_public_id, project.public_id AS project_public_id, + 'RECEIPT' AS record_type, receipt.receipt_date AS business_date, receipt.amount, receipt.currency, + receipt.allocated_amount, NULL AS invoice_no, NULL AS invoice_type, + COALESCE((SELECT SUM(prior.amount) FROM fin_receipt prior + WHERE prior.project_id = receipt.project_id + AND prior.receipt_date <= receipt.receipt_date + AND prior.status IN ('CONFIRMED','PARTIALLY_ALLOCATED','ALLOCATED')), 0) + AS cumulative_receipt_amount, + COALESCE((SELECT SUM(CASE WHEN prior.invoice_type = 'RED_LETTER' + THEN -prior.amount ELSE prior.amount END) + FROM fin_invoice prior + WHERE prior.project_id = receipt.project_id + AND COALESCE(prior.issued_date, prior.requested_date) <= receipt.receipt_date + AND prior.status IN ('ISSUED','RED_LETTERED')), 0) AS cumulative_invoice_amount, + COALESCE((SELECT SUM(receivable.outstanding_amount) FROM fin_receivable receivable + WHERE receivable.project_id = receipt.project_id + AND receivable.status <> 'VOID'), 0) AS outstanding_amount, + (SELECT source.public_id FROM src_source_document source + WHERE source.id = receipt.source_document_id) AS source_document_id, + receipt.status, receipt.updated_at AS updated_at, receipt.amount AS metric_amount + FROM fin_receipt receipt + JOIN md_company company ON company.id = receipt.company_id + LEFT JOIN md_project project ON project.id = receipt.project_id + LEFT JOIN md_counterparty customer ON customer.id = receipt.customer_id + WHERE receipt.status IN ('CONFIRMED','PARTIALLY_ALLOCATED','ALLOCATED') + AND EXISTS ( + SELECT 1 FROM iam_scope scope + JOIN iam_role role ON role.id = scope.role_id + JOIN iam_permission permission ON permission.id = scope.permission_id + WHERE scope.user_id = #{userId} AND role.code = #{roleCode} + AND permission.code = #{permissionCode} AND scope.status = 'ACTIVE' + AND scope.valid_from <= #{scopeAsOf} + AND (scope.valid_to IS NULL OR scope.valid_to >= #{scopeAsOf}) + AND (scope.scope_type = 'GLOBAL' + OR (scope.scope_type = 'COMPANY' AND scope.company_public_id = company.public_id) + OR (scope.scope_type = 'PROJECT' AND scope.project_public_id = project.public_id + AND (scope.company_public_id IS NULL + OR scope.company_public_id = company.public_id))) + ) + UNION ALL + SELECT invoice.public_id AS row_id, invoice.business_no, project.name AS project_name, + company.public_id AS company_public_id, company.name AS company_name, + customer.public_id AS counterparty_public_id, project.public_id AS project_public_id, + 'INVOICE' AS record_type, COALESCE(invoice.issued_date, invoice.requested_date) AS business_date, + CASE WHEN invoice.invoice_type = 'RED_LETTER' THEN -invoice.amount ELSE invoice.amount END AS amount, + COALESCE(contract.currency, project.currency) AS currency, + NULL AS allocated_amount, invoice.invoice_no, invoice.invoice_type, + COALESCE((SELECT SUM(prior.amount) FROM fin_receipt prior + WHERE prior.project_id = invoice.project_id + AND prior.receipt_date <= COALESCE(invoice.issued_date, invoice.requested_date) + AND prior.status IN ('CONFIRMED','PARTIALLY_ALLOCATED','ALLOCATED')), 0) + AS cumulative_receipt_amount, + COALESCE((SELECT SUM(CASE WHEN prior.invoice_type = 'RED_LETTER' + THEN -prior.amount ELSE prior.amount END) + FROM fin_invoice prior + WHERE prior.project_id = invoice.project_id + AND COALESCE(prior.issued_date, prior.requested_date) + <= COALESCE(invoice.issued_date, invoice.requested_date) + AND prior.status IN ('ISSUED','RED_LETTERED')), 0) AS cumulative_invoice_amount, + COALESCE((SELECT SUM(receivable.outstanding_amount) FROM fin_receivable receivable + WHERE receivable.project_id = invoice.project_id + AND receivable.status <> 'VOID'), 0) AS outstanding_amount, + (SELECT source.public_id FROM src_source_document source + WHERE source.id = invoice.source_document_id) AS source_document_id, + invoice.status, invoice.updated_at AS updated_at, + CASE WHEN invoice.invoice_type = 'RED_LETTER' THEN -invoice.amount ELSE invoice.amount END + AS metric_amount + FROM fin_invoice invoice + JOIN md_company company ON company.id = invoice.company_id + JOIN md_project project ON project.id = invoice.project_id + LEFT JOIN md_contract contract ON contract.id = invoice.contract_id + JOIN md_counterparty customer ON customer.id = invoice.counterparty_id + WHERE invoice.status IN ('ISSUED','RED_LETTERED') + AND EXISTS ( + SELECT 1 FROM iam_scope scope + JOIN iam_role role ON role.id = scope.role_id + JOIN iam_permission permission ON permission.id = scope.permission_id + WHERE scope.user_id = #{userId} AND role.code = #{roleCode} + AND permission.code = #{permissionCode} AND scope.status = 'ACTIVE' + AND scope.valid_from <= #{scopeAsOf} + AND (scope.valid_to IS NULL OR scope.valid_to >= #{scopeAsOf}) + AND (scope.scope_type = 'GLOBAL' + OR (scope.scope_type = 'COMPANY' AND scope.company_public_id = company.public_id) + OR (scope.scope_type = 'PROJECT' AND scope.project_public_id = project.public_id + AND (scope.company_public_id IS NULL + OR scope.company_public_id = company.public_id))) + ) + + + SELECT payment.public_id AS row_id, payment.business_no, project.name AS project_name, + company.public_id AS company_public_id, company.name AS company_name, + supplier.public_id AS counterparty_public_id, project.public_id AS project_public_id, + payment.requested_date, payment.requested_amount, payment.approved_amount, + payment.submitted_at, payment.checked_at, payment.approved_at, payment.status, + payment.requested_date AS business_date, payment.currency, + COALESCE(result.result_type, '') AS offline_result, + payment.paid_amount AS result_amount, + result.public_id AS result_id, result.paid_date, result.transaction_no, + COALESCE((SELECT COUNT(DISTINCT result_file.file_id) + FROM fin_payment_result_file result_file + JOIN fin_payment_result verified_result + ON verified_result.id = result_file.payment_result_id + WHERE verified_result.payment_id = payment.id + AND verified_result.status = 'VERIFIED'), 0) + + COALESCE((SELECT COUNT(DISTINCT direct_result.receipt_file_id) + FROM fin_payment_result direct_result + WHERE direct_result.payment_id = payment.id + AND direct_result.status = 'VERIFIED' + AND direct_result.receipt_file_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM fin_payment_result_file linked_receipt + JOIN fin_payment_result linked_result + ON linked_result.id = linked_receipt.payment_result_id + WHERE linked_result.payment_id = payment.id + AND linked_receipt.file_id = direct_result.receipt_file_id + )), 0) AS receipt_file_count, + COALESCE((SELECT voucher.status FROM acc_event event + LEFT JOIN acc_voucher voucher ON voucher.id = event.voucher_id + WHERE event.source_type IN ('PAYMENT', 'PAYMENT_REFUND') + AND EXISTS ( + SELECT 1 FROM fin_payment_result event_result + WHERE event_result.payment_id = payment.id + AND event_result.public_id = event.source_public_id + AND event_result.status = 'VERIFIED' + ) + ORDER BY event.id DESC LIMIT 1), 'NOT_CREATED') AS accounting_status, + (SELECT source.public_id FROM src_source_document source + WHERE source.id = payment.source_document_id) AS source_document_id, + payment.updated_at AS updated_at, payment.requested_amount AS metric_amount + FROM fin_payment_request payment + JOIN md_company company ON company.id = payment.company_id + JOIN md_project project ON project.id = payment.project_id + JOIN md_counterparty supplier ON supplier.id = payment.supplier_id + LEFT JOIN fin_payment_result result ON result.id = ( + SELECT candidate.id FROM fin_payment_result candidate + WHERE candidate.payment_id = payment.id AND candidate.status = 'VERIFIED' + ORDER BY candidate.verified_at DESC, candidate.id DESC LIMIT 1 + ) + WHERE payment.status <> 'VOID' + AND EXISTS ( + SELECT 1 FROM iam_scope scope + JOIN iam_role role ON role.id = scope.role_id + JOIN iam_permission permission ON permission.id = scope.permission_id + WHERE scope.user_id = #{userId} AND role.code = #{roleCode} + AND permission.code = #{permissionCode} AND scope.status = 'ACTIVE' + AND scope.valid_from <= #{scopeAsOf} + AND (scope.valid_to IS NULL OR scope.valid_to >= #{scopeAsOf}) + AND (scope.scope_type = 'GLOBAL' + OR (scope.scope_type = 'COMPANY' AND scope.company_public_id = company.public_id) + OR (scope.scope_type = 'PROJECT' AND scope.project_public_id = project.public_id + AND (scope.company_public_id IS NULL + OR scope.company_public_id = company.public_id))) + ) + + + SELECT task.public_id AS row_id, document.business_no, project.name AS project_name, + company.public_id AS company_public_id, company.name AS company_name, + NULL AS counterparty_public_id, project.public_id AS project_public_id, NULL AS currency, + document.form_type, document.public_id AS source_document_id, + document_version.public_id AS source_version_id, instance.public_id AS workflow_instance_id, + task.node_code, task.status, task.created_at AS arrived_at, + CASE WHEN task.status = 'PENDING' THEN NULL ELSE task.updated_at END AS completed_at, + TIMESTAMPDIFF(SECOND, task.created_at, + CASE WHEN task.status = 'PENDING' THEN UTC_TIMESTAMP(3) ELSE task.updated_at END) + AS duration_seconds, + (SELECT COUNT(*) FROM wf_action action + WHERE action.task_id = task.id AND action.action = 'RETURN') AS return_count, + CASE WHEN task.status = 'PENDING' THEN 1 ELSE 0 END AS pending_task_count, + DATE(task.created_at) AS business_date, task.updated_at AS updated_at, + TIMESTAMPDIFF(SECOND, task.created_at, + CASE WHEN task.status = 'PENDING' THEN UTC_TIMESTAMP(3) ELSE task.updated_at END) + AS metric_amount + FROM wf_task task + JOIN wf_instance instance ON instance.id = task.instance_id + JOIN src_document_version document_version ON document_version.id = instance.source_version_id + JOIN src_source_document document ON document.id = document_version.source_document_id + LEFT JOIN md_project project ON project.id = document.project_id + JOIN md_company company ON company.id = document.company_id + WHERE EXISTS ( + SELECT 1 FROM iam_scope scope + JOIN iam_role role ON role.id = scope.role_id + JOIN iam_permission permission ON permission.id = scope.permission_id + WHERE scope.user_id = #{userId} AND role.code = #{roleCode} + AND permission.code = #{permissionCode} AND scope.status = 'ACTIVE' + AND scope.valid_from <= #{scopeAsOf} + AND (scope.valid_to IS NULL OR scope.valid_to >= #{scopeAsOf}) + AND (scope.scope_type = 'GLOBAL' + OR (scope.scope_type = 'COMPANY' AND scope.company_public_id = company.public_id) + OR (scope.scope_type = 'PROJECT' AND scope.project_public_id = project.public_id + AND (scope.company_public_id IS NULL + OR scope.company_public_id = company.public_id))) + ) + + + SELECT COALESCE(package.public_id, project.public_id) AS row_id, + COALESCE(package.business_no, project.business_no) AS business_no, + project.name AS project_name, company.public_id AS company_public_id, + company.name AS company_name, NULL AS counterparty_public_id, + project.public_id AS project_public_id, NULL AS currency, package.public_id AS package_id, + COALESCE(package.package_version, 0) AS package_version, + COALESCE(package.completeness, 0) AS completeness, + COALESCE((SELECT COUNT(*) FROM arc_package_item item + WHERE item.package_id = package.id AND item.status = 'MISSING'), 0) AS missing_count, + CASE WHEN package.status IN ('PREPARING','CHECKED','REVIEWING','RETURNED') + THEN COALESCE((SELECT COUNT(*) FROM arc_package_item item + WHERE item.package_id = package.id AND item.status = 'MISSING'), 0) + ELSE 0 END AS pending_count, + COALESCE((SELECT COUNT(*) FROM arc_package_item item + WHERE item.package_id = package.id AND item.status = 'NOT_APPLICABLE'), 0) + AS not_applicable_count, + COALESCE((SELECT COUNT(*) FROM arc_package_item item + WHERE item.package_id = package.id AND item.status = 'PRESENT'), 0) AS present_count, + COALESCE((SELECT COUNT(*) FROM arc_package_item item + WHERE item.package_id = package.id), 0) AS total_item_count, + COALESCE(package.frozen, FALSE) AS frozen, + COALESCE(package.status, 'NOT_CREATED') AS status, + DATE(COALESCE(package.updated_at, project.updated_at)) AS business_date, + COALESCE(package.updated_at, project.updated_at) AS updated_at, + COALESCE(package.completeness, 0) AS metric_amount + FROM md_project project + JOIN md_company company ON company.id = project.company_id + LEFT JOIN arc_package package ON package.id = ( + SELECT latest.id FROM arc_package latest + WHERE latest.project_id = project.id + ORDER BY latest.package_version DESC LIMIT 1 + ) + WHERE EXISTS ( + SELECT 1 FROM iam_scope scope + JOIN iam_role role ON role.id = scope.role_id + JOIN iam_permission permission ON permission.id = scope.permission_id + WHERE scope.user_id = #{userId} AND role.code = #{roleCode} + AND permission.code = #{permissionCode} AND scope.status = 'ACTIVE' + AND scope.valid_from <= #{scopeAsOf} + AND (scope.valid_to IS NULL OR scope.valid_to >= #{scopeAsOf}) + AND (scope.scope_type = 'GLOBAL' + OR (scope.scope_type = 'COMPANY' AND scope.company_public_id = company.public_id) + OR (scope.scope_type = 'PROJECT' AND scope.project_public_id = project.public_id + AND (scope.company_public_id IS NULL + OR scope.company_public_id = company.public_id))) + ) + + + """; + + String OUTER_FILTER = """ + AND report.company_public_id = #{companyId} + AND report.project_public_id = #{projectId} + AND report.counterparty_public_id = #{counterpartyId} + AND report.status = #{status} + AND (report.business_no LIKE CONCAT('%', #{keyword}, '%') + OR COALESCE(report.project_name, '') LIKE CONCAT('%', #{keyword}, '%') + OR COALESCE(report.company_name, '') LIKE CONCAT('%', #{keyword}, '%')) + AND report.business_date >= #{dateFrom} + AND report.business_date <= #{dateTo} + """; + + String RELATED_DATE_FILTER = """ + AND related.business_date >= #{dateFrom} + AND related.business_date <= #{dateTo} + """; + + String AUTHORIZED_PARENT = "EXISTS (SELECT 1 FROM (" + REPORT_SELECT + + ") report WHERE report.row_id = #{rowId} AND 1=1 " + OUTER_FILTER + ")"; + + String SOURCE_LINK_SELECT = """ + + + SELECT document.id AS source_document_id, NULL AS source_version_id, + 'PROJECT_FORM' AS relation_type, 'PROJECT' AS owner_type, #{rowId} AS owner_public_id + FROM src_source_document document + JOIN md_project project ON project.id = document.project_id + WHERE project.public_id = #{rowId} + + + SELECT settlement.source_document_id, NULL AS source_version_id, + 'SETTLEMENT' AS relation_type, 'CONTRACT_SETTLEMENT' AS owner_type, + settlement.public_id AS owner_public_id + FROM fin_contract_settlement settlement + JOIN md_contract contract ON contract.id = settlement.contract_id + WHERE contract.public_id = #{rowId} AND settlement.source_document_id IS NOT NULL + UNION ALL + SELECT payable.source_document_id, NULL, 'PAYABLE', 'PAYABLE', payable.public_id + FROM fin_payable payable JOIN md_contract contract ON contract.id = payable.contract_id + WHERE contract.public_id = #{rowId} AND payable.source_document_id IS NOT NULL + UNION ALL + SELECT invoice.source_document_id, NULL, 'INVOICE', 'INVOICE', invoice.public_id + FROM fin_invoice invoice JOIN md_contract contract ON contract.id = invoice.contract_id + WHERE contract.public_id = #{rowId} AND invoice.source_document_id IS NOT NULL + UNION ALL + SELECT payment.source_document_id, NULL, 'PAYMENT', 'PAYMENT', payment.public_id + FROM fin_payment_request payment JOIN md_contract contract ON contract.id = payment.contract_id + WHERE contract.public_id = #{rowId} AND payment.source_document_id IS NOT NULL + + + SELECT receipt.source_document_id, NULL AS source_version_id, + 'RECEIPT' AS relation_type, 'RECEIPT' AS owner_type, receipt.public_id AS owner_public_id + FROM fin_receipt receipt + WHERE receipt.public_id = #{rowId} AND receipt.source_document_id IS NOT NULL + UNION ALL + SELECT invoice.source_document_id, NULL, 'INVOICE', 'INVOICE', invoice.public_id + FROM fin_invoice invoice + WHERE invoice.public_id = #{rowId} AND invoice.source_document_id IS NOT NULL + + + SELECT payment.source_document_id, NULL AS source_version_id, + 'PAYMENT' AS relation_type, 'PAYMENT' AS owner_type, payment.public_id AS owner_public_id + FROM fin_payment_request payment + WHERE payment.public_id = #{rowId} AND payment.source_document_id IS NOT NULL + UNION ALL + SELECT payable.source_document_id, NULL, 'PAYABLE', 'PAYABLE', payable.public_id + FROM fin_payment_request payment + JOIN fin_payable payable ON payable.id = payment.payable_id + WHERE payment.public_id = #{rowId} AND payable.source_document_id IS NOT NULL + + + SELECT version.source_document_id, version.id AS source_version_id, + 'WORKFLOW_TASK' AS relation_type, 'WORKFLOW_TASK' AS owner_type, task.public_id AS owner_public_id + FROM wf_task task + JOIN wf_instance instance ON instance.id = task.instance_id + JOIN src_document_version version ON version.id = instance.source_version_id + WHERE task.public_id = #{rowId} + + + SELECT document.id AS source_document_id, NULL AS source_version_id, + 'PROJECT_SCOPE' AS relation_type, 'ARCHIVE_PACKAGE' AS owner_type, #{rowId} AS owner_public_id + FROM src_source_document document + JOIN md_project project ON project.id = document.project_id + WHERE project.public_id = #{rowId} + OR EXISTS (SELECT 1 FROM arc_package package + WHERE package.public_id = #{rowId} AND package.project_id = project.id) + + + """; + + String RELATED_RECORD_SELECT = """ + + + SELECT 'CONTRACT' AS relation_type, 'CONTRACT' AS object_type, contract.public_id, + contract.business_no, contract.status, + contract.original_amount + contract.approved_change_amount AS amount, + DATE(contract.updated_at) AS business_date, NULL AS source_document_id + FROM md_contract contract JOIN md_project project ON project.id = contract.project_id + WHERE project.public_id = #{rowId} AND contract.status <> 'VOID' + UNION ALL + SELECT 'RECEIPT', 'RECEIPT', receipt.public_id, receipt.business_no, receipt.status, + receipt.amount, receipt.receipt_date, source.public_id + FROM fin_receipt receipt JOIN md_project project ON project.id = receipt.project_id + LEFT JOIN src_source_document source ON source.id = receipt.source_document_id + WHERE project.public_id = #{rowId} + AND receipt.status IN ('CONFIRMED','PARTIALLY_ALLOCATED','ALLOCATED') + UNION ALL + SELECT 'COST', 'PAYABLE', payable.public_id, payable.business_no, payable.status, + payable.amount, payable.business_date, source.public_id + FROM fin_payable payable JOIN md_project project ON project.id = payable.project_id + LEFT JOIN src_source_document source ON source.id = payable.source_document_id + WHERE project.public_id = #{rowId} AND payable.status <> 'VOID' + UNION ALL + SELECT 'PAYMENT', 'PAYMENT_RESULT', result.public_id, payment.business_no, result.result_type, + CASE WHEN result.result_type = 'REFUND' THEN -result.amount ELSE result.amount END, + result.paid_date, source.public_id + FROM fin_payment_result result + JOIN fin_payment_request payment ON payment.id = result.payment_id + JOIN md_project project ON project.id = payment.project_id + LEFT JOIN src_source_document source ON source.id = payment.source_document_id + WHERE project.public_id = #{rowId} AND result.status = 'VERIFIED' + UNION ALL + SELECT 'ACCOUNTING', 'VOUCHER', voucher.public_id, voucher.business_no, voucher.status, + voucher.debit_total, voucher.business_date, NULL + FROM acc_voucher voucher JOIN md_project project ON project.id = voucher.project_id + WHERE project.public_id = #{rowId} AND voucher.status <> 'VOID' + UNION ALL + SELECT 'ARCHIVE', 'ARCHIVE_PACKAGE', package.public_id, package.business_no, package.status, + package.completeness, DATE(package.updated_at), NULL + FROM arc_package package JOIN md_project project ON project.id = package.project_id + WHERE project.public_id = #{rowId} + + + SELECT 'INVOICE' AS relation_type, 'INVOICE' AS object_type, invoice.public_id, + invoice.business_no, invoice.status, + CASE WHEN invoice.invoice_type = 'RED_LETTER' THEN -invoice.amount ELSE invoice.amount END AS amount, + COALESCE(invoice.issued_date, invoice.requested_date) AS business_date, + source.public_id AS source_document_id + FROM fin_invoice invoice JOIN md_contract contract ON contract.id = invoice.contract_id + LEFT JOIN src_source_document source ON source.id = invoice.source_document_id + WHERE contract.public_id = #{rowId} AND invoice.status IN ('ISSUED','RED_LETTERED') + UNION ALL + SELECT 'PAYABLE', 'PAYABLE', payable.public_id, payable.business_no, payable.status, + payable.amount, payable.business_date, source.public_id + FROM fin_payable payable JOIN md_contract contract ON contract.id = payable.contract_id + LEFT JOIN src_source_document source ON source.id = payable.source_document_id + WHERE contract.public_id = #{rowId} AND payable.status <> 'VOID' + UNION ALL + SELECT 'PAYMENT', 'PAYMENT_REQUEST', payment.public_id, payment.business_no, payment.status, + payment.requested_amount, payment.requested_date, source.public_id + FROM fin_payment_request payment JOIN md_contract contract ON contract.id = payment.contract_id + LEFT JOIN src_source_document source ON source.id = payment.source_document_id + WHERE contract.public_id = #{rowId} AND payment.status <> 'VOID' + + + SELECT 'SOURCE' AS relation_type, 'RECEIPT' AS object_type, receipt.public_id, + receipt.business_no, receipt.status, receipt.amount, receipt.receipt_date AS business_date, + source.public_id AS source_document_id + FROM fin_receipt receipt LEFT JOIN src_source_document source ON source.id = receipt.source_document_id + WHERE receipt.public_id = #{rowId} + UNION ALL + SELECT 'SOURCE', 'INVOICE', invoice.public_id, invoice.business_no, invoice.status, + CASE WHEN invoice.invoice_type = 'RED_LETTER' THEN -invoice.amount ELSE invoice.amount END, + COALESCE(invoice.issued_date, invoice.requested_date), source.public_id + FROM fin_invoice invoice LEFT JOIN src_source_document source ON source.id = invoice.source_document_id + WHERE invoice.public_id = #{rowId} + + + SELECT 'PAYMENT_RESULT' AS relation_type, 'PAYMENT_RESULT' AS object_type, result.public_id, + result.result_no AS business_no, result.status, + CASE WHEN result.result_type = 'REFUND' THEN -result.amount ELSE result.amount END AS amount, + result.paid_date AS business_date, source.public_id AS source_document_id + FROM fin_payment_request payment + JOIN fin_payment_result result ON result.payment_id = payment.id + LEFT JOIN src_source_document source ON source.id = payment.source_document_id + WHERE payment.public_id = #{rowId} AND result.status = 'VERIFIED' + + + SELECT 'APPROVAL_ACTION' AS relation_type, 'WORKFLOW_ACTION' AS object_type, + action.public_id, CONCAT(task.node_code, '-', action.sequence_no) AS business_no, + action.action AS status, NULL AS amount, DATE(action.acted_at) AS business_date, + document.public_id AS source_document_id + FROM wf_task task JOIN wf_action action ON action.task_id = task.id + JOIN wf_instance instance ON instance.id = task.instance_id + JOIN src_document_version version ON version.id = instance.source_version_id + JOIN src_source_document document ON document.id = version.source_document_id + WHERE task.public_id = #{rowId} + + + SELECT 'ARCHIVE_ITEM' AS relation_type, 'ARCHIVE_ITEM' AS object_type, item.public_id, + item.item_name AS business_no, item.status, NULL AS amount, DATE(item.created_at) AS business_date, + NULL AS source_document_id + FROM arc_package package JOIN arc_package_item item ON item.package_id = package.id + WHERE package.public_id = #{rowId} + + + """; + + @Select("") + List> list(@Param("reportCode") String reportCode, @Param("permissionCode") String permissionCode, + @Param("userId") long userId, @Param("roleCode") String roleCode, + @Param("scopeAsOf") LocalDateTime scopeAsOf, + @Param("companyId") String companyId, @Param("projectId") String projectId, + @Param("counterpartyId") String counterpartyId, @Param("keyword") String keyword, + @Param("status") String status, @Param("dateFrom") LocalDate dateFrom, + @Param("dateTo") LocalDate dateTo, @Param("limit") int limit, + @Param("offset") int offset, @Param("sortSql") String sortSql); + + @Select("") + long count(@Param("reportCode") String reportCode, @Param("permissionCode") String permissionCode, + @Param("userId") long userId, @Param("roleCode") String roleCode, + @Param("scopeAsOf") LocalDateTime scopeAsOf, + @Param("companyId") String companyId, @Param("projectId") String projectId, + @Param("counterpartyId") String counterpartyId, @Param("keyword") String keyword, + @Param("status") String status, @Param("dateFrom") LocalDate dateFrom, + @Param("dateTo") LocalDate dateTo); + + @Select("") + SummaryRow summary(@Param("reportCode") String reportCode, @Param("permissionCode") String permissionCode, + @Param("userId") long userId, @Param("roleCode") String roleCode, + @Param("scopeAsOf") LocalDateTime scopeAsOf, + @Param("companyId") String companyId, @Param("projectId") String projectId, + @Param("counterpartyId") String counterpartyId, @Param("keyword") String keyword, + @Param("status") String status, @Param("dateFrom") LocalDate dateFrom, + @Param("dateTo") LocalDate dateTo); + + @Select("") + Map findRow(@Param("reportCode") String reportCode, @Param("permissionCode") String permissionCode, + @Param("rowId") String rowId, @Param("userId") long userId, + @Param("roleCode") String roleCode, + @Param("scopeAsOf") LocalDateTime scopeAsOf, + @Param("companyId") String companyId, + @Param("projectId") String projectId, @Param("counterpartyId") String counterpartyId, + @Param("keyword") String keyword, @Param("status") String status, + @Param("dateFrom") LocalDate dateFrom, @Param("dateTo") LocalDate dateTo); + + @Select("") + List> listRelatedRecords(@Param("reportCode") String reportCode, + @Param("permissionCode") String permissionCode, + @Param("rowId") String rowId, @Param("userId") long userId, + @Param("roleCode") String roleCode, + @Param("scopeAsOf") LocalDateTime scopeAsOf, + @Param("companyId") String companyId, + @Param("projectId") String projectId, + @Param("counterpartyId") String counterpartyId, + @Param("keyword") String keyword, @Param("status") String status, + @Param("dateFrom") LocalDate dateFrom, + @Param("dateTo") LocalDate dateTo); + + @Select("") + List> listSourceForms(@Param("reportCode") String reportCode, + @Param("permissionCode") String permissionCode, + @Param("rowId") String rowId, @Param("userId") long userId, + @Param("roleCode") String roleCode, + @Param("scopeAsOf") LocalDateTime scopeAsOf, + @Param("companyId") String companyId, + @Param("projectId") String projectId, + @Param("counterpartyId") String counterpartyId, + @Param("keyword") String keyword, @Param("status") String status, + @Param("dateFrom") LocalDate dateFrom, + @Param("dateTo") LocalDate dateTo); + + @Select("") + List> listApprovals(@Param("reportCode") String reportCode, + @Param("permissionCode") String permissionCode, + @Param("rowId") String rowId, @Param("userId") long userId, + @Param("roleCode") String roleCode, + @Param("scopeAsOf") LocalDateTime scopeAsOf, + @Param("companyId") String companyId, + @Param("projectId") String projectId, + @Param("counterpartyId") String counterpartyId, + @Param("keyword") String keyword, @Param("status") String status, + @Param("dateFrom") LocalDate dateFrom, + @Param("dateTo") LocalDate dateTo); + + @Select(""" + + """) + List> listAttachments(@Param("reportCode") String reportCode, + @Param("permissionCode") String permissionCode, + @Param("attachmentPermissionCode") String attachmentPermissionCode, + @Param("rowId") String rowId, @Param("userId") long userId, + @Param("roleCode") String roleCode, + @Param("scopeAsOf") LocalDateTime scopeAsOf, + @Param("companyId") String companyId, + @Param("projectId") String projectId, + @Param("counterpartyId") String counterpartyId, + @Param("keyword") String keyword, @Param("status") String status, + @Param("dateFrom") LocalDate dateFrom, + @Param("dateTo") LocalDate dateTo); + + @Insert(""" + INSERT INTO export_log (public_id, request_id, actor_public_id, identity_code, resource_type, + filter_json, columns_json, row_count, sha256) + VALUES (#{publicId}, #{requestId}, #{actorPublicId}, #{identityCode}, #{resourceType}, + CAST(#{filterJson} AS JSON), CAST(#{columnsJson} AS JSON), #{rowCount}, #{sha256}) + """) + int insertExportLog(@Param("publicId") String publicId, @Param("requestId") String requestId, + @Param("actorPublicId") String actorPublicId, @Param("identityCode") String identityCode, + @Param("resourceType") String resourceType, @Param("filterJson") String filterJson, + @Param("columnsJson") String columnsJson, @Param("rowCount") int rowCount, + @Param("sha256") String sha256); + + @Insert(""" + INSERT INTO report_export_request ( + public_id, actor_public_id, identity_code, scope_fingerprint, view_scope_fingerprint, report_code, + definition_version, filter_hash, filter_json, columns_json, sort_code, + estimated_rows, status, expires_at + ) VALUES ( + #{publicId}, #{actorPublicId}, #{identityCode}, #{scopeFingerprint}, #{viewScopeFingerprint}, #{reportCode}, + #{definitionVersion}, #{filterHash}, CAST(#{filterJson} AS JSON), + CAST(#{columnsJson} AS JSON), #{sortCode}, #{estimatedRows}, 'PREPARED', + #{expiresAt} + ) + """) + int insertExportRequest(@Param("publicId") String publicId, @Param("actorPublicId") String actorPublicId, + @Param("identityCode") String identityCode, + @Param("scopeFingerprint") String scopeFingerprint, + @Param("viewScopeFingerprint") String viewScopeFingerprint, + @Param("reportCode") String reportCode, + @Param("definitionVersion") String definitionVersion, + @Param("filterHash") String filterHash, @Param("filterJson") String filterJson, + @Param("columnsJson") String columnsJson, @Param("sortCode") String sortCode, + @Param("estimatedRows") int estimatedRows, + @Param("expiresAt") LocalDateTime expiresAt); + + @Select(""" + SELECT id, public_id, actor_public_id, identity_code, scope_fingerprint, view_scope_fingerprint, report_code, + definition_version, filter_hash, CAST(filter_json AS CHAR) AS filter_json, + CAST(columns_json AS CHAR) AS columns_json, sort_code, estimated_rows, status, + expires_at, export_log_public_id + FROM report_export_request + WHERE public_id = #{publicId} + FOR UPDATE + """) + ExportRequestRow lockExportRequest(String publicId); + + @Update(""" + UPDATE report_export_request + SET status = 'COMPLETED', export_log_public_id = #{exportLogPublicId}, + completed_at = UTC_TIMESTAMP(3), updated_at = UTC_TIMESTAMP(3) + WHERE id = #{id} AND status = 'PREPARED' + """) + int completeExportRequest(@Param("id") long id, @Param("exportLogPublicId") String exportLogPublicId); + + @Update(""" + UPDATE report_export_request + SET status = 'EXPIRED', updated_at = UTC_TIMESTAMP(3) + WHERE public_id = #{publicId} + AND actor_public_id = #{actorPublicId} + AND identity_code = #{identityCode} + AND report_code = #{reportCode} + AND status = 'PREPARED' AND expires_at < UTC_TIMESTAMP(3) + """) + int expireExportRequest(@Param("publicId") String publicId, + @Param("actorPublicId") String actorPublicId, + @Param("identityCode") String identityCode, + @Param("reportCode") String reportCode); + + record SummaryRow(BigDecimal metricTotal, long totalElements, long currencyCount, String currencies) { + } + + record ExportRequestRow(long id, String publicId, String actorPublicId, String identityCode, + String scopeFingerprint, String viewScopeFingerprint, String reportCode, String definitionVersion, + String filterHash, String filterJson, String columnsJson, String sortCode, + int estimatedRows, String status, LocalDateTime expiresAt, + String exportLogPublicId) { + } +} diff --git a/backend/src/main/java/com/kaidi/finance/setup/DatabaseType.java b/backend/src/main/java/com/kaidi/finance/setup/DatabaseType.java new file mode 100644 index 0000000..7ffd7be --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/setup/DatabaseType.java @@ -0,0 +1,6 @@ +package com.kaidi.finance.setup; + +public enum DatabaseType { + MYSQL, + POSTGRESQL +} diff --git a/backend/src/main/java/com/kaidi/finance/setup/SetupContracts.java b/backend/src/main/java/com/kaidi/finance/setup/SetupContracts.java new file mode 100644 index 0000000..cab6731 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/setup/SetupContracts.java @@ -0,0 +1,45 @@ +package com.kaidi.finance.setup; + +import jakarta.validation.constraints.AssertTrue; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +public final class SetupContracts { + + private SetupContracts() { + } + + public record DatabaseRequest( + @NotBlank @Size(max = 128) String setupCode, + @NotNull DatabaseType databaseType, + @NotBlank @Size(max = 128) @Pattern(regexp = "[A-Za-z0-9.-]+") String host, + @NotNull @Min(1) @Max(65535) Integer port, + @NotBlank @Size(max = 64) @Pattern(regexp = "[A-Za-z0-9_$]+") String database, + @NotBlank @Size(max = 128) String username, + @NotBlank @Size(max = 512) String password + ) { + } + + public record CompleteRequest( + @NotBlank @Size(max = 128) String setupCode, + @NotNull DatabaseType databaseType, + @NotBlank @Size(max = 128) @Pattern(regexp = "[A-Za-z0-9.-]+") String host, + @NotNull @Min(1) @Max(65535) Integer port, + @NotBlank @Size(max = 64) @Pattern(regexp = "[A-Za-z0-9_$]+") String database, + @NotBlank @Size(max = 128) String username, + @NotBlank @Size(max = 512) String password, + @NotBlank @Size(min = 3, max = 64) @Pattern(regexp = "[A-Za-z0-9._-]+") String adminUsername, + @NotBlank @Size(max = 100) String adminDisplayName, + @NotBlank @Size(min = 12, max = 128) String adminPassword, + @NotBlank @Size(min = 12, max = 128) String adminPasswordConfirmation + ) { + @AssertTrue(message = "管理员密码确认不一致") + public boolean passwordsMatch() { + return adminPassword != null && adminPassword.equals(adminPasswordConfirmation); + } + } +} diff --git a/backend/src/main/java/com/kaidi/finance/setup/SetupController.java b/backend/src/main/java/com/kaidi/finance/setup/SetupController.java new file mode 100644 index 0000000..60200db --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/setup/SetupController.java @@ -0,0 +1,42 @@ +package com.kaidi.finance.setup; + +import com.kaidi.finance.shared.api.ApiResponse; +import jakarta.validation.Valid; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.http.MediaType; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping(value = "/api/v1/setup", produces = MediaType.APPLICATION_JSON_VALUE) +@ConditionalOnProperty(name = "finance.setup.enabled", havingValue = "true") +public class SetupController { + + private final SetupService service; + + public SetupController(SetupService service) { + this.service = service; + } + + @GetMapping("/status") + @PreAuthorize("true") + public ApiResponse status() { + return ApiResponse.ok(service.status()); + } + + @PostMapping(value = "/test-connection", consumes = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("true") + public ApiResponse test(@Valid @RequestBody SetupContracts.DatabaseRequest request) { + return ApiResponse.ok(service.test(request)); + } + + @PostMapping(value = "/complete", consumes = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("true") + public ApiResponse complete(@Valid @RequestBody SetupContracts.CompleteRequest request) { + return ApiResponse.ok(service.complete(request)); + } +} diff --git a/backend/src/main/java/com/kaidi/finance/setup/SetupException.java b/backend/src/main/java/com/kaidi/finance/setup/SetupException.java new file mode 100644 index 0000000..428b7cf --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/setup/SetupException.java @@ -0,0 +1,23 @@ +package com.kaidi.finance.setup; + +import org.springframework.http.HttpStatus; + +public class SetupException extends RuntimeException { + + private final HttpStatus status; + private final String code; + + public SetupException(HttpStatus status, String code, String message) { + super(message); + this.status = status; + this.code = code; + } + + public HttpStatus status() { + return status; + } + + public String code() { + return code; + } +} diff --git a/backend/src/main/java/com/kaidi/finance/setup/SetupExceptionHandler.java b/backend/src/main/java/com/kaidi/finance/setup/SetupExceptionHandler.java new file mode 100644 index 0000000..e3d70bb --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/setup/SetupExceptionHandler.java @@ -0,0 +1,38 @@ +package com.kaidi.finance.setup; + +import java.net.URI; +import java.util.Map; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.http.ProblemDetail; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice +@ConditionalOnProperty(name = "finance.setup.enabled", havingValue = "true") +public class SetupExceptionHandler { + + @ExceptionHandler(SetupException.class) + ResponseEntity handleSetup(SetupException exception) { + ProblemDetail detail = ProblemDetail.forStatusAndDetail(exception.status(), exception.getMessage()); + detail.setTitle(exception.status().getReasonPhrase()); + detail.setType(URI.create("urn:kaidi:setup:" + exception.code().toLowerCase())); + detail.setProperty("code", exception.code()); + return ResponseEntity.status(exception.status()).body(detail); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + ResponseEntity handleValidation(MethodArgumentNotValidException exception) { + ProblemDetail detail = ProblemDetail.forStatusAndDetail( + org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY, "安装向导提交内容校验失败"); + detail.setTitle("Unprocessable Entity"); + detail.setType(URI.create("urn:kaidi:setup:validation_failed")); + detail.setProperty("code", "VALIDATION_FAILED"); + detail.setProperty("fieldErrors", exception.getBindingResult().getFieldErrors().stream() + .collect(java.util.stream.Collectors.toMap( + error -> error.getField(), error -> error.getDefaultMessage(), (left, right) -> left, + java.util.LinkedHashMap::new))); + return ResponseEntity.status(422).body(detail); + } +} diff --git a/backend/src/main/java/com/kaidi/finance/setup/SetupLockedController.java b/backend/src/main/java/com/kaidi/finance/setup/SetupLockedController.java new file mode 100644 index 0000000..e7f0bcb --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/setup/SetupLockedController.java @@ -0,0 +1,22 @@ +package com.kaidi.finance.setup; + +import com.kaidi.finance.shared.api.ApiResponse; +import java.util.List; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.http.MediaType; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping(value = "/api/v1/setup", produces = MediaType.APPLICATION_JSON_VALUE) +@ConditionalOnProperty(name = "finance.setup.enabled", havingValue = "false", matchIfMissing = true) +public class SetupLockedController { + + @GetMapping("/status") + @PreAuthorize("true") + public ApiResponse status() { + return ApiResponse.ok(new SetupViews.Status(false, true, List.of("MYSQL"), "系统已完成安装")); + } +} diff --git a/backend/src/main/java/com/kaidi/finance/setup/SetupProperties.java b/backend/src/main/java/com/kaidi/finance/setup/SetupProperties.java new file mode 100644 index 0000000..d9710b3 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/setup/SetupProperties.java @@ -0,0 +1,13 @@ +package com.kaidi.finance.setup; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "finance.setup") +public record SetupProperties( + boolean enabled, + String tokenSha256, + String envFile, + String markerFile, + boolean restartAfterComplete +) { +} diff --git a/backend/src/main/java/com/kaidi/finance/setup/SetupSecurityConfig.java b/backend/src/main/java/com/kaidi/finance/setup/SetupSecurityConfig.java new file mode 100644 index 0000000..0ac923b --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/setup/SetupSecurityConfig.java @@ -0,0 +1,26 @@ +package com.kaidi.finance.setup; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.web.SecurityFilterChain; + +@Configuration +@ConditionalOnProperty(name = "finance.setup.enabled", havingValue = "true") +public class SetupSecurityConfig { + + @Bean + SecurityFilterChain setupSecurityFilterChain(HttpSecurity http) throws Exception { + http + .csrf(AbstractHttpConfigurer::disable) + .httpBasic(AbstractHttpConfigurer::disable) + .formLogin(AbstractHttpConfigurer::disable) + .logout(AbstractHttpConfigurer::disable) + .authorizeHttpRequests(authorize -> authorize + .requestMatchers("/api/v1/setup/**", "/actuator/health/**", "/error").permitAll() + .anyRequest().denyAll()); + return http.build(); + } +} diff --git a/backend/src/main/java/com/kaidi/finance/setup/SetupService.java b/backend/src/main/java/com/kaidi/finance/setup/SetupService.java new file mode 100644 index 0000000..27dad16 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/setup/SetupService.java @@ -0,0 +1,417 @@ +package com.kaidi.finance.setup; + +import com.kaidi.finance.shared.id.UlidGenerator; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.attribute.PosixFilePermission; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.time.Instant; +import java.util.Base64; +import java.util.EnumSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicBoolean; +import org.flywaydb.core.Flyway; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.springframework.security.crypto.argon2.Argon2PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.support.TransactionTemplate; + +@Service +@ConditionalOnProperty(name = "finance.setup.enabled", havingValue = "true") +public class SetupService { + + private static final String MYSQL_PREFIX = "jdbc:mysql://"; + private static final List SUPPORTED_TYPES = List.of(DatabaseType.MYSQL.name()); + + private final SetupProperties properties; + private final AtomicBoolean locked = new AtomicBoolean(false); + private final SecureRandom secureRandom = new SecureRandom(); + + public SetupService(SetupProperties properties) { + this.properties = properties; + if (properties.tokenSha256() == null || !properties.tokenSha256().matches("(?i)[0-9a-f]{64}")) { + throw new IllegalStateException("FINANCE_SETUP_TOKEN_SHA256 must be a 64-character SHA-256 value"); + } + if (Files.exists(Path.of(properties.markerFile()))) { + locked.set(true); + } + } + + public SetupViews.Status status() { + boolean isLocked = locked.get() || Files.exists(Path.of(properties.markerFile())); + return new SetupViews.Status(!isLocked, isLocked, SUPPORTED_TYPES, + isLocked ? "安装向导已锁定" : "请完成数据库和管理员初始化"); + } + + public SetupViews.Connection test(SetupContracts.DatabaseRequest request) { + ensureOpen(); + verifySetupCode(request.setupCode()); + DatabaseSettings settings = settings(request.databaseType(), request.host(), request.port(), request.database(), + request.username(), request.password()); + if (settings.type() == DatabaseType.POSTGRESQL) { + // Connection testing is useful for diagnostics, but the current business SQL/migration + // baseline is MySQL-specific. Do not allow an apparently successful test to produce a + // database that the main application cannot start against. + ConnectionResult connection = testConnection(settings); + return new SetupViews.Connection(connection.successful(), settings.type().name(), connection.version(), + false, "PostgreSQL 连接可用,但当前 Preview 的业务迁移仅支持 MySQL 8.4"); + } + ConnectionResult connection = testConnection(settings); + return new SetupViews.Connection(true, settings.type().name(), connection.version(), true, + "MySQL 8.4 连接和 DDL/DML 权限验证通过"); + } + + public SetupViews.Completed complete(SetupContracts.CompleteRequest request) { + ensureOpen(); + verifySetupCode(request.setupCode()); + if (!request.adminPassword().equals(request.adminPasswordConfirmation())) { + throw new SetupException(org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY, + "ADMIN_PASSWORD_MISMATCH", "管理员密码确认不一致"); + } + DatabaseSettings settings = settings(request.databaseType(), request.host(), request.port(), request.database(), + request.username(), request.password()); + if (settings.type() != DatabaseType.MYSQL) { + throw new SetupException(org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY, + "DATABASE_ENGINE_NOT_SUPPORTED", "当前 Preview 仅支持 MySQL 8.4,请选择 MySQL"); + } + synchronized (this) { + ensureOpen(); + ConnectionResult connection = testConnection(settings); + if (!connection.successful()) { + throw new SetupException(org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY, + "DATABASE_CONNECTION_FAILED", "数据库连接失败,请检查地址、端口、库名和账号"); + } + migrateAndCreateAdministrator(settings, request); + writeRuntimeEnvironment(settings); + writeMarker(); + locked.set(true); + } + if (properties.restartAfterComplete()) { + Thread restart = new Thread(() -> { + try { + Thread.sleep(750L); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + } + System.exit(75); + }, "kaidi-setup-restart"); + restart.setDaemon(true); + restart.start(); + } + return new SetupViews.Completed(true, request.adminUsername(), + "安装完成,系统正在切换到正式模式", 10); + } + + private void migrateAndCreateAdministrator(DatabaseSettings settings, SetupContracts.CompleteRequest request) { + try { + ensureCompatibleSchema(settings); + Flyway.configure() + .dataSource(settings.jdbcUrl(), settings.username(), settings.password()) + .locations("classpath:db/migration") + .cleanDisabled(true) + .load() + .migrate(); + DriverManagerDataSource dataSource = new DriverManagerDataSource(settings.jdbcUrl(), settings.username(), + settings.password()); + JdbcTemplate jdbc = new JdbcTemplate(dataSource); + TransactionTemplate transaction = new TransactionTemplate(new DataSourceTransactionManager(dataSource)); + transaction.executeWithoutResult(status -> initializeInstallation(jdbc, request)); + } catch (RuntimeException exception) { + if (exception instanceof SetupException setupException) { + throw setupException; + } + throw new SetupException(org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY, + "DATABASE_INITIALIZATION_FAILED", "数据库迁移或管理员初始化失败,请检查账号的 DDL/DML 权限"); + } + } + + private void ensureCompatibleSchema(DatabaseSettings settings) { + JdbcTemplate jdbc = new JdbcTemplate(new DriverManagerDataSource(settings.jdbcUrl(), settings.username(), + settings.password())); + Integer tableCount = jdbc.queryForObject(""" + SELECT COUNT(*) + FROM information_schema.tables + WHERE table_schema = ? + """, Integer.class, settings.database()); + if (tableCount != null && tableCount > 0) { + Integer historyCount = jdbc.queryForObject(""" + SELECT COUNT(*) + FROM information_schema.tables + WHERE table_schema = ? AND table_name = 'flyway_schema_history' + """, Integer.class, settings.database()); + if (historyCount == null || historyCount == 0) { + throw new SetupException(org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY, + "DATABASE_NOT_EMPTY", "请选择空数据库,或提供已有 Kaidi Flyway 数据库"); + } + } + } + + private void initializeInstallation(JdbcTemplate jdbc, SetupContracts.CompleteRequest request) { + List existing = jdbc.query(""" + SELECT setup_token_sha256, administrator_user_id, administrator_username + FROM kaidi_setup_installation + WHERE id = 1 + FOR UPDATE + """, (result, row) -> new InstallationState( + result.getString("setup_token_sha256"), + result.getLong("administrator_user_id"), + result.getString("administrator_username"))); + if (!existing.isEmpty()) { + InstallationState state = existing.get(0); + if (!state.setupTokenSha256().equalsIgnoreCase(properties.tokenSha256()) + || !state.administratorUsername().equals(request.adminUsername())) { + throw new SetupException(org.springframework.http.HttpStatus.CONFLICT, + "INSTALLATION_ALREADY_INITIALIZED", "数据库已经完成安装,请使用原管理员账号登录"); + } + verifyAdministratorGrants(jdbc, state.administratorUserId()); + return; + } + + Integer existingUsers = jdbc.queryForObject("SELECT COUNT(*) FROM iam_user WHERE username = ?", + Integer.class, request.adminUsername()); + if (existingUsers != null && existingUsers > 0) { + throw new SetupException(org.springframework.http.HttpStatus.CONFLICT, "ADMIN_USERNAME_EXISTS", + "管理员账号名称已存在,请换一个名称"); + } + String publicId = new UlidGenerator().next(); + String encodedPassword = Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8() + .encode(request.adminPassword()); + jdbc.update(""" + INSERT INTO iam_user (public_id, username, display_name, department_name, password_hash, + enabled, must_change_password) + VALUES (?, ?, ?, ?, ?, TRUE, FALSE) + """, publicId, request.adminUsername(), request.adminDisplayName(), "系统管理", + encodedPassword); + Long userId = jdbc.queryForObject("SELECT id FROM iam_user WHERE username = ?", Long.class, + request.adminUsername()); + if (userId == null) { + throw new SetupException(org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR, + "ADMIN_CREATE_FAILED", "管理员账号写入失败"); + } + int roleBindings = jdbc.update(""" + INSERT INTO iam_user_role (user_id, role_id) + SELECT ?, id FROM iam_role WHERE code = 'SYSTEM_ADMIN' AND enabled = TRUE + """, userId); + if (roleBindings != 1) { + throw new SetupException(org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY, + "ADMIN_ROLE_INIT_FAILED", "系统管理员角色未正确初始化"); + } + int scopeCount = jdbc.update(""" + INSERT INTO iam_scope ( + user_id, role_id, permission_id, scope_type, company_public_id, project_public_id, + amount_limit, status + ) + SELECT ?, role.id, role_permission.permission_id, 'GLOBAL', NULL, NULL, NULL, 'ACTIVE' + FROM iam_role role + JOIN iam_role_permission role_permission ON role_permission.role_id = role.id + WHERE role.code = 'SYSTEM_ADMIN' AND role.enabled = TRUE + """, userId); + Integer expectedScopes = jdbc.queryForObject(""" + SELECT COUNT(*) + FROM iam_role_permission role_permission + JOIN iam_role role ON role.id = role_permission.role_id + WHERE role.code = 'SYSTEM_ADMIN' AND role.enabled = TRUE + """, Integer.class); + if (expectedScopes == null || expectedScopes < 1 || scopeCount != expectedScopes) { + throw new SetupException(org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY, + "ADMIN_SCOPE_INIT_FAILED", "系统管理员全局数据范围未正确初始化"); + } + jdbc.update(""" + INSERT INTO kaidi_setup_installation + (id, setup_token_sha256, administrator_user_id, administrator_username) + VALUES (1, ?, ?, ?) + """, properties.tokenSha256().toLowerCase(Locale.ROOT), userId, request.adminUsername()); + } + + private void verifyAdministratorGrants(JdbcTemplate jdbc, long userId) { + Integer roleBindings = jdbc.queryForObject(""" + SELECT COUNT(*) + FROM iam_user_role user_role + JOIN iam_role role ON role.id = user_role.role_id + WHERE user_role.user_id = ? AND role.code = 'SYSTEM_ADMIN' AND role.enabled = TRUE + """, Integer.class, userId); + Integer expectedScopes = jdbc.queryForObject(""" + SELECT COUNT(*) + FROM iam_role_permission role_permission + JOIN iam_role role ON role.id = role_permission.role_id + WHERE role.code = 'SYSTEM_ADMIN' AND role.enabled = TRUE + """, Integer.class); + Integer actualScopes = jdbc.queryForObject(""" + SELECT COUNT(*) + FROM iam_scope scope_grant + JOIN iam_role role ON role.id = scope_grant.role_id + WHERE scope_grant.user_id = ? AND role.code = 'SYSTEM_ADMIN' + AND scope_grant.scope_type = 'GLOBAL' AND scope_grant.status = 'ACTIVE' + """, Integer.class, userId); + if (roleBindings == null || roleBindings != 1 || expectedScopes == null || expectedScopes < 1 + || actualScopes == null || actualScopes != expectedScopes) { + throw new SetupException(org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY, + "ADMIN_GRANTS_INCOMPLETE", "已有管理员的角色或全局权限不完整,请先修复数据库"); + } + } + + private ConnectionResult testConnection(DatabaseSettings settings) { + try (Connection connection = DriverManager.getConnection(settings.jdbcUrl(), settings.username(), + settings.password()); Statement statement = connection.createStatement()) { + statement.setQueryTimeout(10); + String version; + try (ResultSet result = statement.executeQuery("SELECT VERSION()")) { + result.next(); + version = result.getString(1); + } + if (settings.type() == DatabaseType.MYSQL && !version.startsWith("8.4.")) { + throw new SetupException(org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY, + "MYSQL_VERSION_UNSUPPORTED", "当前版本要求 MySQL 8.4.x,检测到 " + version); + } + String probe = "kaidi_setup_probe_" + UUID.randomUUID().toString().replace("-", ""); + try { + statement.execute("CREATE TABLE " + probe + " (id INT NOT NULL PRIMARY KEY)"); + statement.execute("INSERT INTO " + probe + " (id) VALUES (1)"); + statement.execute("UPDATE " + probe + " SET id = 2 WHERE id = 1"); + } finally { + statement.execute("DROP TABLE IF EXISTS " + probe); + } + return new ConnectionResult(true, version); + } catch (SQLException exception) { + throw new SetupException(org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY, + "DATABASE_CONNECTION_FAILED", "数据库连接或 DDL/DML 权限验证失败"); + } + } + + private DatabaseSettings settings(DatabaseType type, String host, int port, String database, String username, + String password) { + String jdbcUrl; + if (type == DatabaseType.MYSQL) { + jdbcUrl = MYSQL_PREFIX + host + ":" + port + "/" + database + + "?useUnicode=true&characterEncoding=utf8&connectionTimeZone=UTC&serverTimezone=UTC" + + "&connectTimeout=10000&socketTimeout=10000"; + } else { + jdbcUrl = "jdbc:postgresql://" + host + ":" + port + "/" + database + + "?sslmode=prefer&connectTimeout=10&socketTimeout=10000"; + } + return new DatabaseSettings(type, jdbcUrl, host, port, database, username, password); + } + + private void verifySetupCode(String code) { + byte[] expected = hexToBytes(properties.tokenSha256()); + byte[] actual = sha256(code.getBytes(StandardCharsets.UTF_8)); + if (!MessageDigest.isEqual(expected, actual)) { + throw new SetupException(org.springframework.http.HttpStatus.FORBIDDEN, "SETUP_CODE_INVALID", + "安装码不正确"); + } + } + + private void ensureOpen() { + if (locked.get() || Files.exists(Path.of(properties.markerFile()))) { + locked.set(true); + throw new SetupException(org.springframework.http.HttpStatus.GONE, "SETUP_LOCKED", "安装向导已锁定"); + } + } + + private void writeRuntimeEnvironment(DatabaseSettings settings) { + String fieldKey = System.getenv().getOrDefault("FIELD_ENCRYPTION_KEY", randomFieldKey()); + String content = String.join("\n", + "SPRING_PROFILES_ACTIVE=production", + "FINANCE_SETUP_ENABLED=false", + "FINANCE_SETUP_TOKEN_SHA256=", + "DB_URL=\"" + escape(settings.jdbcUrl()) + "\"", + "DB_USERNAME=\"" + escape(settings.username()) + "\"", + "DB_PASSWORD=\"" + escape(settings.password()) + "\"", + "KAIDI_DB_HOST=\"" + escape(settings.host()) + "\"", + "KAIDI_DB_PORT=\"" + settings.port() + "\"", + "KAIDI_DB_NAME=\"" + escape(settings.database()) + "\"", + "KAIDI_DB_USERNAME=\"" + escape(settings.username()) + "\"", + "KAIDI_DB_PASSWORD=\"" + escape(settings.password()) + "\"", + "FINANCE_BOOTSTRAP_ENABLED=\"false\"", + "FINANCE_BOOTSTRAP_PASSWORD=\"\"", + "FIELD_ENCRYPTION_KEY=\"" + escape(fieldKey) + "\"", + "") ; + writeRestrictedFile(Path.of(properties.envFile()), content); + } + + private void writeMarker() { + writeRestrictedFile(Path.of(properties.markerFile()), "lockedAt=" + Instant.now() + "\n"); + } + + private String randomFieldKey() { + byte[] bytes = new byte[32]; + secureRandom.nextBytes(bytes); + return Base64.getEncoder().encodeToString(bytes); + } + + private void writeRestrictedFile(Path file, String content) { + try { + Files.createDirectories(file.getParent()); + Path temporary = file.resolveSibling(file.getFileName() + ".next"); + Files.writeString(temporary, content, StandardCharsets.UTF_8); + setOwnerOnly(temporary); + try { + Files.move(temporary, file, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } catch (AtomicMoveNotSupportedException exception) { + Files.move(temporary, file, StandardCopyOption.REPLACE_EXISTING); + } + setOwnerOnly(file); + } catch (IOException exception) { + throw new SetupException(org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR, + "SETUP_STATE_WRITE_FAILED", "安装状态文件写入失败,请检查应用目录权限"); + } + } + + private void setOwnerOnly(Path file) throws IOException { + try { + Set permissions = EnumSet.of(PosixFilePermission.OWNER_READ, + PosixFilePermission.OWNER_WRITE); + Files.setPosixFilePermissions(file, permissions); + } catch (UnsupportedOperationException ignored) { + // Windows development environments do not expose POSIX permissions. + } + } + + private static String escape(String value) { + return value.replace("\\", "\\\\").replace("\"", "\\\""); + } + + private static byte[] sha256(byte[] value) { + try { + return MessageDigest.getInstance("SHA-256").digest(value); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException(exception); + } + } + + private static byte[] hexToBytes(String value) { + byte[] bytes = new byte[value.length() / 2]; + for (int index = 0; index < bytes.length; index++) { + bytes[index] = (byte) Integer.parseInt(value.substring(index * 2, index * 2 + 2), 16); + } + return bytes; + } + + private record DatabaseSettings(DatabaseType type, String jdbcUrl, String host, int port, String database, + String username, String password) { + } + + private record ConnectionResult(boolean successful, String version) { + } + + private record InstallationState(String setupTokenSha256, long administratorUserId, + String administratorUsername) { + } +} diff --git a/backend/src/main/java/com/kaidi/finance/setup/SetupViews.java b/backend/src/main/java/com/kaidi/finance/setup/SetupViews.java new file mode 100644 index 0000000..aba2fbc --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/setup/SetupViews.java @@ -0,0 +1,34 @@ +package com.kaidi.finance.setup; + +import java.util.List; + +public final class SetupViews { + + private SetupViews() { + } + + public record Status( + boolean required, + boolean locked, + List supportedDatabaseTypes, + String message + ) { + } + + public record Connection( + boolean successful, + String databaseType, + String serverVersion, + boolean schemaReady, + String message + ) { + } + + public record Completed( + boolean completed, + String username, + String message, + int restartAfterSeconds + ) { + } +} diff --git a/backend/src/main/java/com/kaidi/finance/shared/api/ApiResponse.java b/backend/src/main/java/com/kaidi/finance/shared/api/ApiResponse.java new file mode 100644 index 0000000..b9f3078 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/shared/api/ApiResponse.java @@ -0,0 +1,15 @@ +package com.kaidi.finance.shared.api; + +import com.kaidi.finance.shared.infrastructure.RequestContext; +import java.util.Map; + +public record ApiResponse(T data, Map meta, String requestId) { + + public static ApiResponse ok(T data) { + return new ApiResponse<>(data, null, RequestContext.requestId()); + } + + public static ApiResponse ok(T data, Map meta) { + return new ApiResponse<>(data, meta, RequestContext.requestId()); + } +} diff --git a/backend/src/main/java/com/kaidi/finance/shared/api/BusinessException.java b/backend/src/main/java/com/kaidi/finance/shared/api/BusinessException.java new file mode 100644 index 0000000..8625582 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/shared/api/BusinessException.java @@ -0,0 +1,34 @@ +package com.kaidi.finance.shared.api; + +import java.util.Map; +import org.springframework.http.HttpStatus; + +public class BusinessException extends RuntimeException { + + private final HttpStatus status; + private final ErrorCode code; + private final Map fieldErrors; + + public BusinessException(HttpStatus status, ErrorCode code, String message) { + this(status, code, message, Map.of()); + } + + public BusinessException(HttpStatus status, ErrorCode code, String message, Map fieldErrors) { + super(message); + this.status = status; + this.code = code; + this.fieldErrors = Map.copyOf(fieldErrors); + } + + public HttpStatus status() { + return status; + } + + public ErrorCode code() { + return code; + } + + public Map fieldErrors() { + return fieldErrors; + } +} diff --git a/backend/src/main/java/com/kaidi/finance/shared/api/ErrorCode.java b/backend/src/main/java/com/kaidi/finance/shared/api/ErrorCode.java new file mode 100644 index 0000000..a6d07eb --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/shared/api/ErrorCode.java @@ -0,0 +1,42 @@ +package com.kaidi.finance.shared.api; + +public enum ErrorCode { + AUTH_BAD_CREDENTIALS, + AUTH_ACCOUNT_DISABLED, + AUTH_ACCOUNT_LOCKED, + AUTH_ROLE_REQUIRED, + AUTH_ROLE_NOT_GRANTED, + AUTH_SESSION_REQUIRED, + AUTH_PASSWORD_CHANGE_REQUIRED, + ACCESS_DENIED, + PERMISSION_DENIED, + DATA_SCOPE_DENIED, + AMOUNT_LIMIT_EXCEEDED, + QUERY_PARAMETER_UNKNOWN, + SORT_FIELD_NOT_ALLOWED, + QUERY_PARAMETER_INVALID, + VALIDATION_FAILED, + RESOURCE_NOT_FOUND, + DUPLICATE_RESOURCE, + DUPLICATE_SOURCE, + IDEMPOTENCY_KEY_REUSED, + COMMAND_IN_PROGRESS, + VERSION_CONFLICT, + CONCURRENT_MODIFICATION, + INVALID_STATE_TRANSITION, + SOD_VIOLATION, + ATTACHMENT_REQUIRED, + FUNDS_INSUFFICIENT, + CONTRACT_LIMIT_EXCEEDED, + PAYABLE_LIMIT_EXCEEDED, + ACCOUNT_NOT_ACTIVE, + PROJECT_RISK_BLOCKED, + FILE_EMPTY, + FILE_TOO_LARGE, + FILE_TYPE_NOT_ALLOWED, + FILE_REJECTED, + FILE_SCANNER_UNAVAILABLE, + FILE_WATERMARK_UNSUPPORTED, + FILE_STORAGE_ERROR, + INTERNAL_ERROR +} diff --git a/backend/src/main/java/com/kaidi/finance/shared/api/GlobalExceptionHandler.java b/backend/src/main/java/com/kaidi/finance/shared/api/GlobalExceptionHandler.java new file mode 100644 index 0000000..6b2e7c1 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/shared/api/GlobalExceptionHandler.java @@ -0,0 +1,122 @@ +package com.kaidi.finance.shared.api; + +import com.kaidi.finance.shared.audit.DeniedAccessAuditService; +import com.kaidi.finance.shared.infrastructure.RequestContext; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.ConstraintViolationException; +import java.net.URI; +import java.sql.SQLException; +import java.util.LinkedHashMap; +import java.util.Map; +import org.springframework.dao.ConcurrencyFailureException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.http.ProblemDetail; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.validation.FieldError; +import org.springframework.web.servlet.NoHandlerFoundException; +import org.springframework.web.servlet.resource.NoResourceFoundException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.method.annotation.HandlerMethodValidationException; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class); + private final DeniedAccessAuditService deniedAccessAuditService; + + public GlobalExceptionHandler(DeniedAccessAuditService deniedAccessAuditService) { + this.deniedAccessAuditService = deniedAccessAuditService; + } + + @ExceptionHandler(BusinessException.class) + public ResponseEntity handleBusiness(BusinessException exception, HttpServletRequest request) { + deniedAccessAuditService.record(request, exception.status(), exception.code().name()); + return response(exception.status(), exception.code().name(), exception.getMessage(), exception.fieldErrors(), request); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleValidation(MethodArgumentNotValidException exception, + HttpServletRequest request) { + Map errors = new LinkedHashMap<>(); + for (FieldError fieldError : exception.getBindingResult().getFieldErrors()) { + errors.putIfAbsent(fieldError.getField(), fieldError.getDefaultMessage()); + } + return response(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.VALIDATION_FAILED.name(), + "提交内容校验失败", errors, request); + } + + @ExceptionHandler({HandlerMethodValidationException.class, ConstraintViolationException.class, + MethodArgumentTypeMismatchException.class}) + public ResponseEntity handleRequestParameterValidation(Exception exception, + HttpServletRequest request) { + return response(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.VALIDATION_FAILED.name(), + "查询参数校验失败", Map.of(), request); + } + + @ExceptionHandler(AccessDeniedException.class) + public ResponseEntity handleAccessDenied(AccessDeniedException exception, + HttpServletRequest request) { + deniedAccessAuditService.record(request, HttpStatus.FORBIDDEN, ErrorCode.PERMISSION_DENIED.name()); + return response(HttpStatus.FORBIDDEN, ErrorCode.PERMISSION_DENIED.name(), "当前身份没有执行该操作的权限", + Map.of(), request); + } + + @ExceptionHandler(ConcurrencyFailureException.class) + public ResponseEntity handleConcurrentModification(ConcurrencyFailureException exception, + HttpServletRequest request) { + return concurrentModification(request); + } + + @ExceptionHandler({NoResourceFoundException.class, NoHandlerFoundException.class}) + public ResponseEntity handleNotFound(Exception exception, HttpServletRequest request) { + return response(HttpStatus.NOT_FOUND, ErrorCode.RESOURCE_NOT_FOUND.name(), "请求的资源不存在", Map.of(), request); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleUnexpected(Exception exception, HttpServletRequest request) { + if (isLockFailure(exception)) { + log.warn("Concurrent database modification requestId={}", RequestContext.requestId(), exception); + return concurrentModification(request); + } + log.error("Unhandled request failure requestId={}", RequestContext.requestId(), exception); + return response(HttpStatus.INTERNAL_SERVER_ERROR, ErrorCode.INTERNAL_ERROR.name(), + "系统处理失败,请使用请求编号联系管理员", Map.of(), request); + } + + private ResponseEntity concurrentModification(HttpServletRequest request) { + return response(HttpStatus.CONFLICT, ErrorCode.CONCURRENT_MODIFICATION.name(), + "数据正在被其他操作更新,请重新加载后重试", Map.of(), request); + } + + private boolean isLockFailure(Throwable exception) { + for (Throwable current = exception; current != null && current.getCause() != current; + current = current.getCause()) { + if (current instanceof SQLException sqlException + && (sqlException.getErrorCode() == 1205 || sqlException.getErrorCode() == 1213 + || sqlException.getErrorCode() == 3572 || "40001".equals(sqlException.getSQLState()))) { + return true; + } + } + return false; + } + + private ResponseEntity response(HttpStatus status, String code, String detail, + Map fieldErrors, HttpServletRequest request) { + ProblemDetail problem = ProblemDetail.forStatusAndDetail(status, detail); + problem.setTitle(status.getReasonPhrase()); + problem.setType(URI.create("urn:kaidi:problem:" + code.toLowerCase())); + problem.setInstance(URI.create(request.getRequestURI())); + problem.setProperty("code", code); + problem.setProperty("requestId", RequestContext.requestId()); + if (!fieldErrors.isEmpty()) { + problem.setProperty("fieldErrors", fieldErrors); + } + return ResponseEntity.status(status).body(problem); + } +} diff --git a/backend/src/main/java/com/kaidi/finance/shared/api/PageResult.java b/backend/src/main/java/com/kaidi/finance/shared/api/PageResult.java new file mode 100644 index 0000000..bcf4ae7 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/shared/api/PageResult.java @@ -0,0 +1,17 @@ +package com.kaidi.finance.shared.api; + +import java.util.List; +import java.util.Map; + +public record PageResult(List items, long totalElements, int page, int size) { + + public Map meta() { + long totalPages = totalElements == 0 ? 0 : (totalElements + size - 1) / size; + return Map.of( + "page", page, + "size", size, + "totalElements", totalElements, + "totalPages", totalPages + ); + } +} diff --git a/backend/src/main/java/com/kaidi/finance/shared/audit/AuditEntry.java b/backend/src/main/java/com/kaidi/finance/shared/audit/AuditEntry.java new file mode 100644 index 0000000..a94f49b --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/shared/audit/AuditEntry.java @@ -0,0 +1,8 @@ +package com.kaidi.finance.shared.audit; + +public record AuditEntry(String publicId, String requestId, String userPublicId, String username, + String activeRole, String companyPublicId, String projectPublicId, + String actionCode, String objectType, String objectPublicId, String resultCode, + String reason, String beforeJson, String afterJson, String ipAddress, + String userAgent) { +} diff --git a/backend/src/main/java/com/kaidi/finance/shared/audit/AuditMapper.java b/backend/src/main/java/com/kaidi/finance/shared/audit/AuditMapper.java new file mode 100644 index 0000000..fea6cc2 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/shared/audit/AuditMapper.java @@ -0,0 +1,39 @@ +package com.kaidi.finance.shared.audit; + +import java.time.LocalDateTime; +import java.util.List; +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@org.apache.ibatis.annotations.Mapper +public interface AuditMapper { + + @Insert(""" + INSERT INTO audit_log ( + public_id, request_id, user_public_id, username, active_role, company_public_id, + project_public_id, action_code, object_type, object_public_id, result_code, reason, + before_json, after_json, ip_address, user_agent + ) VALUES ( + #{publicId}, #{requestId}, #{userPublicId}, #{username}, #{activeRole}, #{companyPublicId}, + #{projectPublicId}, #{actionCode}, #{objectType}, #{objectPublicId}, #{resultCode}, #{reason}, + #{beforeJson}, #{afterJson}, #{ipAddress}, #{userAgent} + ) + """) + int insert(AuditEntry entry); + + @Select(""" + SELECT created_at, ip_address, user_agent + FROM audit_log + WHERE user_public_id = #{userPublicId} + AND action_code = 'AUTH_LOGIN' + AND result_code = 'SUCCESS' + ORDER BY created_at DESC, id DESC + LIMIT #{limit} + """) + List listRecentSuccessfulLogins(@Param("userPublicId") String userPublicId, + @Param("limit") int limit); + + record LoginAuditRow(LocalDateTime occurredAt, String ipAddress, String userAgent) { + } +} diff --git a/backend/src/main/java/com/kaidi/finance/shared/audit/AuditService.java b/backend/src/main/java/com/kaidi/finance/shared/audit/AuditService.java new file mode 100644 index 0000000..5b3b360 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/shared/audit/AuditService.java @@ -0,0 +1,109 @@ +package com.kaidi.finance.shared.audit; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.kaidi.finance.iam.domain.FinancePrincipal; +import com.kaidi.finance.shared.id.UlidGenerator; +import com.kaidi.finance.shared.infrastructure.RequestContext; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpSession; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Service; + +@Service +public class AuditService { + + public static final String ACTIVE_ROLE_SESSION_KEY = "KAIDI_ACTIVE_ROLE"; + + private final AuditMapper auditMapper; + private final UlidGenerator ulidGenerator; + private final ObjectMapper objectMapper; + private final HttpServletRequest request; + + public AuditService(AuditMapper auditMapper, UlidGenerator ulidGenerator, ObjectMapper objectMapper, + HttpServletRequest request) { + this.auditMapper = auditMapper; + this.ulidGenerator = ulidGenerator; + this.objectMapper = objectMapper; + this.request = request; + } + + public void record(String action, String objectType, String objectPublicId, String result, + String reason, Object before, Object after) { + recordScoped(null, null, action, objectType, objectPublicId, result, reason, before, after); + } + + /** + * Records an event together with the business scope that owns the object. + * + * Audit queries use these two denormalized columns for company/project + * row-level filtering. Keeping the original record(...) overload preserves + * compatibility for session/system events whose scope is not available. + */ + public void recordScoped(String companyPublicId, String projectPublicId, + String action, String objectType, String objectPublicId, String result, + String reason, Object before, Object after) { + Actor actor = currentActor(); + insert(actor, companyPublicId, projectPublicId, action, objectType, objectPublicId, result, reason, + before, after); + } + + public void recordForUser(String userPublicId, String username, String action, String result, String reason) { + insert(new Actor(userPublicId, username, null), null, null, action, "SESSION", userPublicId, result, reason, + null, null); + } + + private void insert(Actor actor, String companyPublicId, String projectPublicId, String action, + String objectType, String objectPublicId, String result, String reason, + Object before, Object after) { + AuditEntry entry = new AuditEntry( + ulidGenerator.next(), RequestContext.requestId(), actor.publicId(), actor.username(), actor.activeRole(), + companyPublicId, projectPublicId, action, objectType, objectPublicId, result, sanitizeReason(reason), + json(before), json(after), + clientIp(), truncate(request.getHeader("User-Agent"), 500) + ); + auditMapper.insert(entry); + } + + private Actor currentActor() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication != null && authentication.getPrincipal() instanceof FinancePrincipal principal) { + HttpSession session = request.getSession(false); + String activeRole = session == null ? null : (String) session.getAttribute(ACTIVE_ROLE_SESSION_KEY); + return new Actor(principal.publicId(), principal.username(), activeRole); + } + return new Actor(null, null, null); + } + + private String json(Object value) { + if (value == null) { + return null; + } + try { + return objectMapper.writeValueAsString(value); + } catch (JsonProcessingException exception) { + throw new IllegalStateException("Audit value cannot be serialized", exception); + } + } + + private String clientIp() { + String forwarded = request.getHeader("X-Forwarded-For"); + if (forwarded != null && !forwarded.isBlank()) { + return truncate(forwarded.split(",")[0].trim(), 64); + } + return truncate(request.getRemoteAddr(), 64); + } + + private String truncate(String value, int max) { + return value == null || value.length() <= max ? value : value.substring(0, max); + } + + private String sanitizeReason(String reason) { + if (reason == null || reason.isBlank()) return reason; + return reason.replaceAll("(? LOGIN_FAILURE_CODES = Set.of( + "AUTH_BAD_CREDENTIALS", "AUTH_ACCOUNT_DISABLED", "AUTH_ACCOUNT_LOCKED", "ACCESS_DENIED" + ); + + private final DeniedAccessAuditWriter writer; + + public DeniedAccessAuditService(DeniedAccessAuditWriter writer) { + this.writer = writer; + } + + public void record(HttpServletRequest request, HttpStatus status, String errorCode) { + if ((status != HttpStatus.UNAUTHORIZED && status != HttpStatus.FORBIDDEN) + || request.getAttribute(RECORDED_ATTRIBUTE) != null) { + return; + } + request.setAttribute(RECORDED_ATTRIBUTE, Boolean.TRUE); + + if (isExistingLoginFailureAudit(request, errorCode)) { + return; + } + + String method = truncate(request.getMethod(), 16); + String uri = truncate(request.getRequestURI(), 1000); + try { + writer.record(method, uri, status.value(), errorCode); + } catch (RuntimeException exception) { + log.error("Denied access audit failed requestId={} method={} uri={} code={}", + RequestContext.requestId(), method, uri, errorCode, exception); + } + } + + private boolean isExistingLoginFailureAudit(HttpServletRequest request, String errorCode) { + return "/api/v1/auth/login".equals(request.getRequestURI()) && LOGIN_FAILURE_CODES.contains(errorCode); + } + + private String truncate(String value, int maxLength) { + if (value == null || value.length() <= maxLength) { + return value; + } + return value.substring(0, maxLength); + } +} diff --git a/backend/src/main/java/com/kaidi/finance/shared/audit/DeniedAccessAuditWriter.java b/backend/src/main/java/com/kaidi/finance/shared/audit/DeniedAccessAuditWriter.java new file mode 100644 index 0000000..b2b63e7 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/shared/audit/DeniedAccessAuditWriter.java @@ -0,0 +1,27 @@ +package com.kaidi.finance.shared.audit; + +import java.util.Map; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class DeniedAccessAuditWriter { + + private final AuditService auditService; + + public DeniedAccessAuditWriter(AuditService auditService) { + this.auditService = auditService; + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void record(String method, String uri, int status, String errorCode) { + auditService.record("HTTP_ACCESS_DENIED", "HTTP_ENDPOINT", null, "DENIED", errorCode, null, + Map.of( + "method", method, + "uri", uri, + "status", status, + "errorCode", errorCode + )); + } +} diff --git a/backend/src/main/java/com/kaidi/finance/shared/config/OpenApiProblemConfiguration.java b/backend/src/main/java/com/kaidi/finance/shared/config/OpenApiProblemConfiguration.java new file mode 100644 index 0000000..c31f3ca --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/shared/config/OpenApiProblemConfiguration.java @@ -0,0 +1,81 @@ +package com.kaidi.finance.shared.config; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.Operation; +import io.swagger.v3.oas.models.media.IntegerSchema; +import io.swagger.v3.oas.models.media.MapSchema; +import io.swagger.v3.oas.models.media.ObjectSchema; +import io.swagger.v3.oas.models.media.Schema; +import io.swagger.v3.oas.models.media.StringSchema; +import io.swagger.v3.oas.models.responses.ApiResponse; +import io.swagger.v3.oas.models.responses.ApiResponses; +import java.util.List; +import org.springdoc.core.customizers.OpenApiCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.MediaType; + +@Configuration +public class OpenApiProblemConfiguration { + + private static final String PROBLEM_SCHEMA = "#/components/schemas/ApiProblem"; + + @Bean + OpenApiCustomizer financeProblemResponses() { + return openApi -> { + addProblemSchema(openApi); + add(openApi.getPaths().get("/api/v1/audit/logs").getGet(), + response("400", "查询参数不受支持"), response("401", "登录会话缺失或失效"), + response("403", "缺少审计查看权限"), response("422", "筛选或分页参数无效")); + add(openApi.getPaths().get("/api/v1/audit/logs/{publicId}").getGet(), + response("401", "登录会话缺失或失效"), response("403", "缺少权限或数据范围不匹配"), + response("404", "审计日志不存在")); + add(openApi.getPaths().get("/api/v1/audit/exports").getPost(), + response("400", "请求体格式无效"), response("401", "登录会话缺失或失效"), + response("403", "缺少审计导出权限"), response("422", "导出筛选参数无效")); + add(openApi.getPaths().get("/api/v1/masterdata/{resource}/{publicId}/versions").getGet(), + response("400", "请求参数格式无效"), response("401", "登录会话缺失或失效"), + response("403", "缺少资源查看权限"), response("404", "主数据对象或版本不存在"), + response("422", "分页参数无效")); + }; + } + + private static void addProblemSchema(OpenAPI openApi) { + Components components = openApi.getComponents() == null ? new Components() : openApi.getComponents(); + openApi.setComponents(components); + MapSchema fieldErrors = new MapSchema(); + fieldErrors.setAdditionalProperties(new StringSchema()); + Schema problem = new ObjectSchema() + .addProperty("type", new StringSchema().format("uri")) + .addProperty("title", new StringSchema()) + .addProperty("status", new IntegerSchema().format("int32")) + .addProperty("detail", new StringSchema()) + .addProperty("instance", new StringSchema().format("uri")) + .addProperty("code", new StringSchema()) + .addProperty("requestId", new StringSchema()) + .addProperty("fieldErrors", fieldErrors) + .required(List.of("type", "title", "status", "detail", "instance", "code", "requestId")); + components.addSchemas("ApiProblem", problem); + } + + @SafeVarargs + private static void add(Operation operation, Response... responses) { + ApiResponses target = operation.getResponses() == null ? new ApiResponses() : operation.getResponses(); + operation.setResponses(target); + for (Response response : responses) { + io.swagger.v3.oas.models.media.MediaType problemMedia = new io.swagger.v3.oas.models.media.MediaType() + .schema(new Schema<>().$ref(PROBLEM_SCHEMA)); + target.addApiResponse(response.code(), new ApiResponse().description(response.description()) + .content(new io.swagger.v3.oas.models.media.Content() + .addMediaType(MediaType.APPLICATION_PROBLEM_JSON_VALUE, problemMedia))); + } + } + + private static Response response(String code, String description) { + return new Response(code, description); + } + + private record Response(String code, String description) { + } +} diff --git a/backend/src/main/java/com/kaidi/finance/shared/config/SecurityConfig.java b/backend/src/main/java/com/kaidi/finance/shared/config/SecurityConfig.java new file mode 100644 index 0000000..c0ab708 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/shared/config/SecurityConfig.java @@ -0,0 +1,55 @@ +package com.kaidi.finance.shared.config; + +import com.kaidi.finance.shared.security.SecurityProblemWriter; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.crypto.argon2.Argon2PasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.csrf.CookieCsrfTokenRepository; +import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler; + +@Configuration +@EnableMethodSecurity +public class SecurityConfig { + + @Bean + SecurityFilterChain securityFilterChain(HttpSecurity http, SecurityProblemWriter problemWriter) throws Exception { + CookieCsrfTokenRepository csrfRepository = CookieCsrfTokenRepository.withHttpOnlyFalse(); + csrfRepository.setCookiePath("/"); + CsrfTokenRequestAttributeHandler csrfHandler = new CsrfTokenRequestAttributeHandler(); + + http + .csrf(csrf -> csrf + .csrfTokenRepository(csrfRepository) + .csrfTokenRequestHandler(csrfHandler)) + .cors(Customizer.withDefaults()) + .httpBasic(AbstractHttpConfigurer::disable) + .formLogin(AbstractHttpConfigurer::disable) + .logout(AbstractHttpConfigurer::disable) + .requestCache(AbstractHttpConfigurer::disable) + .authorizeHttpRequests(authorize -> authorize + .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() + .requestMatchers("/api/v1/auth/csrf", "/api/v1/auth/login", "/actuator/health/**", + "/api-docs/**", "/swagger-ui.html", "/swagger-ui/**", "/error").permitAll() + .requestMatchers("/api/v1/**").permitAll() + .anyRequest().authenticated()) + .exceptionHandling(exceptions -> exceptions + .authenticationEntryPoint((request, response, exception) -> problemWriter.write(request, response, + HttpStatus.UNAUTHORIZED, "AUTH_SESSION_REQUIRED", "请先登录或重新登录")) + .accessDeniedHandler((request, response, exception) -> problemWriter.write(request, response, + HttpStatus.FORBIDDEN, "PERMISSION_DENIED", "当前身份没有执行该操作的权限"))); + return http.build(); + } + + @Bean + PasswordEncoder passwordEncoder() { + return Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8(); + } +} diff --git a/backend/src/main/java/com/kaidi/finance/shared/file/ClamAvFileScanner.java b/backend/src/main/java/com/kaidi/finance/shared/file/ClamAvFileScanner.java new file mode 100644 index 0000000..20887be --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/shared/file/ClamAvFileScanner.java @@ -0,0 +1,84 @@ +package com.kaidi.finance.shared.file; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Locale; + +final class ClamAvFileScanner implements FileScanner { + + private static final byte[] INSTREAM_COMMAND = "zINSTREAM\0".getBytes(StandardCharsets.US_ASCII); + private static final int MAX_RESPONSE_BYTES = 4_096; + + private final FileScannerProperties properties; + + ClamAvFileScanner(FileScannerProperties properties) { + this.properties = properties; + } + + @Override + public ScanResult scan(Path path) { + try (Socket socket = new Socket()) { + socket.connect(new InetSocketAddress(properties.host(), properties.port()), + properties.connectTimeoutMillis()); + socket.setSoTimeout(properties.readTimeoutMillis()); + try (InputStream file = Files.newInputStream(path); + InputStream response = socket.getInputStream(); + OutputStream request = socket.getOutputStream()) { + request.write(INSTREAM_COMMAND); + transferChunks(file, request); + request.write(new byte[4]); + request.flush(); + return parseResponse(readResponse(response)); + } + } catch (IOException exception) { + return ScanResult.unavailable("本地病毒扫描服务不可用"); + } + } + + private void transferChunks(InputStream input, OutputStream output) throws IOException { + byte[] buffer = new byte[properties.chunkBytes()]; + int read; + while ((read = input.read(buffer)) >= 0) { + if (read == 0) { + continue; + } + output.write(ByteBuffer.allocate(4).putInt(read).array()); + output.write(buffer, 0, read); + } + } + + private String readResponse(InputStream input) throws IOException { + ByteArrayOutputStream response = new ByteArrayOutputStream(); + int value; + while (response.size() < MAX_RESPONSE_BYTES && (value = input.read()) >= 0) { + if (value == 0 || value == '\n') { + break; + } + response.write(value); + } + return response.toString(StandardCharsets.UTF_8).trim(); + } + + private ScanResult parseResponse(String response) { + String normalized = response.toUpperCase(Locale.ROOT); + if (normalized.endsWith(" OK")) { + return ScanResult.clean("ClamAV: OK"); + } + if (normalized.contains(" FOUND")) { + return ScanResult.infected(limitDetail(response)); + } + return ScanResult.unavailable("本地病毒扫描服务返回异常"); + } + + private String limitDetail(String detail) { + return detail.length() <= 500 ? detail : detail.substring(0, 500); + } +} diff --git a/backend/src/main/java/com/kaidi/finance/shared/file/FileApplicationService.java b/backend/src/main/java/com/kaidi/finance/shared/file/FileApplicationService.java new file mode 100644 index 0000000..c1cc7c2 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/shared/file/FileApplicationService.java @@ -0,0 +1,603 @@ +package com.kaidi.finance.shared.file; + +import com.kaidi.finance.iam.domain.FinancePrincipal; +import com.kaidi.finance.shared.api.BusinessException; +import com.kaidi.finance.shared.api.ErrorCode; +import com.kaidi.finance.shared.audit.AuditService; +import com.kaidi.finance.shared.id.UlidGenerator; +import com.kaidi.finance.shared.security.IdentityContext; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.security.DigestInputStream; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.HexFormat; +import java.util.Locale; +import java.util.Set; +import java.util.function.Supplier; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.core.io.FileSystemResource; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionTemplate; +import org.springframework.web.multipart.MultipartFile; + +@Service +public class FileApplicationService { + + private static final Logger LOGGER = LoggerFactory.getLogger(FileApplicationService.class); + private static final Set ALLOWED_EXTENSIONS = Set.of( + "pdf", "ofd", "docx", "xlsx", "csv", "jpg", "jpeg", "png" + ); + + private final FileStorageProperties properties; + private final FileMapper fileMapper; + private final FileScanner fileScanner; + private final FileRecordService fileRecordService; + private final UlidGenerator ulidGenerator; + private final IdentityContext identityContext; + private final AuditService auditService; + private final TransactionTemplate requiresNewTransaction; + + public FileApplicationService(FileStorageProperties properties, FileMapper fileMapper, + FileScanner fileScanner, FileRecordService fileRecordService, + UlidGenerator ulidGenerator, IdentityContext identityContext, + AuditService auditService, PlatformTransactionManager transactionManager) { + this.properties = properties; + this.fileMapper = fileMapper; + this.fileScanner = fileScanner; + this.fileRecordService = fileRecordService; + this.ulidGenerator = ulidGenerator; + this.identityContext = identityContext; + this.auditService = auditService; + this.requiresNewTransaction = new TransactionTemplate(transactionManager); + this.requiresNewTransaction.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); + } + + public FileView upload(MultipartFile multipartFile) { + FinancePrincipal principal = identityContext.requirePrincipal(); + validate(multipartFile); + String originalName = cleanName(multipartFile.getOriginalFilename()); + String extension = extension(originalName); + String publicId = ulidGenerator.next(); + Path tempFile = properties.tempPath().resolve(publicId + ".upload"); + Path storedPath = null; + FileScanner.ScanResult scanResult; + FileView stored; + + try { + Files.createDirectories(tempFile.getParent()); + try (InputStream input = multipartFile.getInputStream()) { + Files.copy(input, tempFile, StandardCopyOption.REPLACE_EXISTING); + } + String sha256 = sha256(tempFile); + scanResult = fileScanner.scan(tempFile); + String scanStatus = switch (scanResult.status()) { + case CLEAN -> "AVAILABLE"; + case INFECTED -> "REJECTED"; + case UNAVAILABLE -> "QUARANTINED"; + }; + String directory = "AVAILABLE".equals(scanStatus) + ? LocalDate.now(ZoneOffset.UTC).toString() + : ".quarantine/" + LocalDate.now(ZoneOffset.UTC); + String storageKey = directory + "/" + publicId + "." + extension; + storedPath = properties.rootPath().resolve(storageKey).normalize(); + if (!storedPath.startsWith(properties.rootPath())) { + throw new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.FILE_TYPE_NOT_ALLOWED, + "文件名不合法"); + } + Files.createDirectories(storedPath.getParent()); + moveAtomically(tempFile, storedPath); + FileObjectRecord record = new FileObjectRecord(); + record.setPublicId(publicId); + record.setOriginalName(originalName); + record.setStorageKey(storageKey); + record.setMediaType(multipartFile.getContentType() == null ? "application/octet-stream" + : multipartFile.getContentType()); + record.setExtension(extension); + record.setSizeBytes(multipartFile.getSize()); + record.setSha256(sha256); + record.setScanStatus(scanStatus); + record.setScanDetail(limitScanDetail(scanResult.detail())); + record.setScannedAt(LocalDateTime.now(ZoneOffset.UTC)); + record.setUploadedBy(principal.publicId()); + stored = fileRecordService.persist(record); + } catch (IOException exception) { + deleteQuietly(tempFile); + deleteQuietly(storedPath); + throw new BusinessException(HttpStatus.INTERNAL_SERVER_ERROR, ErrorCode.FILE_STORAGE_ERROR, + "文件保存失败,请稍后重试"); + } catch (RuntimeException exception) { + deleteQuietly(tempFile); + deleteQuietly(storedPath); + throw exception; + } + + if (scanResult.status() == FileScanner.Status.INFECTED) { + throw new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.FILE_REJECTED, + "文件未通过安全检查,已拒绝接收"); + } + if (scanResult.status() == FileScanner.Status.UNAVAILABLE) { + throw new FileScannerUnavailableException(stored); + } + return stored; + } + + /** + * Stores a file produced by a trusted domain service. Generated exports do + * not pass through the upload scanner because their bytes originate inside + * this process, but they still use the same immutable file-object storage, + * SHA-256 metadata and download controls as user uploads. + */ + public FileView storeGenerated(String originalName, String mediaType, String extension, byte[] content) { + FinancePrincipal principal = identityContext.requirePrincipal(); + if (content == null || content.length == 0) { + throw new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.FILE_EMPTY, + "生成文件内容不能为空"); + } + if (content.length > properties.maxBytes()) { + throw new BusinessException(HttpStatus.PAYLOAD_TOO_LARGE, ErrorCode.FILE_TOO_LARGE, + "生成文件超过 100MB 限制"); + } + String safeName = cleanName(originalName); + String safeExtension = extension == null ? "" : extension.trim().toLowerCase(Locale.ROOT); + if (!ALLOWED_EXTENSIONS.contains(safeExtension) || !safeName.toLowerCase(Locale.ROOT).endsWith('.' + safeExtension)) { + throw new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.FILE_TYPE_NOT_ALLOWED, + "生成文件类型不在允许范围内"); + } + String publicId = ulidGenerator.next(); + String storageKey = LocalDate.now(ZoneOffset.UTC) + "/generated/" + publicId + "." + safeExtension; + Path storedPath = properties.rootPath().resolve(storageKey).normalize(); + if (!storedPath.startsWith(properties.rootPath())) { + throw new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.FILE_TYPE_NOT_ALLOWED, + "生成文件名不合法"); + } + try { + Files.createDirectories(storedPath.getParent()); + try (OutputStream output = Files.newOutputStream(storedPath)) { + output.write(content); + } + FileObjectRecord record = new FileObjectRecord(); + record.setPublicId(publicId); + record.setOriginalName(safeName); + record.setStorageKey(storageKey); + record.setMediaType(mediaType == null || mediaType.isBlank() ? "application/octet-stream" : mediaType); + record.setExtension(safeExtension); + record.setSizeBytes(content.length); + record.setSha256(sha256(content)); + record.setScanStatus("AVAILABLE"); + record.setScanDetail("SERVER_GENERATED"); + record.setScannedAt(LocalDateTime.now(ZoneOffset.UTC)); + record.setUploadedBy(principal.publicId()); + return fileRecordService.persist(record); + } catch (IOException exception) { + deleteQuietly(storedPath); + throw new BusinessException(HttpStatus.INTERNAL_SERVER_ERROR, ErrorCode.FILE_STORAGE_ERROR, + "生成文件保存失败,请稍后重试"); + } catch (RuntimeException exception) { + deleteQuietly(storedPath); + throw exception; + } + } + + @Transactional(readOnly = true) + public FileObjectRecord require(String publicId) { + FileObjectRecord file = fileMapper.findByPublicId(publicId); + if (file == null) { + throw new BusinessException(HttpStatus.NOT_FOUND, ErrorCode.RESOURCE_NOT_FOUND, "文件不存在或已失效"); + } + return file; + } + + @Transactional(readOnly = true) + public FileView metadata(String publicId) { + return FileView.from(requireOwnedByCurrentUser(publicId)); + } + + public FileView rescanOwned(String publicId) { + return rescan(publicId, true); + } + + public FileView rescanForBusiness(String publicId) { + return rescan(publicId, false); + } + + /** + * Reads an AVAILABLE file for a trusted server-side processor. This is + * deliberately separate from {@link #download(String)} so internal jobs do + * not create a user-visible FILE_DOWNLOAD audit event or bypass the storage + * root/path checks. + */ + @Transactional(readOnly = true) + public byte[] readAvailable(String publicId) { + FileObjectRecord file = require(publicId); + requireAvailable(file); + Path root = properties.rootPath(); + Path path = root.resolve(file.getStorageKey()).normalize(); + if (!path.startsWith(root) || !Files.isRegularFile(path)) { + throw new BusinessException(HttpStatus.NOT_FOUND, ErrorCode.RESOURCE_NOT_FOUND, "文件本体不存在"); + } + try { + return Files.readAllBytes(path); + } catch (IOException exception) { + throw new BusinessException(HttpStatus.INTERNAL_SERVER_ERROR, ErrorCode.FILE_STORAGE_ERROR, + "文件读取失败,请稍后重试"); + } + } + + @Transactional + public FileDownload download(String publicId) { + FileObjectRecord file = requireOwnedByCurrentUser(publicId); + return readDownload(file, true); + } + + /** + * Reads a file after a domain service has already checked its business + * object and data scope. Generic file URLs are uploader-only; archive and + * other scoped modules must call this method instead of widening the generic + * file permission into a global download grant. + */ + @Transactional + public FileDownload downloadForBusiness(String publicId) { + return readDownload(require(publicId), true); + } + + private FileDownload readDownload(FileObjectRecord file, boolean audit) { + requireAvailable(file); + Path root = properties.rootPath(); + Path path = root.resolve(file.getStorageKey()).normalize(); + if (!path.startsWith(root) || !Files.isRegularFile(path)) { + throw new BusinessException(HttpStatus.NOT_FOUND, ErrorCode.RESOURCE_NOT_FOUND, "文件本体不存在"); + } + if (audit) { + auditService.record("FILE_DOWNLOAD", "FILE", file.getPublicId(), "SUCCESS", null, null, null); + } + return new FileDownload(file, new FileSystemResource(path)); + } + + private FileObjectRecord requireOwnedByCurrentUser(String publicId) { + FileObjectRecord file = require(publicId); + requireOwner(file); + return file; + } + + private void requireOwner(FileObjectRecord file) { + String actorPublicId = identityContext.requirePrincipal().publicId(); + if (!actorPublicId.equals(file.getUploadedBy())) { + throw new BusinessException(HttpStatus.FORBIDDEN, ErrorCode.DATA_SCOPE_DENIED, + "文件尚未关联到当前业务范围,只有上传人可以直接访问"); + } + } + + private FileView rescan(String publicId, boolean requireOwnership) { + RescanPlan plan = inNewTransaction(() -> prepareRescan(publicId, requireOwnership)); + if (!plan.requiresPromotion()) { + return plan.result(); + } + return promote(plan); + } + + private RescanPlan prepareRescan(String publicId, boolean requireOwnership) { + FileObjectRecord file = fileMapper.lockByPublicId(publicId); + if (file == null) { + throw new BusinessException(HttpStatus.NOT_FOUND, ErrorCode.RESOURCE_NOT_FOUND, "文件不存在或已失效"); + } + if (requireOwnership) { + requireOwner(file); + } + if ("AVAILABLE".equals(file.getScanStatus())) { + return RescanPlan.completed(FileView.from(file)); + } + if (!"QUARANTINED".equals(file.getScanStatus())) { + throw new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.INVALID_STATE_TRANSITION, + "只有隔离中的文件可以重新扫描"); + } + + FileView before = FileView.from(file); + if (file.getPromotionStorageKey() != null && !file.getPromotionStorageKey().isBlank()) { + return RescanPlan.promotion(file, before); + } + + Path source = storedPath(file); + FileScanner.ScanResult scanResult; + try { + String actualSha256 = sha256(source); + scanResult = actualSha256.equalsIgnoreCase(file.getSha256()) + ? fileScanner.scan(source) + : FileScanner.ScanResult.infected("文件哈希与上传记录不一致"); + } catch (IOException exception) { + throw new BusinessException(HttpStatus.INTERNAL_SERVER_ERROR, ErrorCode.FILE_STORAGE_ERROR, + "隔离文件读取失败,请稍后重试"); + } + + String scanStatus = switch (scanResult.status()) { + case CLEAN -> "AVAILABLE"; + case INFECTED -> "REJECTED"; + case UNAVAILABLE -> "QUARANTINED"; + }; + file.setScanStatus(scanStatus); + file.setScanDetail(limitScanDetail(scanResult.detail())); + file.setScannedAt(LocalDateTime.now(ZoneOffset.UTC)); + + if ("AVAILABLE".equals(scanStatus)) { + String storageKey = LocalDate.now(ZoneOffset.UTC) + "/" + file.getPublicId() + "." + file.getExtension(); + Path target = properties.rootPath().resolve(storageKey).normalize(); + if (!target.startsWith(properties.rootPath())) { + throw new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.FILE_TYPE_NOT_ALLOWED, + "文件存储路径不合法"); + } + file.setScanStatus("QUARANTINED"); + file.setPromotionStorageKey(storageKey); + if (fileMapper.preparePromotion(file) != 1) { + throw concurrentFileChange(); + } + return RescanPlan.promotion(file, before); + } + + if (fileMapper.updateRescanResult(file) != 1) { + throw concurrentFileChange(); + } + FileView result = FileView.from(fileMapper.findByPublicId(publicId)); + auditRescan(before, result); + return RescanPlan.completed(result); + } + + private FileView promote(RescanPlan plan) { + Path source = storagePath(plan.sourceStorageKey()); + Path target = storagePath(plan.targetStorageKey()); + try { + String integrityFailure = promoteFile(source, target, plan.expectedSha256()); + if (integrityFailure != null) { + return rejectPromotion(plan, integrityFailure); + } + } catch (IOException exception) { + throw new BusinessException(HttpStatus.INTERNAL_SERVER_ERROR, ErrorCode.FILE_STORAGE_ERROR, + "隔离文件转存失败,请稍后重试"); + } + + FileView result = inNewTransaction(() -> completePromotion(plan)); + if (!source.equals(target) && Files.isRegularFile(source)) { + try { + Files.delete(source); + } catch (IOException exception) { + LOGGER.warn("Failed to remove duplicate quarantine source for file {}", plan.publicId(), exception); + } + } + return result; + } + + private String promoteFile(Path source, Path target, String expectedSha256) throws IOException { + boolean sourceExists = Files.isRegularFile(source); + boolean targetExists = Files.isRegularFile(target); + if (!sourceExists && !targetExists) { + throw new IOException("Neither quarantine source nor promotion target exists"); + } + if (sourceExists && !expectedSha256.equalsIgnoreCase(sha256(source))) { + return "隔离文件哈希与上传记录不一致"; + } + if (targetExists && !expectedSha256.equalsIgnoreCase(sha256(target))) { + return "转存目标文件哈希与上传记录不一致"; + } + if (sourceExists && !targetExists) { + Files.createDirectories(target.getParent()); + if (!Files.getFileStore(source).equals(Files.getFileStore(target.getParent()))) { + throw new IOException("Quarantine and available paths are on different file stores"); + } + Files.move(source, target, StandardCopyOption.ATOMIC_MOVE); + } + return null; + } + + private FileView completePromotion(RescanPlan plan) { + FileObjectRecord file = fileMapper.lockByPublicId(plan.publicId()); + if (file == null) { + throw new BusinessException(HttpStatus.NOT_FOUND, ErrorCode.RESOURCE_NOT_FOUND, "文件不存在或已失效"); + } + if ("AVAILABLE".equals(file.getScanStatus()) && plan.targetStorageKey().equals(file.getStorageKey())) { + return FileView.from(file); + } + requireMatchingPromotion(file, plan); + file.setScanDetail(plan.scanDetail()); + file.setScannedAt(plan.scannedAt()); + if (fileMapper.completePromotion(file) != 1) { + throw concurrentFileChange(); + } + FileView result = FileView.from(fileMapper.findByPublicId(plan.publicId())); + auditRescan(plan.before(), result); + return result; + } + + private FileView rejectPromotion(RescanPlan plan, String detail) { + return inNewTransaction(() -> { + FileObjectRecord file = fileMapper.lockByPublicId(plan.publicId()); + if (file == null) { + throw new BusinessException(HttpStatus.NOT_FOUND, ErrorCode.RESOURCE_NOT_FOUND, "文件不存在或已失效"); + } + if ("AVAILABLE".equals(file.getScanStatus()) && plan.targetStorageKey().equals(file.getStorageKey())) { + return FileView.from(file); + } + requireMatchingPromotion(file, plan); + file.setScanDetail(limitScanDetail(detail)); + file.setScannedAt(LocalDateTime.now(ZoneOffset.UTC)); + if (fileMapper.rejectPromotion(file) != 1) { + throw concurrentFileChange(); + } + FileView result = FileView.from(fileMapper.findByPublicId(plan.publicId())); + auditRescan(plan.before(), result); + return result; + }); + } + + private void requireMatchingPromotion(FileObjectRecord file, RescanPlan plan) { + if (!"QUARANTINED".equals(file.getScanStatus()) + || !plan.targetStorageKey().equals(file.getPromotionStorageKey())) { + throw concurrentFileChange(); + } + } + + private BusinessException concurrentFileChange() { + return new BusinessException(HttpStatus.CONFLICT, ErrorCode.CONCURRENT_MODIFICATION, + "文件状态已发生变化,请刷新后重试"); + } + + private void auditRescan(FileView before, FileView result) { + auditService.record("FILE_RESCAN", "FILE", result.publicId(), scanResultCode(result.scanStatus()), + result.scanDetail(), before, result); + } + + private String scanResultCode(String scanStatus) { + return switch (scanStatus) { + case "AVAILABLE" -> "SUCCESS"; + case "QUARANTINED" -> "UNAVAILABLE"; + case "REJECTED" -> "REJECTED"; + default -> scanStatus; + }; + } + + private Path storagePath(String storageKey) { + Path root = properties.rootPath(); + Path path = root.resolve(storageKey).normalize(); + if (!path.startsWith(root)) { + throw new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.FILE_TYPE_NOT_ALLOWED, + "文件存储路径不合法"); + } + return path; + } + + private T inNewTransaction(Supplier action) { + T result = requiresNewTransaction.execute(status -> action.get()); + if (result == null) { + throw new IllegalStateException("文件重扫事务未返回结果"); + } + return result; + } + + private Path storedPath(FileObjectRecord file) { + Path root = properties.rootPath(); + Path path = root.resolve(file.getStorageKey()).normalize(); + if (!path.startsWith(root) || !Files.isRegularFile(path)) { + throw new BusinessException(HttpStatus.NOT_FOUND, ErrorCode.RESOURCE_NOT_FOUND, "文件本体不存在"); + } + return path; + } + + private void validate(MultipartFile file) { + if (file == null || file.isEmpty()) { + throw new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.FILE_EMPTY, "请选择非空文件"); + } + if (file.getSize() > properties.maxBytes()) { + throw new BusinessException(HttpStatus.PAYLOAD_TOO_LARGE, ErrorCode.FILE_TOO_LARGE, "文件超过 100MB 限制"); + } + String extension = extension(cleanName(file.getOriginalFilename())); + if (!ALLOWED_EXTENSIONS.contains(extension)) { + throw new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.FILE_TYPE_NOT_ALLOWED, + "仅允许 PDF、OFD、DOCX、XLSX、CSV、JPG 和 PNG 文件"); + } + } + + private void requireAvailable(FileObjectRecord file) { + if ("AVAILABLE".equals(file.getScanStatus())) { + return; + } + if ("QUARANTINED".equals(file.getScanStatus()) || "SCANNING".equals(file.getScanStatus())) { + throw new BusinessException(HttpStatus.SERVICE_UNAVAILABLE, ErrorCode.FILE_SCANNER_UNAVAILABLE, + "文件仍在隔离或安全检查中,暂不可下载"); + } + throw new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.FILE_REJECTED, + "文件未通过安全检查,禁止下载"); + } + + private String limitScanDetail(String detail) { + if (detail == null || detail.isBlank()) { + return null; + } + String normalized = detail.trim(); + return normalized.length() <= 500 ? normalized : normalized.substring(0, 500); + } + + private String cleanName(String originalName) { + if (originalName == null || originalName.isBlank()) { + return "unnamed"; + } + String normalized = originalName.replace('\\', '/'); + String name = normalized.substring(normalized.lastIndexOf('/') + 1).trim(); + return name.length() > 255 ? name.substring(name.length() - 255) : name; + } + + private String extension(String name) { + int dot = name.lastIndexOf('.'); + return dot < 0 || dot == name.length() - 1 ? "" : name.substring(dot + 1).toLowerCase(Locale.ROOT); + } + + private String sha256(Path path) throws IOException { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + try (InputStream input = Files.newInputStream(path); DigestInputStream stream = new DigestInputStream(input, digest)) { + stream.transferTo(java.io.OutputStream.nullOutputStream()); + } + return HexFormat.of().formatHex(digest.digest()); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 is not available", exception); + } + } + + private String sha256(byte[] content) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(content)); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 is not available", exception); + } + } + + private void moveAtomically(Path source, Path target) throws IOException { + try { + Files.move(source, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } catch (AtomicMoveNotSupportedException exception) { + Files.move(source, target, StandardCopyOption.REPLACE_EXISTING); + } + } + + private void deleteQuietly(Path path) { + if (path == null) { + return; + } + try { + Files.deleteIfExists(path); + } catch (IOException ignored) { + // The original failure is more useful to the caller. + } + } + + private record RescanPlan(FileView result, FileView before, String publicId, + String sourceStorageKey, String targetStorageKey, String expectedSha256, + String scanDetail, LocalDateTime scannedAt) { + + static RescanPlan completed(FileView result) { + return new RescanPlan(result, null, result.publicId(), null, null, null, null, null); + } + + static RescanPlan promotion(FileObjectRecord file, FileView before) { + return new RescanPlan(null, before, file.getPublicId(), file.getStorageKey(), + file.getPromotionStorageKey(), file.getSha256(), file.getScanDetail(), file.getScannedAt()); + } + + boolean requiresPromotion() { + return targetStorageKey != null; + } + } + + public record FileDownload(FileObjectRecord metadata, FileSystemResource resource) { + } +} diff --git a/backend/src/main/java/com/kaidi/finance/shared/file/FileController.java b/backend/src/main/java/com/kaidi/finance/shared/file/FileController.java new file mode 100644 index 0000000..0a84c34 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/shared/file/FileController.java @@ -0,0 +1,118 @@ +package com.kaidi.finance.shared.file; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.kaidi.finance.shared.api.ApiResponse; +import com.kaidi.finance.shared.api.BusinessException; +import com.kaidi.finance.shared.api.ErrorCode; +import com.kaidi.finance.shared.idempotency.IdempotencyApplicationService; +import io.swagger.v3.oas.annotations.Operation; +import jakarta.servlet.http.HttpServletRequest; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import org.springframework.core.io.Resource; +import org.springframework.http.ContentDisposition; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +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.RequestMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +@RestController +@RequestMapping("/api/v1/files") +public class FileController { + + private final FileApplicationService fileService; + private final IdempotencyApplicationService idempotencyService; + + public FileController(FileApplicationService fileService, + IdempotencyApplicationService idempotencyService) { + this.fileService = fileService; + this.idempotencyService = idempotencyService; + } + + @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @PreAuthorize("@authorizationService.hasPermission('file:upload')") + public ApiResponse upload(@RequestParam("file") MultipartFile file, + @RequestHeader(value = "Idempotency-Key", required = false) String key, + HttpServletRequest request) { + FileView result = idempotencyService.executeMultipart(key, request.getMethod(), request.getRequestURI(), + Map.of(), file, new TypeReference() { }, + view -> "QUARANTINED".equals(view.scanStatus()) ? 503 : 200, + () -> uploadPersisted(file)); + if ("QUARANTINED".equals(result.scanStatus())) { + throw new BusinessException(HttpStatus.SERVICE_UNAVAILABLE, ErrorCode.FILE_SCANNER_UNAVAILABLE, + "文件安全检查暂不可用,文件已隔离,请稍后重试"); + } + return ApiResponse.ok(result); + } + + private FileView uploadPersisted(MultipartFile file) { + try { + return fileService.upload(file); + } catch (FileScannerUnavailableException exception) { + return exception.persistedFile(); + } + } + + @GetMapping("/{publicId}") + @PreAuthorize("@authorizationService.hasPermission('file:download')") + public ApiResponse metadata(@PathVariable String publicId) { + return ApiResponse.ok(fileService.metadata(publicId)); + } + + @PostMapping("/{publicId}/rescan") + @Operation(operationId = "rescanOwnedFile", summary = "重新扫描本人上传的隔离文件") + @PreAuthorize("@authorizationService.hasPermission('file:upload')") + public ApiResponse rescan(@PathVariable String publicId, + @RequestHeader(value = "Idempotency-Key", required = false) String key, + HttpServletRequest request) { + FileView result = idempotencyService.execute(key, request.getMethod(), request.getRequestURI(), + Map.of("filePublicId", publicId), new TypeReference() { }, FileController::scanStatus, + () -> fileService.rescanOwned(publicId)); + requireAcceptedScan(result); + return ApiResponse.ok(result); + } + + @GetMapping("/{publicId}/content") + @PreAuthorize("@authorizationService.hasPermission('file:download')") + public ResponseEntity download(@PathVariable String publicId) { + FileApplicationService.FileDownload download = fileService.download(publicId); + FileObjectRecord file = download.metadata(); + ContentDisposition disposition = ContentDisposition.attachment() + .filename(file.getOriginalName(), StandardCharsets.UTF_8).build(); + return ResponseEntity.ok() + .contentType(MediaType.parseMediaType(file.getMediaType())) + .contentLength(file.getSizeBytes()) + .header(HttpHeaders.CONTENT_DISPOSITION, disposition.toString()) + .header("X-Content-Type-Options", "nosniff") + .body(download.resource()); + } + + private static void requireAcceptedScan(FileView result) { + if ("QUARANTINED".equals(result.scanStatus())) { + throw new FileScannerUnavailableException(result); + } + if ("REJECTED".equals(result.scanStatus())) { + throw new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.FILE_REJECTED, + "文件未通过安全检查,已拒绝接收"); + } + } + + private static int scanStatus(FileView result) { + if ("QUARANTINED".equals(result.scanStatus())) { + return 503; + } + if ("REJECTED".equals(result.scanStatus())) { + return 422; + } + return 200; + } +} diff --git a/backend/src/main/java/com/kaidi/finance/shared/file/FileMapper.java b/backend/src/main/java/com/kaidi/finance/shared/file/FileMapper.java new file mode 100644 index 0000000..6a1f794 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/shared/file/FileMapper.java @@ -0,0 +1,79 @@ +package com.kaidi.finance.shared.file; + +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +@org.apache.ibatis.annotations.Mapper +public interface FileMapper { + + @Insert(""" + INSERT INTO file_object (public_id, original_name, storage_key, media_type, extension, size_bytes, + sha256, scan_status, scan_detail, scanned_at, uploaded_by) + VALUES (#{publicId}, #{originalName}, #{storageKey}, #{mediaType}, #{extension}, #{sizeBytes}, + #{sha256}, #{scanStatus}, #{scanDetail}, #{scannedAt}, #{uploadedBy}) + """) + int insert(FileObjectRecord file); + + @Select(""" + SELECT id, public_id, original_name, storage_key, promotion_storage_key, media_type, extension, size_bytes, sha256, + scan_status, scan_detail, scanned_at, uploaded_by, created_at + FROM file_object WHERE public_id = #{publicId} + """) + FileObjectRecord findByPublicId(String publicId); + + @Select(""" + SELECT id, public_id, original_name, storage_key, promotion_storage_key, media_type, extension, size_bytes, sha256, + scan_status, scan_detail, scanned_at, uploaded_by, created_at + FROM file_object WHERE public_id = #{publicId} + FOR UPDATE + """) + FileObjectRecord lockByPublicId(String publicId); + + @Update(""" + UPDATE file_object + SET storage_key = #{storageKey}, + promotion_storage_key = NULL, + scan_status = #{scanStatus}, + scan_detail = #{scanDetail}, + scanned_at = #{scannedAt} + WHERE id = #{id} AND scan_status = 'QUARANTINED' + """) + int updateRescanResult(FileObjectRecord file); + + @Update(""" + UPDATE file_object + SET promotion_storage_key = #{promotionStorageKey}, + scan_detail = #{scanDetail}, + scanned_at = #{scannedAt} + WHERE id = #{id} + AND scan_status = 'QUARANTINED' + AND promotion_storage_key IS NULL + """) + int preparePromotion(FileObjectRecord file); + + @Update(""" + UPDATE file_object + SET storage_key = #{promotionStorageKey}, + promotion_storage_key = NULL, + scan_status = 'AVAILABLE', + scan_detail = #{scanDetail}, + scanned_at = #{scannedAt} + WHERE id = #{id} + AND scan_status = 'QUARANTINED' + AND promotion_storage_key = #{promotionStorageKey} + """) + int completePromotion(FileObjectRecord file); + + @Update(""" + UPDATE file_object + SET promotion_storage_key = NULL, + scan_status = 'REJECTED', + scan_detail = #{scanDetail}, + scanned_at = #{scannedAt} + WHERE id = #{id} + AND scan_status = 'QUARANTINED' + AND promotion_storage_key = #{promotionStorageKey} + """) + int rejectPromotion(FileObjectRecord file); +} diff --git a/backend/src/main/java/com/kaidi/finance/shared/file/FileObjectRecord.java b/backend/src/main/java/com/kaidi/finance/shared/file/FileObjectRecord.java new file mode 100644 index 0000000..1f7ba68 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/shared/file/FileObjectRecord.java @@ -0,0 +1,50 @@ +package com.kaidi.finance.shared.file; + +import java.time.LocalDateTime; + +public class FileObjectRecord { + + private long id; + private String publicId; + private String originalName; + private String storageKey; + private String promotionStorageKey; + private String mediaType; + private String extension; + private long sizeBytes; + private String sha256; + private String scanStatus; + private String scanDetail; + private LocalDateTime scannedAt; + private String uploadedBy; + private LocalDateTime createdAt; + + public long getId() { return id; } + public void setId(long id) { this.id = id; } + public String getPublicId() { return publicId; } + public void setPublicId(String publicId) { this.publicId = publicId; } + public String getOriginalName() { return originalName; } + public void setOriginalName(String originalName) { this.originalName = originalName; } + public String getStorageKey() { return storageKey; } + public void setStorageKey(String storageKey) { this.storageKey = storageKey; } + public String getPromotionStorageKey() { return promotionStorageKey; } + public void setPromotionStorageKey(String promotionStorageKey) { this.promotionStorageKey = promotionStorageKey; } + public String getMediaType() { return mediaType; } + public void setMediaType(String mediaType) { this.mediaType = mediaType; } + public String getExtension() { return extension; } + public void setExtension(String extension) { this.extension = extension; } + public long getSizeBytes() { return sizeBytes; } + public void setSizeBytes(long sizeBytes) { this.sizeBytes = sizeBytes; } + public String getSha256() { return sha256; } + public void setSha256(String sha256) { this.sha256 = sha256; } + public String getScanStatus() { return scanStatus; } + public void setScanStatus(String scanStatus) { this.scanStatus = scanStatus; } + public String getScanDetail() { return scanDetail; } + public void setScanDetail(String scanDetail) { this.scanDetail = scanDetail; } + public LocalDateTime getScannedAt() { return scannedAt; } + public void setScannedAt(LocalDateTime scannedAt) { this.scannedAt = scannedAt; } + public String getUploadedBy() { return uploadedBy; } + public void setUploadedBy(String uploadedBy) { this.uploadedBy = uploadedBy; } + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } +} diff --git a/backend/src/main/java/com/kaidi/finance/shared/file/FileRecordService.java b/backend/src/main/java/com/kaidi/finance/shared/file/FileRecordService.java new file mode 100644 index 0000000..1c25921 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/shared/file/FileRecordService.java @@ -0,0 +1,28 @@ +package com.kaidi.finance.shared.file; + +import com.kaidi.finance.shared.audit.AuditService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@Service +class FileRecordService { + + private final FileMapper fileMapper; + private final AuditService auditService; + + FileRecordService(FileMapper fileMapper, AuditService auditService) { + this.fileMapper = fileMapper; + this.auditService = auditService; + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + FileView persist(FileObjectRecord record) { + fileMapper.insert(record); + FileObjectRecord stored = fileMapper.findByPublicId(record.getPublicId()); + FileView view = FileView.from(stored); + String result = "AVAILABLE".equals(stored.getScanStatus()) ? "SUCCESS" : "REJECTED"; + auditService.record("FILE_UPLOAD", "FILE", stored.getPublicId(), result, stored.getScanDetail(), null, view); + return view; + } +} diff --git a/backend/src/main/java/com/kaidi/finance/shared/file/FileScanner.java b/backend/src/main/java/com/kaidi/finance/shared/file/FileScanner.java new file mode 100644 index 0000000..08c8cec --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/shared/file/FileScanner.java @@ -0,0 +1,29 @@ +package com.kaidi.finance.shared.file; + +import java.nio.file.Path; + +public interface FileScanner { + + ScanResult scan(Path path); + + enum Status { + CLEAN, + INFECTED, + UNAVAILABLE + } + + record ScanResult(Status status, String detail) { + + public static ScanResult clean(String detail) { + return new ScanResult(Status.CLEAN, detail); + } + + public static ScanResult infected(String detail) { + return new ScanResult(Status.INFECTED, detail); + } + + public static ScanResult unavailable(String detail) { + return new ScanResult(Status.UNAVAILABLE, detail); + } + } +} diff --git a/backend/src/main/java/com/kaidi/finance/shared/file/FileScannerConfiguration.java b/backend/src/main/java/com/kaidi/finance/shared/file/FileScannerConfiguration.java new file mode 100644 index 0000000..8cb8471 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/shared/file/FileScannerConfiguration.java @@ -0,0 +1,32 @@ +package com.kaidi.finance.shared.file; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; + +@Configuration(proxyBeanMethods = false) +class FileScannerConfiguration { + + @Bean + @ConditionalOnProperty(prefix = "finance.storage.scanner", name = "enabled", havingValue = "true") + FileScanner clamAvFileScanner(FileScannerProperties properties) { + return new ClamAvFileScanner(properties); + } + + @Bean + @Profile("local") + @ConditionalOnProperty(prefix = "finance.storage.scanner", name = "enabled", havingValue = "false", + matchIfMissing = true) + FileScanner trustedLocalFileScanner() { + return path -> FileScanner.ScanResult.clean("本地开发模式:外部病毒扫描未启用"); + } + + @Bean + @Profile("!local") + @ConditionalOnMissingBean(FileScanner.class) + FileScanner unavailableFileScanner() { + return path -> FileScanner.ScanResult.unavailable("本地病毒扫描服务未配置"); + } +} diff --git a/backend/src/main/java/com/kaidi/finance/shared/file/FileScannerProperties.java b/backend/src/main/java/com/kaidi/finance/shared/file/FileScannerProperties.java new file mode 100644 index 0000000..2e25067 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/shared/file/FileScannerProperties.java @@ -0,0 +1,16 @@ +package com.kaidi.finance.shared.file; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "finance.storage.scanner") +public record FileScannerProperties(boolean enabled, String host, int port, int connectTimeoutMillis, + int readTimeoutMillis, int chunkBytes) { + + public FileScannerProperties { + host = host == null || host.isBlank() ? "127.0.0.1" : host.trim(); + port = port <= 0 ? 3310 : port; + connectTimeoutMillis = connectTimeoutMillis <= 0 ? 2_000 : connectTimeoutMillis; + readTimeoutMillis = readTimeoutMillis <= 0 ? 30_000 : readTimeoutMillis; + chunkBytes = chunkBytes <= 0 ? 65_536 : Math.min(chunkBytes, 1_048_576); + } +} diff --git a/backend/src/main/java/com/kaidi/finance/shared/file/FileScannerUnavailableException.java b/backend/src/main/java/com/kaidi/finance/shared/file/FileScannerUnavailableException.java new file mode 100644 index 0000000..4d1ca0c --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/shared/file/FileScannerUnavailableException.java @@ -0,0 +1,20 @@ +package com.kaidi.finance.shared.file; + +import com.kaidi.finance.shared.api.BusinessException; +import com.kaidi.finance.shared.api.ErrorCode; +import org.springframework.http.HttpStatus; + +public final class FileScannerUnavailableException extends BusinessException { + + private final FileView persistedFile; + + public FileScannerUnavailableException(FileView persistedFile) { + super(HttpStatus.SERVICE_UNAVAILABLE, ErrorCode.FILE_SCANNER_UNAVAILABLE, + "文件安全检查暂不可用,文件已隔离,请稍后重试"); + this.persistedFile = persistedFile; + } + + public FileView persistedFile() { + return persistedFile; + } +} diff --git a/backend/src/main/java/com/kaidi/finance/shared/file/FileStorageProperties.java b/backend/src/main/java/com/kaidi/finance/shared/file/FileStorageProperties.java new file mode 100644 index 0000000..2655248 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/shared/file/FileStorageProperties.java @@ -0,0 +1,16 @@ +package com.kaidi.finance.shared.file; + +import java.nio.file.Path; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "finance.storage") +public record FileStorageProperties(String root, String temp, long maxBytes) { + + public Path rootPath() { + return Path.of(root).toAbsolutePath().normalize(); + } + + public Path tempPath() { + return Path.of(temp).toAbsolutePath().normalize(); + } +} diff --git a/backend/src/main/java/com/kaidi/finance/shared/file/FileView.java b/backend/src/main/java/com/kaidi/finance/shared/file/FileView.java new file mode 100644 index 0000000..609dc8a --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/shared/file/FileView.java @@ -0,0 +1,14 @@ +package com.kaidi.finance.shared.file; + +import java.time.LocalDateTime; + +public record FileView(String publicId, String originalName, String mediaType, String extension, long sizeBytes, + String sha256, String scanStatus, String scanDetail, LocalDateTime scannedAt, + LocalDateTime createdAt) { + + static FileView from(FileObjectRecord file) { + return new FileView(file.getPublicId(), file.getOriginalName(), file.getMediaType(), file.getExtension(), + file.getSizeBytes(), file.getSha256(), file.getScanStatus(), file.getScanDetail(), file.getScannedAt(), + file.getCreatedAt()); + } +} diff --git a/backend/src/main/java/com/kaidi/finance/shared/id/UlidGenerator.java b/backend/src/main/java/com/kaidi/finance/shared/id/UlidGenerator.java new file mode 100644 index 0000000..60af032 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/shared/id/UlidGenerator.java @@ -0,0 +1,33 @@ +package com.kaidi.finance.shared.id; + +import java.math.BigInteger; +import java.security.SecureRandom; +import org.springframework.stereotype.Component; + +@Component +public class UlidGenerator { + + private static final char[] ENCODING = "0123456789ABCDEFGHJKMNPQRSTVWXYZ".toCharArray(); + private final SecureRandom random = new SecureRandom(); + + public String next() { + byte[] value = new byte[16]; + long timestamp = System.currentTimeMillis(); + for (int index = 5; index >= 0; index--) { + value[index] = (byte) timestamp; + timestamp >>>= 8; + } + byte[] randomness = new byte[10]; + random.nextBytes(randomness); + System.arraycopy(randomness, 0, value, 6, randomness.length); + + BigInteger number = new BigInteger(1, value); + char[] encoded = new char[26]; + for (int index = encoded.length - 1; index >= 0; index--) { + BigInteger[] division = number.divideAndRemainder(BigInteger.valueOf(32)); + encoded[index] = ENCODING[division[1].intValue()]; + number = division[0]; + } + return new String(encoded); + } +} diff --git a/backend/src/main/java/com/kaidi/finance/shared/idempotency/IdempotencyApplicationService.java b/backend/src/main/java/com/kaidi/finance/shared/idempotency/IdempotencyApplicationService.java new file mode 100644 index 0000000..b566737 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/shared/idempotency/IdempotencyApplicationService.java @@ -0,0 +1,271 @@ +package com.kaidi.finance.shared.idempotency; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.kaidi.finance.iam.domain.FinancePrincipal; +import com.kaidi.finance.shared.api.BusinessException; +import com.kaidi.finance.shared.api.ErrorCode; +import com.kaidi.finance.shared.security.IdentityContext; +import com.kaidi.finance.shared.security.AuthorizationService; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.HexFormat; +import java.util.function.Supplier; +import java.util.function.ToIntFunction; +import java.util.regex.Pattern; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +/** + * Transactional idempotency for the first write commands. The legacy shared + * table predates the full scope columns, so commandName deliberately includes + * active role, HTTP method and normalized path while user_public_id supplies + * the actor dimension. A later shared-table migration can split those values + * without changing callers. + */ +@Service +public class IdempotencyApplicationService { + + private static final Pattern KEY = Pattern.compile("[\\x20-\\x7E]{16,64}"); + + private final IdempotencyMapper mapper; + private final IdentityContext identityContext; + private final AuthorizationService authorizationService; + private final ObjectMapper objectMapper; + + public IdempotencyApplicationService(IdempotencyMapper mapper, IdentityContext identityContext, + AuthorizationService authorizationService, ObjectMapper objectMapper) { + this.mapper = mapper; + this.identityContext = identityContext; + this.authorizationService = authorizationService; + this.objectMapper = objectMapper; + } + + @Transactional + public T execute(String key, String method, String path, Object requestBody, + TypeReference responseType, Supplier action) { + return executeWithHash(key, method, path, hash(requestBody), responseType, ignored -> 200, action); + } + + @Transactional + public T execute(String key, String method, String path, Object requestBody, + TypeReference responseType, ToIntFunction responseStatus, + Supplier action) { + return executeWithHash(key, method, path, hash(requestBody), responseType, responseStatus, action); + } + + /** + * Computes a repeatable multipart fingerprint without loading the uploaded + * file into heap. The upload service opens the part again after this pass. + */ + @Transactional + public T executeMultipart(String key, String method, String path, Object metadata, MultipartFile file, + TypeReference responseType, Supplier action) { + return executeMultipart(key, method, path, metadata, file, responseType, ignored -> 200, action); + } + + @Transactional + public T executeMultipart(String key, String method, String path, Object metadata, MultipartFile file, + TypeReference responseType, ToIntFunction responseStatus, + Supplier action) { + return executeWithHash(key, method, path, multipartHash(metadata, file), responseType, responseStatus, + action); + } + + private T executeWithHash(String key, String method, String path, String hash, + TypeReference responseType, ToIntFunction responseStatus, + Supplier action) { + validateKey(key); + FinancePrincipal actor = identityContext.requirePrincipal(); + String role = identityContext.requireActiveRole(); + String command = normalizeCommand(role, method, path, authorizationService.activeScopeFingerprint()); + IdempotencyRecord record = mapper.find(key, actor.publicId(), command); + int claimed = 0; + if (record != null && mapper.deleteIfExpired(record.id()) == 1) { + record = null; + } + if (record == null) { + claimed = mapper.claim(key, actor.publicId(), command, hash, + LocalDateTime.now(ZoneOffset.UTC).plusHours(24)); + record = mapper.find(key, actor.publicId(), command); + } + if (record == null) { + throw new BusinessException(HttpStatus.CONFLICT, ErrorCode.COMMAND_IN_PROGRESS, + "命令正在处理中,请稍后重试"); + } + if (!hash.equalsIgnoreCase(record.requestHash())) { + throw new BusinessException(HttpStatus.CONFLICT, ErrorCode.IDEMPOTENCY_KEY_REUSED, + "Idempotency-Key 已用于不同请求"); + } + if ("COMPLETED".equals(record.state())) { + return replay(record, responseType); + } + if (claimed == 1 && "STARTED".equals(record.state())) { + T result = action.get(); + String responseJson; + try { + responseJson = objectMapper.writeValueAsString(result); + } catch (JsonProcessingException exception) { + throw new IllegalStateException("幂等结果无法序列化", exception); + } + int status = responseStatus.applyAsInt(result); + if (status < 100 || status > 599) { + throw new IllegalArgumentException("幂等响应状态必须在 100 至 599 之间"); + } + if (mapper.complete(record.id(), hash, status, responseJson) != 1) { + throw new BusinessException(HttpStatus.CONFLICT, ErrorCode.COMMAND_IN_PROGRESS, + "命令状态已被其他请求更新,请稍后重试"); + } + return result; + } + throw new BusinessException(HttpStatus.CONFLICT, ErrorCode.COMMAND_IN_PROGRESS, + "命令正在处理中,请稍后重试"); + } + + /** + * Variant for commands which intentionally persist a blocking check before + * returning a deterministic business error. The payment approval command + * uses this boundary: its service transaction is explicitly + * {@code noRollbackFor=BusinessException.class}, so the check snapshot and + * audit event remain visible while the STARTED reservation is removed and + * the failed command is not cached as a successful replay. + */ + @Transactional(noRollbackFor = BusinessException.class) + public T executeAllowingBusinessException(String key, String method, String path, Object requestBody, + TypeReference responseType, Supplier action) { + validateKey(key); + FinancePrincipal actor = identityContext.requirePrincipal(); + String role = identityContext.requireActiveRole(); + String command = normalizeCommand(role, method, path, authorizationService.activeScopeFingerprint()); + String hash = hash(requestBody); + IdempotencyRecord record = mapper.find(key, actor.publicId(), command); + int claimed = 0; + if (record != null && mapper.deleteIfExpired(record.id()) == 1) { + record = null; + } + if (record == null) { + claimed = mapper.claim(key, actor.publicId(), command, hash, + LocalDateTime.now(ZoneOffset.UTC).plusHours(24)); + record = mapper.find(key, actor.publicId(), command); + } + if (record == null) { + throw new BusinessException(HttpStatus.CONFLICT, ErrorCode.COMMAND_IN_PROGRESS, + "命令正在处理中,请稍后重试"); + } + if (!hash.equalsIgnoreCase(record.requestHash())) { + throw new BusinessException(HttpStatus.CONFLICT, ErrorCode.IDEMPOTENCY_KEY_REUSED, + "Idempotency-Key 已用于不同请求"); + } + if ("COMPLETED".equals(record.state())) { + return replay(record, responseType); + } + if (claimed != 1 || !"STARTED".equals(record.state())) { + throw new BusinessException(HttpStatus.CONFLICT, ErrorCode.COMMAND_IN_PROGRESS, + "命令正在处理中,请稍后重试"); + } + try { + T result = action.get(); + String responseJson; + try { + responseJson = objectMapper.writeValueAsString(result); + } catch (JsonProcessingException exception) { + throw new IllegalStateException("幂等结果无法序列化", exception); + } + if (mapper.complete(record.id(), hash, 200, responseJson) != 1) { + throw new BusinessException(HttpStatus.CONFLICT, ErrorCode.COMMAND_IN_PROGRESS, + "命令状态已被其他请求更新,请稍后重试"); + } + return result; + } catch (BusinessException exception) { + mapper.deleteStarted(record.id(), hash); + throw exception; + } + } + + private T replay(IdempotencyRecord record, TypeReference responseType) { + if (record.responseJson() == null) { + throw new BusinessException(HttpStatus.CONFLICT, ErrorCode.COMMAND_IN_PROGRESS, + "命令正在处理中,请稍后重试"); + } + try { + return objectMapper.readValue(record.responseJson(), responseType); + } catch (JsonProcessingException exception) { + throw new BusinessException(HttpStatus.INTERNAL_SERVER_ERROR, ErrorCode.INTERNAL_ERROR, + "幂等结果无法恢复,请使用请求编号联系管理员"); + } + } + + private String normalizeCommand(String role, String method, String path, String scopeFingerprint) { + String safePath = path == null ? "/" : path.split("\\?", 2)[0].replaceAll("/{2,}", "/"); + while (safePath.length() > 1 && safePath.endsWith("/")) safePath = safePath.substring(0, safePath.length() - 1); + safePath = safePath.replaceFirst("^/api/v1/archives(?=/|$)", "/api/v1/archive"); + String prefix = role + "|" + method.toUpperCase(java.util.Locale.ROOT) + "|" + safePath; + String commandFingerprint = hash(prefix).substring(0, 24); + String scopeKey = scopeFingerprint == null ? hash("").substring(0, 24) + : scopeFingerprint.substring(0, Math.min(24, scopeFingerprint.length())); + String suffix = "|C" + commandFingerprint + "|S" + scopeKey; + int prefixLimit = Math.max(1, 100 - suffix.length()); + if (prefix.length() > prefixLimit) prefix = prefix.substring(0, prefixLimit); + return prefix + suffix; + } + + private String hash(Object body) { + try { + byte[] bytes = body == null ? "{}".getBytes(StandardCharsets.UTF_8) + : objectMapper.writeValueAsBytes(body); + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)); + } catch (JsonProcessingException | NoSuchAlgorithmException exception) { + throw new IllegalStateException("无法计算幂等请求摘要", exception); + } + } + + private String multipartHash(Object metadata, MultipartFile file) { + try { + MessageDigest requestDigest = MessageDigest.getInstance("SHA-256"); + updatePart(requestDigest, objectMapper.writeValueAsBytes(metadata)); + updatePart(requestDigest, utf8(file == null ? null : file.getOriginalFilename())); + updatePart(requestDigest, utf8(file == null ? null : file.getContentType())); + updatePart(requestDigest, utf8(file == null ? null : Long.toString(file.getSize()))); + + MessageDigest fileDigest = MessageDigest.getInstance("SHA-256"); + if (file != null) { + try (InputStream input = file.getInputStream()) { + byte[] buffer = new byte[8192]; + int read; + while ((read = input.read(buffer)) != -1) fileDigest.update(buffer, 0, read); + } + } + updatePart(requestDigest, fileDigest.digest()); + return HexFormat.of().formatHex(requestDigest.digest()); + } catch (JsonProcessingException | NoSuchAlgorithmException exception) { + throw new IllegalStateException("无法计算 multipart 幂等请求摘要", exception); + } catch (IOException exception) { + throw new BusinessException(HttpStatus.INTERNAL_SERVER_ERROR, ErrorCode.FILE_STORAGE_ERROR, + "文件读取失败,请稍后重试"); + } + } + + private void validateKey(String key) { + if (key == null || !KEY.matcher(key).matches()) { + throw new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.VALIDATION_FAILED, + "Idempotency-Key 必须为 16 至 64 位可打印 ASCII 字符"); + } + } + + private byte[] utf8(String value) { + return (value == null ? "" : value).getBytes(StandardCharsets.UTF_8); + } + + private void updatePart(MessageDigest digest, byte[] part) { + digest.update(java.nio.ByteBuffer.allocate(Integer.BYTES).putInt(part.length).array()); + digest.update(part); + } +} diff --git a/backend/src/main/java/com/kaidi/finance/shared/idempotency/IdempotencyMapper.java b/backend/src/main/java/com/kaidi/finance/shared/idempotency/IdempotencyMapper.java new file mode 100644 index 0000000..4f03528 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/shared/idempotency/IdempotencyMapper.java @@ -0,0 +1,52 @@ +package com.kaidi.finance.shared.idempotency; + +import org.apache.ibatis.annotations.Delete; +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +@Mapper +public interface IdempotencyMapper { + + @Select(""" + SELECT id, idempotency_key, user_public_id, command_name, request_hash, + response_status, CAST(response_json AS CHAR) AS response_json, + state, expires_at + FROM sys_idempotency_record + WHERE idempotency_key = #{key} AND user_public_id = #{userPublicId} + AND command_name = #{commandName} + """) + IdempotencyRecord find(@Param("key") String key, @Param("userPublicId") String userPublicId, + @Param("commandName") String commandName); + + /** INSERT IGNORE makes the unique key the concurrency reservation. */ + @Insert(""" + INSERT IGNORE INTO sys_idempotency_record ( + idempotency_key, user_public_id, command_name, request_hash, + response_status, response_json, state, expires_at + ) VALUES ( + #{key}, #{userPublicId}, #{commandName}, #{requestHash}, + NULL, NULL, 'STARTED', #{expiresAt} + ) + """) + int claim(@Param("key") String key, @Param("userPublicId") String userPublicId, + @Param("commandName") String commandName, @Param("requestHash") String requestHash, + @Param("expiresAt") java.time.LocalDateTime expiresAt); + + @Update(""" + UPDATE sys_idempotency_record + SET state = 'COMPLETED', response_status = #{responseStatus}, + response_json = CAST(#{responseJson} AS JSON), updated_at = UTC_TIMESTAMP(3) + WHERE id = #{id} AND state = 'STARTED' AND request_hash = #{requestHash} + """) + int complete(@Param("id") long id, @Param("requestHash") String requestHash, + @Param("responseStatus") int responseStatus, @Param("responseJson") String responseJson); + + @Delete("DELETE FROM sys_idempotency_record WHERE id = #{id} AND expires_at <= UTC_TIMESTAMP(3)") + int deleteIfExpired(long id); + + @Delete("DELETE FROM sys_idempotency_record WHERE id = #{id} AND state = 'STARTED' AND request_hash = #{requestHash}") + int deleteStarted(@Param("id") long id, @Param("requestHash") String requestHash); +} diff --git a/backend/src/main/java/com/kaidi/finance/shared/idempotency/IdempotencyRecord.java b/backend/src/main/java/com/kaidi/finance/shared/idempotency/IdempotencyRecord.java new file mode 100644 index 0000000..7a10859 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/shared/idempotency/IdempotencyRecord.java @@ -0,0 +1,16 @@ +package com.kaidi.finance.shared.idempotency; + +import java.time.LocalDateTime; + +public record IdempotencyRecord( + long id, + String idempotencyKey, + String userPublicId, + String commandName, + String requestHash, + Integer responseStatus, + String responseJson, + String state, + LocalDateTime expiresAt +) { +} diff --git a/backend/src/main/java/com/kaidi/finance/shared/infrastructure/RequestContext.java b/backend/src/main/java/com/kaidi/finance/shared/infrastructure/RequestContext.java new file mode 100644 index 0000000..8a41876 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/shared/infrastructure/RequestContext.java @@ -0,0 +1,22 @@ +package com.kaidi.finance.shared.infrastructure; + +public final class RequestContext { + + private static final ThreadLocal REQUEST_ID = new ThreadLocal<>(); + + private RequestContext() { + } + + public static void setRequestId(String requestId) { + REQUEST_ID.set(requestId); + } + + public static String requestId() { + String requestId = REQUEST_ID.get(); + return requestId == null ? "UNKNOWN" : requestId; + } + + public static void clear() { + REQUEST_ID.remove(); + } +} diff --git a/backend/src/main/java/com/kaidi/finance/shared/infrastructure/RequestIdFilter.java b/backend/src/main/java/com/kaidi/finance/shared/infrastructure/RequestIdFilter.java new file mode 100644 index 0000000..736b3e3 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/shared/infrastructure/RequestIdFilter.java @@ -0,0 +1,42 @@ +package com.kaidi.finance.shared.infrastructure; + +import com.kaidi.finance.shared.id.UlidGenerator; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import org.slf4j.MDC; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +@Component +@Order(Ordered.HIGHEST_PRECEDENCE) +public class RequestIdFilter extends OncePerRequestFilter { + + private static final String HEADER = "X-Request-Id"; + private final UlidGenerator ulidGenerator; + + public RequestIdFilter(UlidGenerator ulidGenerator) { + this.ulidGenerator = ulidGenerator; + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + String supplied = request.getHeader(HEADER); + String requestId = supplied != null && supplied.matches("[0-9A-HJKMNP-TV-Z]{26}") + ? supplied : ulidGenerator.next(); + RequestContext.setRequestId(requestId); + MDC.put("requestId", requestId); + response.setHeader(HEADER, requestId); + try { + filterChain.doFilter(request, response); + } finally { + MDC.remove("requestId"); + RequestContext.clear(); + } + } +} diff --git a/backend/src/main/java/com/kaidi/finance/shared/security/AuthorizationMapper.java b/backend/src/main/java/com/kaidi/finance/shared/security/AuthorizationMapper.java new file mode 100644 index 0000000..ea81dc3 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/shared/security/AuthorizationMapper.java @@ -0,0 +1,103 @@ +package com.kaidi.finance.shared.security; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@org.apache.ibatis.annotations.Mapper +public interface AuthorizationMapper { + + @Select(""" + SELECT scope.scope_type, scope.company_public_id, scope.project_public_id, scope.amount_limit + FROM iam_scope scope + JOIN iam_role role ON role.id = scope.role_id + JOIN iam_permission permission ON permission.id = scope.permission_id + JOIN iam_user_role user_role ON user_role.user_id = scope.user_id AND user_role.role_id = scope.role_id + WHERE scope.user_id = #{userId} + AND role.code = #{roleCode} + AND role.enabled = TRUE + AND permission.code = #{permissionCode} + AND scope.status = 'ACTIVE' + AND scope.valid_from <= UTC_TIMESTAMP(3) + AND (scope.valid_to IS NULL OR scope.valid_to >= UTC_TIMESTAMP(3)) + AND ( + scope.scope_type = 'GLOBAL' + OR (scope.scope_type = 'COMPANY' + AND #{companyPublicId} IS NOT NULL + AND scope.company_public_id = #{companyPublicId}) + OR (scope.scope_type = 'PROJECT' + AND #{projectPublicId} IS NOT NULL + AND scope.project_public_id = #{projectPublicId} + AND (scope.company_public_id IS NULL OR scope.company_public_id = #{companyPublicId})) + ) + ORDER BY CASE scope.scope_type WHEN 'PROJECT' THEN 1 WHEN 'COMPANY' THEN 2 ELSE 3 END + """) + List findMatchingScopes(@Param("userId") long userId, + @Param("roleCode") String roleCode, + @Param("permissionCode") String permissionCode, + @Param("companyPublicId") String companyPublicId, + @Param("projectPublicId") String projectPublicId); + + @Select(""" + SELECT permission.code AS permission_code, scope.scope_type, + scope.company_public_id, scope.project_public_id, scope.amount_limit, + scope.valid_from, scope.valid_to + FROM iam_scope scope + JOIN iam_role role ON role.id = scope.role_id + JOIN iam_permission permission ON permission.id = scope.permission_id + JOIN iam_user_role user_role ON user_role.user_id = scope.user_id + AND user_role.role_id = scope.role_id + WHERE scope.user_id = #{userId} + AND role.code = #{roleCode} + AND role.enabled = TRUE + AND scope.status = 'ACTIVE' + AND scope.valid_from <= #{scopeAsOf} + AND (scope.valid_to IS NULL OR scope.valid_to >= #{scopeAsOf}) + ORDER BY permission.code, scope.scope_type, scope.company_public_id, + scope.project_public_id, scope.amount_limit, scope.valid_from, scope.valid_to + """) + List findActiveScopes(@Param("userId") long userId, + @Param("roleCode") String roleCode, + @Param("scopeAsOf") LocalDateTime scopeAsOf); + + @Select(""" + SELECT role.code AS role_code, role.name AS role_name, scope.scope_type, + scope.company_public_id, company.business_no AS company_code, company.name AS company_name, + scope.project_public_id, project.business_no AS project_code, project.name AS project_name, + COUNT(DISTINCT permission.code) AS permission_count, + MAX(scope.amount_limit) AS amount_limit + FROM iam_scope scope + JOIN iam_role role ON role.id = scope.role_id + JOIN iam_permission permission ON permission.id = scope.permission_id + JOIN iam_user_role user_role ON user_role.user_id = scope.user_id + AND user_role.role_id = scope.role_id + LEFT JOIN md_company company ON company.public_id = scope.company_public_id + LEFT JOIN md_project project ON project.public_id = scope.project_public_id + WHERE scope.user_id = #{userId} + AND (#{roleCode} IS NULL OR role.code = #{roleCode}) + AND role.enabled = TRUE + AND scope.status = 'ACTIVE' + AND scope.valid_from <= UTC_TIMESTAMP(3) + AND (scope.valid_to IS NULL OR scope.valid_to >= UTC_TIMESTAMP(3)) + GROUP BY role.code, role.name, role.sort_order, scope.scope_type, + scope.company_public_id, company.business_no, company.name, + scope.project_public_id, project.business_no, project.name + ORDER BY role.sort_order, role.code, scope.scope_type, + company.business_no, project.business_no + """) + List findProfileScopes(@Param("userId") long userId, + @Param("roleCode") String roleCode); + + record ScopeFingerprintRow(String permissionCode, String scopeType, String companyPublicId, + String projectPublicId, BigDecimal amountLimit, + LocalDateTime validFrom, LocalDateTime validTo) { + } + + record ProfileScopeRow(String roleCode, String roleName, String scopeType, + String companyPublicId, String companyCode, String companyName, + String projectPublicId, String projectCode, String projectName, + long permissionCount, BigDecimal amountLimit) { + } +} diff --git a/backend/src/main/java/com/kaidi/finance/shared/security/AuthorizationService.java b/backend/src/main/java/com/kaidi/finance/shared/security/AuthorizationService.java new file mode 100644 index 0000000..a34757a --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/shared/security/AuthorizationService.java @@ -0,0 +1,139 @@ +package com.kaidi.finance.shared.security; + +import com.kaidi.finance.iam.domain.FinancePrincipal; +import com.kaidi.finance.iam.infrastructure.UserAccountMapper; +import com.kaidi.finance.shared.api.BusinessException; +import com.kaidi.finance.shared.api.ErrorCode; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.List; +import java.util.HexFormat; +import java.util.stream.Collectors; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; + +@Service("authorizationService") +public class AuthorizationService { + + private final IdentityContext identityContext; + private final UserAccountMapper userAccountMapper; + private final AuthorizationMapper authorizationMapper; + + public AuthorizationService(IdentityContext identityContext, UserAccountMapper userAccountMapper, + AuthorizationMapper authorizationMapper) { + this.identityContext = identityContext; + this.userAccountMapper = userAccountMapper; + this.authorizationMapper = authorizationMapper; + } + + public boolean hasPermission(String permissionCode) { + FinancePrincipal principal = identityContext.requirePrincipal(); + if (principal.mustChangePassword()) { + throw new BusinessException(HttpStatus.FORBIDDEN, ErrorCode.AUTH_PASSWORD_CHANGE_REQUIRED, + "首次登录必须先修改密码"); + } + String role = identityContext.activeRole(); + return role != null && userAccountMapper.countPermission(principal.userId(), role, permissionCode) > 0; + } + + public void requirePermission(String permissionCode) { + if (!hasPermission(permissionCode)) { + throw new BusinessException(HttpStatus.FORBIDDEN, ErrorCode.PERMISSION_DENIED, + "当前身份没有执行该操作的权限"); + } + } + + public void requireGlobalScope(String permissionCode) { + requireScope(permissionCode, null, null, null); + } + + public boolean hasScope(String permissionCode, String companyPublicId, String projectPublicId) { + return hasScope(permissionCode, companyPublicId, projectPublicId, null); + } + + public boolean hasScope(String permissionCode, String companyPublicId, String projectPublicId, + BigDecimal amount) { + if (!hasPermission(permissionCode)) { + return false; + } + FinancePrincipal principal = identityContext.requirePrincipal(); + String role = identityContext.requireActiveRole(); + List scopes = authorizationMapper.findMatchingScopes(principal.userId(), role, permissionCode, + companyPublicId, projectPublicId); + if (scopes.isEmpty()) { + return false; + } + return amount == null || scopes.stream().anyMatch(scope -> scope.amountLimit() == null) + || scopes.stream().map(ScopeGrant::amountLimit).max(BigDecimal::compareTo) + .filter(limit -> amount.compareTo(limit) <= 0).isPresent(); + } + + public void requireScope(String permissionCode, String companyPublicId, String projectPublicId) { + requireScope(permissionCode, companyPublicId, projectPublicId, null); + } + + public void requireScope(String permissionCode, String companyPublicId, String projectPublicId, + BigDecimal amount) { + requirePermission(permissionCode); + FinancePrincipal principal = identityContext.requirePrincipal(); + String role = identityContext.requireActiveRole(); + List scopes = authorizationMapper.findMatchingScopes(principal.userId(), role, permissionCode, + companyPublicId, projectPublicId); + if (scopes.isEmpty()) { + throw new BusinessException(HttpStatus.FORBIDDEN, ErrorCode.DATA_SCOPE_DENIED, + "当前身份无权访问该公司或项目的数据"); + } + if (amount == null || scopes.stream().anyMatch(scope -> scope.amountLimit() == null)) { + return; + } + BigDecimal maximum = scopes.stream().map(ScopeGrant::amountLimit).max(BigDecimal::compareTo).orElseThrow(); + if (amount.compareTo(maximum) > 0) { + throw new BusinessException(HttpStatus.FORBIDDEN, ErrorCode.AMOUNT_LIMIT_EXCEEDED, + "该金额超过当前身份的授权额度"); + } + } + + /** + * Stable digest of the active grants for the current user and role. It is + * used by idempotency records as a replay guard; a scope revocation or + * replacement therefore forces the original command through business + * authorization again instead of returning a cached DTO. + */ + public String activeScopeFingerprint() { + return activeScopeFingerprint(null); + } + + public String activeScopeFingerprint(String permissionCode) { + return activeScopeFingerprint(permissionCode, LocalDateTime.now(ZoneOffset.UTC)); + } + + public String activeScopeFingerprint(String permissionCode, LocalDateTime scopeAsOf) { + FinancePrincipal principal = identityContext.requirePrincipal(); + String role = identityContext.requireActiveRole(); + String canonical = authorizationMapper.findActiveScopes(principal.userId(), role, scopeAsOf).stream() + .filter(scope -> permissionCode == null || permissionCode.equals(scope.permissionCode())) + .map(scope -> permissionCode == null + ? String.join("|", value(scope.permissionCode()), value(scope.scopeType()), + value(scope.companyPublicId()), value(scope.projectPublicId()), + scope.amountLimit() == null ? "" : scope.amountLimit().toPlainString(), + String.valueOf(scope.validFrom()), String.valueOf(scope.validTo())) + : String.join("|", value(scope.scopeType()), value(scope.companyPublicId()), + value(scope.projectPublicId()), + scope.amountLimit() == null ? "" : scope.amountLimit().toPlainString())) + .collect(Collectors.joining("\n")); + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(canonical.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 is not available", exception); + } + } + + private String value(String value) { + return value == null ? "" : value; + } +} diff --git a/backend/src/main/java/com/kaidi/finance/shared/security/FieldEncryptionKey.java b/backend/src/main/java/com/kaidi/finance/shared/security/FieldEncryptionKey.java new file mode 100644 index 0000000..41fc94c --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/shared/security/FieldEncryptionKey.java @@ -0,0 +1,33 @@ +package com.kaidi.finance.shared.security; + +import java.util.Base64; + +/** Validates the AES-256 field-encryption key shared by sensitive-value components. */ +public final class FieldEncryptionKey { + + public static final String LOCAL_DEVELOPMENT_DEFAULT = + "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY="; + + private FieldEncryptionKey() { + } + + public static byte[] decode(String encodedKey, boolean rejectKnownDefault) { + String value = encodedKey == null ? "" : encodedKey.trim(); + if (value.isEmpty()) { + throw new IllegalStateException("finance.crypto.field-key must be injected"); + } + if (rejectKnownDefault && LOCAL_DEVELOPMENT_DEFAULT.equals(value)) { + throw new IllegalStateException("finance.crypto.field-key must not use the local development default"); + } + final byte[] decoded; + try { + decoded = Base64.getDecoder().decode(value); + } catch (IllegalArgumentException exception) { + throw new IllegalStateException("finance.crypto.field-key must be a Base64 encoded 256-bit key", exception); + } + if (decoded.length != 32) { + throw new IllegalStateException("finance.crypto.field-key must be a Base64 encoded 256-bit key"); + } + return decoded; + } +} diff --git a/backend/src/main/java/com/kaidi/finance/shared/security/IdentityContext.java b/backend/src/main/java/com/kaidi/finance/shared/security/IdentityContext.java new file mode 100644 index 0000000..50ac498 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/shared/security/IdentityContext.java @@ -0,0 +1,78 @@ +package com.kaidi.finance.shared.security; + +import com.kaidi.finance.iam.domain.FinancePrincipal; +import com.kaidi.finance.shared.api.BusinessException; +import com.kaidi.finance.shared.api.ErrorCode; +import com.kaidi.finance.shared.audit.AuditService; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpSession; +import java.time.Duration; +import java.time.Instant; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.session.FindByIndexNameSessionRepository; +import org.springframework.session.Session; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; + +@Component +public class IdentityContext { + + private final HttpServletRequest request; + private final FindByIndexNameSessionRepository sessionRepository; + private final Duration absoluteTimeout; + + public IdentityContext(HttpServletRequest request, + FindByIndexNameSessionRepository sessionRepository, + @Value("${finance.session.absolute-timeout:8h}") Duration absoluteTimeout) { + this.request = request; + this.sessionRepository = sessionRepository; + this.absoluteTimeout = absoluteTimeout; + } + + public FinancePrincipal requirePrincipal() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null || !(authentication.getPrincipal() instanceof FinancePrincipal principal)) { + throw new BusinessException(HttpStatus.UNAUTHORIZED, ErrorCode.AUTH_SESSION_REQUIRED, "登录会话已失效"); + } + HttpSession session = request.getSession(false); + if (session == null) { + SecurityContextHolder.clearContext(); + throw new BusinessException(HttpStatus.UNAUTHORIZED, ErrorCode.AUTH_SESSION_REQUIRED, "登录会话已失效"); + } + Instant absoluteExpiresAt = absoluteExpiresAt(session); + if (absoluteExpiresAt.isBefore(Instant.now())) { + sessionRepository.deleteById(session.getId()); + session.invalidate(); + SecurityContextHolder.clearContext(); + throw new BusinessException(HttpStatus.UNAUTHORIZED, ErrorCode.AUTH_SESSION_REQUIRED, "登录会话已过期,请重新登录"); + } + return principal; + } + + public Instant absoluteExpiresAt(HttpSession session) { + Object value = session.getAttribute(SessionLifecycle.ABSOLUTE_EXPIRES_AT); + if (value instanceof Instant instant) return instant; + Instant fallback = Instant.ofEpochMilli(session.getCreationTime()).plus(absoluteTimeout); + session.setAttribute(SessionLifecycle.ABSOLUTE_EXPIRES_AT, fallback); + return fallback; + } + + public String activeRole() { + HttpSession session = request.getSession(false); + return session == null ? null : (String) session.getAttribute(AuditService.ACTIVE_ROLE_SESSION_KEY); + } + + public String requireActiveRole() { + String role = activeRole(); + if (role == null) { + throw new BusinessException(HttpStatus.CONFLICT, ErrorCode.AUTH_ROLE_REQUIRED, "请先选择当前工作身份"); + } + return role; + } + + public boolean isSystemAdministrator() { + return "SYSTEM_ADMIN".equals(activeRole()); + } +} diff --git a/backend/src/main/java/com/kaidi/finance/shared/security/ScopeGrant.java b/backend/src/main/java/com/kaidi/finance/shared/security/ScopeGrant.java new file mode 100644 index 0000000..c4b5de0 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/shared/security/ScopeGrant.java @@ -0,0 +1,7 @@ +package com.kaidi.finance.shared.security; + +import java.math.BigDecimal; + +public record ScopeGrant(String scopeType, String companyPublicId, String projectPublicId, + BigDecimal amountLimit) { +} diff --git a/backend/src/main/java/com/kaidi/finance/shared/security/SecurityProblemWriter.java b/backend/src/main/java/com/kaidi/finance/shared/security/SecurityProblemWriter.java new file mode 100644 index 0000000..46a5e69 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/shared/security/SecurityProblemWriter.java @@ -0,0 +1,39 @@ +package com.kaidi.finance.shared.security; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.kaidi.finance.shared.audit.DeniedAccessAuditService; +import com.kaidi.finance.shared.infrastructure.RequestContext; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.net.URI; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ProblemDetail; +import org.springframework.stereotype.Component; + +@Component +public class SecurityProblemWriter { + + private final ObjectMapper objectMapper; + private final DeniedAccessAuditService deniedAccessAuditService; + + public SecurityProblemWriter(ObjectMapper objectMapper, DeniedAccessAuditService deniedAccessAuditService) { + this.objectMapper = objectMapper; + this.deniedAccessAuditService = deniedAccessAuditService; + } + + public void write(HttpServletRequest request, HttpServletResponse response, HttpStatus status, + String code, String detail) throws IOException { + deniedAccessAuditService.record(request, status, code); + ProblemDetail problem = ProblemDetail.forStatusAndDetail(status, detail); + problem.setTitle(status.getReasonPhrase()); + problem.setType(URI.create("urn:kaidi:problem:" + code.toLowerCase())); + problem.setInstance(URI.create(request.getRequestURI())); + problem.setProperty("code", code); + problem.setProperty("requestId", RequestContext.requestId()); + response.setStatus(status.value()); + response.setContentType(MediaType.APPLICATION_PROBLEM_JSON_VALUE); + objectMapper.writeValue(response.getOutputStream(), problem); + } +} diff --git a/backend/src/main/java/com/kaidi/finance/shared/security/SessionLifecycle.java b/backend/src/main/java/com/kaidi/finance/shared/security/SessionLifecycle.java new file mode 100644 index 0000000..30a099e --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/shared/security/SessionLifecycle.java @@ -0,0 +1,15 @@ +package com.kaidi.finance.shared.security; + +/** Shared Spring Session attribute names and limits for the R1 session contract. */ +public final class SessionLifecycle { + + public static final String ABSOLUTE_EXPIRES_AT = "KAIDI_SESSION_ABSOLUTE_EXPIRES_AT"; + /** A short, non-identifying device label shown in the user's profile. */ + public static final String DEVICE_SUMMARY = "KAIDI_SESSION_DEVICE_SUMMARY"; + /** A masked client address; the raw address is never stored in the session. */ + public static final String MASKED_IP = "KAIDI_SESSION_MASKED_IP"; + public static final int MAX_SESSIONS_PER_USER = 3; + + private SessionLifecycle() { + } +} diff --git a/backend/src/main/java/com/kaidi/finance/source/api/SourceContracts.java b/backend/src/main/java/com/kaidi/finance/source/api/SourceContracts.java new file mode 100644 index 0000000..960aa30 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/source/api/SourceContracts.java @@ -0,0 +1,270 @@ +package com.kaidi.finance.source.api; + +import com.fasterxml.jackson.databind.JsonNode; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +public final class SourceContracts { + + private SourceContracts() { + } + + public record TemplateFieldView( + String code, + String label, + String sectionCode, + String sectionName, + String dataType, + boolean required, + boolean repeatable, + Integer maxLength, + Integer numericPrecision, + Integer numericScale, + JsonNode options, + String unit, + String sensitivity, + String maskingRule, + String helpText, + int sortOrder + ) { + } + + public record TemplateView( + String publicId, + String formType, + int templateVersion, + String name, + String groupCode, + String groupName, + String purpose, + List initiatorRoles, + String status, + LocalDateTime publishedAt, + long myDraftCount, + long myPendingCount, + boolean canCreate, + List fields + ) { + } + + public record ReferenceView(String publicId, String name) { + } + + public record ContractReferenceView( + String publicId, + String name, + String businessNo, + String companyId, + String projectId, + String counterpartyId, + String counterpartyName, + String counterpartyType + ) { + } + + public record FormReferenceOptionsView( + List companies, + List projects, + List suppliers, + List customers, + List contracts, + List payables, + List costCategories + ) { + } + + public record PayableReferenceView( + String publicId, + String businessNo, + String projectId, + String contractId, + String supplierId, + String amount, + String paidAmount, + String remainingAmount + ) { + } + + public record SourceFormCreateRequest( + @NotBlank @Pattern(regexp = "OA-(0[1-9]|1[0-4])", message = "表单类型必须为 OA-01 至 OA-14") + String formType, + Integer templateVersion, + @NotBlank @Size(max = 32) String sourceSystem, + @NotBlank @Size(max = 100) String sourceNo, + @NotBlank @Size(max = 32) String sourceVersion, + @NotBlank @Size(max = 26) String companyId, + @Size(max = 26) String projectId, + @NotBlank @Size(max = 100) String applicantName, + @NotNull LocalDate businessDate, + @NotNull JsonNode values + ) { + } + + public record SourceFormUpdateRequest( + @NotBlank @Size(max = 100) String sourceNo, + @NotBlank @Size(max = 32) String sourceVersion, + @NotBlank @Size(max = 26) String companyId, + @Size(max = 26) String projectId, + @NotBlank @Size(max = 100) String applicantName, + @NotNull LocalDate businessDate, + @NotNull JsonNode values, + @NotNull Long version + ) { + } + + public record VersionCommandRequest(@NotNull Long version) { + } + + public record ReasonCommandRequest( + @NotNull Long version, + @NotBlank @Size(max = 1000) String opinion + ) { + } + + public record WorkflowActionRequest( + @NotNull Long version, + @NotBlank @Size(max = 1000) String opinion, + List<@NotBlank @Size(max = 100) String> returnPoints + ) { + } + + public record ValidationIssueView( + String level, + String sectionCode, + String fieldCode, + Integer rowNo, + String message + ) { + } + + public record ValidationView( + String documentId, + long version, + boolean valid, + boolean canSubmit, + List errors, + List warnings, + LocalDateTime validatedAt + ) { + } + + public record ApprovalView( + String publicId, + String nodeCode, + String nodeName, + String actorName, + String action, + String opinion, + LocalDateTime actedAt + ) { + } + + public record SourceFormSummaryView( + String publicId, + String businessNo, + String formType, + String formName, + int templateVersion, + String sourceSystem, + String sourceNo, + String sourceVersion, + ReferenceView company, + ReferenceView project, + String applicantName, + LocalDate businessDate, + String status, + String currentNodeCode, + String currentNodeName, + long version, + String createdByName, + LocalDateTime createdAt, + LocalDateTime updatedAt, + List allowedActions + ) { + } + + public record SourceFormDetailView( + SourceFormSummaryView summary, + int documentVersion, + String snapshotSha256, + JsonNode values, + List fields, + List approvals, + ValidationView validation + ) { + } + + public record WorkflowTaskSummaryView( + String publicId, + String instancePublicId, + String nodeCode, + String nodeName, + int sequenceNo, + String status, + long version, + String assigneeName, + LocalDateTime arrivedAt, + LocalDateTime dueAt, + LocalDateTime updatedAt, + String keyAmount, + SourceFormSummaryView form, + List allowedActions + ) { + } + + public record WorkflowTaskDetailView( + WorkflowTaskSummaryView task, + SourceFormDetailView form, + List timeline + ) { + } + + public record SourceImportRowRequest( + @NotNull Integer rowNo, + @NotBlank @Size(max = 100) String sourceNo, + @NotBlank @Size(max = 32) String sourceVersion, + @NotBlank @Size(max = 26) String companyId, + @Size(max = 26) String projectId, + @NotBlank @Size(max = 100) String applicantName, + @NotNull LocalDate businessDate, + @NotNull JsonNode values + ) { + } + + public record SourceImportRequest( + @NotBlank @Pattern(regexp = "OA-(0[1-9]|1[0-4])", message = "表单类型必须为 OA-01 至 OA-14") String formType, + @NotNull Integer templateVersion, + @NotBlank @Pattern(regexp = "EXCEL_IMPORT|OA_EXPORT", message = "导入来源必须为 EXCEL_IMPORT 或 OA_EXPORT") String sourceSystem, + @NotBlank @Size(max = 26) String sourceFileId, + @NotBlank @Pattern(regexp = "[0-9a-fA-F]{64}", message = "文件摘要格式无效") String fileSha256, + @NotNull @Size(min = 1, max = 500) List<@Valid SourceImportRowRequest> rows + ) { + } + + public record SourceImportIssueView(int rowNo, String fieldCode, String message) { + } + + public record SourceImportSummaryView( + String publicId, + String batchNo, + String formType, + String sourceSystem, + String sourceFileId, + String fileSha256, + int totalRows, + int validRows, + int errorRows, + String status, + long version, + LocalDateTime createdAt, + LocalDateTime updatedAt, + List issues + ) { + } +} diff --git a/backend/src/main/java/com/kaidi/finance/source/api/SourceFormController.java b/backend/src/main/java/com/kaidi/finance/source/api/SourceFormController.java new file mode 100644 index 0000000..7e792c5 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/source/api/SourceFormController.java @@ -0,0 +1,220 @@ +package com.kaidi.finance.source.api; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.kaidi.finance.shared.api.ApiResponse; +import com.kaidi.finance.shared.api.PageResult; +import com.kaidi.finance.shared.idempotency.IdempotencyApplicationService; +import com.kaidi.finance.source.api.SourceContracts.FormReferenceOptionsView; +import com.kaidi.finance.source.api.SourceContracts.ReasonCommandRequest; +import com.kaidi.finance.source.api.SourceContracts.SourceFormCreateRequest; +import com.kaidi.finance.source.api.SourceContracts.SourceFormDetailView; +import com.kaidi.finance.source.api.SourceContracts.SourceFormSummaryView; +import com.kaidi.finance.source.api.SourceContracts.SourceFormUpdateRequest; +import com.kaidi.finance.source.api.SourceContracts.SourceImportRequest; +import com.kaidi.finance.source.api.SourceContracts.SourceImportSummaryView; +import com.kaidi.finance.source.api.SourceContracts.TemplateView; +import com.kaidi.finance.source.api.SourceContracts.ValidationView; +import com.kaidi.finance.source.api.SourceContracts.VersionCommandRequest; +import com.kaidi.finance.source.application.SourceFormApplicationService; +import com.kaidi.finance.source.application.SourceImportApplicationService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import java.time.LocalDate; +import java.util.List; +import java.util.Map; +import java.util.function.Supplier; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/source") +public class SourceFormController { + + private final SourceFormApplicationService service; + private final SourceImportApplicationService importService; + private final IdempotencyApplicationService idempotencyService; + + public SourceFormController(SourceFormApplicationService service, SourceImportApplicationService importService, + IdempotencyApplicationService idempotencyService) { + this.service = service; + this.importService = importService; + this.idempotencyService = idempotencyService; + } + + @GetMapping("/templates") + @PreAuthorize("@authorizationService.hasPermission('source:template:view')") + public ApiResponse> templates() { + return ApiResponse.ok(service.listTemplates()); + } + + @GetMapping("/templates/{formType}/versions/{version}") + @PreAuthorize("@authorizationService.hasPermission('source:template:view')") + public ApiResponse template(@PathVariable String formType, @PathVariable int version) { + return ApiResponse.ok(service.getTemplate(formType, version)); + } + + @GetMapping({"/references", "/forms/references"}) + @PreAuthorize("@authorizationService.hasPermission('source:form:view')") + public ApiResponse references() { + return ApiResponse.ok(service.references()); + } + + @GetMapping("/forms") + @Operation(operationId = "listSourceForms", summary = "查询来源表单") + @PreAuthorize("@authorizationService.hasPermission('source:form:view')") + public ApiResponse> forms( + @Parameter(schema = @Schema(allowableValues = { + "PROJECT_PREPARATION", "INCOME_EXPENSE", "PROJECT_PROCESS", "CLOSING_ARCHIVE", "BANK_INFORMATION" + })) + @RequestParam(required = false) String groupCode, + @Parameter(schema = @Schema(pattern = "^OA-(0[1-9]|1[0-4])$")) + @RequestParam(required = false) String formType, + @Parameter(schema = @Schema(minimum = "1")) + @RequestParam(required = false) Integer templateVersion, + @Parameter(schema = @Schema(pattern = "^[0-9A-HJKMNP-TV-Z]{26}$")) + @RequestParam(required = false) String projectId, + @RequestParam(required = false) String status, + @RequestParam(required = false) String sourceSystem, + @RequestParam(required = false) Boolean createdByMe, + @RequestParam(required = false) String keyword, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate createdDateFrom, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate createdDateTo, + @Parameter(schema = @Schema(allowableValues = { + "updatedAt,asc", "updatedAt,desc", "createdAt,asc", "createdAt,desc", + "businessNo,asc", "businessNo,desc", "status,asc", "status,desc" + })) + @RequestParam(defaultValue = "updatedAt,desc") String sort, + @RequestParam(defaultValue = "1") int page, + @Parameter(schema = @Schema(type = "integer", format = "int32", allowableValues = {"20", "50", "100"})) + @RequestParam(defaultValue = "20") int size + ) { + PageResult result = service.listForms( + groupCode, formType, templateVersion, projectId, status, sourceSystem, createdByMe, keyword, + createdDateFrom, createdDateTo, sort, page, size + ); + return ApiResponse.ok(result.items(), result.meta()); + } + + @GetMapping("/forms/{publicId}") + @PreAuthorize("@authorizationService.hasPermission('source:form:view')") + public ApiResponse form(@PathVariable String publicId) { + return ApiResponse.ok(service.getForm(publicId)); + } + + @PostMapping("/forms") + @PreAuthorize("@authorizationService.hasPermission('source:form:create')") + public ApiResponse create( + @Valid @RequestBody SourceFormCreateRequest request, + @RequestHeader(value = "Idempotency-Key", required = false) String key, + HttpServletRequest httpRequest + ) { + return ApiResponse.ok(command(key, httpRequest, request, new TypeReference() { }, + () -> service.create(request))); + } + + @PatchMapping("/forms/{publicId}") + @PreAuthorize("@authorizationService.hasPermission('source:form:edit')") + public ApiResponse update( + @PathVariable String publicId, + @Valid @RequestBody SourceFormUpdateRequest request, + @RequestHeader(value = "Idempotency-Key", required = false) String key, + HttpServletRequest httpRequest + ) { + return ApiResponse.ok(command(key, httpRequest, request, new TypeReference() { }, + () -> service.update(publicId, request))); + } + + @PostMapping("/forms/{publicId}/validate") + @PreAuthorize("@authorizationService.hasPermission('source:form:validate')") + public ApiResponse validate( + @PathVariable String publicId, + @RequestHeader(value = "Idempotency-Key", required = false) String key, + HttpServletRequest httpRequest + ) { + return ApiResponse.ok(command(key, httpRequest, Map.of(), new TypeReference() { }, + () -> service.validate(publicId))); + } + + @PostMapping("/forms/{publicId}/submit") + @PreAuthorize("@authorizationService.hasPermission('source:form:submit')") + public ApiResponse submit( + @PathVariable String publicId, + @Valid @RequestBody VersionCommandRequest request, + @RequestHeader(value = "Idempotency-Key", required = false) String key, + HttpServletRequest httpRequest + ) { + return ApiResponse.ok(command(key, httpRequest, request, new TypeReference() { }, + () -> service.submit(publicId, request.version()))); + } + + @PostMapping("/forms/{publicId}/withdraw") + @PreAuthorize("@authorizationService.hasPermission('source:form:withdraw')") + public ApiResponse withdraw( + @PathVariable String publicId, + @Valid @RequestBody ReasonCommandRequest request, + @RequestHeader(value = "Idempotency-Key", required = false) String key, + HttpServletRequest httpRequest + ) { + return ApiResponse.ok(command(key, httpRequest, request, new TypeReference() { }, + () -> service.withdraw(publicId, request.version(), request.opinion()))); + } + + @PostMapping("/forms/{publicId}/void") + @PreAuthorize("@authorizationService.hasPermission('source:form:void')") + public ApiResponse voidForm( + @PathVariable String publicId, + @Valid @RequestBody ReasonCommandRequest request, + @RequestHeader(value = "Idempotency-Key", required = false) String key, + HttpServletRequest httpRequest + ) { + return ApiResponse.ok(command(key, httpRequest, request, new TypeReference() { }, + () -> service.voidForm(publicId, request.version(), request.opinion()))); + } + + @PostMapping("/imports") + @PreAuthorize("@authorizationService.hasPermission('source:form:import')") + public ApiResponse createImport( + @Valid @RequestBody SourceImportRequest request, + @RequestHeader(value = "Idempotency-Key", required = false) String key, + HttpServletRequest httpRequest + ) { + return ApiResponse.ok(command(key, httpRequest, request, new TypeReference() { }, + () -> importService.create(request))); + } + + @GetMapping("/imports/{publicId}") + @PreAuthorize("@authorizationService.hasPermission('source:form:import')") + public ApiResponse importBatch(@PathVariable String publicId) { + return ApiResponse.ok(importService.get(publicId)); + } + + @PostMapping("/imports/{publicId}/confirm") + @PreAuthorize("@authorizationService.hasPermission('source:form:import')") + public ApiResponse> confirmImport( + @PathVariable String publicId, + @Valid @RequestBody VersionCommandRequest request, + @RequestHeader(value = "Idempotency-Key", required = false) String key, + HttpServletRequest httpRequest + ) { + return ApiResponse.ok(command(key, httpRequest, request, + new TypeReference>() { }, + () -> importService.confirm(publicId, request.version()))); + } + + private T command(String key, HttpServletRequest request, Object body, TypeReference type, + Supplier action) { + return idempotencyService.execute(key, request.getMethod(), request.getRequestURI(), body, type, action); + } +} diff --git a/backend/src/main/java/com/kaidi/finance/source/api/WorkflowController.java b/backend/src/main/java/com/kaidi/finance/source/api/WorkflowController.java new file mode 100644 index 0000000..bf0fbdc --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/source/api/WorkflowController.java @@ -0,0 +1,159 @@ +package com.kaidi.finance.source.api; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.kaidi.finance.shared.api.ApiResponse; +import com.kaidi.finance.shared.api.PageResult; +import com.kaidi.finance.shared.idempotency.IdempotencyApplicationService; +import com.kaidi.finance.source.api.SourceContracts.ReasonCommandRequest; +import com.kaidi.finance.source.api.SourceContracts.SourceFormDetailView; +import com.kaidi.finance.source.api.SourceContracts.WorkflowActionRequest; +import com.kaidi.finance.source.api.SourceContracts.WorkflowTaskDetailView; +import com.kaidi.finance.source.api.SourceContracts.WorkflowTaskSummaryView; +import com.kaidi.finance.source.application.WorkflowApplicationService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import java.time.LocalDate; +import java.util.List; +import java.util.function.Supplier; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.format.annotation.DateTimeFormat; +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; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/workflow") +public class WorkflowController { + + private final WorkflowApplicationService service; + private final IdempotencyApplicationService idempotencyService; + + public WorkflowController(WorkflowApplicationService service, + IdempotencyApplicationService idempotencyService) { + this.service = service; + this.idempotencyService = idempotencyService; + } + + @GetMapping("/tasks") + @Operation(operationId = "listWorkflowTasks") + @PreAuthorize("@authorizationService.hasPermission('workflow:task:view')") + public ApiResponse> tasks( + @Parameter(schema = @Schema(allowableValues = { + "TODO", "PROCESSED", "CREATED", "RETURNED", "COMPLETED" + })) + @RequestParam(defaultValue = "TODO") String view, + @Parameter(schema = @Schema(pattern = "OA-(0[1-9]|1[0-4])")) + @RequestParam(required = false) String formType, + @Parameter(schema = @Schema(allowableValues = { + "PENDING", "APPROVED", "RETURNED", "REJECTED", "CANCELLED" + })) + @RequestParam(required = false) String status, + @RequestParam(required = false) String keyword, + @Parameter(schema = @Schema(pattern = "[0-9A-HJKMNP-TV-Z]{26}")) + @RequestParam(required = false) String projectId, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) + LocalDate arrivedDateFrom, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) + LocalDate arrivedDateTo, + @RequestParam(required = false) Boolean overdue, + @Parameter(schema = @Schema(allowableValues = { + "arrivedAt,asc", "arrivedAt,desc", "dueAt,asc", "dueAt,desc", + "updatedAt,asc", "updatedAt,desc" + })) + @RequestParam(defaultValue = "arrivedAt,asc") String sort, + @RequestParam(defaultValue = "1") int page, + @Parameter(schema = @Schema(type = "integer", format = "int32", allowableValues = {"20", "50", "100"})) + @RequestParam(defaultValue = "20") int size + ) { + PageResult result = service.listTasks( + view, formType, status, keyword, projectId, arrivedDateFrom, arrivedDateTo, + overdue, sort, page, size + ); + return ApiResponse.ok(result.items(), result.meta()); + } + + @GetMapping("/tasks/{publicId}") + @Operation(operationId = "getWorkflowTask") + @PreAuthorize("@authorizationService.hasPermission('workflow:task:view')") + public ApiResponse task(@PathVariable String publicId) { + return ApiResponse.ok(service.getTask(publicId)); + } + + @PostMapping("/tasks/{publicId}/approve") + @Operation(operationId = "approveWorkflowTask") + @PreAuthorize("@authorizationService.hasPermission('workflow:task:approve')") + public ApiResponse approve( + @PathVariable String publicId, + @Valid @RequestBody WorkflowActionRequest request, + @RequestHeader(value = "Idempotency-Key", required = false) String key, + HttpServletRequest httpRequest + ) { + return ApiResponse.ok(command(key, httpRequest, request, new TypeReference() { }, + () -> service.approve(publicId, request))); + } + + @PostMapping("/tasks/{publicId}/return") + @Operation(operationId = "returnWorkflowTask") + @PreAuthorize("@authorizationService.hasPermission('workflow:task:return')") + public ApiResponse returnForCorrection( + @PathVariable String publicId, + @Valid @RequestBody WorkflowActionRequest request, + @RequestHeader(value = "Idempotency-Key", required = false) String key, + HttpServletRequest httpRequest + ) { + return ApiResponse.ok(command(key, httpRequest, request, new TypeReference() { }, + () -> service.returnForCorrection(publicId, request))); + } + + @PostMapping("/tasks/{publicId}/reject") + @Operation(operationId = "rejectWorkflowTask") + @PreAuthorize("@authorizationService.hasPermission('workflow:task:reject')") + public ApiResponse reject( + @PathVariable String publicId, + @Valid @RequestBody WorkflowActionRequest request, + @RequestHeader(value = "Idempotency-Key", required = false) String key, + HttpServletRequest httpRequest + ) { + return ApiResponse.ok(command(key, httpRequest, request, new TypeReference() { }, + () -> service.reject(publicId, request))); + } + + @PostMapping("/instances/{instancePublicId}/withdraw") + @Operation(operationId = "withdrawWorkflowInstance") + @PreAuthorize("@authorizationService.hasPermission('workflow:instance:withdraw')") + public ApiResponse withdraw( + @PathVariable String instancePublicId, + @Valid @RequestBody ReasonCommandRequest request, + @RequestHeader(value = "Idempotency-Key", required = false) String key, + HttpServletRequest httpRequest + ) { + return ApiResponse.ok(command(key, httpRequest, request, new TypeReference() { }, + () -> service.withdrawInstance(instancePublicId, request.version(), request.opinion()))); + } + + @PostMapping("/instances/{instancePublicId}/void") + @Operation(operationId = "voidWorkflowInstance") + @PreAuthorize("@authorizationService.hasPermission('workflow:instance:void')") + public ApiResponse voidInstance( + @PathVariable String instancePublicId, + @Valid @RequestBody ReasonCommandRequest request, + @RequestHeader(value = "Idempotency-Key", required = false) String key, + HttpServletRequest httpRequest + ) { + return ApiResponse.ok(command(key, httpRequest, request, new TypeReference() { }, + () -> service.voidInstance(instancePublicId, request.version(), request.opinion()))); + } + + private T command(String key, HttpServletRequest request, Object body, TypeReference type, + Supplier action) { + return idempotencyService.execute(key, request.getMethod(), request.getRequestURI(), body, type, action); + } +} diff --git a/backend/src/main/java/com/kaidi/finance/source/application/ApprovedSourceProjectionService.java b/backend/src/main/java/com/kaidi/finance/source/application/ApprovedSourceProjectionService.java new file mode 100644 index 0000000..ff1db5a --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/source/application/ApprovedSourceProjectionService.java @@ -0,0 +1,782 @@ +package com.kaidi.finance.source.application; + +import com.fasterxml.jackson.databind.JsonNode; +import com.kaidi.finance.masterdata.application.BankAccountProtector; +import com.kaidi.finance.operations.application.ApprovedPayableProjectionService; +import com.kaidi.finance.operations.application.ApprovedPayableProjectionService.ApprovedPayableCommand; +import com.kaidi.finance.operations.application.ApprovedSettlementProjectionService; +import com.kaidi.finance.operations.application.ApprovedSettlementProjectionService.ApprovedSettlementCommand; +import com.kaidi.finance.payment.application.PaymentApplicationService; +import com.kaidi.finance.payment.application.PaymentApplicationService.ApprovedOa06PaymentCommand; +import com.kaidi.finance.receivable.application.ApprovedInvoiceProjectionService; +import com.kaidi.finance.receivable.application.ApprovedInvoiceProjectionService.ApprovedInvoiceCommand; +import com.kaidi.finance.receivable.application.ApprovedReceiptProjectionService; +import com.kaidi.finance.receivable.application.ApprovedReceiptProjectionService.ApprovedReceiptCommand; +import com.kaidi.finance.shared.api.BusinessException; +import com.kaidi.finance.shared.api.ErrorCode; +import com.kaidi.finance.shared.audit.AuditService; +import com.kaidi.finance.shared.id.UlidGenerator; +import com.kaidi.finance.source.domain.SourceRecords.DocumentRecord; +import com.kaidi.finance.source.infrastructure.ApprovedSourceProjectionMapper; +import com.kaidi.finance.source.infrastructure.ApprovedSourceProjectionMapper.ProjectionIdentity; +import com.kaidi.finance.source.infrastructure.ApprovedSourceProjectionMapper.SupplierAccountIdentity; +import com.kaidi.finance.source.infrastructure.ApprovedSourceProjectionMapper.SupplierIdentity; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.text.Normalizer; +import java.time.LocalDate; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.regex.Pattern; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class ApprovedSourceProjectionService { + + private static final Pattern PUBLIC_ID = Pattern.compile("[0-9A-HJKMNP-TV-Z]{26}"); + private static final Set ACCOUNT_CATEGORIES = Set.of("BASIC", "GENERAL", "SPECIAL", "PROJECT", "OTHER"); + private static final Set CURRENCIES = Set.of("CNY", "USD", "EUR", "HKD"); + private static final Set INVOICE_TYPES = Set.of("SPECIAL", "NORMAL", "RECEIPT"); + + private final ApprovedSourceProjectionMapper mapper; + private final BankAccountProtector accountProtector; + private final ApprovedPayableProjectionService payableProjectionService; + private final ApprovedSettlementProjectionService settlementProjectionService; + private final ApprovedInvoiceProjectionService invoiceProjectionService; + private final ApprovedReceiptProjectionService receiptProjectionService; + private final PaymentApplicationService paymentService; + private final UlidGenerator ulidGenerator; + private final AuditService auditService; + + public ApprovedSourceProjectionService( + ApprovedSourceProjectionMapper mapper, + BankAccountProtector accountProtector, + ApprovedPayableProjectionService payableProjectionService, + ApprovedSettlementProjectionService settlementProjectionService, + ApprovedInvoiceProjectionService invoiceProjectionService, + ApprovedReceiptProjectionService receiptProjectionService, + PaymentApplicationService paymentService, + UlidGenerator ulidGenerator, + AuditService auditService + ) { + this.mapper = mapper; + this.accountProtector = accountProtector; + this.payableProjectionService = payableProjectionService; + this.settlementProjectionService = settlementProjectionService; + this.invoiceProjectionService = invoiceProjectionService; + this.receiptProjectionService = receiptProjectionService; + this.paymentService = paymentService; + this.ulidGenerator = ulidGenerator; + this.auditService = auditService; + } + + @Transactional(propagation = Propagation.MANDATORY) + public void projectApproved( + DocumentRecord document, + long sourceVersionId, + JsonNode values, + long reviewerId, + String opinion + ) { + if (document.currentVersionId() == null || document.currentVersionId() != sourceVersionId) { + throw invalidState("审批来源版本与表单当前版本不一致"); + } + switch (document.formType().toUpperCase(Locale.ROOT)) { + case "OA-02" -> projectSupplier(document, sourceVersionId, values, reviewerId, opinion); + case "OA-03" -> projectContract(document, sourceVersionId, values, reviewerId, opinion); + case "OA-04" -> projectPayable(document, sourceVersionId, values, reviewerId, opinion); + case "OA-05" -> projectReceipt(document, sourceVersionId, values, reviewerId, opinion); + case "OA-06" -> projectPayment(document, sourceVersionId, values, reviewerId, opinion); + case "OA-07" -> projectCompanyBankAccount(document, sourceVersionId, values, reviewerId, opinion); + case "OA-08" -> projectInvoice(document, sourceVersionId, values, reviewerId, opinion); + case "OA-11" -> projectSettlement(document, sourceVersionId, values, reviewerId, opinion); + default -> { + // Other OA projections are introduced independently. + } + } + } + + private void projectSettlement( + DocumentRecord document, + long sourceVersionId, + JsonNode values, + long reviewerId, + String opinion + ) { + if (document.projectId() == null) { + throw validation("projectId", "OA-11 必须关联生效项目"); + } + JsonNode amounts = singleRow(values, "amounts"); + settlementProjectionService.project(new ApprovedSettlementCommand( + document.companyId(), + document.projectId(), + document.id(), + document.publicId(), + sourceVersionId, + requiredText(values, "costContractNo", 64), + requiredText(values, "supplierName", 200), + amount(amounts.get("contractAmount"), "amounts", "合同金额"), + amount(amounts.get("settlementAmount"), "amounts", "结算金额"), + amount(amounts.get("invoicedAmount"), "amounts", "累计已开票金额"), + amount(amounts.get("paidAmount"), "amounts", "累计已付款金额"), + document.businessNo(), + document.businessDate(), + reviewerId, + opinion + )); + } + + private void projectPayable( + DocumentRecord document, + long sourceVersionId, + JsonNode values, + long reviewerId, + String opinion + ) { + requireProjectionTemplate(document); + if (document.projectId() == null) { + throw validation("projectId", "OA-04 必须关联生效项目"); + } + JsonNode invoiceDetails = requiredArray(values, "invoiceDetails"); + BigDecimal invoicedAmount = BigDecimal.ZERO.setScale(2); + int rowNo = 0; + for (JsonNode row : invoiceDetails) { + rowNo++; + if (!row.isObject()) { + throw validation("invoiceDetails", "发票明细第 " + rowNo + " 行格式无效"); + } + invoicedAmount = invoicedAmount.add(positiveAmount( + row.get("taxInclusiveAmount"), "invoiceDetails", "发票明细第 " + rowNo + " 行含税金额" + )); + } + payableProjectionService.project(new ApprovedPayableCommand( + document.companyId(), + document.projectId(), + document.id(), + document.publicId(), + sourceVersionId, + requiredPublicId(values, "contractId"), + requiredPublicId(values, "supplierId"), + requiredPublicId(values, "costCategoryId"), + requiredText(values, "costContractNo", 64), + requiredText(values, "supplierName", 200), + requiredText(values, "costType", 64), + document.businessDate(), + positiveAmount(values.get("payableAmount"), "payableAmount", "本次应付金额"), + invoicedAmount, + document.createdBy(), + reviewerId, + opinion + )); + } + + private void projectPayment( + DocumentRecord document, + long sourceVersionId, + JsonNode values, + long reviewerId, + String opinion + ) { + requireProjectionTemplate(document); + if (document.projectId() == null || document.projectPublicId() == null) { + throw validation("projectId", "OA-06 必须关联生效项目"); + } + String supplierPublicId = requiredPublicId(values, "supplierId"); + SupplierIdentity supplier = mapper.findActiveSupplier(supplierPublicId); + if (supplier == null) { + throw validation("supplierId", "供应商不存在、未生效或类型不符"); + } + + JsonNode details = singleRow(values, "paymentDetails"); + String supplierName = requiredRowText(details, "supplier", "paymentDetails", 200); + if (!normalizedText(supplierName).equals(normalizedText(supplier.name()))) { + throw validation("paymentDetails", "支付明细供应商与所选生效供应商不一致"); + } + BigDecimal requestedAmount = positiveAmount( + values.get("requestedAmount"), "requestedAmount", "本次申请金额" + ); + BigDecimal detailRequestedAmount = positiveAmount( + details.get("requestAmount"), "paymentDetails", "支付明细本次申请金额" + ); + if (requestedAmount.compareTo(detailRequestedAmount) != 0) { + throw validation("paymentDetails", "支付明细本次申请金额与表单申请金额不一致"); + } + + JsonNode accountSnapshot = singleRow(values, "payeeAccount"); + if (!normalizedText(requiredRowText(accountSnapshot, "payee", "payeeAccount", 200)) + .equals(normalizedText(supplier.name()))) { + throw validation("payeeAccount", "收款对象与所选生效供应商不一致"); + } + String accountName = requiredRowText(accountSnapshot, "accountName", "payeeAccount", 200); + String bankName = requiredRowText(accountSnapshot, "bankName", "payeeAccount", 200); + BankAccountProtector.ProtectedAccount protectedAccount = protect( + requiredRowText(accountSnapshot, "accountNo", "payeeAccount", 64), "payeeAccount" + ); + SupplierAccountIdentity account = mapper.findActiveSupplierAccount( + supplier.id(), protectedAccount.hash() + ); + if (account == null || !normalizedText(accountName).equals(normalizedText(account.accountName())) + || !normalizedText(bankName).equals(normalizedText(account.bankName()))) { + throw validation("payeeAccount", "供应商账户不存在、未生效、已过期或快照信息不一致"); + } + + paymentService.projectApprovedOa06(new ApprovedOa06PaymentCommand( + document.id(), + document.publicId(), + sourceVersionId, + document.companyPublicId(), + document.projectPublicId(), + requiredPublicId(values, "contractId"), + requiredPublicId(values, "payableId"), + supplierPublicId, + account.publicId(), + positiveAmount(values.get("contractAmount"), "contractAmount", "合同金额"), + positiveAmount(details.get("payableAmount"), "paymentDetails", "累计应付金额"), + amount(details.get("paidAmount"), "paymentDetails", "累计已付金额"), + requestedAmount, + document.businessDate(), + requiredRowText(details, "costContractNo", "paymentDetails", 64), + supplierName, + requiredRowText(details, "subject", "paymentDetails", 200), + paymentAttachmentFileIds(values), + document.createdBy(), + reviewerId, + opinion + )); + } + + private void projectSupplier( + DocumentRecord document, + long sourceVersionId, + JsonNode values, + long reviewerId, + String opinion + ) { + requireProjectionTemplate(document); + ProjectionIdentity existing = mapper.findCounterpartyBySourceDocument(document.id()); + if (existing != null) { + requireSameProjectionVersion(existing, sourceVersionId); + return; + } + if (!mapper.isActiveCompany(document.companyId())) { + throw validation("companyId", "公司不存在或已停用,不能生成供应商主数据"); + } + + String supplierName = requiredText(values, "supplierName", 200); + String taxNo = normalizeCode(requiredText(values, "taxNo", 64), 64, "taxNo"); + JsonNode contact = singleRow(values, "contact"); + String contactName = requiredRowText(contact, "name", "contact", 100); + String contactPhone = requiredRowText(contact, "phone", "contact", 32); + List accounts = projectedAccounts(values); + String counterpartyPublicId = ulidGenerator.next(); + try { + mapper.insertProjectedCounterparty( + counterpartyPublicId, + normalizeCode("SUP-" + document.businessNo(), 64, "businessNo"), + supplierName, + taxNo, + contactName, + contactPhone, + document.createdBy(), + reviewerId, + document.id(), + sourceVersionId + ); + ProjectionIdentity counterparty = mapper.findCounterpartyBySourceDocument(document.id()); + if (counterparty == null) { + throw new IllegalStateException("OA-02 counterparty projection was not persisted"); + } + List accountPublicIds = new ArrayList<>(); + int sourceRowNo = 0; + for (ProjectedAccount account : accounts) { + sourceRowNo++; + String accountPublicId = ulidGenerator.next(); + mapper.insertProjectedBankAccount( + accountPublicId, + counterparty.id(), + account.category(), + account.accountName(), + account.bankName(), + account.protectedAccount().ciphertext(), + account.protectedAccount().hash(), + account.protectedAccount().masked(), + mapper.nextCounterpartyBankAccountVersion(counterparty.id()), + account.validFrom().atStartOfDay(), + account.validTo() == null ? null : account.validTo().atStartOfDay(), + document.createdBy(), + reviewerId, + document.id(), + sourceVersionId, + sourceRowNo + ); + accountPublicIds.add(accountPublicId); + auditService.recordScoped( + document.companyPublicId(), null, + "SOURCE_OA02_BANK_ACCOUNT_PROJECT", "BANK_ACCOUNT", accountPublicId, + "SUCCESS", opinion, null, + Map.of("sourceDocumentId", document.publicId(), "status", "REVIEWING") + ); + } + auditService.recordScoped( + document.companyPublicId(), null, + "SOURCE_OA02_COUNTERPARTY_PROJECT", "COUNTERPARTY", counterparty.publicId(), + "SUCCESS", opinion, null, + Map.of( + "sourceDocumentId", document.publicId(), + "status", "REVIEWING", + "bankAccountIds", accountPublicIds + ) + ); + } catch (DuplicateKeyException exception) { + throw duplicate("供应商税号、业务编号或来源投影已存在"); + } + } + + private void projectContract( + DocumentRecord document, + long sourceVersionId, + JsonNode values, + long reviewerId, + String opinion + ) { + requireProjectionTemplate(document); + ProjectionIdentity existing = mapper.findContractBySourceDocument(document.id()); + if (existing != null) { + requireSameProjectionVersion(existing, sourceVersionId); + return; + } + if (document.projectId() == null || !mapper.isActiveCompany(document.companyId()) + || !mapper.isActiveProject(document.projectId(), document.companyId())) { + throw validation("projectId", "公司或项目不存在、未生效或归属关系已变化"); + } + + String supplierPublicId = requiredText(values, "supplierId", 26); + if (!PUBLIC_ID.matcher(supplierPublicId).matches()) { + throw validation("supplierId", "供应商标识格式无效"); + } + SupplierIdentity supplier = mapper.findActiveSupplier(supplierPublicId); + if (supplier == null) { + throw validation("supplierId", "供应商不存在、未生效或不是供应商类型"); + } + validateSupplierSnapshot(values, supplier); + + String currency = requiredText(values, "currency", 3).toUpperCase(Locale.ROOT); + if (!CURRENCIES.contains(currency)) { + throw validation("currency", "币种必须是 CNY、USD、EUR 或 HKD"); + } + String contractPublicId = ulidGenerator.next(); + try { + mapper.insertProjectedContract( + contractPublicId, + document.companyId(), + document.projectId(), + supplier.id(), + normalizeCode(requiredText(values, "contractNo", 64), 64, "contractNo"), + requiredText(values, "contractName", 200), + amount(values.get("contractAmount")), + currency, + document.createdBy(), + reviewerId, + opinion, + document.id(), + sourceVersionId + ); + auditService.recordScoped( + document.companyPublicId(), document.projectPublicId(), + "SOURCE_OA03_CONTRACT_PROJECT", "CONTRACT", contractPublicId, + "SUCCESS", opinion, null, + Map.of( + "sourceDocumentId", document.publicId(), + "status", "ACTIVE", + "supplierId", supplier.publicId() + ) + ); + } catch (DuplicateKeyException exception) { + throw duplicate("同一公司下合同编号或来源投影已存在"); + } + } + + private void projectCompanyBankAccount( + DocumentRecord document, + long sourceVersionId, + JsonNode values, + long reviewerId, + String opinion + ) { + requireProjectionTemplate(document); + ProjectionIdentity existing = mapper.findBankAccountBySourceDocument(document.id()); + if (existing != null) { + requireSameProjectionVersion(existing, sourceVersionId); + return; + } + if (!mapper.isActiveCompany(document.companyId())) { + throw validation("companyId", "公司不存在或已停用,不能生成银行账户版本"); + } + + String category = requiredText(values, "accountCategory", 32).toUpperCase(Locale.ROOT); + if (!ACCOUNT_CATEGORIES.contains(category)) { + throw validation("accountCategory", "账户类别必须是 BASIC、GENERAL、SPECIAL、PROJECT 或 OTHER"); + } + BankAccountProtector.ProtectedAccount protectedAccount = protect( + requiredText(values, "accountNo", 64), "accountNo" + ); + if (mapper.countActiveCompanyBankAccount(document.companyId(), protectedAccount.hash()) > 0) { + throw duplicate("该公司已存在相同的有效银行账号"); + } + String accountPublicId = ulidGenerator.next(); + try { + mapper.insertProjectedCompanyBankAccount( + accountPublicId, + document.companyId(), + category, + requiredText(values, "accountName", 200), + requiredText(values, "bankName", 200), + protectedAccount.ciphertext(), + protectedAccount.hash(), + protectedAccount.masked(), + mapper.nextCompanyBankAccountVersion(document.companyId()), + document.businessDate().atStartOfDay(), + document.createdBy(), + reviewerId, + document.id(), + sourceVersionId + ); + auditService.recordScoped( + document.companyPublicId(), null, + "SOURCE_OA07_BANK_ACCOUNT_PROJECT", "BANK_ACCOUNT", accountPublicId, + "SUCCESS", opinion, null, + Map.of("sourceDocumentId", document.publicId(), "status", "REVIEWING") + ); + } catch (DuplicateKeyException exception) { + throw duplicate("银行账号、账户版本或来源投影已存在"); + } + } + + private void projectReceipt( + DocumentRecord document, + long sourceVersionId, + JsonNode values, + long reviewerId, + String opinion + ) { + requireProjectionTemplate(document); + if (document.projectId() == null) { + throw validation("projectId", "OA-05 必须关联生效项目"); + } + receiptProjectionService.project(new ApprovedReceiptCommand( + document.companyId(), + document.projectId(), + document.id(), + document.publicId(), + sourceVersionId, + document.businessDate(), + positiveAmount(values.get("receiptAmount"), "receiptAmount", "收款金额"), + requiredText(values, "ownerUnit", 200), + document.createdBy(), + reviewerId, + opinion + )); + } + + private void projectInvoice( + DocumentRecord document, + long sourceVersionId, + JsonNode values, + long reviewerId, + String opinion + ) { + requireProjectionTemplate(document); + if (document.projectId() == null) { + throw validation("projectId", "OA-08 必须关联生效项目"); + } + String counterpartyId = requiredPublicId(values, "counterpartyId"); + String contractId = requiredPublicId(values, "contractId"); + BigDecimal contractAmount = amount(values.get("contractAmount"), "contractAmount", "合同金额"); + BigDecimal invoicedAmount = amount(values.get("invoicedAmount"), "invoicedAmount", "已开票金额"); + String invoiceType = requiredText(values, "invoiceType", 32).toUpperCase(Locale.ROOT); + if (!INVOICE_TYPES.contains(invoiceType)) { + throw validation("invoiceType", "发票类型必须是 SPECIAL、NORMAL 或 RECEIPT"); + } + JsonNode invoiceHeader = singleRow(values, "invoiceHeader"); + invoiceProjectionService.project(new ApprovedInvoiceCommand( + document.companyId(), + document.projectId(), + document.id(), + document.publicId(), + sourceVersionId, + counterpartyId, + contractId, + contractAmount, + invoicedAmount, + requiredText(values, "customerName", 200), + requiredRowText(invoiceHeader, "title", "invoiceHeader", 200), + requiredRowText(invoiceHeader, "taxNo", "invoiceHeader", 64), + invoiceType, + positiveAmount(values.get("invoiceAmount"), "invoiceAmount", "本次开票金额"), + rate(values.get("taxRate")), + document.businessDate(), + document.createdBy(), + reviewerId, + opinion + )); + } + + private List projectedAccounts(JsonNode values) { + JsonNode rows = requiredArray(values, "bankAccounts"); + List accounts = new ArrayList<>(); + Set accountHashes = new HashSet<>(); + int rowNo = 0; + for (JsonNode row : rows) { + rowNo++; + if (!row.isObject()) { + throw validation("bankAccounts", "账户明细第 " + rowNo + " 行格式无效"); + } + String category = requiredRowText(row, "accountCategory", "bankAccounts", 32) + .toUpperCase(Locale.ROOT); + if (!ACCOUNT_CATEGORIES.contains(category)) { + throw validation("bankAccounts", "账户明细第 " + rowNo + " 行账户类别无效"); + } + BankAccountProtector.ProtectedAccount protectedAccount = protect( + requiredRowText(row, "accountNo", "bankAccounts", 64), "bankAccounts" + ); + if (!accountHashes.add(protectedAccount.hash())) { + throw validation("bankAccounts", "账户明细存在重复账号"); + } + LocalDate validFrom = date(row.get("validFrom"), "bankAccounts", "生效日期"); + LocalDate validTo = optionalDate(row.get("validTo"), "bankAccounts", "失效日期"); + if (validTo != null && validTo.isBefore(validFrom)) { + throw validation("bankAccounts", "账户失效日期不能早于生效日期"); + } + accounts.add(new ProjectedAccount( + requiredRowText(row, "accountName", "bankAccounts", 200), + requiredRowText(row, "bankName", "bankAccounts", 200), + category, + protectedAccount, + validFrom, + validTo + )); + } + return List.copyOf(accounts); + } + + private void validateSupplierSnapshot(JsonNode values, SupplierIdentity supplier) { + JsonNode snapshot = singleRow(values, "supplierSnapshot"); + String snapshotSupplier = requiredRowText(snapshot, "supplier", "supplierSnapshot", 200); + if (!normalizedText(snapshotSupplier).equals(normalizedText(supplier.name()))) { + throw validation("supplierSnapshot", "供应商快照名称与所选生效供应商不一致"); + } + String accountName = requiredRowText(snapshot, "accountName", "supplierSnapshot", 200); + String bankName = requiredRowText(snapshot, "bankName", "supplierSnapshot", 200); + BankAccountProtector.ProtectedAccount protectedAccount = protect( + requiredRowText(snapshot, "accountNo", "supplierSnapshot", 64), "supplierSnapshot" + ); + SupplierAccountIdentity account = mapper.findActiveSupplierAccount(supplier.id(), protectedAccount.hash()); + if (account == null || !normalizedText(accountName).equals(normalizedText(account.accountName())) + || !normalizedText(bankName).equals(normalizedText(account.bankName()))) { + throw validation("supplierSnapshot", "供应商账户不存在、未生效、已过期或快照信息不一致"); + } + } + + private void requireProjectionTemplate(DocumentRecord document) { + if (document.templateVersion() < 2) { + throw validation("templateVersion", "该表单使用旧模板,需按最新模板重新发起后才能生成下游数据"); + } + } + + private void requireSameProjectionVersion(ProjectionIdentity existing, long sourceVersionId) { + if (existing.sourceVersionId() != sourceVersionId) { + throw invalidState("来源单据已有其他版本生成的下游数据"); + } + } + + private JsonNode requiredArray(JsonNode values, String field) { + JsonNode value = values == null ? null : values.get(field); + if (value == null || !value.isArray() || value.isEmpty()) { + throw validation(field, "明细不能为空"); + } + return value; + } + + private List paymentAttachmentFileIds(JsonNode values) { + JsonNode rows = requiredArray(values, "attachmentMatrix"); + Set unique = new HashSet<>(); + List result = new ArrayList<>(); + int rowNo = 0; + for (JsonNode row : rows) { + rowNo++; + if (!row.isObject()) { + throw validation("attachmentMatrix", "付款附件第 " + rowNo + " 行格式无效"); + } + JsonNode fileValue = row.get("fileId"); + if (fileValue == null || fileValue.isNull() || fileValue.asText().isBlank()) continue; + if (!fileValue.isTextual() || !PUBLIC_ID.matcher(fileValue.asText()).matches()) { + throw validation("attachmentMatrix", "付款附件第 " + rowNo + " 行文件标识无效"); + } + if (unique.add(fileValue.asText())) result.add(fileValue.asText()); + } + return List.copyOf(result); + } + + private JsonNode singleRow(JsonNode values, String field) { + JsonNode rows = requiredArray(values, field); + if (rows.size() != 1 || !rows.get(0).isObject()) { + throw validation(field, "该明细必须且只能填写一行"); + } + return rows.get(0); + } + + private String requiredText(JsonNode values, String field, int maxLength) { + JsonNode value = values == null ? null : values.get(field); + if (value == null || !value.isTextual()) { + throw validation(field, "字段不能为空或格式无效"); + } + String normalized = Normalizer.normalize(value.asText(), Normalizer.Form.NFKC).strip(); + if (normalized.isEmpty() || normalized.length() > maxLength) { + throw validation(field, "字段不能为空或超过长度限制"); + } + return normalized; + } + + private String requiredPublicId(JsonNode values, String field) { + String publicId = requiredText(values, field, 26); + if (!PUBLIC_ID.matcher(publicId).matches()) { + throw validation(field, "公开标识格式无效"); + } + return publicId; + } + + private String requiredRowText(JsonNode row, String column, String field, int maxLength) { + JsonNode value = row.get(column); + if (value == null || !value.isTextual()) { + throw validation(field, column + " 不能为空或格式无效"); + } + String normalized = Normalizer.normalize(value.asText(), Normalizer.Form.NFKC).strip(); + if (normalized.isEmpty() || normalized.length() > maxLength) { + throw validation(field, column + " 不能为空或超过长度限制"); + } + return normalized; + } + + private String normalizeCode(String value, int maxLength, String field) { + String normalized = Normalizer.normalize(value, Normalizer.Form.NFKC).strip().toUpperCase(Locale.ROOT); + if (normalized.isEmpty() || normalized.length() > maxLength) { + throw validation(field, "业务编码为空或超过长度限制"); + } + return normalized; + } + + private String normalizedText(String value) { + return Normalizer.normalize(value, Normalizer.Form.NFKC).strip().toUpperCase(Locale.ROOT); + } + + private BankAccountProtector.ProtectedAccount protect(String accountNo, String field) { + try { + return accountProtector.protect(accountNo); + } catch (IllegalArgumentException exception) { + throw validation(field, exception.getMessage()); + } + } + + private LocalDate date(JsonNode value, String field, String label) { + if (value == null || !value.isTextual() || value.asText().isBlank()) { + throw validation(field, label + "不能为空"); + } + try { + return LocalDate.parse(value.asText()); + } catch (DateTimeParseException exception) { + throw validation(field, label + "格式无效"); + } + } + + private LocalDate optionalDate(JsonNode value, String field, String label) { + if (value == null || value.isNull() || value.asText().isBlank()) { + return null; + } + return date(value, field, label); + } + + private BigDecimal amount(JsonNode value) { + return amount(value, "contractAmount", "合同金额"); + } + + private BigDecimal amount(JsonNode value, String field, String label) { + if (value == null || !(value.isNumber() || value.isTextual())) { + throw validation(field, label + "格式无效"); + } + try { + BigDecimal amount = new BigDecimal(value.asText()).setScale(2, RoundingMode.UNNECESSARY); + if (amount.signum() < 0 || amount.precision() > 20) { + throw new ArithmeticException(); + } + return amount; + } catch (NumberFormatException | ArithmeticException exception) { + throw validation(field, label + "必须为非负数且最多保留两位小数"); + } + } + + private BigDecimal positiveAmount(JsonNode value, String field, String label) { + if (value == null || !(value.isNumber() || value.isTextual())) { + throw validation(field, label + "格式无效"); + } + try { + BigDecimal parsed = new BigDecimal(value.asText()).setScale(2, RoundingMode.UNNECESSARY); + if (parsed.signum() <= 0 || parsed.precision() > 20) { + throw new ArithmeticException(); + } + return parsed; + } catch (NumberFormatException | ArithmeticException exception) { + throw validation(field, label + "必须大于零且最多保留两位小数"); + } + } + + private BigDecimal rate(JsonNode value) { + if (value == null || !(value.isNumber() || value.isTextual())) { + throw validation("taxRate", "税率格式无效"); + } + try { + BigDecimal parsed = new BigDecimal(value.asText()).setScale(6, RoundingMode.UNNECESSARY); + if (parsed.signum() < 0 || parsed.compareTo(BigDecimal.ONE) > 0 || parsed.precision() > 10) { + throw new ArithmeticException(); + } + return parsed; + } catch (NumberFormatException | ArithmeticException exception) { + throw validation("taxRate", "税率必须在 0 至 1 之间且最多保留六位小数"); + } + } + + private BusinessException validation(String field, String message) { + return new BusinessException( + HttpStatus.UNPROCESSABLE_ENTITY, + ErrorCode.VALIDATION_FAILED, + "审批来源不能生成下游数据", + Map.of(field, message) + ); + } + + private BusinessException duplicate(String message) { + return new BusinessException(HttpStatus.CONFLICT, ErrorCode.DUPLICATE_RESOURCE, message); + } + + private BusinessException invalidState(String message) { + return new BusinessException(HttpStatus.CONFLICT, ErrorCode.INVALID_STATE_TRANSITION, message); + } + + private record ProjectedAccount( + String accountName, + String bankName, + String category, + BankAccountProtector.ProtectedAccount protectedAccount, + LocalDate validFrom, + LocalDate validTo + ) { + } +} diff --git a/backend/src/main/java/com/kaidi/finance/source/application/SensitiveValueCipher.java b/backend/src/main/java/com/kaidi/finance/source/application/SensitiveValueCipher.java new file mode 100644 index 0000000..ef144a6 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/source/application/SensitiveValueCipher.java @@ -0,0 +1,75 @@ +package com.kaidi.finance.source.application; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.kaidi.finance.shared.security.FieldEncryptionKey; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.SecureRandom; +import java.util.Base64; +import javax.crypto.Cipher; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Component +public class SensitiveValueCipher { + + private static final String ALGORITHM = "AES/GCM/NoPadding"; + private static final int IV_BYTES = 12; + private static final int TAG_BITS = 128; + private final SecretKeySpec key; + private final SecureRandom secureRandom = new SecureRandom(); + private final ObjectMapper objectMapper; + + public SensitiveValueCipher( + @Value("${finance.crypto.field-key}") String encodedKey, + ObjectMapper objectMapper, + @Value("${finance.crypto.reject-default:false}") boolean rejectKnownDefault + ) { + byte[] decoded = FieldEncryptionKey.decode(encodedKey, rejectKnownDefault); + this.key = new SecretKeySpec(decoded, "AES"); + this.objectMapper = objectMapper; + } + + public JsonNode encrypt(JsonNode value) { + try { + byte[] iv = new byte[IV_BYTES]; + secureRandom.nextBytes(iv); + Cipher cipher = Cipher.getInstance(ALGORITHM); + cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(TAG_BITS, iv)); + byte[] plaintext = objectMapper.writeValueAsString(value).getBytes(StandardCharsets.UTF_8); + byte[] ciphertext = cipher.doFinal(plaintext); + byte[] packed = ByteBuffer.allocate(iv.length + ciphertext.length).put(iv).put(ciphertext).array(); + ObjectNode envelope = objectMapper.createObjectNode(); + envelope.put("$encrypted", Base64.getEncoder().encodeToString(packed)); + envelope.put("$algorithm", "AES-256-GCM"); + return envelope; + } catch (GeneralSecurityException | java.io.IOException exception) { + throw new IllegalStateException("Sensitive field encryption failed", exception); + } + } + + public JsonNode decrypt(JsonNode envelope) { + if (envelope == null || !envelope.isObject() || !envelope.hasNonNull("$encrypted")) { + return envelope; + } + try { + byte[] packed = Base64.getDecoder().decode(envelope.get("$encrypted").asText()); + if (packed.length <= IV_BYTES) { + throw new IllegalStateException("Sensitive field ciphertext is invalid"); + } + byte[] iv = java.util.Arrays.copyOfRange(packed, 0, IV_BYTES); + byte[] ciphertext = java.util.Arrays.copyOfRange(packed, IV_BYTES, packed.length); + Cipher cipher = Cipher.getInstance(ALGORITHM); + cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(TAG_BITS, iv)); + byte[] plaintext = cipher.doFinal(ciphertext); + return objectMapper.readTree(plaintext); + } catch (GeneralSecurityException | java.io.IOException | IllegalArgumentException exception) { + throw new IllegalStateException("Sensitive field decryption failed", exception); + } + } +} diff --git a/backend/src/main/java/com/kaidi/finance/source/application/SourceFormApplicationService.java b/backend/src/main/java/com/kaidi/finance/source/application/SourceFormApplicationService.java new file mode 100644 index 0000000..af76cde --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/source/application/SourceFormApplicationService.java @@ -0,0 +1,1335 @@ +package com.kaidi.finance.source.application; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.kaidi.finance.iam.domain.FinancePrincipal; +import com.kaidi.finance.shared.api.BusinessException; +import com.kaidi.finance.shared.api.ErrorCode; +import com.kaidi.finance.shared.api.PageResult; +import com.kaidi.finance.shared.audit.AuditService; +import com.kaidi.finance.shared.id.UlidGenerator; +import com.kaidi.finance.shared.security.AuthorizationService; +import com.kaidi.finance.shared.security.IdentityContext; +import com.kaidi.finance.source.api.SourceContracts.ApprovalView; +import com.kaidi.finance.source.api.SourceContracts.FormReferenceOptionsView; +import com.kaidi.finance.source.api.SourceContracts.ReferenceView; +import com.kaidi.finance.source.api.SourceContracts.SourceFormCreateRequest; +import com.kaidi.finance.source.api.SourceContracts.SourceFormDetailView; +import com.kaidi.finance.source.api.SourceContracts.SourceFormSummaryView; +import com.kaidi.finance.source.api.SourceContracts.SourceFormUpdateRequest; +import com.kaidi.finance.source.api.SourceContracts.TemplateFieldView; +import com.kaidi.finance.source.api.SourceContracts.TemplateView; +import com.kaidi.finance.source.api.SourceContracts.ValidationIssueView; +import com.kaidi.finance.source.api.SourceContracts.ValidationView; +import com.kaidi.finance.source.domain.SourceRecords.DocumentRecord; +import com.kaidi.finance.source.domain.SourceRecords.DocumentWrite; +import com.kaidi.finance.source.domain.SourceRecords.FieldRecord; +import com.kaidi.finance.source.domain.SourceRecords.InstanceRecord; +import com.kaidi.finance.source.domain.SourceRecords.TaskWrite; +import com.kaidi.finance.source.domain.SourceRecords.TemplateRecord; +import com.kaidi.finance.source.domain.SourceRecords.VersionWrite; +import com.kaidi.finance.source.domain.SourceRecords.WorkflowWrite; +import com.kaidi.finance.source.infrastructure.SourceWorkflowMapper; +import com.kaidi.finance.source.infrastructure.SourceWorkflowMapper.DownstreamCorrectionRecord; +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.regex.Pattern; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class SourceFormApplicationService { + + private static final Pattern PUBLIC_ID = Pattern.compile("[0-9A-HJKMNP-TV-Z]{26}"); + private static final Set SOURCE_SYSTEMS = Set.of("MANUAL", "OA_EXPORT", "EXCEL_IMPORT"); + private static final Set FILTER_STATUSES = Set.of( + "DRAFT", "SUBMITTED", "PROJECT_REVIEW", "FINANCE_REVIEW", "RETURNED", "REJECTED", + "APPROVED", "RESULT_PENDING", "ARCHIVE_PENDING", "ARCHIVED", "VOID" + ); + private static final Map GROUP_NAMES = Map.of( + "PROJECT_PREPARATION", "项目准备", + "INCOME_EXPENSE", "收支执行", + "PROJECT_PROCESS", "项目过程", + "CLOSING_ARCHIVE", "结束归档", + "BANK_INFORMATION", "银行资料" + ); + private static final Map NODE_NAMES = Map.of( + "PROJECT_REVIEW", "项目复核", + "FINANCE_REVIEW", "财务复核" + ); + private static final Map FORM_SORT_COLUMNS = Map.of( + "updatedAt", "document.updated_at", + "createdAt", "document.created_at", + "businessNo", "document.business_no", + "status", "document.status" + ); + private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai"); + + private final SourceWorkflowMapper mapper; + private final IdentityContext identityContext; + private final AuthorizationService authorizationService; + private final AuditService auditService; + private final UlidGenerator ulidGenerator; + private final ObjectMapper objectMapper; + private final SensitiveValueCipher sensitiveValueCipher; + + public SourceFormApplicationService( + SourceWorkflowMapper mapper, + IdentityContext identityContext, + AuthorizationService authorizationService, + AuditService auditService, + UlidGenerator ulidGenerator, + ObjectMapper objectMapper, + SensitiveValueCipher sensitiveValueCipher + ) { + this.mapper = mapper; + this.identityContext = identityContext; + this.authorizationService = authorizationService; + this.auditService = auditService; + this.ulidGenerator = ulidGenerator; + this.objectMapper = objectMapper; + this.sensitiveValueCipher = sensitiveValueCipher; + } + + @Transactional(readOnly = true) + public List listTemplates() { + authorizationService.requirePermission("source:template:view"); + FinancePrincipal actor = identityContext.requirePrincipal(); + return mapper.listActiveTemplates(actor.userId()).stream() + .map(template -> templateView(template, mapper.listTemplateFields(template.id()))) + .toList(); + } + + @Transactional(readOnly = true) + public TemplateView getTemplate(String formType, Integer version) { + authorizationService.requirePermission("source:template:view"); + TemplateRecord template = requireTemplate(formType, version); + return templateView(template, mapper.listTemplateFields(template.id())); + } + + @Transactional(readOnly = true) + public FormReferenceOptionsView references() { + authorizationService.requirePermission("source:form:view"); + FinancePrincipal actor = identityContext.requirePrincipal(); + String role = identityContext.requireActiveRole(); + return new FormReferenceOptionsView( + mapper.listVisibleCompanies(actor.userId(), role), + mapper.listVisibleProjects(actor.userId(), role), + mapper.listActiveSupplierReferences(), + mapper.listActiveCustomerReferences(), + mapper.listVisibleContracts(actor.userId(), role), + mapper.listVisiblePayables(actor.userId(), role), + mapper.listActiveCostCategoryReferences() + ); + } + + @Transactional(readOnly = true) + public PageResult listForms( + String formType, + String projectId, + String status, + String sourceSystem, + Boolean createdByMe, + String keyword, + int page, + int size + ) { + return listForms( + null, formType, null, projectId, status, sourceSystem, createdByMe, keyword, + null, null, "updatedAt,desc", page, size + ); + } + + @Transactional(readOnly = true) + public PageResult listForms( + String groupCode, + String formType, + Integer templateVersion, + String projectId, + String status, + String sourceSystem, + Boolean createdByMe, + String keyword, + LocalDate createdDateFrom, + LocalDate createdDateTo, + String sort, + int page, + int size + ) { + authorizationService.requirePermission("source:form:view"); + Page request = page(page, size); + String safeGroupCode = optionalGroupCode(groupCode); + String safeFormType = optionalFormType(formType); + if (templateVersion != null && templateVersion < 1) { + throw queryError("templateVersion 参数无效"); + } + String safeProjectId = trimToNull(projectId, 26); + if (safeProjectId != null && !PUBLIC_ID.matcher(safeProjectId).matches()) { + throw queryError("projectId 参数无效"); + } + String safeStatus = optionalStatus(status); + String safeSourceSystem = optionalSourceSystem(sourceSystem); + String safeKeyword = trimToNull(keyword, 100); + if (createdDateFrom != null && createdDateTo != null && createdDateFrom.isAfter(createdDateTo)) { + throw queryError("createdDateFrom 不能晚于 createdDateTo"); + } + LocalDateTime createdAtFrom = businessDateStartUtc(createdDateFrom); + LocalDateTime createdAtToExclusive = businessDateStartUtc( + createdDateTo == null ? null : createdDateTo.plusDays(1) + ); + String orderBy = formOrderBy(sort); + FinancePrincipal actor = identityContext.requirePrincipal(); + String role = identityContext.requireActiveRole(); + long total = mapper.countDocuments( + actor.userId(), role, safeGroupCode, safeFormType, templateVersion, safeProjectId, safeStatus, + safeSourceSystem, createdByMe, safeKeyword, createdAtFrom, createdAtToExclusive + ); + List items = mapper.listDocuments( + actor.userId(), role, safeGroupCode, safeFormType, templateVersion, safeProjectId, safeStatus, + safeSourceSystem, createdByMe, safeKeyword, createdAtFrom, createdAtToExclusive, orderBy, + request.size(), request.offset() + ).stream().map(this::summaryView).toList(); + return new PageResult<>(items, total, request.page(), request.size()); + } + + @Transactional(readOnly = true) + public SourceFormDetailView getForm(String publicId) { + DocumentRecord document = requireDocument(publicId); + requireViewScope(document); + return detailView(document); + } + + @Transactional + public SourceFormDetailView create(SourceFormCreateRequest request) { + authorizationService.requirePermission("source:form:create"); + FinancePrincipal actor = identityContext.requirePrincipal(); + TemplateRecord template = requireTemplate(request.formType(), request.templateVersion()); + requireInitiator(template); + ReferenceIds references = references(request.companyId(), request.projectId(), request.formType()); + authorizationService.requireScope( + "source:form:create", request.companyId(), blankToNull(request.projectId()) + ); + JsonNode plainValues = requireObjectValues(request.values()); + List fields = mapper.listTemplateFields(template.id()); + JsonNode storedValues = protectSensitiveValues(plainValues, fields); + String documentPublicId = ulidGenerator.next(); + + DocumentWrite document = new DocumentWrite(); + document.setPublicId(documentPublicId); + document.setBusinessNo(businessNo(template.formType(), documentPublicId)); + document.setFormType(template.formType()); + document.setSourceSystem(sourceSystem(request.sourceSystem())); + document.setSourceNo(normalizeDisplay(request.sourceNo(), 100)); + document.setNormalizedSourceNo(normalizeKey(request.sourceNo(), 100)); + document.setSourceVersion(normalizeDisplay(request.sourceVersion(), 32)); + document.setNormalizedSourceVersion(normalizeKey(request.sourceVersion(), 32)); + document.setTemplateId(template.id()); + document.setCompanyId(references.companyId()); + document.setProjectId(references.projectId()); + document.setApplicantName(normalizeDisplay(request.applicantName(), 100)); + document.setBusinessDate(request.businessDate()); + document.setActorId(actor.userId()); + + try { + mapper.insertDocument(document); + VersionWrite version = version( + document.getId(), 1, null, template.id(), plainValues, storedValues, actor.userId() + ); + mapper.insertDocumentVersion(version); + persistFieldValues(version.getId(), storedValues); + if (mapper.attachInitialVersion(document.getId(), version.getId(), actor.userId()) != 1) { + throw conflict(); + } + } catch (DuplicateKeyException exception) { + throw duplicate(); + } + + DocumentRecord stored = requireDocument(documentPublicId); + SourceFormDetailView result = detailView(stored); + auditService.recordScoped(stored.companyPublicId(), stored.projectPublicId(), + "SOURCE_FORM_CREATE", "SOURCE_FORM", documentPublicId, "SUCCESS", null, null, result.summary()); + return result; + } + + @Transactional + public SourceFormDetailView update(String publicId, SourceFormUpdateRequest request) { + authorizationService.requirePermission("source:form:edit"); + FinancePrincipal actor = identityContext.requirePrincipal(); + DocumentRecord current = requireDocument(publicId); + requireOwner(current, actor); + requireVersion(current.version(), request.version()); + requireState(current.status(), Set.of("DRAFT", "RETURNED")); + ReferenceIds references = references(request.companyId(), request.projectId(), current.formType()); + authorizationService.requireScope( + "source:form:edit", request.companyId(), blankToNull(request.projectId()) + ); + List fields = mapper.listTemplateFields(current.templateId()); + JsonNode plainValues = requireObjectValues(request.values()); + JsonNode storedValues = protectSensitiveValues(plainValues, fields); + VersionWrite version = version( + current.id(), current.documentVersion() + 1, current.currentVersionId(), current.templateId(), + plainValues, storedValues, actor.userId() + ); + SourceFormSummaryView before = summaryView(current); + try { + mapper.insertDocumentVersion(version); + persistFieldValues(version.getId(), storedValues); + int changed = mapper.updateDocumentDraft( + current.id(), normalizeDisplay(request.sourceNo(), 100), normalizeKey(request.sourceNo(), 100), + normalizeDisplay(request.sourceVersion(), 32), normalizeKey(request.sourceVersion(), 32), + references.companyId(), references.projectId(), normalizeDisplay(request.applicantName(), 100), + request.businessDate(), version.getId(), actor.userId(), request.version() + ); + if (changed != 1) { + throw conflict(); + } + } catch (DuplicateKeyException exception) { + throw duplicate(); + } + DocumentRecord stored = requireDocument(publicId); + SourceFormDetailView result = detailView(stored); + auditService.recordScoped(stored.companyPublicId(), stored.projectPublicId(), + "SOURCE_FORM_UPDATE", "SOURCE_FORM", publicId, "SUCCESS", null, before, result.summary()); + return result; + } + + @Transactional(readOnly = true) + public ValidationView validate(String publicId) { + authorizationService.requirePermission("source:form:validate"); + FinancePrincipal actor = identityContext.requirePrincipal(); + DocumentRecord document = requireDocument(publicId); + requireOwner(document, actor); + requireViewScope(document); + return validation(document, readPlainValues(document), true); + } + + @Transactional + public SourceFormDetailView submit(String publicId, long expectedVersion) { + authorizationService.requirePermission("source:form:submit"); + FinancePrincipal actor = identityContext.requirePrincipal(); + DocumentRecord current = requireDocument(publicId); + requireOwner(current, actor); + requireVersion(current.version(), expectedVersion); + requireState(current.status(), Set.of("DRAFT", "RETURNED")); + references(current.companyPublicId(), current.projectPublicId(), current.formType()); + authorizationService.requireScope( + "source:form:submit", current.companyPublicId(), current.projectPublicId() + ); + ValidationView validation = validation(current, readPlainValues(current), true); + if (!validation.valid()) { + Map fieldErrors = new LinkedHashMap<>(); + for (ValidationIssueView issue : validation.errors()) { + fieldErrors.putIfAbsent(issue.fieldCode(), issue.message()); + } + throw new BusinessException( + HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.VALIDATION_FAILED, + "表单校验未通过,不能提交", fieldErrors + ); + } + InstanceRecord previous = mapper.findActiveInstance(current.id()); + if (previous != null && "RETURNED".equals(previous.status())) { + if (mapper.closeReturnedInstance(previous.id(), previous.version(), actor.userId()) != 1) { + throw conflict(); + } + } else if (previous != null) { + throw invalidState("该表单已有运行中的审批流程"); + } + Long definitionVersionId = mapper.findActiveWorkflowDefinitionVersionId(); + if (definitionVersionId == null) { + throw new BusinessException( + HttpStatus.CONFLICT, ErrorCode.INVALID_STATE_TRANSITION, "来源表单审批定义尚未发布" + ); + } + long assigneeId = reviewer("PROJECT_MANAGER", current.createdBy(), actor.userId()); + WorkflowWrite workflow = new WorkflowWrite(); + workflow.setPublicId(ulidGenerator.next()); + workflow.setDefinitionVersionId(definitionVersionId); + workflow.setSourceVersionId(current.currentVersionId()); + workflow.setCurrentNodeCode("PROJECT_REVIEW"); + workflow.setActorId(actor.userId()); + mapper.insertWorkflowInstance(workflow); + + TaskWrite task = new TaskWrite(); + task.setPublicId(ulidGenerator.next()); + task.setInstanceId(workflow.getId()); + task.setNodeCode("PROJECT_REVIEW"); + task.setSequenceNo(1); + task.setAssigneeId(assigneeId); + task.setDueAt(LocalDateTime.now(ZoneOffset.UTC).plusDays(3)); + mapper.insertWorkflowTask(task); + mapper.markVersionSubmitted(current.currentVersionId()); + if (mapper.markDocumentSubmitted(current.id(), expectedVersion, actor.userId()) != 1) { + throw conflict(); + } + mapper.insertApproval( + ulidGenerator.next(), current.currentVersionId(), "APPLICANT", actor.displayName(), actor.userId(), + "SUBMIT", "提交审批", null + ); + DocumentRecord stored = requireDocument(publicId); + SourceFormDetailView result = detailView(stored); + auditService.recordScoped(current.companyPublicId(), current.projectPublicId(), + "SOURCE_FORM_SUBMIT", "SOURCE_FORM", publicId, "SUCCESS", "提交审批", summaryView(current), + result.summary()); + return result; + } + + @Transactional(propagation = Propagation.MANDATORY) + public void returnApprovedOa08ForCorrection( + long documentId, + long sourceVersionId, + String invoicePublicId, + List returnPoints, + String opinion + ) { + FinancePrincipal actor = identityContext.requirePrincipal(); + DownstreamCorrectionRecord document = mapper.lockDocumentForDownstreamCorrection(documentId); + if (document == null || !"OA-08".equalsIgnoreCase(document.formType())) { + throw invalidState("开票候选未关联可更正的 OA-08 来源表单"); + } + if (!"APPROVED".equals(document.status()) + || document.currentVersionId() == null + || document.currentVersionId() != sourceVersionId) { + throw invalidState("OA-08 来源状态或当前版本已变化,请刷新后重试"); + } + LinkedHashSet normalizedPoints = new LinkedHashSet<>(); + if (returnPoints != null) { + for (String point : returnPoints) { + if (point != null && !point.isBlank()) normalizedPoints.add(point.strip()); + } + } + if (normalizedPoints.isEmpty()) { + throw validationError("returnPoints", "来源开票候选退回时必须选择需要更正的字段"); + } + Set fieldCodes = mapper.listTemplateFields(document.templateId()).stream() + .map(FieldRecord::fieldCode) + .collect(java.util.stream.Collectors.toSet()); + String unknownPoint = normalizedPoints.stream().filter(point -> !fieldCodes.contains(point)) + .findFirst().orElse(null); + if (unknownPoint != null) { + throw validationError("returnPoints", "退回点不属于当前 OA-08 模板:" + unknownPoint); + } + String normalizedOpinion = normalizeDisplay(opinion, 1000); + if (mapper.returnApprovedDocumentForCorrection( + document.id(), sourceVersionId, document.version(), actor.userId() + ) != 1) { + throw conflict(); + } + String snapshot = objectMapper.valueToTree(Map.of( + "returnPoints", List.copyOf(normalizedPoints), + "invoicePublicId", invoicePublicId + )).toString(); + mapper.insertApproval( + ulidGenerator.next(), sourceVersionId, "FINANCE_REVIEW", actor.displayName(), actor.userId(), + "RETURN", normalizedOpinion, snapshot + ); + auditService.recordScoped( + document.companyPublicId(), document.projectPublicId(), + "SOURCE_OA08_INVOICE_RETURN", "SOURCE_FORM", document.publicId(), "SUCCESS", + normalizedOpinion, + Map.of("status", document.status(), "sourceVersionId", sourceVersionId), + Map.of( + "status", "RETURNED", + "invoicePublicId", invoicePublicId, + "returnPoints", List.copyOf(normalizedPoints) + ) + ); + } + + @Transactional + public SourceFormDetailView withdraw(String publicId, long expectedVersion, String opinion) { + authorizationService.requirePermission("source:form:withdraw"); + FinancePrincipal actor = identityContext.requirePrincipal(); + DocumentRecord current = requireDocument(publicId); + requireOwner(current, actor); + requireVersion(current.version(), expectedVersion); + requireState(current.status(), Set.of("PROJECT_REVIEW")); + InstanceRecord instance = mapper.findActiveInstance(current.id()); + if (instance == null || !"PROJECT_REVIEW".equals(instance.currentNodeCode())) { + throw invalidState("首个审批节点已处理,当前不能撤回"); + } + if (mapper.withdrawInstance(instance.id(), instance.version(), actor.userId()) != 1) { + throw conflict(); + } + mapper.cancelPendingTasks(instance.id()); + if (mapper.updateDocumentState(current.id(), expectedVersion, "DRAFT", actor.userId()) != 1) { + throw conflict(); + } + String reason = normalizeDisplay(opinion, 1000); + mapper.insertApproval( + ulidGenerator.next(), current.currentVersionId(), "APPLICANT", actor.displayName(), actor.userId(), + "WITHDRAW", reason, null + ); + DocumentRecord stored = requireDocument(publicId); + auditService.recordScoped(current.companyPublicId(), current.projectPublicId(), + "SOURCE_FORM_WITHDRAW", "SOURCE_FORM", publicId, "SUCCESS", reason, + summaryView(current), summaryView(stored)); + return detailView(stored); + } + + @Transactional + public SourceFormDetailView voidForm(String publicId, long expectedVersion, String opinion) { + authorizationService.requirePermission("source:form:void"); + FinancePrincipal actor = identityContext.requirePrincipal(); + DocumentRecord current = requireDocument(publicId); + requireOwner(current, actor); + requireVersion(current.version(), expectedVersion); + requireState(current.status(), Set.of("DRAFT", "RETURNED", "PROJECT_REVIEW")); + InstanceRecord instance = mapper.findActiveInstance(current.id()); + if (instance != null) { + if (mapper.voidInstance(instance.id(), instance.version(), actor.userId()) != 1) { + throw conflict(); + } + mapper.cancelPendingTasks(instance.id()); + } + if (mapper.updateDocumentState(current.id(), expectedVersion, "VOID", actor.userId()) != 1) { + throw conflict(); + } + String reason = normalizeDisplay(opinion, 1000); + mapper.insertApproval( + ulidGenerator.next(), current.currentVersionId(), "APPLICANT", actor.displayName(), actor.userId(), + "VOID", reason, null + ); + DocumentRecord stored = requireDocument(publicId); + auditService.recordScoped(current.companyPublicId(), current.projectPublicId(), + "SOURCE_FORM_VOID", "SOURCE_FORM", publicId, "SUCCESS", reason, + summaryView(current), summaryView(stored)); + return detailView(stored); + } + + @Transactional(readOnly = true) + public SourceFormDetailView detailForWorkflow(String publicId) { + DocumentRecord document = requireDocument(publicId); + requireViewScope(document); + return detailView(document); + } + + @Transactional(readOnly = true) + public DocumentRecord documentRecord(String publicId) { + DocumentRecord document = requireDocument(publicId); + requireViewScope(document); + return document; + } + + /** + * Validates an import row against the same versioned template rules used by the form commands. + * The method intentionally does not persist a document; persistence only happens when the + * import batch is explicitly confirmed. + */ + @Transactional(readOnly = true) + public List validateImportValues( + String formType, + Integer version, + String companyPublicId, + String projectPublicId, + JsonNode values + ) { + authorizationService.requirePermission("source:form:import"); + TemplateRecord template = requireTemplate(formType, version); + ReferenceIds references = references(companyPublicId, projectPublicId, template.formType()); + FinancePrincipal actor = identityContext.requirePrincipal(); + LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC); + DocumentRecord draft = new DocumentRecord( + 0L, "IMPORT", "IMPORT", template.formType(), template.name(), template.id(), template.templateVersion(), + "EXCEL_IMPORT", "IMPORT", "IMPORT", "1", "1", references.companyId(), companyPublicId, "", + references.projectId(), projectPublicId, "", actor.displayName(), java.time.LocalDate.now(ZoneOffset.UTC), + null, 0, "", "{}", "DRAFT", null, null, 0L, actor.userId(), actor.displayName(), + actor.userId(), now, now + ); + JsonNode objectValues = requireObjectValues(values); + return validation(draft, objectValues, false).errors(); + } + + public SourceFormSummaryView summaryForWorkflow(DocumentRecord document) { + return summaryView(document); + } + + JsonNode plainValuesForProjection(DocumentRecord document) { + return readPlainValues(document); + } + + public String nodeName(String code) { + return code == null ? null : NODE_NAMES.getOrDefault(code, code); + } + + private SourceFormDetailView detailView(DocumentRecord document) { + List fields = mapper.listTemplateFields(document.templateId()); + JsonNode plainValues = readPlainValues(document, fields); + JsonNode visibleValues = visibleValues(document, plainValues, fields); + ValidationView validation = validation(document, plainValues, false); + List approvals = mapper.listApprovals(document.id()).stream() + .map(item -> new ApprovalView( + item.publicId(), item.nodeCode(), nodeName(item.nodeCode()), item.actorName(), item.action(), + item.opinion(), item.actedAt() + )) + .toList(); + return new SourceFormDetailView( + summaryView(document), document.documentVersion(), document.snapshotSha256(), visibleValues, + fields.stream().map(this::fieldView).toList(), approvals, validation + ); + } + + private SourceFormSummaryView summaryView(DocumentRecord document) { + ReferenceView project = document.projectPublicId() == null + ? null : new ReferenceView(document.projectPublicId(), document.projectName()); + return new SourceFormSummaryView( + document.publicId(), document.businessNo(), document.formType(), document.formName(), + document.templateVersion(), document.sourceSystem(), document.sourceNo(), document.sourceVersion(), + new ReferenceView(document.companyPublicId(), document.companyName()), project, + document.applicantName(), document.businessDate(), document.status(), document.currentNodeCode(), + nodeName(document.currentNodeCode()), document.version(), document.createdByName(), + document.createdAt(), document.updatedAt(), allowedActions(document) + ); + } + + private TemplateView templateView(TemplateRecord template, List fields) { + List roles = List.of(template.initiatorRoles().split(",")); + return new TemplateView( + template.publicId(), template.formType(), template.templateVersion(), template.name(), + template.groupCode(), GROUP_NAMES.getOrDefault(template.groupCode(), template.groupCode()), + template.purpose(), roles, template.status(), template.publishedAt(), template.myDraftCount(), + template.myPendingCount(), (roles.contains(identityContext.requireActiveRole()) + || identityContext.isSystemAdministrator()) + && authorizationService.hasPermission("source:form:create"), + fields.stream().map(this::fieldView).toList() + ); + } + + private TemplateFieldView fieldView(FieldRecord field) { + return new TemplateFieldView( + field.fieldCode(), field.label(), field.sectionCode(), field.sectionName(), field.dataType(), + field.requiredFlag(), field.repeatable(), field.maxLength(), field.numericPrecision(), + field.numericScale(), readJson(field.optionsJson(), objectMapper.createArrayNode()), field.unit(), + field.sensitivity(), field.maskingRule(), field.helpText(), field.sortOrder() + ); + } + + private ValidationView validation(DocumentRecord document, JsonNode values, boolean command) { + List errors = new ArrayList<>(); + List warnings = new ArrayList<>(); + if (requiresProject(document.formType()) && document.projectPublicId() == null) { + errors.add(issue("COMMON", "projectId", "该类表单必须选择项目")); + } + List fields = mapper.listTemplateFields(document.templateId()); + Set known = new LinkedHashSet<>(); + for (FieldRecord field : fields) { + known.add(field.fieldCode()); + JsonNode value = values.get(field.fieldCode()); + if (field.requiredFlag() && empty(value)) { + errors.add(issue(field.sectionCode(), field.fieldCode(), field.label() + "为必填项")); + continue; + } + if (empty(value)) { + continue; + } + validateType(document, field, value, errors); + } + values.fieldNames().forEachRemaining(code -> { + if (!known.contains(code)) { + errors.add(issue("UNKNOWN", code, "字段不属于当前模板版本")); + } + }); + if ("OA-10".equals(document.formType())) { + JsonNode photos = values.get("sitePhotos"); + if (photos != null && photos.isArray() && photos.size() < 4) { + errors.add(issue("ATTACHMENT", "sitePhotos", "项目形象照片至少上传 4 张")); + } + } + if ("OA-06".equals(document.formType())) { + BigDecimal available = decimal(values.get("availableFund")); + BigDecimal requested = decimal(values.get("requestedAmount")); + if (available != null && requested != null && requested.compareTo(available) > 0) { + errors.add(issue("PAYMENT", "requestedAmount", "本次申请金额不能超过可用资金")); + } + } + if ("OA-08".equals(document.formType())) { + BigDecimal contract = decimal(values.get("contractAmount")); + BigDecimal invoiced = decimal(values.get("invoicedAmount")); + BigDecimal current = decimal(values.get("invoiceAmount")); + if (contract != null && invoiced != null && current != null + && invoiced.add(current).compareTo(contract) > 0) { + errors.add(issue("INVOICE", "invoiceAmount", "累计开票金额不能超过合同金额")); + } + } + boolean owner = document.createdBy() == identityContext.requirePrincipal().userId() + || identityContext.isSystemAdministrator(); + boolean editable = Set.of("DRAFT", "RETURNED").contains(document.status()); + boolean canSubmit = errors.isEmpty() && owner && editable + && authorizationService.hasPermission("source:form:submit"); + if (command && !editable) { + throw invalidState("当前状态不允许校验或提交"); + } + return new ValidationView( + document.publicId(), document.version(), errors.isEmpty(), canSubmit, + List.copyOf(errors), List.copyOf(warnings), LocalDateTime.now(ZoneOffset.UTC) + ); + } + + private void validateType(DocumentRecord document, FieldRecord field, JsonNode value, + List errors) { + switch (field.dataType()) { + case "TEXT", "LONG_TEXT" -> { + if (!value.isTextual()) { + errors.add(issue(field.sectionCode(), field.fieldCode(), field.label() + "必须为文本")); + } else if (field.maxLength() != null && value.asText().length() > field.maxLength()) { + errors.add(issue( + field.sectionCode(), field.fieldCode(), field.label() + "不能超过 " + field.maxLength() + " 个字符" + )); + } + } + case "DECIMAL", "INTEGER" -> { + if (decimal(value) == null) { + errors.add(issue(field.sectionCode(), field.fieldCode(), field.label() + "必须为有效数字")); + } else if (decimal(value).signum() < 0) { + errors.add(issue(field.sectionCode(), field.fieldCode(), field.label() + "不能小于 0")); + } + } + case "DATE" -> { + try { + java.time.LocalDate.parse(value.asText()); + } catch (DateTimeParseException exception) { + errors.add(issue(field.sectionCode(), field.fieldCode(), field.label() + "日期格式无效")); + } + } + case "BOOLEAN" -> { + if (!value.isBoolean()) { + errors.add(issue(field.sectionCode(), field.fieldCode(), field.label() + "必须选择是或否")); + } + } + case "TABLE" -> { + if (!value.isArray()) { + errors.add(issue(field.sectionCode(), field.fieldCode(), field.label() + "必须为明细行")); + } else if (!field.repeatable() && value.size() > 1) { + errors.add(issue(field.sectionCode(), field.fieldCode(), field.label() + "只允许一行")); + } + if (value.isArray()) { + validateTableColumns(document, field, value, errors); + } + if (("attachmentMatrix".equals(field.fieldCode())) && value.isArray()) { + int row = 0; + for (JsonNode item : value) { + row++; + if (item.isObject() + && item.path("required").isBoolean() + && item.path("required").asBoolean() + && empty(item.get("fileId")) + && empty(item.get("notApplicableReason"))) { + errors.add(new ValidationIssueView( + "ERROR", field.sectionCode(), field.fieldCode(), row, + "必需附件必须上传文件或填写不涉及原因" + )); + } + } + } + } + case "ENUM" -> { + if (!value.isTextual()) { + errors.add(issue(field.sectionCode(), field.fieldCode(), field.label() + "必须选择有效选项")); + } else { + JsonNode options = readJson(field.optionsJson(), objectMapper.createArrayNode()); + if (options.isArray() && !options.isEmpty()) { + boolean matched = false; + for (JsonNode option : options) { + if (value.asText().equals(option.path("code").asText())) { + matched = true; + break; + } + } + if (!matched) { + errors.add(issue(field.sectionCode(), field.fieldCode(), field.label() + "选项无效")); + } + } + } + } + case "REFERENCE" -> { + if (!value.isTextual() || !PUBLIC_ID.matcher(value.asText()).matches()) { + errors.add(issue(field.sectionCode(), field.fieldCode(), field.label() + "引用标识无效")); + } else if ("supplierId".equals(field.fieldCode()) + && mapper.findActiveSupplierId(value.asText()) == null) { + errors.add(issue(field.sectionCode(), field.fieldCode(), "供应商不存在、未生效或类型不符")); + } else if ("contractId".equals(field.fieldCode()) + && mapper.findActiveContractId(value.asText()) == null) { + errors.add(issue(field.sectionCode(), field.fieldCode(), "合同不存在或未生效")); + } else if ("payableId".equals(field.fieldCode()) + && mapper.findActivePayableId(value.asText()) == null) { + errors.add(issue(field.sectionCode(), field.fieldCode(), "应付不存在、已失效或已付清")); + } else if ("costCategoryId".equals(field.fieldCode()) + && mapper.findActiveCostCategoryId(value.asText()) == null) { + errors.add(issue(field.sectionCode(), field.fieldCode(), "成本分类不存在或未生效")); + } + } + case "FILE" -> { + if (!(value.isTextual() || value.isArray())) { + errors.add(issue(field.sectionCode(), field.fieldCode(), field.label() + "文件值格式无效")); + } else if (value.isTextual()) { + validateFileReference(document, field, value, null, errors); + } else { + int row = 0; + for (JsonNode fileId : value) { + row++; + validateFileReference(document, field, fileId, row, errors); + } + } + } + default -> { + // DATETIME is retained for templates that do not currently require projection-specific checks. + } + } + } + + private void validateTableColumns( + DocumentRecord document, + FieldRecord field, + JsonNode rows, + List errors + ) { + JsonNode columns = readJson(field.optionsJson(), objectMapper.createArrayNode()); + if (!columns.isArray() || columns.isEmpty()) { + return; + } + int rowNo = 0; + for (JsonNode row : rows) { + rowNo++; + if (!row.isObject()) { + errors.add(new ValidationIssueView( + "ERROR", field.sectionCode(), field.fieldCode(), rowNo, field.label() + "明细行格式无效" + )); + continue; + } + for (JsonNode column : columns) { + String code = column.path("code").asText(); + if (code.isBlank()) { + continue; + } + String label = column.path("label").asText(code); + JsonNode cell = row.get(code); + if (column.path("required").asBoolean(false) && empty(cell)) { + errors.add(new ValidationIssueView( + "ERROR", field.sectionCode(), field.fieldCode(), rowNo, label + "为必填项" + )); + continue; + } + if (empty(cell)) { + continue; + } + String dataType = column.path("dataType").asText(); + if (dataType.isBlank() && fileColumn(code)) { + dataType = "FILE"; + } + if (dataType.isBlank()) { + continue; + } + validateTableCell(document, field, column, label, dataType, cell, rowNo, errors); + } + } + } + + private void validateTableCell( + DocumentRecord document, + FieldRecord field, + JsonNode column, + String label, + String dataType, + JsonNode cell, + int rowNo, + List errors + ) { + switch (dataType) { + case "TEXT", "LONG_TEXT" -> { + if (!cell.isTextual()) { + errors.add(tableIssue(field, rowNo, label + "必须为文本")); + } else if (column.has("maxLength") && cell.asText().length() > column.path("maxLength").asInt()) { + errors.add(tableIssue( + field, rowNo, label + "不能超过 " + column.path("maxLength").asInt() + " 个字符" + )); + } + } + case "DATE" -> { + try { + LocalDate.parse(cell.asText()); + } catch (DateTimeParseException exception) { + errors.add(tableIssue(field, rowNo, label + "日期格式无效")); + } + } + case "BOOLEAN" -> { + if (!cell.isBoolean()) { + errors.add(tableIssue(field, rowNo, label + "必须选择是或否")); + } + } + case "ENUM" -> { + boolean matched = false; + for (JsonNode option : column.path("options")) { + if (cell.isTextual() && cell.asText().equals(option.path("code").asText())) { + matched = true; + break; + } + } + if (!matched) { + errors.add(tableIssue(field, rowNo, label + "选项无效")); + } + } + case "DECIMAL", "INTEGER" -> { + BigDecimal parsed = decimal(cell); + if (parsed == null || parsed.signum() < 0 + || ("INTEGER".equals(dataType) && parsed.stripTrailingZeros().scale() > 0)) { + errors.add(tableIssue(field, rowNo, label + "必须为有效非负数")); + } + } + case "REFERENCE" -> { + if (!cell.isTextual() || !PUBLIC_ID.matcher(cell.asText()).matches()) { + errors.add(tableIssue(field, rowNo, label + "引用标识无效")); + } + } + case "FILE" -> { + if (cell.isArray()) { + for (JsonNode fileId : cell) { + validateFileReference(document, field, fileId, rowNo, field.label() + label, errors); + } + } else { + validateFileReference(document, field, cell, rowNo, field.label() + label, errors); + } + } + default -> errors.add(tableIssue(field, rowNo, label + "字段类型配置无效")); + } + } + + private boolean fileColumn(String code) { + return code.toLowerCase(Locale.ROOT).endsWith("fileid"); + } + + private ValidationIssueView tableIssue(FieldRecord field, int row, String message) { + return new ValidationIssueView("ERROR", field.sectionCode(), field.fieldCode(), row, message); + } + + private void validateFileReference(DocumentRecord document, FieldRecord field, JsonNode value, + Integer row, List errors) { + validateFileReference(document, field, value, row, field.label(), errors); + } + + private void validateFileReference(DocumentRecord document, FieldRecord field, JsonNode value, + Integer row, String label, List errors) { + if (!value.isTextual() || !PUBLIC_ID.matcher(value.asText()).matches()) { + errors.add(fileIssue(field, row, label + "标识无效")); + return; + } + FinancePrincipal actor = identityContext.requirePrincipal(); + SourceWorkflowMapper.SourceFileRef file = mapper.findFileForSource( + value.asText(), actor.publicId(), document.companyPublicId(), document.projectPublicId() + ); + if (file == null) { + errors.add(fileIssue(field, row, label + "引用的文件不存在")); + } else if (!identityContext.isSystemAdministrator() + && !file.uploadedByActor() && !file.linkedToDocumentScope()) { + errors.add(fileIssue(field, row, label + "不属于当前账号或表单范围")); + } else if (!"AVAILABLE".equals(file.scanStatus())) { + errors.add(fileIssue(field, row, label + "尚未通过安全检查")); + } + } + + private ValidationIssueView fileIssue(FieldRecord field, Integer row, String message) { + return row == null ? issue(field.sectionCode(), field.fieldCode(), message) + : new ValidationIssueView("ERROR", field.sectionCode(), field.fieldCode(), row, message); + } + + private JsonNode readPlainValues(DocumentRecord document) { + return readPlainValues(document, mapper.listTemplateFields(document.templateId())); + } + + private JsonNode readPlainValues(DocumentRecord document, List fields) { + JsonNode stored = readJson(document.payloadJson(), objectMapper.createObjectNode()); + ObjectNode plain = objectMapper.createObjectNode(); + Map definitions = fields.stream().collect( + java.util.stream.Collectors.toMap(FieldRecord::fieldCode, item -> item) + ); + stored.fields().forEachRemaining(entry -> { + FieldRecord field = definitions.get(entry.getKey()); + JsonNode value = field != null && protectedField(field) + ? sensitiveValueCipher.decrypt(entry.getValue()) : entry.getValue(); + plain.set(entry.getKey(), value.deepCopy()); + }); + return plain; + } + + private JsonNode visibleValues(DocumentRecord document, JsonNode plainValues, List fields) { + FinancePrincipal actor = identityContext.requirePrincipal(); + boolean canReadSensitive = document.createdBy() == actor.userId() + || authorizationService.hasPermission("source:sensitive:view"); + if (canReadSensitive) { + return plainValues; + } + ObjectNode masked = plainValues.deepCopy(); + for (FieldRecord field : fields) { + if (protectedField(field) && masked.has(field.fieldCode())) { + masked.put(field.fieldCode(), "[受限字段]"); + } + } + return masked; + } + + private JsonNode protectSensitiveValues(JsonNode plainValues, List fields) { + ObjectNode stored = objectMapper.createObjectNode(); + Map definitions = fields.stream().collect( + java.util.stream.Collectors.toMap(FieldRecord::fieldCode, item -> item) + ); + plainValues.fields().forEachRemaining(entry -> { + FieldRecord field = definitions.get(entry.getKey()); + JsonNode value = field != null && protectedField(field) + ? sensitiveValueCipher.encrypt(entry.getValue()) : entry.getValue().deepCopy(); + stored.set(entry.getKey(), value); + }); + return stored; + } + + private boolean protectedField(FieldRecord field) { + return Set.of("SENSITIVE", "RESTRICTED").contains(field.sensitivity()); + } + + private void persistFieldValues(long versionId, JsonNode storedValues) { + storedValues.fields().forEachRemaining(entry -> { + try { + mapper.insertFieldValue(versionId, entry.getKey(), objectMapper.writeValueAsString(entry.getValue())); + } catch (JsonProcessingException exception) { + throw new IllegalStateException("Source field cannot be serialized", exception); + } + }); + } + + private VersionWrite version( + long documentId, + int versionNo, + Long previousVersionId, + long templateId, + JsonNode plainValues, + JsonNode storedValues, + long actorId + ) { + VersionWrite version = new VersionWrite(); + version.setPublicId(ulidGenerator.next()); + version.setDocumentId(documentId); + version.setVersionNo(versionNo); + version.setPreviousVersionId(previousVersionId); + version.setTemplateId(templateId); + version.setSnapshotSha256(sha256(canonicalize(plainValues).toString())); + version.setPayloadJson(storedValues.toString()); + version.setActorId(actorId); + return version; + } + + private JsonNode canonicalize(JsonNode value) { + if (value == null || value.isValueNode()) { + return value; + } + if (value.isArray()) { + ArrayNode array = objectMapper.createArrayNode(); + value.forEach(item -> array.add(canonicalize(item))); + return array; + } + ObjectNode object = objectMapper.createObjectNode(); + Map sorted = new TreeMap<>(); + value.fields().forEachRemaining(entry -> sorted.put(entry.getKey(), entry.getValue())); + sorted.forEach((key, item) -> object.set(key, canonicalize(item))); + return object; + } + + private List allowedActions(DocumentRecord document) { + FinancePrincipal actor = identityContext.requirePrincipal(); + List actions = new ArrayList<>(); + actions.add("VIEW"); + if (document.createdBy() != actor.userId() && !identityContext.isSystemAdministrator()) { + return actions; + } + if (Set.of("DRAFT", "RETURNED").contains(document.status())) { + if (authorizationService.hasPermission("source:form:edit")) actions.add("EDIT"); + if (authorizationService.hasPermission("source:form:validate")) actions.add("VALIDATE"); + if (authorizationService.hasPermission("source:form:submit")) actions.add("SUBMIT"); + if (authorizationService.hasPermission("source:form:void")) actions.add("VOID"); + } else if ("PROJECT_REVIEW".equals(document.status())) { + if (authorizationService.hasPermission("source:form:withdraw")) actions.add("WITHDRAW"); + if (authorizationService.hasPermission("source:form:void")) actions.add("VOID"); + } + return List.copyOf(actions); + } + + private void requireViewScope(DocumentRecord document) { + authorizationService.requireScope( + "source:form:view", document.companyPublicId(), document.projectPublicId() + ); + } + + private void requireInitiator(TemplateRecord template) { + String role = identityContext.requireActiveRole(); + if (!identityContext.isSystemAdministrator() + && !List.of(template.initiatorRoles().split(",")).contains(role)) { + throw new BusinessException( + HttpStatus.FORBIDDEN, ErrorCode.PERMISSION_DENIED, "当前身份不能发起该类表单" + ); + } + } + + private ReferenceIds references(String companyPublicId, String projectPublicId, String formType) { + if (!PUBLIC_ID.matcher(companyPublicId).matches()) { + throw validationError("companyId", "公司标识格式无效"); + } + Long companyId = mapper.findActiveCompanyId(companyPublicId); + if (companyId == null) { + throw validationError("companyId", "公司不存在或未生效"); + } + String project = blankToNull(projectPublicId); + if (project == null) { + if (requiresProject(formType)) { + throw validationError("projectId", "该类表单必须选择项目"); + } + return new ReferenceIds(companyId, null); + } + if (!PUBLIC_ID.matcher(project).matches()) { + throw validationError("projectId", "项目标识格式无效"); + } + Long projectId = mapper.findProjectId(project, companyPublicId); + if (projectId == null) { + throw validationError("projectId", "项目不存在、未生效或不属于所选公司"); + } + return new ReferenceIds(companyId, projectId); + } + + private boolean requiresProject(String formType) { + return !Set.of("OA-02", "OA-07").contains(formType.toUpperCase(Locale.ROOT)); + } + + private TemplateRecord requireTemplate(String formType, Integer version) { + String normalized = optionalFormType(formType); + if (normalized == null) { + throw validationError("formType", "表单类型不能为空"); + } + TemplateRecord template = mapper.findActiveTemplate(normalized, version); + if (template == null) { + throw new BusinessException(HttpStatus.NOT_FOUND, ErrorCode.RESOURCE_NOT_FOUND, "表单模板不存在或未发布"); + } + return template; + } + + private DocumentRecord requireDocument(String publicId) { + if (publicId == null || !PUBLIC_ID.matcher(publicId).matches()) { + throw new BusinessException(HttpStatus.NOT_FOUND, ErrorCode.RESOURCE_NOT_FOUND, "来源表单不存在"); + } + DocumentRecord document = mapper.findDocument(publicId); + if (document == null) { + throw new BusinessException(HttpStatus.NOT_FOUND, ErrorCode.RESOURCE_NOT_FOUND, "来源表单不存在"); + } + return document; + } + + private JsonNode requireObjectValues(JsonNode values) { + if (values == null || !values.isObject()) { + throw validationError("values", "表单字段必须为对象结构"); + } + return values.deepCopy(); + } + + private void requireOwner(DocumentRecord document, FinancePrincipal actor) { + if (document.createdBy() != actor.userId() && !identityContext.isSystemAdministrator()) { + throw new BusinessException(HttpStatus.FORBIDDEN, ErrorCode.PERMISSION_DENIED, "只有表单发起人可以执行该操作"); + } + } + + private void requireVersion(long current, long expected) { + if (current != expected) { + throw conflict(); + } + } + + private void requireState(String current, Set allowed) { + if (!allowed.contains(current)) { + throw invalidState("当前状态不允许执行该操作"); + } + } + + private long reviewer(String role, long initiatorId, long adjacentApproverId) { + Long reviewer = mapper.findReviewer(role, initiatorId, adjacentApproverId); + if (reviewer == null) { + throw invalidState("审批角色尚未配置可用人员"); + } + return reviewer; + } + + private String optionalFormType(String formType) { + if (formType == null || formType.isBlank()) return null; + String normalized = formType.trim().toUpperCase(Locale.ROOT); + if (!normalized.matches("OA-(0[1-9]|1[0-4])")) { + throw queryError("formType 参数无效"); + } + return normalized; + } + + private String optionalGroupCode(String groupCode) { + if (groupCode == null || groupCode.isBlank()) return null; + String normalized = groupCode.trim().toUpperCase(Locale.ROOT); + if (!GROUP_NAMES.containsKey(normalized)) throw queryError("groupCode 参数无效"); + return normalized; + } + + private LocalDateTime businessDateStartUtc(LocalDate value) { + if (value == null) return null; + return value.atStartOfDay(BUSINESS_ZONE).withZoneSameInstant(ZoneOffset.UTC).toLocalDateTime(); + } + + private String formOrderBy(String value) { + String normalized = value == null || value.isBlank() ? "updatedAt,desc" : value.trim(); + String[] parts = normalized.split(",", -1); + if (parts.length != 2 || !FORM_SORT_COLUMNS.containsKey(parts[0])) { + throw queryError("排序字段不在允许范围内"); + } + String direction = parts[1].toUpperCase(Locale.ROOT); + if (!Set.of("ASC", "DESC").contains(direction)) { + throw queryError("排序方向只允许 asc 或 desc"); + } + return FORM_SORT_COLUMNS.get(parts[0]) + " " + direction + ", document.public_id " + direction; + } + + private String optionalStatus(String status) { + if (status == null || status.isBlank()) return null; + String normalized = status.trim().toUpperCase(Locale.ROOT); + if (!FILTER_STATUSES.contains(normalized)) throw queryError("status 参数无效"); + return normalized; + } + + private String optionalSourceSystem(String value) { + if (value == null || value.isBlank()) return null; + return sourceSystem(value); + } + + private String sourceSystem(String value) { + String normalized = value.trim().toUpperCase(Locale.ROOT); + if (!SOURCE_SYSTEMS.contains(normalized)) { + throw validationError("sourceSystem", "来源系统必须为 MANUAL、OA_EXPORT 或 EXCEL_IMPORT"); + } + return normalized; + } + + private Page page(int page, int size) { + if (page < 1 || size < 1 || size > 100) throw queryError("分页参数无效"); + long offset = (long) (page - 1) * size; + if (offset > Integer.MAX_VALUE) throw queryError("页码超出允许范围"); + return new Page(page, size, (int) offset); + } + + private String businessNo(String formType, String publicId) { + return formType.replace("-", "") + "-" + java.time.LocalDate.now(ZoneOffset.UTC) + .toString().replace("-", "") + "-" + publicId.substring(18); + } + + private String normalizeDisplay(String value, int max) { + String normalized = value == null ? "" : value.strip(); + if (normalized.isEmpty() || normalized.length() > max) { + throw new BusinessException( + HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.VALIDATION_FAILED, "文本内容为空或超过长度限制" + ); + } + return normalized; + } + + private String normalizeKey(String value, int max) { + return normalizeDisplay(value, max).replaceAll("\\s+", "").toUpperCase(Locale.ROOT); + } + + private String trimToNull(String value, int max) { + if (value == null || value.isBlank()) return null; + String normalized = value.trim(); + if (normalized.length() > max) throw queryError("查询关键字过长"); + return normalized; + } + + private String blankToNull(String value) { + return value == null || value.isBlank() ? null : value.trim(); + } + + private boolean empty(JsonNode value) { + return value == null || value.isNull() || (value.isTextual() && value.asText().isBlank()) + || (value.isArray() && value.isEmpty()); + } + + private BigDecimal decimal(JsonNode value) { + if (value == null || value.isNull() || (!value.isNumber() && !value.isTextual())) return null; + try { + return new BigDecimal(value.asText()); + } catch (NumberFormatException exception) { + return null; + } + } + + private ValidationIssueView issue(String section, String field, String message) { + return new ValidationIssueView("ERROR", section, field, null, message); + } + + private JsonNode readJson(String json, JsonNode fallback) { + if (json == null || json.isBlank()) return fallback; + try { + return objectMapper.readTree(json); + } catch (JsonProcessingException exception) { + throw new IllegalStateException("Stored source JSON is invalid", exception); + } + } + + private String sha256(String content) { + try { + return HexFormat.of().formatHex( + MessageDigest.getInstance("SHA-256").digest(content.getBytes(StandardCharsets.UTF_8)) + ); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 is not available", exception); + } + } + + private BusinessException validationError(String field, String message) { + return new BusinessException( + HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.VALIDATION_FAILED, "表单校验失败", Map.of(field, message) + ); + } + + private BusinessException queryError(String message) { + return new BusinessException(HttpStatus.BAD_REQUEST, ErrorCode.QUERY_PARAMETER_INVALID, message); + } + + private BusinessException conflict() { + return new BusinessException( + HttpStatus.CONFLICT, ErrorCode.VERSION_CONFLICT, "数据已被其他用户更新,请刷新后重试" + ); + } + + private BusinessException duplicate() { + return new BusinessException( + HttpStatus.CONFLICT, ErrorCode.DUPLICATE_RESOURCE, "相同表单类型、来源系统、来源单号和来源版本已存在" + ); + } + + private BusinessException invalidState(String message) { + return new BusinessException(HttpStatus.CONFLICT, ErrorCode.INVALID_STATE_TRANSITION, message); + } + + private record Page(int page, int size, int offset) { + } + + private record ReferenceIds(long companyId, Long projectId) { + } +} diff --git a/backend/src/main/java/com/kaidi/finance/source/application/SourceImportApplicationService.java b/backend/src/main/java/com/kaidi/finance/source/application/SourceImportApplicationService.java new file mode 100644 index 0000000..3e8b376 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/source/application/SourceImportApplicationService.java @@ -0,0 +1,274 @@ +package com.kaidi.finance.source.application; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.kaidi.finance.iam.domain.FinancePrincipal; +import com.kaidi.finance.shared.api.BusinessException; +import com.kaidi.finance.shared.api.ErrorCode; +import com.kaidi.finance.shared.audit.AuditService; +import com.kaidi.finance.shared.file.FileApplicationService; +import com.kaidi.finance.shared.file.FileObjectRecord; +import com.kaidi.finance.shared.id.UlidGenerator; +import com.kaidi.finance.shared.security.AuthorizationService; +import com.kaidi.finance.shared.security.IdentityContext; +import com.kaidi.finance.source.api.SourceContracts.SourceFormCreateRequest; +import com.kaidi.finance.source.api.SourceContracts.SourceFormDetailView; +import com.kaidi.finance.source.api.SourceContracts.SourceImportIssueView; +import com.kaidi.finance.source.api.SourceContracts.SourceImportRequest; +import com.kaidi.finance.source.api.SourceContracts.SourceImportRowRequest; +import com.kaidi.finance.source.api.SourceContracts.SourceImportSummaryView; +import com.kaidi.finance.source.api.SourceContracts.TemplateView; +import com.kaidi.finance.source.api.SourceContracts.ValidationIssueView; +import com.kaidi.finance.source.domain.SourceRecords.ImportBatchRecord; +import com.kaidi.finance.source.domain.SourceRecords.ImportBatchWrite; +import com.kaidi.finance.source.domain.SourceRecords.ImportRowRecord; +import com.kaidi.finance.source.domain.SourceRecords.ImportRowWrite; +import com.kaidi.finance.source.infrastructure.SourceWorkflowMapper; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.regex.Pattern; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class SourceImportApplicationService { + + private static final Pattern PUBLIC_ID = Pattern.compile("[0-9A-HJKMNP-TV-Z]{26}"); + + private final SourceWorkflowMapper mapper; + private final SourceFormApplicationService sourceService; + private final FileApplicationService fileService; + private final IdentityContext identityContext; + private final AuthorizationService authorizationService; + private final AuditService auditService; + private final UlidGenerator ulidGenerator; + private final ObjectMapper objectMapper; + + public SourceImportApplicationService( + SourceWorkflowMapper mapper, + SourceFormApplicationService sourceService, + FileApplicationService fileService, + IdentityContext identityContext, + AuthorizationService authorizationService, + AuditService auditService, + UlidGenerator ulidGenerator, + ObjectMapper objectMapper + ) { + this.mapper = mapper; + this.sourceService = sourceService; + this.fileService = fileService; + this.identityContext = identityContext; + this.authorizationService = authorizationService; + this.auditService = auditService; + this.ulidGenerator = ulidGenerator; + this.objectMapper = objectMapper; + } + + @Transactional + public SourceImportSummaryView create(SourceImportRequest request) { + authorizationService.requirePermission("source:form:import"); + FinancePrincipal actor = identityContext.requirePrincipal(); + String formType = request.formType().trim().toUpperCase(Locale.ROOT); + String sourceSystem = request.sourceSystem().trim().toUpperCase(Locale.ROOT); + TemplateView template = sourceService.getTemplate(formType, request.templateVersion()); + if (!template.canCreate()) { + throw new BusinessException(HttpStatus.FORBIDDEN, ErrorCode.PERMISSION_DENIED, "当前身份不能导入该类表单"); + } + FileObjectRecord file = requireSourceFile(request, actor); + + List allIssues = new ArrayList<>(); + Set rowNumbers = new HashSet<>(); + Set sourceKeys = new HashSet<>(); + List validations = new ArrayList<>(); + for (SourceImportRowRequest row : request.rows()) { + List issues = new ArrayList<>(); + if (!rowNumbers.add(row.rowNo())) { + issues.add(issue(row.rowNo(), null, "行号重复")); + } + if (row.rowNo() < 1) { + issues.add(issue(row.rowNo(), null, "行号必须大于 0")); + } + String sourceKey = normalizeKey(row.sourceNo()) + "\u0000" + normalizeKey(row.sourceVersion()); + if (!sourceKeys.add(sourceKey)) { + issues.add(issue(row.rowNo(), "sourceNo", "导入文件内存在重复的来源单号和版本")); + } + if (PUBLIC_ID.matcher(row.companyId()).matches() == false) { + issues.add(issue(row.rowNo(), "companyId", "公司标识格式无效")); + } + if (row.projectId() != null && !row.projectId().isBlank() && !PUBLIC_ID.matcher(row.projectId()).matches()) { + issues.add(issue(row.rowNo(), "projectId", "项目标识格式无效")); + } + if (mapper.existsDocumentSource(formType, sourceSystem, normalizeKey(row.sourceNo()), normalizeKey(row.sourceVersion()))) { + issues.add(issue(row.rowNo(), "sourceNo", "相同表单类型、来源系统、来源单号和版本已存在")); + } + if (issues.isEmpty()) { + try { + for (ValidationIssueView validation : sourceService.validateImportValues( + formType, request.templateVersion(), row.companyId(), row.projectId(), row.values() + )) { + issues.add(issue(row.rowNo(), validation.fieldCode(), validation.message())); + } + } catch (BusinessException exception) { + String field = exception.fieldErrors().keySet().stream().findFirst().orElse(null); + String message = exception.fieldErrors().values().stream().findFirst().orElse(exception.getMessage()); + issues.add(issue(row.rowNo(), field, message)); + } + } + allIssues.addAll(issues); + validations.add(new RowValidation(row, issues)); + } + + int validRows = (int) validations.stream().filter(item -> item.issues().isEmpty()).count(); + int errorRows = validations.size() - validRows; + String publicId = ulidGenerator.next(); + ImportBatchWrite batch = new ImportBatchWrite(); + batch.setPublicId(publicId); + batch.setBatchNo("IMP-" + java.time.LocalDate.now(ZoneOffset.UTC).toString().replace("-", "") + "-" + publicId.substring(18)); + batch.setFormType(formType); + batch.setSourceSystem(sourceSystem); + batch.setSourceFileId(file.getId()); + batch.setFileSha256(file.getSha256()); + batch.setTotalRows(validations.size()); + batch.setValidRows(validRows); + batch.setErrorRows(errorRows); + batch.setValidationJson(json(allIssues)); + batch.setStatus(errorRows == 0 ? "READY" : "INVALID"); + batch.setActorId(actor.userId()); + mapper.insertImportBatch(batch); + for (RowValidation validation : validations) { + ImportRowWrite stored = new ImportRowWrite(); + stored.setBatchId(batch.getId()); + stored.setRowNo(validation.row().rowNo()); + stored.setSourceNo(validation.row().sourceNo().trim()); + stored.setSourceVersion(validation.row().sourceVersion().trim()); + stored.setPayloadJson(json(payload(validation.row(), request.templateVersion()))); + stored.setValidationJson(json(validation.issues())); + stored.setStatus(validation.issues().isEmpty() ? "VALID" : "INVALID"); + mapper.insertImportRow(stored); + } + SourceImportSummaryView result = summary(mapper.findImportBatch(publicId)); + auditService.record("SOURCE_IMPORT_CREATE", "SOURCE_IMPORT", publicId, "SUCCESS", null, null, result); + return result; + } + + @Transactional(readOnly = true) + public SourceImportSummaryView get(String publicId) { + authorizationService.requirePermission("source:form:import"); + ImportBatchRecord batch = requireBatch(publicId); + requireOwner(batch); + return summary(batch); + } + + @Transactional + public List confirm(String publicId, long expectedVersion) { + authorizationService.requirePermission("source:form:import"); + FinancePrincipal actor = identityContext.requirePrincipal(); + ImportBatchRecord batch = mapper.findImportBatchForUpdate(publicId); + if (batch == null) throw notFound(); + requireOwner(batch); + if (batch.version() != expectedVersion) throw conflict(); + if (!"READY".equals(batch.status())) throw invalidState("只有校验通过的导入批次可以确认"); + List rows = mapper.listImportRows(batch.id()); + List created = new ArrayList<>(); + for (ImportRowRecord row : rows) { + if (!"VALID".equals(row.status())) throw invalidState("导入批次包含未通过校验的行"); + JsonNode payload = readJson(row.payloadJson()); + created.add(sourceService.create(new SourceFormCreateRequest( + batch.formType(), templateVersion(payload), batch.sourceSystem(), + payload.path("sourceNo").asText(), payload.path("sourceVersion").asText(), + payload.path("companyId").asText(), nullableText(payload, "projectId"), + payload.path("applicantName").asText(), java.time.LocalDate.parse(payload.path("businessDate").asText()), + payload.path("values") + ))); + } + if (mapper.markImportRowsConfirmed(batch.id()) != rows.size() + || mapper.markImportBatchConfirmed(batch.id(), expectedVersion, actor.userId()) != 1) { + throw conflict(); + } + auditService.record("SOURCE_IMPORT_CONFIRM", "SOURCE_IMPORT", publicId, "SUCCESS", null, batch, created.size()); + return created; + } + + private FileObjectRecord requireSourceFile(SourceImportRequest request, FinancePrincipal actor) { + if (!PUBLIC_ID.matcher(request.sourceFileId()).matches()) throw validation("sourceFileId", "文件标识格式无效"); + FileObjectRecord file = fileService.require(request.sourceFileId()); + if (!actor.publicId().equals(file.getUploadedBy()) && !identityContext.isSystemAdministrator()) { + throw new BusinessException(HttpStatus.FORBIDDEN, ErrorCode.PERMISSION_DENIED, "只能使用当前账号上传的导入文件"); + } + if (!"AVAILABLE".equals(file.getScanStatus())) { + throw new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.FILE_REJECTED, "导入文件未通过安全检查"); + } + if (!file.getSha256().equalsIgnoreCase(request.fileSha256())) { + throw validation("fileSha256", "文件摘要与服务端记录不一致"); + } + return file; + } + + private ObjectNode payload(SourceImportRowRequest row, int templateVersion) { + ObjectNode payload = objectMapper.createObjectNode(); + payload.put("templateVersion", templateVersion); + payload.put("sourceNo", row.sourceNo().trim()); + payload.put("sourceVersion", row.sourceVersion().trim()); + payload.put("companyId", row.companyId()); + if (row.projectId() == null || row.projectId().isBlank()) payload.putNull("projectId"); else payload.put("projectId", row.projectId()); + payload.put("applicantName", row.applicantName().trim()); + payload.put("businessDate", row.businessDate().toString()); + payload.set("values", row.values().deepCopy()); + return payload; + } + + private int templateVersion(JsonNode payload) { + // The batch is created against one immutable template version; the version is carried as a local marker. + return payload.path("templateVersion").asInt(1); + } + + private SourceImportSummaryView summary(ImportBatchRecord batch) { + return new SourceImportSummaryView( + batch.publicId(), batch.batchNo(), batch.formType(), batch.sourceSystem(), batch.sourceFilePublicId(), + batch.fileSha256(), batch.totalRows(), batch.validRows(), batch.errorRows(), batch.status(), batch.version(), + batch.createdAt(), batch.updatedAt(), issues(batch.validationJson()) + ); + } + + private List issues(String json) { + if (json == null || json.isBlank()) return List.of(); + try { + return objectMapper.readerForListOf(SourceImportIssueView.class).readValue(json); + } catch (JsonProcessingException exception) { + return List.of(new SourceImportIssueView(0, null, "导入批次校验摘要损坏")); + } + } + + private JsonNode readJson(String json) { + try { return objectMapper.readTree(json); } + catch (JsonProcessingException exception) { throw new IllegalStateException("导入批次数据损坏", exception); } + } + + private String nullableText(JsonNode node, String field) { return node.hasNonNull(field) ? node.path(field).asText() : null; } + private String normalizeKey(String value) { return value == null ? "" : value.trim().replaceAll("\\s+", "").toUpperCase(Locale.ROOT); } + private SourceImportIssueView issue(int rowNo, String field, String message) { return new SourceImportIssueView(rowNo, field, message == null ? "校验失败" : message); } + private String json(Object value) { try { return objectMapper.writeValueAsString(value); } catch (JsonProcessingException exception) { throw new IllegalStateException("导入数据无法序列化", exception); } } + + private ImportBatchRecord requireBatch(String publicId) { ImportBatchRecord batch = mapper.findImportBatch(publicId); if (batch == null) throw notFound(); return batch; } + private void requireOwner(ImportBatchRecord batch) { + if (batch.createdBy() != identityContext.requirePrincipal().userId() + && !identityContext.isSystemAdministrator()) { + throw new BusinessException(HttpStatus.FORBIDDEN, ErrorCode.PERMISSION_DENIED, + "只有导入发起人或超级管理员可以查看或确认批次"); + } + } + private BusinessException notFound() { return new BusinessException(HttpStatus.NOT_FOUND, ErrorCode.RESOURCE_NOT_FOUND, "导入批次不存在"); } + private BusinessException conflict() { return new BusinessException(HttpStatus.CONFLICT, ErrorCode.VERSION_CONFLICT, "导入批次已被其他操作更新,请刷新后重试"); } + private BusinessException invalidState(String message) { return new BusinessException(HttpStatus.CONFLICT, ErrorCode.INVALID_STATE_TRANSITION, message); } + private BusinessException validation(String field, String message) { return new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.VALIDATION_FAILED, "导入校验失败", java.util.Map.of(field, message)); } + + private record RowValidation(SourceImportRowRequest row, List issues) { } +} diff --git a/backend/src/main/java/com/kaidi/finance/source/application/WorkflowApplicationService.java b/backend/src/main/java/com/kaidi/finance/source/application/WorkflowApplicationService.java new file mode 100644 index 0000000..e77755f --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/source/application/WorkflowApplicationService.java @@ -0,0 +1,486 @@ +package com.kaidi.finance.source.application; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.kaidi.finance.iam.domain.FinancePrincipal; +import com.kaidi.finance.project.application.ProjectApplicationService; +import com.kaidi.finance.shared.api.BusinessException; +import com.kaidi.finance.shared.api.ErrorCode; +import com.kaidi.finance.shared.api.PageResult; +import com.kaidi.finance.shared.audit.AuditService; +import com.kaidi.finance.shared.id.UlidGenerator; +import com.kaidi.finance.shared.infrastructure.RequestContext; +import com.kaidi.finance.shared.security.AuthorizationService; +import com.kaidi.finance.shared.security.IdentityContext; +import com.kaidi.finance.source.api.SourceContracts.SourceFormDetailView; +import com.kaidi.finance.source.api.SourceContracts.SourceFormSummaryView; +import com.kaidi.finance.source.api.SourceContracts.WorkflowActionRequest; +import com.kaidi.finance.source.api.SourceContracts.WorkflowTaskDetailView; +import com.kaidi.finance.source.api.SourceContracts.WorkflowTaskSummaryView; +import com.kaidi.finance.source.domain.SourceRecords.DocumentRecord; +import com.kaidi.finance.source.domain.SourceRecords.TaskRecord; +import com.kaidi.finance.source.domain.SourceRecords.TaskWrite; +import com.kaidi.finance.source.infrastructure.SourceWorkflowMapper; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.regex.Pattern; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class WorkflowApplicationService { + + private static final Pattern PUBLIC_ID = Pattern.compile("[0-9A-HJKMNP-TV-Z]{26}"); + private static final Set VIEWS = Set.of("TODO", "PROCESSED", "CREATED", "RETURNED", "COMPLETED"); + private static final Set ACTIONS = Set.of("APPROVE", "RETURN", "REJECT"); + private static final Set TASK_STATUSES = + Set.of("PENDING", "APPROVED", "RETURNED", "REJECTED", "CANCELLED"); + private static final Map SORT_COLUMNS = Map.of( + "arrivedAt", "task.created_at", + "dueAt", "task.due_at", + "updatedAt", "task.updated_at" + ); + private static final Map KEY_AMOUNT_FIELDS = Map.of( + "OA-01", "awardAmount", + "OA-02", "purchaseAmount", + "OA-03", "contractAmount", + "OA-04", "payableAmount", + "OA-05", "receiptAmount", + "OA-06", "requestedAmount", + "OA-08", "invoiceAmount", + "OA-10", "contractAmount", + "OA-13", "monthlyReceivable" + ); + + private final SourceWorkflowMapper mapper; + private final SourceFormApplicationService sourceService; + private final ProjectApplicationService projectService; + private final ApprovedSourceProjectionService projectionService; + private final IdentityContext identityContext; + private final AuthorizationService authorizationService; + private final AuditService auditService; + private final UlidGenerator ulidGenerator; + private final ObjectMapper objectMapper; + + public WorkflowApplicationService( + SourceWorkflowMapper mapper, + SourceFormApplicationService sourceService, + ProjectApplicationService projectService, + ApprovedSourceProjectionService projectionService, + IdentityContext identityContext, + AuthorizationService authorizationService, + AuditService auditService, + UlidGenerator ulidGenerator, + ObjectMapper objectMapper + ) { + this.mapper = mapper; + this.sourceService = sourceService; + this.projectService = projectService; + this.projectionService = projectionService; + this.identityContext = identityContext; + this.authorizationService = authorizationService; + this.auditService = auditService; + this.ulidGenerator = ulidGenerator; + this.objectMapper = objectMapper; + } + + @Transactional(readOnly = true) + public PageResult listTasks( + String view, + String formType, + String status, + String keyword, + String projectId, + LocalDate arrivedDateFrom, + LocalDate arrivedDateTo, + Boolean overdue, + String sort, + int page, + int size + ) { + authorizationService.requirePermission("workflow:task:view"); + Page request = page(page, size); + String safeView = view(view); + String safeFormType = formType(formType); + String safeStatus = taskStatus(status); + String safeKeyword = trimToNull(keyword, 100); + String safeProjectId = publicIdFilter(projectId); + if (arrivedDateFrom != null && arrivedDateTo != null && arrivedDateTo.isBefore(arrivedDateFrom)) { + throw queryError("到达日期结束值不能早于开始值"); + } + LocalDateTime arrivedAtFrom = arrivedDateFrom == null ? null : arrivedDateFrom.atStartOfDay(); + LocalDateTime arrivedAtToExclusive = arrivedDateTo == null ? null : arrivedDateTo.plusDays(1).atStartOfDay(); + String orderBy = orderBy(sort); + FinancePrincipal actor = identityContext.requirePrincipal(); + String roleCode = identityContext.requireActiveRole(); + long total = mapper.countTasks( + actor.userId(), roleCode, safeView, safeFormType, safeStatus, safeKeyword, safeProjectId, + arrivedAtFrom, arrivedAtToExclusive, overdue + ); + List items = mapper.listTasks( + actor.userId(), roleCode, safeView, safeFormType, safeStatus, safeKeyword, safeProjectId, + arrivedAtFrom, arrivedAtToExclusive, overdue, orderBy, request.size(), request.offset() + ).stream().map(this::summary).toList(); + return new PageResult<>(items, total, request.page(), request.size()); + } + + @Transactional(readOnly = true) + public WorkflowTaskDetailView getTask(String publicId) { + authorizationService.requirePermission("workflow:task:view"); + TaskRecord task = requireTask(publicId, identityContext.requireActiveRole()); + SourceFormDetailView form = sourceService.detailForWorkflow(task.documentPublicId()); + return new WorkflowTaskDetailView( + summary(task, form.summary(), keyAmount(form.summary().formType(), form.values())), + form, form.approvals() + ); + } + + @Transactional + public WorkflowTaskDetailView approve(String publicId, WorkflowActionRequest request) { + return act(publicId, "APPROVE", request); + } + + @Transactional + public WorkflowTaskDetailView returnForCorrection(String publicId, WorkflowActionRequest request) { + return act(publicId, "RETURN", request); + } + + @Transactional + public WorkflowTaskDetailView reject(String publicId, WorkflowActionRequest request) { + return act(publicId, "REJECT", request); + } + + @Transactional + public SourceFormDetailView withdrawInstance(String instancePublicId, long version, String opinion) { + String documentPublicId = documentForInstance(instancePublicId); + return sourceService.withdraw(documentPublicId, version, opinion); + } + + @Transactional + public SourceFormDetailView voidInstance(String instancePublicId, long version, String opinion) { + String documentPublicId = documentForInstance(instancePublicId); + return sourceService.voidForm(documentPublicId, version, opinion); + } + + private WorkflowTaskDetailView act(String publicId, String action, WorkflowActionRequest request) { + if (!ACTIONS.contains(action)) { + throw new IllegalArgumentException("Unsupported workflow action"); + } + authorizationService.requirePermission(permission(action)); + FinancePrincipal actor = identityContext.requirePrincipal(); + String roleCode = identityContext.requireActiveRole(); + TaskRecord current = requireTask(publicId, roleCode); + requireNodeRole(current, roleCode); + if (current.assigneeId() != actor.userId()) { + throw new BusinessException( + HttpStatus.FORBIDDEN, ErrorCode.PERMISSION_DENIED, "只有当前节点处理人可以执行审批动作" + ); + } + if (!"PENDING".equals(current.status())) { + throw invalidState("审批任务已处理"); + } + if (current.version() != request.version()) { + throw conflict(); + } + if ("RETURN".equals(action) && (request.returnPoints() == null || request.returnPoints().isEmpty())) { + throw new BusinessException( + HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.VALIDATION_FAILED, + "退回补件必须选择需要修改的字段或附件", Map.of("returnPoints", "至少选择一项退回点") + ); + } + DocumentRecord document = sourceService.documentRecord(current.documentPublicId()); + if ("RETURN".equals(action)) { + requireReturnPoints(document, request.returnPoints()); + } + String expectedDocumentState = current.nodeCode(); + if (!expectedDocumentState.equals(document.status())) { + throw invalidState("任务节点与表单状态不一致,请刷新后重试"); + } + String taskStatus = switch (action) { + case "APPROVE" -> "APPROVED"; + case "RETURN" -> "RETURNED"; + case "REJECT" -> "REJECTED"; + default -> throw new IllegalStateException(); + }; + if (mapper.completeTask(current.id(), current.nodeCode(), request.version(), actor.userId(), taskStatus) != 1) { + throw conflict(); + } + String returnPointsJson = json(request.returnPoints() == null ? List.of() : request.returnPoints()); + mapper.insertWorkflowAction( + ulidGenerator.next(), current.id(), current.sequenceNo(), action, + request.opinion().trim(), returnPointsJson, actor.userId(), RequestContext.requestId() + ); + + String targetDocumentState; + if ("RETURN".equals(action)) { + finish(current, actor.userId(), "RETURNED"); + targetDocumentState = "RETURNED"; + } else if ("REJECT".equals(action)) { + finish(current, actor.userId(), "REJECTED"); + targetDocumentState = "REJECTED"; + } else if ("PROJECT_REVIEW".equals(current.nodeCode())) { + advanceToFinance(current, document.createdBy(), actor.userId()); + targetDocumentState = "FINANCE_REVIEW"; + } else if ("FINANCE_REVIEW".equals(current.nodeCode())) { + finish(current, actor.userId(), "APPROVED"); + targetDocumentState = "OA-12".equalsIgnoreCase(document.formType()) + ? "ARCHIVE_PENDING" : "APPROVED"; + } else { + throw invalidState("审批节点配置无效"); + } + if (mapper.updateDocumentStateBySourceVersion( + current.sourceVersionId(), targetDocumentState, actor.userId() + ) != 1) { + throw conflict(); + } + if ("APPROVE".equals(action) && "FINANCE_REVIEW".equals(current.nodeCode())) { + if ("OA-01".equalsIgnoreCase(document.formType()) && document.projectPublicId() != null) { + projectService.activateFromApprovedSource(document.projectPublicId(), document.publicId()); + } else if (Set.of("OA-02", "OA-03", "OA-04", "OA-05", "OA-06", "OA-07", "OA-08", "OA-11") + .contains(document.formType().toUpperCase(Locale.ROOT))) { + projectionService.projectApproved( + document, + current.sourceVersionId(), + sourceService.plainValuesForProjection(document), + actor.userId(), + request.opinion().trim() + ); + } + } + mapper.insertApproval( + ulidGenerator.next(), current.sourceVersionId(), current.nodeCode(), actor.displayName(), actor.userId(), + action, request.opinion().trim(), "RETURN".equals(action) + ? json(Map.of("returnPoints", request.returnPoints())) : null + ); + auditService.recordScoped(document.companyPublicId(), document.projectPublicId(), + "WORKFLOW_TASK_" + action, "WORKFLOW_TASK", publicId, "SUCCESS", request.opinion().trim(), + Map.of("status", current.status(), "node", current.nodeCode()), + Map.of("status", taskStatus, "sourceStatus", targetDocumentState)); + auditService.recordScoped(document.companyPublicId(), document.projectPublicId(), + "SOURCE_FORM_" + action, "SOURCE_FORM", document.publicId(), "SUCCESS", request.opinion().trim(), + Map.of("status", document.status()), Map.of("status", targetDocumentState)); + return getTask(publicId); + } + + private void advanceToFinance(TaskRecord current, long initiatorId, long actorId) { + if (mapper.moveInstance(current.instanceId(), current.instanceVersion(), "FINANCE_REVIEW", actorId) != 1) { + throw conflict(); + } + TaskWrite next = new TaskWrite(); + next.setPublicId(ulidGenerator.next()); + next.setInstanceId(current.instanceId()); + next.setNodeCode("FINANCE_REVIEW"); + next.setSequenceNo(current.sequenceNo() + 1); + next.setAssigneeId(reviewer("FINANCE_MANAGER", initiatorId, actorId)); + next.setDueAt(LocalDateTime.now(ZoneOffset.UTC).plusDays(2)); + mapper.insertWorkflowTask(next); + } + + private void finish(TaskRecord current, long actorId, String status) { + if (mapper.finishInstance(current.instanceId(), current.instanceVersion(), status, actorId) != 1) { + throw conflict(); + } + } + + private WorkflowTaskSummaryView summary(TaskRecord task) { + DocumentRecord document = sourceService.documentRecord(task.documentPublicId()); + return summary( + task, sourceService.summaryForWorkflow(document), keyAmount(document.formType(), document.payloadJson()) + ); + } + + private WorkflowTaskSummaryView summary(TaskRecord task, SourceFormSummaryView form, String keyAmount) { + return new WorkflowTaskSummaryView( + task.publicId(), task.instancePublicId(), task.nodeCode(), sourceService.nodeName(task.nodeCode()), + task.sequenceNo(), task.status(), task.version(), task.assigneeName(), task.createdAt(), task.dueAt(), + task.updatedAt(), keyAmount, form, allowedActions(task) + ); + } + + private String keyAmount(String formType, String payloadJson) { + if (payloadJson == null || payloadJson.isBlank()) return null; + try { + return keyAmount(formType, objectMapper.readTree(payloadJson)); + } catch (JsonProcessingException exception) { + throw new IllegalStateException("Stored source JSON is invalid", exception); + } + } + + private String keyAmount(String formType, JsonNode values) { + String field = KEY_AMOUNT_FIELDS.get(formType); + if (field == null || values == null || !values.isObject()) return null; + JsonNode amount = values.get(field); + if (amount == null || (!amount.isNumber() && !amount.isTextual()) || amount.asText().isBlank()) return null; + try { + return new BigDecimal(amount.asText()).toPlainString(); + } catch (NumberFormatException exception) { + return null; + } + } + + private List allowedActions(TaskRecord task) { + FinancePrincipal actor = identityContext.requirePrincipal(); + String roleCode = identityContext.requireActiveRole(); + if (!"PENDING".equals(task.status()) || task.assigneeId() != actor.userId() + || !roleCode.equals(nodeRole(task.nodeCode()))) return List.of(); + List actions = new ArrayList<>(); + if (authorizationService.hasPermission("workflow:task:approve")) actions.add("APPROVE"); + if (authorizationService.hasPermission("workflow:task:return")) actions.add("RETURN"); + if (authorizationService.hasPermission("workflow:task:reject")) actions.add("REJECT"); + return List.copyOf(actions); + } + + private String documentForInstance(String instancePublicId) { + if (instancePublicId == null || !PUBLIC_ID.matcher(instancePublicId).matches()) { + throw new BusinessException(HttpStatus.NOT_FOUND, ErrorCode.RESOURCE_NOT_FOUND, "审批流程不存在"); + } + String document = mapper.findDocumentPublicIdByInstance(instancePublicId); + if (document == null) { + throw new BusinessException(HttpStatus.NOT_FOUND, ErrorCode.RESOURCE_NOT_FOUND, "审批流程不存在"); + } + return document; + } + + private TaskRecord requireTask(String publicId, String roleCode) { + if (publicId == null || !PUBLIC_ID.matcher(publicId).matches()) { + throw new BusinessException(HttpStatus.NOT_FOUND, ErrorCode.RESOURCE_NOT_FOUND, "审批任务不存在"); + } + TaskRecord task = mapper.findTask(publicId, roleCode); + if (task == null) { + throw new BusinessException(HttpStatus.NOT_FOUND, ErrorCode.RESOURCE_NOT_FOUND, "审批任务不存在"); + } + return task; + } + + private void requireNodeRole(TaskRecord task, String roleCode) { + if (!roleCode.equals(nodeRole(task.nodeCode()))) { + throw new BusinessException(HttpStatus.FORBIDDEN, ErrorCode.PERMISSION_DENIED, + "当前工作身份不能处理该审批节点"); + } + } + + private String nodeRole(String nodeCode) { + return switch (nodeCode) { + case "PROJECT_REVIEW" -> "PROJECT_MANAGER"; + case "FINANCE_REVIEW" -> "FINANCE_MANAGER"; + default -> ""; + }; + } + + private String permission(String action) { + return switch (action) { + case "APPROVE" -> "workflow:task:approve"; + case "RETURN" -> "workflow:task:return"; + case "REJECT" -> "workflow:task:reject"; + default -> throw new IllegalArgumentException("Unsupported workflow action"); + }; + } + + private void requireReturnPoints(DocumentRecord document, List returnPoints) { + Set validPoints = mapper.listTemplateFields(document.templateId()).stream() + .map(field -> field.fieldCode()).collect(java.util.stream.Collectors.toSet()); + for (String returnPoint : returnPoints) { + if (!validPoints.contains(returnPoint)) { + throw new BusinessException( + HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.VALIDATION_FAILED, + "退回点不属于当前模板字段或附件", Map.of("returnPoints", "包含无效退回点: " + returnPoint) + ); + } + } + } + + private long reviewer(String role, long initiatorId, long adjacentApproverId) { + Long reviewer = mapper.findReviewer(role, initiatorId, adjacentApproverId); + if (reviewer == null) throw invalidState("审批角色尚未配置可用人员"); + return reviewer; + } + + private String view(String value) { + String normalized = value == null || value.isBlank() ? "TODO" : value.trim().toUpperCase(Locale.ROOT); + if (!VIEWS.contains(normalized)) throw queryError("view 参数无效"); + return normalized; + } + + private String formType(String value) { + if (value == null || value.isBlank()) return null; + String normalized = value.trim().toUpperCase(Locale.ROOT); + if (!normalized.matches("OA-(0[1-9]|1[0-4])")) throw queryError("formType 参数无效"); + return normalized; + } + + private String taskStatus(String value) { + if (value == null || value.isBlank()) return null; + String normalized = value.trim().toUpperCase(Locale.ROOT); + if (!TASK_STATUSES.contains(normalized)) throw queryError("status 参数无效"); + return normalized; + } + + private String publicIdFilter(String value) { + String normalized = trimToNull(value, 26); + if (normalized != null && !PUBLIC_ID.matcher(normalized).matches()) { + throw queryError("projectId 参数无效"); + } + return normalized; + } + + private String orderBy(String value) { + String normalized = value == null || value.isBlank() ? "arrivedAt,asc" : value.trim(); + String[] parts = normalized.split(",", -1); + if (parts.length != 2 || !SORT_COLUMNS.containsKey(parts[0])) { + throw queryError("排序字段不在允许范围内"); + } + String direction = parts[1].toUpperCase(Locale.ROOT); + if (!Set.of("ASC", "DESC").contains(direction)) { + throw queryError("排序方向只允许 asc 或 desc"); + } + return SORT_COLUMNS.get(parts[0]) + " " + direction; + } + + private String trimToNull(String value, int max) { + if (value == null || value.isBlank()) return null; + String normalized = value.trim(); + if (normalized.length() > max) throw queryError("查询参数过长"); + return normalized; + } + + private Page page(int page, int size) { + if (page < 1 || !Set.of(20, 50, 100).contains(size)) { + throw queryError("分页参数只能使用 page>=1 且 size 为 20、50 或 100"); + } + return new Page(page, size, (page - 1) * size); + } + + private String json(Object value) { + try { + return objectMapper.writeValueAsString(value); + } catch (JsonProcessingException exception) { + throw new IllegalStateException("Workflow value cannot be serialized", exception); + } + } + + private BusinessException queryError(String message) { + return new BusinessException(HttpStatus.BAD_REQUEST, ErrorCode.QUERY_PARAMETER_INVALID, message); + } + + private BusinessException conflict() { + return new BusinessException( + HttpStatus.CONFLICT, ErrorCode.VERSION_CONFLICT, "任务已被其他用户处理,请刷新后重试" + ); + } + + private BusinessException invalidState(String message) { + return new BusinessException(HttpStatus.CONFLICT, ErrorCode.INVALID_STATE_TRANSITION, message); + } + + private record Page(int page, int size, int offset) { + } +} diff --git a/backend/src/main/java/com/kaidi/finance/source/domain/SourceRecords.java b/backend/src/main/java/com/kaidi/finance/source/domain/SourceRecords.java new file mode 100644 index 0000000..ab3314e --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/source/domain/SourceRecords.java @@ -0,0 +1,355 @@ +package com.kaidi.finance.source.domain; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +public final class SourceRecords { + + private SourceRecords() { + } + + public record TemplateRecord( + long id, + String publicId, + String formType, + int templateVersion, + String name, + String groupCode, + String purpose, + String initiatorRoles, + String status, + LocalDateTime publishedAt, + long myDraftCount, + long myPendingCount + ) { + } + + public record FieldRecord( + long id, + long templateId, + String fieldCode, + String label, + String sectionCode, + String sectionName, + String dataType, + boolean requiredFlag, + boolean repeatable, + Integer maxLength, + Integer numericPrecision, + Integer numericScale, + String optionsJson, + String unit, + String sensitivity, + String maskingRule, + String helpText, + int sortOrder + ) { + } + + public record DocumentRecord( + long id, + String publicId, + String businessNo, + String formType, + String formName, + long templateId, + int templateVersion, + String sourceSystem, + String sourceNo, + String normalizedSourceNo, + String sourceVersion, + String normalizedSourceVersion, + long companyId, + String companyPublicId, + String companyName, + Long projectId, + String projectPublicId, + String projectName, + String applicantName, + LocalDate businessDate, + Long currentVersionId, + int documentVersion, + String snapshotSha256, + String payloadJson, + String status, + String currentNodeCode, + String instancePublicId, + long version, + long createdBy, + String createdByName, + long updatedBy, + LocalDateTime createdAt, + LocalDateTime updatedAt + ) { + } + + public record ApprovalRecord( + String publicId, + String nodeCode, + String actorName, + String action, + String opinion, + LocalDateTime actedAt + ) { + } + + public record TaskRecord( + long id, + String publicId, + long instanceId, + String instancePublicId, + String nodeCode, + int sequenceNo, + long assigneeId, + String assigneeName, + long instanceVersion, + long sourceVersionId, + LocalDateTime dueAt, + String status, + long version, + LocalDateTime createdAt, + LocalDateTime updatedAt, + String documentPublicId + ) { + } + + public record InstanceRecord( + long id, + String publicId, + long sourceVersionId, + String currentNodeCode, + String status, + long version, + long createdBy + ) { + } + + public record ImportBatchRecord( + long id, + String publicId, + String batchNo, + String formType, + String sourceSystem, + long sourceFileId, + String sourceFilePublicId, + String fileSha256, + int totalRows, + int validRows, + int errorRows, + String validationJson, + String status, + long version, + long createdBy, + String createdByName, + LocalDateTime createdAt, + LocalDateTime updatedAt + ) { + } + + public record ImportRowRecord( + long id, + long batchId, + int rowNo, + String sourceNo, + String sourceVersion, + String payloadJson, + String validationJson, + String status + ) { + } + + public static final class ImportBatchWrite { + private Long id; + private String publicId; + private String batchNo; + private String formType; + private String sourceSystem; + private long sourceFileId; + private String fileSha256; + private int totalRows; + private int validRows; + private int errorRows; + private String validationJson; + private String status; + private long actorId; + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + public String getPublicId() { return publicId; } + public void setPublicId(String publicId) { this.publicId = publicId; } + public String getBatchNo() { return batchNo; } + public void setBatchNo(String batchNo) { this.batchNo = batchNo; } + public String getFormType() { return formType; } + public void setFormType(String formType) { this.formType = formType; } + public String getSourceSystem() { return sourceSystem; } + public void setSourceSystem(String sourceSystem) { this.sourceSystem = sourceSystem; } + public long getSourceFileId() { return sourceFileId; } + public void setSourceFileId(long sourceFileId) { this.sourceFileId = sourceFileId; } + public String getFileSha256() { return fileSha256; } + public void setFileSha256(String fileSha256) { this.fileSha256 = fileSha256; } + public int getTotalRows() { return totalRows; } + public void setTotalRows(int totalRows) { this.totalRows = totalRows; } + public int getValidRows() { return validRows; } + public void setValidRows(int validRows) { this.validRows = validRows; } + public int getErrorRows() { return errorRows; } + public void setErrorRows(int errorRows) { this.errorRows = errorRows; } + public String getValidationJson() { return validationJson; } + public void setValidationJson(String validationJson) { this.validationJson = validationJson; } + public String getStatus() { return status; } + public void setStatus(String status) { this.status = status; } + public long getActorId() { return actorId; } + public void setActorId(long actorId) { this.actorId = actorId; } + } + + public static final class ImportRowWrite { + private long batchId; + private int rowNo; + private String sourceNo; + private String sourceVersion; + private String payloadJson; + private String validationJson; + private String status; + + public long getBatchId() { return batchId; } + public void setBatchId(long batchId) { this.batchId = batchId; } + public int getRowNo() { return rowNo; } + public void setRowNo(int rowNo) { this.rowNo = rowNo; } + public String getSourceNo() { return sourceNo; } + public void setSourceNo(String sourceNo) { this.sourceNo = sourceNo; } + public String getSourceVersion() { return sourceVersion; } + public void setSourceVersion(String sourceVersion) { this.sourceVersion = sourceVersion; } + public String getPayloadJson() { return payloadJson; } + public void setPayloadJson(String payloadJson) { this.payloadJson = payloadJson; } + public String getValidationJson() { return validationJson; } + public void setValidationJson(String validationJson) { this.validationJson = validationJson; } + public String getStatus() { return status; } + public void setStatus(String status) { this.status = status; } + } + + public static final class DocumentWrite { + private Long id; + private String publicId; + private String businessNo; + private String formType; + private String sourceSystem; + private String sourceNo; + private String normalizedSourceNo; + private String sourceVersion; + private String normalizedSourceVersion; + private long templateId; + private long companyId; + private Long projectId; + private String applicantName; + private LocalDate businessDate; + private long actorId; + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + public String getPublicId() { return publicId; } + public void setPublicId(String publicId) { this.publicId = publicId; } + public String getBusinessNo() { return businessNo; } + public void setBusinessNo(String businessNo) { this.businessNo = businessNo; } + public String getFormType() { return formType; } + public void setFormType(String formType) { this.formType = formType; } + public String getSourceSystem() { return sourceSystem; } + public void setSourceSystem(String sourceSystem) { this.sourceSystem = sourceSystem; } + public String getSourceNo() { return sourceNo; } + public void setSourceNo(String sourceNo) { this.sourceNo = sourceNo; } + public String getNormalizedSourceNo() { return normalizedSourceNo; } + public void setNormalizedSourceNo(String normalizedSourceNo) { this.normalizedSourceNo = normalizedSourceNo; } + public String getSourceVersion() { return sourceVersion; } + public void setSourceVersion(String sourceVersion) { this.sourceVersion = sourceVersion; } + public String getNormalizedSourceVersion() { return normalizedSourceVersion; } + public void setNormalizedSourceVersion(String normalizedSourceVersion) { this.normalizedSourceVersion = normalizedSourceVersion; } + public long getTemplateId() { return templateId; } + public void setTemplateId(long templateId) { this.templateId = templateId; } + public long getCompanyId() { return companyId; } + public void setCompanyId(long companyId) { this.companyId = companyId; } + public Long getProjectId() { return projectId; } + public void setProjectId(Long projectId) { this.projectId = projectId; } + public String getApplicantName() { return applicantName; } + public void setApplicantName(String applicantName) { this.applicantName = applicantName; } + public LocalDate getBusinessDate() { return businessDate; } + public void setBusinessDate(LocalDate businessDate) { this.businessDate = businessDate; } + public long getActorId() { return actorId; } + public void setActorId(long actorId) { this.actorId = actorId; } + } + + public static final class VersionWrite { + private Long id; + private String publicId; + private long documentId; + private int versionNo; + private Long previousVersionId; + private long templateId; + private String snapshotSha256; + private String payloadJson; + private long actorId; + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + public String getPublicId() { return publicId; } + public void setPublicId(String publicId) { this.publicId = publicId; } + public long getDocumentId() { return documentId; } + public void setDocumentId(long documentId) { this.documentId = documentId; } + public int getVersionNo() { return versionNo; } + public void setVersionNo(int versionNo) { this.versionNo = versionNo; } + public Long getPreviousVersionId() { return previousVersionId; } + public void setPreviousVersionId(Long previousVersionId) { this.previousVersionId = previousVersionId; } + public long getTemplateId() { return templateId; } + public void setTemplateId(long templateId) { this.templateId = templateId; } + public String getSnapshotSha256() { return snapshotSha256; } + public void setSnapshotSha256(String snapshotSha256) { this.snapshotSha256 = snapshotSha256; } + public String getPayloadJson() { return payloadJson; } + public void setPayloadJson(String payloadJson) { this.payloadJson = payloadJson; } + public long getActorId() { return actorId; } + public void setActorId(long actorId) { this.actorId = actorId; } + } + + public static final class WorkflowWrite { + private Long id; + private String publicId; + private long definitionVersionId; + private long sourceVersionId; + private String currentNodeCode; + private long actorId; + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + public String getPublicId() { return publicId; } + public void setPublicId(String publicId) { this.publicId = publicId; } + public long getDefinitionVersionId() { return definitionVersionId; } + public void setDefinitionVersionId(long definitionVersionId) { this.definitionVersionId = definitionVersionId; } + public long getSourceVersionId() { return sourceVersionId; } + public void setSourceVersionId(long sourceVersionId) { this.sourceVersionId = sourceVersionId; } + public String getCurrentNodeCode() { return currentNodeCode; } + public void setCurrentNodeCode(String currentNodeCode) { this.currentNodeCode = currentNodeCode; } + public long getActorId() { return actorId; } + public void setActorId(long actorId) { this.actorId = actorId; } + } + + public static final class TaskWrite { + private Long id; + private String publicId; + private long instanceId; + private String nodeCode; + private int sequenceNo; + private long assigneeId; + private LocalDateTime dueAt; + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + public String getPublicId() { return publicId; } + public void setPublicId(String publicId) { this.publicId = publicId; } + public long getInstanceId() { return instanceId; } + public void setInstanceId(long instanceId) { this.instanceId = instanceId; } + public String getNodeCode() { return nodeCode; } + public void setNodeCode(String nodeCode) { this.nodeCode = nodeCode; } + public int getSequenceNo() { return sequenceNo; } + public void setSequenceNo(int sequenceNo) { this.sequenceNo = sequenceNo; } + public long getAssigneeId() { return assigneeId; } + public void setAssigneeId(long assigneeId) { this.assigneeId = assigneeId; } + public LocalDateTime getDueAt() { return dueAt; } + public void setDueAt(LocalDateTime dueAt) { this.dueAt = dueAt; } + } +} diff --git a/backend/src/main/java/com/kaidi/finance/source/infrastructure/ApprovedSourceProjectionMapper.java b/backend/src/main/java/com/kaidi/finance/source/infrastructure/ApprovedSourceProjectionMapper.java new file mode 100644 index 0000000..44ff5b3 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/source/infrastructure/ApprovedSourceProjectionMapper.java @@ -0,0 +1,220 @@ +package com.kaidi.finance.source.infrastructure; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface ApprovedSourceProjectionMapper { + + @Select(""" + SELECT id, public_id, source_version_id + FROM md_counterparty + WHERE source_document_id = #{sourceDocumentId} + """) + ProjectionIdentity findCounterpartyBySourceDocument(long sourceDocumentId); + + @Insert(""" + INSERT INTO md_counterparty ( + public_id, business_no, counterparty_type, name, normalized_tax_no, + contact_name, contact_phone, status, created_by, updated_by, + submitted_by, submitted_at, source_document_id, source_version_id + ) VALUES ( + #{publicId}, #{businessNo}, 'SUPPLIER', #{name}, #{taxNo}, + #{contactName}, #{contactPhone}, 'REVIEWING', #{createdBy}, #{reviewerId}, + #{reviewerId}, UTC_TIMESTAMP(3), #{sourceDocumentId}, #{sourceVersionId} + ) + """) + int insertProjectedCounterparty( + @Param("publicId") String publicId, + @Param("businessNo") String businessNo, + @Param("name") String name, + @Param("taxNo") String taxNo, + @Param("contactName") String contactName, + @Param("contactPhone") String contactPhone, + @Param("createdBy") long createdBy, + @Param("reviewerId") long reviewerId, + @Param("sourceDocumentId") long sourceDocumentId, + @Param("sourceVersionId") long sourceVersionId + ); + + @Select(""" + SELECT COALESCE(MAX(version_no), 0) + 1 + FROM md_bank_account_version + WHERE counterparty_id = #{counterpartyId} + """) + int nextCounterpartyBankAccountVersion(long counterpartyId); + + @Insert(""" + INSERT INTO md_bank_account_version ( + public_id, owner_type, counterparty_id, account_category, account_name, bank_name, + account_no_ciphertext, account_no_hash, masked_account_no, version_no, + valid_from, valid_to, status, created_by, updated_by, submitted_by, submitted_at, + source_document_id, source_version_id, source_row_no + ) VALUES ( + #{publicId}, 'COUNTERPARTY', #{counterpartyId}, #{accountCategory}, #{accountName}, #{bankName}, + #{ciphertext}, #{accountHash}, #{maskedAccountNo}, #{versionNo}, + #{validFrom}, #{validTo}, 'REVIEWING', #{createdBy}, #{reviewerId}, #{reviewerId}, UTC_TIMESTAMP(3), + #{sourceDocumentId}, #{sourceVersionId}, #{sourceRowNo} + ) + """) + int insertProjectedBankAccount( + @Param("publicId") String publicId, + @Param("counterpartyId") long counterpartyId, + @Param("accountCategory") String accountCategory, + @Param("accountName") String accountName, + @Param("bankName") String bankName, + @Param("ciphertext") byte[] ciphertext, + @Param("accountHash") String accountHash, + @Param("maskedAccountNo") String maskedAccountNo, + @Param("versionNo") int versionNo, + @Param("validFrom") LocalDateTime validFrom, + @Param("validTo") LocalDateTime validTo, + @Param("createdBy") long createdBy, + @Param("reviewerId") long reviewerId, + @Param("sourceDocumentId") long sourceDocumentId, + @Param("sourceVersionId") long sourceVersionId, + @Param("sourceRowNo") int sourceRowNo + ); + + @Select(""" + SELECT id, public_id, source_version_id + FROM md_bank_account_version + WHERE source_document_id = #{sourceDocumentId} + ORDER BY source_row_no + LIMIT 1 + """) + ProjectionIdentity findBankAccountBySourceDocument(long sourceDocumentId); + + @Select(""" + SELECT COALESCE(MAX(version_no), 0) + 1 + FROM md_bank_account_version + WHERE company_id = #{companyId} + """) + int nextCompanyBankAccountVersion(long companyId); + + @Select(""" + SELECT COUNT(*) + FROM md_bank_account_version + WHERE company_id = #{companyId} + AND account_no_hash = #{accountHash} + AND status <> 'DISABLED' + """) + long countActiveCompanyBankAccount( + @Param("companyId") long companyId, + @Param("accountHash") String accountHash + ); + + @Insert(""" + INSERT INTO md_bank_account_version ( + public_id, owner_type, company_id, account_category, account_name, bank_name, + account_no_ciphertext, account_no_hash, masked_account_no, version_no, + valid_from, status, created_by, updated_by, submitted_by, submitted_at, + source_document_id, source_version_id, source_row_no + ) VALUES ( + #{publicId}, 'COMPANY', #{companyId}, #{accountCategory}, #{accountName}, #{bankName}, + #{ciphertext}, #{accountHash}, #{maskedAccountNo}, #{versionNo}, + #{validFrom}, 'REVIEWING', #{createdBy}, #{reviewerId}, #{reviewerId}, UTC_TIMESTAMP(3), + #{sourceDocumentId}, #{sourceVersionId}, 1 + ) + """) + int insertProjectedCompanyBankAccount( + @Param("publicId") String publicId, + @Param("companyId") long companyId, + @Param("accountCategory") String accountCategory, + @Param("accountName") String accountName, + @Param("bankName") String bankName, + @Param("ciphertext") byte[] ciphertext, + @Param("accountHash") String accountHash, + @Param("maskedAccountNo") String maskedAccountNo, + @Param("versionNo") int versionNo, + @Param("validFrom") LocalDateTime validFrom, + @Param("createdBy") long createdBy, + @Param("reviewerId") long reviewerId, + @Param("sourceDocumentId") long sourceDocumentId, + @Param("sourceVersionId") long sourceVersionId + ); + + @Select(""" + SELECT id, public_id, name + FROM md_counterparty + WHERE public_id = #{publicId} + AND counterparty_type IN ('SUPPLIER', 'BOTH') + AND status = 'ACTIVE' + """) + SupplierIdentity findActiveSupplier(String publicId); + + @Select(""" + SELECT id, public_id, account_name, bank_name + FROM md_bank_account_version + WHERE counterparty_id = #{counterpartyId} + AND account_no_hash = #{accountHash} + AND status = 'ACTIVE' + AND valid_from <= UTC_TIMESTAMP(3) + AND (valid_to IS NULL OR valid_to >= UTC_TIMESTAMP(3)) + ORDER BY version_no DESC + LIMIT 1 + """) + SupplierAccountIdentity findActiveSupplierAccount( + @Param("counterpartyId") long counterpartyId, + @Param("accountHash") String accountHash + ); + + @Select("SELECT COUNT(*) > 0 FROM md_company WHERE id = #{companyId} AND status = 'ACTIVE'") + boolean isActiveCompany(long companyId); + + @Select(""" + SELECT COUNT(*) > 0 + FROM md_project + WHERE id = #{projectId} AND company_id = #{companyId} AND status = 'ACTIVE' + """) + boolean isActiveProject(@Param("projectId") long projectId, @Param("companyId") long companyId); + + @Select(""" + SELECT id, public_id, source_version_id + FROM md_contract + WHERE source_document_id = #{sourceDocumentId} + """) + ProjectionIdentity findContractBySourceDocument(long sourceDocumentId); + + @Insert(""" + INSERT INTO md_contract ( + public_id, company_id, project_id, counterparty_id, business_no, name, + original_amount, approved_change_amount, currency, status, + created_by, updated_by, submitted_by, submitted_at, reviewed_by, reviewed_at, + review_opinion, source_document_id, source_version_id + ) VALUES ( + #{publicId}, #{companyId}, #{projectId}, #{counterpartyId}, #{businessNo}, #{name}, + #{originalAmount}, 0, #{currency}, 'ACTIVE', + #{createdBy}, #{reviewerId}, #{createdBy}, UTC_TIMESTAMP(3), #{reviewerId}, UTC_TIMESTAMP(3), + #{opinion}, #{sourceDocumentId}, #{sourceVersionId} + ) + """) + int insertProjectedContract( + @Param("publicId") String publicId, + @Param("companyId") long companyId, + @Param("projectId") long projectId, + @Param("counterpartyId") long counterpartyId, + @Param("businessNo") String businessNo, + @Param("name") String name, + @Param("originalAmount") BigDecimal originalAmount, + @Param("currency") String currency, + @Param("createdBy") long createdBy, + @Param("reviewerId") long reviewerId, + @Param("opinion") String opinion, + @Param("sourceDocumentId") long sourceDocumentId, + @Param("sourceVersionId") long sourceVersionId + ); + + record ProjectionIdentity(long id, String publicId, long sourceVersionId) { + } + + record SupplierIdentity(long id, String publicId, String name) { + } + + record SupplierAccountIdentity(long id, String publicId, String accountName, String bankName) { + } +} diff --git a/backend/src/main/java/com/kaidi/finance/source/infrastructure/SourceWorkflowMapper.java b/backend/src/main/java/com/kaidi/finance/source/infrastructure/SourceWorkflowMapper.java new file mode 100644 index 0000000..e8cb89b --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/source/infrastructure/SourceWorkflowMapper.java @@ -0,0 +1,999 @@ +package com.kaidi.finance.source.infrastructure; + +import com.kaidi.finance.source.api.SourceContracts.ContractReferenceView; +import com.kaidi.finance.source.api.SourceContracts.PayableReferenceView; +import com.kaidi.finance.source.api.SourceContracts.ReferenceView; +import com.kaidi.finance.source.domain.SourceRecords.ApprovalRecord; +import com.kaidi.finance.source.domain.SourceRecords.DocumentRecord; +import com.kaidi.finance.source.domain.SourceRecords.DocumentWrite; +import com.kaidi.finance.source.domain.SourceRecords.FieldRecord; +import com.kaidi.finance.source.domain.SourceRecords.InstanceRecord; +import com.kaidi.finance.source.domain.SourceRecords.ImportBatchRecord; +import com.kaidi.finance.source.domain.SourceRecords.ImportBatchWrite; +import com.kaidi.finance.source.domain.SourceRecords.ImportRowRecord; +import com.kaidi.finance.source.domain.SourceRecords.ImportRowWrite; +import com.kaidi.finance.source.domain.SourceRecords.TaskRecord; +import com.kaidi.finance.source.domain.SourceRecords.TaskWrite; +import com.kaidi.finance.source.domain.SourceRecords.TemplateRecord; +import com.kaidi.finance.source.domain.SourceRecords.VersionWrite; +import com.kaidi.finance.source.domain.SourceRecords.WorkflowWrite; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import org.apache.ibatis.annotations.Delete; +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Options; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +@org.apache.ibatis.annotations.Mapper +public interface SourceWorkflowMapper { + + String DOCUMENT_COLUMNS = """ + document.id AS id, + document.public_id AS public_id, + document.business_no AS business_no, + document.form_type AS form_type, + template.name AS form_name, + template.id AS template_id, + template.template_version AS template_version, + document.source_system AS source_system, + document.source_no AS source_no, + document.normalized_source_no AS normalized_source_no, + document.source_version AS source_version, + document.normalized_source_version AS normalized_source_version, + document.company_id AS company_id, + company.public_id AS company_public_id, + company.name AS company_name, + document.project_id AS project_id, + project.public_id AS project_public_id, + project.name AS project_name, + document.applicant_name AS applicant_name, + document.business_date AS business_date, + document.current_version_id AS current_version_id, + COALESCE(document_version.version_no, 0) AS document_version, + document_version.snapshot_sha256 AS snapshot_sha256, + document_version.payload_json AS payload_json, + document.status AS status, + workflow.current_node_code AS current_node_code, + workflow.public_id AS instance_public_id, + document.version AS version, + document.created_by AS created_by, + creator.display_name AS created_by_name, + document.updated_by AS updated_by, + document.created_at AS created_at, + document.updated_at AS updated_at + """; + + String DOCUMENT_JOINS = """ + JOIN src_template template ON template.id = document.template_id + JOIN md_company company ON company.id = document.company_id + LEFT JOIN md_project project ON project.id = document.project_id + JOIN iam_user creator ON creator.id = document.created_by + LEFT JOIN src_document_version document_version ON document_version.id = document.current_version_id + LEFT JOIN wf_instance workflow ON workflow.id = ( + SELECT MAX(candidate.id) + FROM wf_instance candidate + JOIN src_document_version candidate_version ON candidate_version.id = candidate.source_version_id + WHERE candidate_version.source_document_id = document.id + ) + """; + + @Select(""" + SELECT template.id, template.public_id, template.form_type, template.template_version, + template.name, template.group_code, template.purpose, template.initiator_roles, + template.status, template.published_at, + (SELECT COUNT(*) FROM src_source_document draft + WHERE draft.template_id = template.id AND draft.created_by = #{userId} + AND draft.status IN ('DRAFT', 'RETURNED')) AS my_draft_count, + (SELECT COUNT(*) + FROM wf_task task + JOIN wf_instance instance ON instance.id = task.instance_id + JOIN src_document_version version ON version.id = instance.source_version_id + JOIN src_source_document source ON source.id = version.source_document_id + WHERE source.template_id = template.id AND task.assignee_id = #{userId} + AND task.status = 'PENDING') AS my_pending_count + FROM src_template template + WHERE template.status = 'ACTIVE' + ORDER BY FIELD(template.group_code, 'PROJECT_PREPARATION', 'INCOME_EXPENSE', + 'PROJECT_PROCESS', 'CLOSING_ARCHIVE', 'BANK_INFORMATION'), + template.form_type, template.template_version DESC + """) + List listActiveTemplates(long userId); + + @Select(""" + SELECT template.id, template.public_id, template.form_type, template.template_version, + template.name, template.group_code, template.purpose, template.initiator_roles, + template.status, template.published_at, 0 AS my_draft_count, 0 AS my_pending_count + FROM src_template template + WHERE template.form_type = #{formType} + AND template.template_version = COALESCE(#{version}, ( + SELECT MAX(active.template_version) FROM src_template active + WHERE active.form_type = #{formType} AND active.status = 'ACTIVE' + )) + AND template.status = 'ACTIVE' + """) + TemplateRecord findActiveTemplate(@Param("formType") String formType, @Param("version") Integer version); + + @Select(""" + SELECT id, template_id, field_code, label, section_code, section_name, data_type, + required_flag, repeatable, max_length, numeric_precision, numeric_scale, + CAST(options_json AS CHAR) AS options_json, unit, sensitivity, masking_rule, + help_text, sort_order + FROM src_template_field + WHERE template_id = #{templateId} + ORDER BY sort_order, id + """) + List listTemplateFields(long templateId); + + @Select(""" + SELECT company.public_id AS publicId, company.name AS name + FROM md_company company + WHERE company.status = 'ACTIVE' + AND EXISTS ( + SELECT 1 FROM iam_scope scope + JOIN iam_role role ON role.id = scope.role_id + JOIN iam_permission permission ON permission.id = scope.permission_id + WHERE scope.user_id = #{userId} AND role.code = #{roleCode} + AND permission.code = 'source:form:view' AND scope.status = 'ACTIVE' + AND scope.valid_from <= UTC_TIMESTAMP(3) + AND (scope.valid_to IS NULL OR scope.valid_to >= UTC_TIMESTAMP(3)) + AND (scope.scope_type = 'GLOBAL' + OR (scope.scope_type = 'COMPANY' AND scope.company_public_id = company.public_id)) + ) + ORDER BY company.business_no, company.public_id + """) + List listVisibleCompanies(@Param("userId") long userId, @Param("roleCode") String roleCode); + + @Select(""" + SELECT project.public_id AS publicId, project.name AS name + FROM md_project project + JOIN md_company company ON company.id = project.company_id + WHERE project.status = 'ACTIVE' AND company.status = 'ACTIVE' + AND EXISTS ( + SELECT 1 FROM iam_scope scope + JOIN iam_role role ON role.id = scope.role_id + JOIN iam_permission permission ON permission.id = scope.permission_id + WHERE scope.user_id = #{userId} AND role.code = #{roleCode} + AND permission.code = 'source:form:view' AND scope.status = 'ACTIVE' + AND scope.valid_from <= UTC_TIMESTAMP(3) + AND (scope.valid_to IS NULL OR scope.valid_to >= UTC_TIMESTAMP(3)) + AND (scope.scope_type = 'GLOBAL' + OR (scope.scope_type = 'COMPANY' AND scope.company_public_id = company.public_id) + OR (scope.scope_type = 'PROJECT' AND scope.project_public_id = project.public_id)) + ) + ORDER BY project.business_no, project.public_id + """) + List listVisibleProjects(@Param("userId") long userId, @Param("roleCode") String roleCode); + + @Select(""" + SELECT counterparty.public_id AS publicId, counterparty.name AS name + FROM md_counterparty counterparty + WHERE counterparty.status = 'ACTIVE' + AND counterparty.counterparty_type IN ('SUPPLIER', 'BOTH') + ORDER BY counterparty.business_no, counterparty.public_id + """) + List listActiveSupplierReferences(); + + @Select(""" + SELECT counterparty.public_id AS publicId, counterparty.name AS name + FROM md_counterparty counterparty + WHERE counterparty.status = 'ACTIVE' + AND counterparty.counterparty_type IN ('CUSTOMER', 'BOTH') + ORDER BY counterparty.business_no, counterparty.public_id + """) + List listActiveCustomerReferences(); + + @Select(""" + SELECT contract.public_id AS publicId, contract.name AS name, + contract.business_no AS businessNo, + company.public_id AS companyId, project.public_id AS projectId, + counterparty.public_id AS counterpartyId, + counterparty.name AS counterpartyName, + counterparty.counterparty_type AS counterpartyType + FROM md_contract contract + JOIN md_company company ON company.id = contract.company_id + JOIN md_project project ON project.id = contract.project_id + JOIN md_counterparty counterparty ON counterparty.id = contract.counterparty_id + WHERE contract.status = 'ACTIVE' + AND company.status = 'ACTIVE' + AND project.status = 'ACTIVE' + AND counterparty.status = 'ACTIVE' + AND counterparty.counterparty_type IN ('CUSTOMER', 'SUPPLIER', 'BOTH') + AND EXISTS ( + SELECT 1 FROM iam_scope scope + JOIN iam_role role ON role.id = scope.role_id + JOIN iam_permission permission ON permission.id = scope.permission_id + WHERE scope.user_id = #{userId} AND role.code = #{roleCode} + AND permission.code = 'source:form:view' AND scope.status = 'ACTIVE' + AND scope.valid_from <= UTC_TIMESTAMP(3) + AND (scope.valid_to IS NULL OR scope.valid_to >= UTC_TIMESTAMP(3)) + AND (scope.scope_type = 'GLOBAL' + OR (scope.scope_type = 'COMPANY' AND scope.company_public_id = company.public_id) + OR (scope.scope_type = 'PROJECT' AND scope.project_public_id = project.public_id)) + ) + ORDER BY contract.business_no, contract.public_id + """) + List listVisibleContracts(@Param("userId") long userId, + @Param("roleCode") String roleCode); + + @Select(""" + SELECT payable.public_id AS publicId, payable.business_no AS businessNo, + project.public_id AS projectId, contract.public_id AS contractId, + supplier.public_id AS supplierId, + CAST(payable.amount AS CHAR) AS amount, + CAST(payable.paid_amount AS CHAR) AS paidAmount, + CAST(payable.amount - payable.paid_amount AS CHAR) AS remainingAmount + FROM fin_payable payable + JOIN md_company company ON company.id = payable.company_id + JOIN md_project project ON project.id = payable.project_id + JOIN md_contract contract ON contract.id = payable.contract_id + JOIN md_counterparty supplier ON supplier.id = payable.counterparty_id + WHERE payable.status IN ('CONFIRMED', 'PART_PAID') + AND payable.amount > payable.paid_amount + AND company.status = 'ACTIVE' + AND project.status = 'ACTIVE' + AND contract.status = 'ACTIVE' + AND supplier.status = 'ACTIVE' + AND EXISTS ( + SELECT 1 FROM iam_scope scope + JOIN iam_role role ON role.id = scope.role_id + JOIN iam_permission permission ON permission.id = scope.permission_id + WHERE scope.user_id = #{userId} AND role.code = #{roleCode} + AND permission.code = 'source:form:view' AND scope.status = 'ACTIVE' + AND scope.valid_from <= UTC_TIMESTAMP(3) + AND (scope.valid_to IS NULL OR scope.valid_to >= UTC_TIMESTAMP(3)) + AND (scope.scope_type = 'GLOBAL' + OR (scope.scope_type = 'COMPANY' AND scope.company_public_id = company.public_id) + OR (scope.scope_type = 'PROJECT' AND scope.project_public_id = project.public_id)) + ) + ORDER BY payable.business_date DESC, payable.business_no, payable.public_id + """) + List listVisiblePayables(@Param("userId") long userId, + @Param("roleCode") String roleCode); + + @Select(""" + SELECT category.public_id AS publicId, category.name AS name + FROM md_cost_category category + WHERE category.status = 'ACTIVE' + ORDER BY category.code, category.public_id + """) + List listActiveCostCategoryReferences(); + + @Select(""" + SELECT id + FROM md_counterparty + WHERE public_id = #{publicId} + AND status = 'ACTIVE' + AND counterparty_type IN ('SUPPLIER', 'BOTH') + """) + Long findActiveSupplierId(String publicId); + + @Select("SELECT id FROM md_cost_category WHERE public_id = #{publicId} AND status = 'ACTIVE'") + Long findActiveCostCategoryId(String publicId); + + @Select("SELECT id FROM md_contract WHERE public_id = #{publicId} AND status = 'ACTIVE'") + Long findActiveContractId(String publicId); + + @Select(""" + SELECT id FROM fin_payable + WHERE public_id = #{publicId} AND status IN ('CONFIRMED', 'PART_PAID') + AND amount > paid_amount + """) + Long findActivePayableId(String publicId); + + @Select("SELECT id FROM md_company WHERE public_id = #{publicId} AND status = 'ACTIVE'") + Long findActiveCompanyId(String publicId); + + @Select(""" + SELECT COUNT(*) > 0 + FROM src_source_document + WHERE form_type = #{formType} AND source_system = #{sourceSystem} + AND normalized_source_no = #{sourceNo} AND normalized_source_version = #{sourceVersion} + """) + boolean existsDocumentSource(@Param("formType") String formType, + @Param("sourceSystem") String sourceSystem, + @Param("sourceNo") String sourceNo, + @Param("sourceVersion") String sourceVersion); + + @Select(""" + SELECT project.id + FROM md_project project + JOIN md_company company ON company.id = project.company_id + WHERE project.public_id = #{projectPublicId} AND company.public_id = #{companyPublicId} + AND project.status = 'ACTIVE' AND company.status = 'ACTIVE' + """) + Long findProjectId(@Param("projectPublicId") String projectPublicId, + @Param("companyPublicId") String companyPublicId); + + @Insert(""" + INSERT INTO src_source_document ( + public_id, business_no, form_type, source_system, source_no, normalized_source_no, + source_version, normalized_source_version, template_id, company_id, project_id, + applicant_name, business_date, status, created_by, updated_by + ) VALUES ( + #{publicId}, #{businessNo}, #{formType}, #{sourceSystem}, #{sourceNo}, #{normalizedSourceNo}, + #{sourceVersion}, #{normalizedSourceVersion}, #{templateId}, #{companyId}, #{projectId}, + #{applicantName}, #{businessDate}, 'DRAFT', #{actorId}, #{actorId} + ) + """) + @Options(useGeneratedKeys = true, keyProperty = "id") + int insertDocument(DocumentWrite document); + + @Insert(""" + INSERT INTO src_document_version ( + public_id, source_document_id, version_no, previous_version_id, template_id, + snapshot_sha256, payload_json, created_by + ) VALUES ( + #{publicId}, #{documentId}, #{versionNo}, #{previousVersionId}, #{templateId}, + #{snapshotSha256}, CAST(#{payloadJson} AS JSON), #{actorId} + ) + """) + @Options(useGeneratedKeys = true, keyProperty = "id") + int insertDocumentVersion(VersionWrite version); + + @Insert(""" + INSERT INTO src_field_value (document_version_id, field_code, row_no, typed_value_json, schema_version) + VALUES (#{versionId}, #{fieldCode}, 0, CAST(#{valueJson} AS JSON), 1) + """) + int insertFieldValue(@Param("versionId") long versionId, @Param("fieldCode") String fieldCode, + @Param("valueJson") String valueJson); + + @Update(""" + UPDATE src_source_document + SET current_version_id = #{versionId}, updated_by = #{actorId} + WHERE id = #{documentId} AND current_version_id IS NULL AND version = 0 + """) + int attachInitialVersion(@Param("documentId") long documentId, @Param("versionId") long versionId, + @Param("actorId") long actorId); + + @Update(""" + UPDATE src_source_document + SET source_no = #{sourceNo}, normalized_source_no = #{normalizedSourceNo}, + source_version = #{sourceVersion}, normalized_source_version = #{normalizedSourceVersion}, + company_id = #{companyId}, project_id = #{projectId}, applicant_name = #{applicantName}, + business_date = #{businessDate}, current_version_id = #{currentVersionId}, + status = 'DRAFT', updated_by = #{actorId}, version = version + 1 + WHERE id = #{id} AND version = #{expectedVersion} AND status IN ('DRAFT', 'RETURNED') + """) + int updateDocumentDraft(@Param("id") long id, + @Param("sourceNo") String sourceNo, + @Param("normalizedSourceNo") String normalizedSourceNo, + @Param("sourceVersion") String sourceVersion, + @Param("normalizedSourceVersion") String normalizedSourceVersion, + @Param("companyId") long companyId, + @Param("projectId") Long projectId, + @Param("applicantName") String applicantName, + @Param("businessDate") LocalDate businessDate, + @Param("currentVersionId") long currentVersionId, + @Param("actorId") long actorId, + @Param("expectedVersion") long expectedVersion); + + @Select(""" + SELECT + """ + DOCUMENT_COLUMNS + """ + FROM src_source_document document + """ + DOCUMENT_JOINS + """ + WHERE document.public_id = #{publicId} + """) + DocumentRecord findDocument(String publicId); + + @Select(""" + SELECT document.id, document.public_id, document.form_type, document.template_id, + document.current_version_id, document.status, document.version, document.created_by, + company.public_id AS company_public_id, project.public_id AS project_public_id + FROM src_source_document document + JOIN md_company company ON company.id = document.company_id + LEFT JOIN md_project project ON project.id = document.project_id + WHERE document.id = #{documentId} + FOR UPDATE + """) + DownstreamCorrectionRecord lockDocumentForDownstreamCorrection(long documentId); + + @Update(""" + UPDATE src_source_document + SET status = 'RETURNED', updated_by = #{actorId}, version = version + 1 + WHERE id = #{documentId} AND version = #{expectedVersion} + AND status = 'APPROVED' AND current_version_id = #{sourceVersionId} + """) + int returnApprovedDocumentForCorrection(@Param("documentId") long documentId, + @Param("sourceVersionId") long sourceVersionId, + @Param("expectedVersion") long expectedVersion, + @Param("actorId") long actorId); + + @Select(""" + + """) + List listDocuments(@Param("userId") long userId, + @Param("roleCode") String roleCode, + @Param("groupCode") String groupCode, + @Param("formType") String formType, + @Param("templateVersion") Integer templateVersion, + @Param("projectPublicId") String projectPublicId, + @Param("status") String status, + @Param("sourceSystem") String sourceSystem, + @Param("createdByMe") Boolean createdByMe, + @Param("keyword") String keyword, + @Param("createdAtFrom") LocalDateTime createdAtFrom, + @Param("createdAtToExclusive") LocalDateTime createdAtToExclusive, + @Param("orderBy") String orderBy, + @Param("limit") int limit, + @Param("offset") int offset); + + @Select(""" + + """) + long countDocuments(@Param("userId") long userId, + @Param("roleCode") String roleCode, + @Param("groupCode") String groupCode, + @Param("formType") String formType, + @Param("templateVersion") Integer templateVersion, + @Param("projectPublicId") String projectPublicId, + @Param("status") String status, + @Param("sourceSystem") String sourceSystem, + @Param("createdByMe") Boolean createdByMe, + @Param("keyword") String keyword, + @Param("createdAtFrom") LocalDateTime createdAtFrom, + @Param("createdAtToExclusive") LocalDateTime createdAtToExclusive); + + @Select(""" + SELECT public_id, node_code, actor_name, action, opinion, acted_at + FROM src_approval_snapshot + WHERE document_version_id IN ( + SELECT version.id FROM src_document_version version + WHERE version.source_document_id = #{documentId} + ) + ORDER BY acted_at, id + """) + List listApprovals(long documentId); + + @Insert(""" + INSERT INTO src_approval_snapshot ( + public_id, document_version_id, node_code, actor_name, actor_id, + action, opinion, acted_at, source_snapshot_json + ) VALUES ( + #{publicId}, #{documentVersionId}, #{nodeCode}, #{actorName}, #{actorId}, + #{action}, #{opinion}, UTC_TIMESTAMP(3), #{snapshotJson} + ) + """) + int insertApproval(@Param("publicId") String publicId, + @Param("documentVersionId") long documentVersionId, + @Param("nodeCode") String nodeCode, + @Param("actorName") String actorName, + @Param("actorId") long actorId, + @Param("action") String action, + @Param("opinion") String opinion, + @Param("snapshotJson") String snapshotJson); + + @Select(""" + SELECT version.id + FROM wf_definition_version version + JOIN wf_definition definition ON definition.id = version.definition_id + WHERE definition.code = 'SOURCE_STANDARD_REVIEW' + AND definition.status = 'ACTIVE' AND version.status = 'ACTIVE' + ORDER BY version.version_no DESC LIMIT 1 + """) + Long findActiveWorkflowDefinitionVersionId(); + + @Select(""" + SELECT user_account.id + FROM iam_user user_account + JOIN iam_user_role assignment ON assignment.user_id = user_account.id + JOIN iam_role role ON role.id = assignment.role_id + WHERE role.code = #{roleCode} AND user_account.enabled = TRUE + AND user_account.id NOT IN (#{initiatorId}, #{adjacentApproverId}) + ORDER BY CASE user_account.username WHEN 'demo' THEN 0 ELSE 1 END, + user_account.id + LIMIT 1 + """) + Long findReviewer(@Param("roleCode") String roleCode, + @Param("initiatorId") long initiatorId, + @Param("adjacentApproverId") long adjacentApproverId); + + /** + * Resolves a file only when the actor can already access it through the + * uploader relationship or an active archive link in this document's scope. + */ + @Select(""" + SELECT file.id, file.public_id, file.scan_status, + (file.uploaded_by = #{actorPublicId}) AS uploaded_by_actor, + EXISTS ( + SELECT 1 FROM file_link link + WHERE link.file_id = file.id AND link.active = TRUE + AND link.archive_status IN ('ACTIVE', 'ARCHIVED', 'FROZEN') + AND (link.company_public_id = #{companyPublicId} + OR link.project_public_id = #{projectPublicId}) + ) AS linked_to_document_scope + FROM file_object file + WHERE file.public_id = #{publicId} + """) + SourceFileRef findFileForSource(@Param("publicId") String publicId, + @Param("actorPublicId") String actorPublicId, + @Param("companyPublicId") String companyPublicId, + @Param("projectPublicId") String projectPublicId); + + @Insert(""" + INSERT INTO wf_instance ( + public_id, definition_version_id, source_version_id, current_node_code, + status, created_by, updated_by + ) VALUES ( + #{publicId}, #{definitionVersionId}, #{sourceVersionId}, #{currentNodeCode}, + 'RUNNING', #{actorId}, #{actorId} + ) + """) + @Options(useGeneratedKeys = true, keyProperty = "id") + int insertWorkflowInstance(WorkflowWrite instance); + + @Insert(""" + INSERT INTO wf_task (public_id, instance_id, node_code, sequence_no, assignee_id, due_at, status) + VALUES (#{publicId}, #{instanceId}, #{nodeCode}, #{sequenceNo}, #{assigneeId}, #{dueAt}, 'PENDING') + """) + @Options(useGeneratedKeys = true, keyProperty = "id") + int insertWorkflowTask(TaskWrite task); + + @Update(""" + UPDATE src_document_version SET submitted_at = UTC_TIMESTAMP(3) + WHERE id = #{versionId} AND submitted_at IS NULL + """) + int markVersionSubmitted(long versionId); + + @Update(""" + UPDATE src_source_document + SET status = 'PROJECT_REVIEW', updated_by = #{actorId}, version = version + 1 + WHERE id = #{documentId} AND version = #{expectedVersion} AND status IN ('DRAFT', 'RETURNED') + """) + int markDocumentSubmitted(@Param("documentId") long documentId, + @Param("expectedVersion") long expectedVersion, + @Param("actorId") long actorId); + + @Select(""" + SELECT instance.id, instance.public_id, instance.source_version_id, instance.current_node_code, + instance.status, instance.version, instance.created_by + FROM wf_instance instance + JOIN src_document_version version ON version.id = instance.source_version_id + WHERE version.source_document_id = #{documentId} + AND instance.status IN ('RUNNING', 'RETURNED') + ORDER BY instance.id DESC LIMIT 1 + """) + InstanceRecord findActiveInstance(long documentId); + + @Update(""" + UPDATE wf_instance + SET status = 'WITHDRAWN', current_node_code = NULL, updated_by = #{actorId}, version = version + 1 + WHERE id = #{instanceId} AND version = #{expectedVersion} AND status = 'RUNNING' + """) + int withdrawInstance(@Param("instanceId") long instanceId, + @Param("expectedVersion") long expectedVersion, + @Param("actorId") long actorId); + + @Update(""" + UPDATE wf_instance + SET status = 'VOID', current_node_code = NULL, updated_by = #{actorId}, version = version + 1 + WHERE id = #{instanceId} AND version = #{expectedVersion} AND status IN ('RUNNING', 'RETURNED') + """) + int voidInstance(@Param("instanceId") long instanceId, + @Param("expectedVersion") long expectedVersion, + @Param("actorId") long actorId); + + @Update(""" + UPDATE wf_instance + SET status = 'WITHDRAWN', current_node_code = NULL, updated_by = #{actorId}, version = version + 1 + WHERE id = #{instanceId} AND version = #{expectedVersion} AND status = 'RETURNED' + """) + int closeReturnedInstance(@Param("instanceId") long instanceId, + @Param("expectedVersion") long expectedVersion, + @Param("actorId") long actorId); + + @Update(""" + UPDATE wf_task SET status = 'CANCELLED', version = version + 1 + WHERE instance_id = #{instanceId} AND status = 'PENDING' + """) + int cancelPendingTasks(long instanceId); + + @Update(""" + UPDATE src_source_document + SET status = #{targetStatus}, updated_by = #{actorId}, version = version + 1 + WHERE id = #{documentId} AND version = #{expectedVersion} + AND status IN ('DRAFT', 'PROJECT_REVIEW', 'FINANCE_REVIEW', 'RETURNED') + """) + int updateDocumentState(@Param("documentId") long documentId, + @Param("expectedVersion") long expectedVersion, + @Param("targetStatus") String targetStatus, + @Param("actorId") long actorId); + + @Select(""" + + """) + List listTasks(@Param("userId") long userId, + @Param("roleCode") String roleCode, + @Param("view") String view, + @Param("formType") String formType, + @Param("status") String status, + @Param("keyword") String keyword, + @Param("projectPublicId") String projectPublicId, + @Param("arrivedAtFrom") java.time.LocalDateTime arrivedAtFrom, + @Param("arrivedAtToExclusive") java.time.LocalDateTime arrivedAtToExclusive, + @Param("overdue") Boolean overdue, + @Param("orderBy") String orderBy, + @Param("limit") int limit, + @Param("offset") int offset); + + @Select(""" + + """) + long countTasks(@Param("userId") long userId, + @Param("roleCode") String roleCode, + @Param("view") String view, + @Param("formType") String formType, + @Param("status") String status, + @Param("keyword") String keyword, + @Param("projectPublicId") String projectPublicId, + @Param("arrivedAtFrom") java.time.LocalDateTime arrivedAtFrom, + @Param("arrivedAtToExclusive") java.time.LocalDateTime arrivedAtToExclusive, + @Param("overdue") Boolean overdue); + + @Select(""" + SELECT task.id, task.public_id, task.instance_id, instance.public_id AS instance_public_id, + task.node_code, task.sequence_no, task.assignee_id, assignee.display_name AS assignee_name, + instance.version AS instance_version, instance.source_version_id, + task.due_at, task.status, task.version, task.created_at, task.updated_at, + document.public_id AS document_public_id + FROM wf_task task + JOIN wf_instance instance ON instance.id = task.instance_id + JOIN src_document_version document_version ON document_version.id = instance.source_version_id + JOIN src_source_document document ON document.id = document_version.source_document_id + JOIN iam_user assignee ON assignee.id = task.assignee_id + WHERE task.public_id = #{publicId} + AND ((#{roleCode} = 'PROJECT_MANAGER' AND task.node_code = 'PROJECT_REVIEW') + OR (#{roleCode} = 'FINANCE_MANAGER' AND task.node_code = 'FINANCE_REVIEW') + OR #{roleCode} = 'SYSTEM_ADMIN') + """) + TaskRecord findTask(@Param("publicId") String publicId, @Param("roleCode") String roleCode); + + @Select(""" + SELECT document.public_id + FROM wf_instance instance + JOIN src_document_version version ON version.id = instance.source_version_id + JOIN src_source_document document ON document.id = version.source_document_id + WHERE instance.public_id = #{instancePublicId} + """) + String findDocumentPublicIdByInstance(String instancePublicId); + + @Update(""" + UPDATE wf_task SET status = #{targetStatus}, version = version + 1 + WHERE id = #{taskId} AND version = #{expectedVersion} AND status = 'PENDING' + AND assignee_id = #{actorId} AND node_code = #{nodeCode} + """) + int completeTask(@Param("taskId") long taskId, + @Param("nodeCode") String nodeCode, + @Param("expectedVersion") long expectedVersion, + @Param("actorId") long actorId, + @Param("targetStatus") String targetStatus); + + @Insert(""" + INSERT INTO wf_action ( + public_id, task_id, sequence_no, action, opinion, return_points_json, + actor_id, request_id + ) VALUES ( + #{publicId}, #{taskId}, #{sequenceNo}, #{action}, #{opinion}, + CAST(#{returnPointsJson} AS JSON), #{actorId}, #{requestId} + ) + """) + int insertWorkflowAction(@Param("publicId") String publicId, + @Param("taskId") long taskId, + @Param("sequenceNo") int sequenceNo, + @Param("action") String action, + @Param("opinion") String opinion, + @Param("returnPointsJson") String returnPointsJson, + @Param("actorId") long actorId, + @Param("requestId") String requestId); + + @Update(""" + UPDATE wf_instance + SET current_node_code = #{nodeCode}, updated_by = #{actorId}, version = version + 1 + WHERE id = #{instanceId} AND version = #{expectedVersion} AND status = 'RUNNING' + """) + int moveInstance(@Param("instanceId") long instanceId, + @Param("expectedVersion") long expectedVersion, + @Param("nodeCode") String nodeCode, + @Param("actorId") long actorId); + + @Update(""" + UPDATE wf_instance + SET status = #{targetStatus}, current_node_code = NULL, updated_by = #{actorId}, version = version + 1 + WHERE id = #{instanceId} AND version = #{expectedVersion} AND status = 'RUNNING' + """) + int finishInstance(@Param("instanceId") long instanceId, + @Param("expectedVersion") long expectedVersion, + @Param("targetStatus") String targetStatus, + @Param("actorId") long actorId); + + @Update(""" + UPDATE src_source_document document + JOIN src_document_version version ON version.source_document_id = document.id + SET document.status = #{targetStatus}, document.updated_by = #{actorId}, + document.version = document.version + 1 + WHERE version.id = #{sourceVersionId} + AND document.status IN ('PROJECT_REVIEW', 'FINANCE_REVIEW') + """) + int updateDocumentStateBySourceVersion(@Param("sourceVersionId") long sourceVersionId, + @Param("targetStatus") String targetStatus, + @Param("actorId") long actorId); + + record SourceFileRef(long id, String publicId, String scanStatus, + boolean uploadedByActor, boolean linkedToDocumentScope) { + } + + record DownstreamCorrectionRecord(long id, String publicId, String formType, long templateId, + Long currentVersionId, String status, long version, long createdBy, + String companyPublicId, String projectPublicId) { + } + + @Insert(""" + INSERT INTO src_import_batch ( + public_id, batch_no, form_type, source_system, source_file_id, file_sha256, + total_rows, valid_rows, error_rows, validation_json, status, created_by, updated_by + ) VALUES ( + #{publicId}, #{batchNo}, #{formType}, #{sourceSystem}, #{sourceFileId}, #{fileSha256}, + #{totalRows}, #{validRows}, #{errorRows}, CAST(#{validationJson} AS JSON), #{status}, #{actorId}, #{actorId} + ) + """) + @Options(useGeneratedKeys = true, keyProperty = "id") + int insertImportBatch(ImportBatchWrite batch); + + @Insert(""" + INSERT INTO src_import_row ( + batch_id, row_no, source_no, source_version, payload_json, validation_json, status + ) VALUES ( + #{batchId}, #{rowNo}, #{sourceNo}, #{sourceVersion}, CAST(#{payloadJson} AS JSON), + CAST(#{validationJson} AS JSON), #{status} + ) + """) + int insertImportRow(ImportRowWrite row); + + @Select(""" + SELECT batch.id, batch.public_id, batch.batch_no, batch.form_type, batch.source_system, + batch.source_file_id, file.public_id AS source_file_public_id, batch.file_sha256, + batch.total_rows, batch.valid_rows, batch.error_rows, + CAST(batch.validation_json AS CHAR) AS validation_json, batch.status, batch.version, + batch.created_by, creator.display_name AS created_by_name, batch.created_at, batch.updated_at + FROM src_import_batch batch + JOIN file_object file ON file.id = batch.source_file_id + JOIN iam_user creator ON creator.id = batch.created_by + WHERE batch.public_id = #{publicId} + """) + ImportBatchRecord findImportBatch(String publicId); + + @Select(""" + SELECT batch.id, batch.public_id, batch.batch_no, batch.form_type, batch.source_system, + batch.source_file_id, file.public_id AS source_file_public_id, batch.file_sha256, + batch.total_rows, batch.valid_rows, batch.error_rows, + CAST(batch.validation_json AS CHAR) AS validation_json, batch.status, batch.version, + batch.created_by, creator.display_name AS created_by_name, batch.created_at, batch.updated_at + FROM src_import_batch batch + JOIN file_object file ON file.id = batch.source_file_id + JOIN iam_user creator ON creator.id = batch.created_by + WHERE batch.public_id = #{publicId} + FOR UPDATE + """) + ImportBatchRecord findImportBatchForUpdate(String publicId); + + @Select(""" + SELECT id, batch_id, row_no, source_no, source_version, + CAST(payload_json AS CHAR) AS payload_json, + CAST(validation_json AS CHAR) AS validation_json, status + FROM src_import_row + WHERE batch_id = #{batchId} + ORDER BY row_no + """) + List listImportRows(long batchId); + + @Update(""" + UPDATE src_import_batch + SET status = 'CONFIRMED', version = version + 1, updated_by = #{actorId} + WHERE id = #{id} AND version = #{expectedVersion} AND status = 'READY' + """) + int markImportBatchConfirmed(@Param("id") long id, + @Param("expectedVersion") long expectedVersion, + @Param("actorId") long actorId); + + @Update(""" + UPDATE src_import_row SET status = 'CONFIRMED' + WHERE batch_id = #{batchId} AND status = 'VALID' + """) + int markImportRowsConfirmed(long batchId); + + @Delete("DELETE FROM src_field_value WHERE document_version_id = #{versionId}") + int deleteVersionValues(long versionId); +} diff --git a/backend/src/main/java/com/kaidi/finance/update/api/SystemUpdateContracts.java b/backend/src/main/java/com/kaidi/finance/update/api/SystemUpdateContracts.java new file mode 100644 index 0000000..e7f2a59 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/update/api/SystemUpdateContracts.java @@ -0,0 +1,26 @@ +package com.kaidi.finance.update.api; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +public final class SystemUpdateContracts { + + private SystemUpdateContracts() { + } + + public record DownloadUpdateRequest( + @NotBlank + @Pattern(regexp = "^[0-9A-Za-z][0-9A-Za-z._+\\-]{0,63}$", message = "版本号格式无效") + String version + ) { + } + + public record InstallUpdateRequest( + @NotBlank + @Pattern(regexp = "^[0-9A-Za-z][0-9A-Za-z._+\\-]{0,63}$", message = "版本号格式无效") + String version, + @Size(max = 500) String reason + ) { + } +} diff --git a/backend/src/main/java/com/kaidi/finance/update/api/SystemUpdateController.java b/backend/src/main/java/com/kaidi/finance/update/api/SystemUpdateController.java new file mode 100644 index 0000000..a8044a5 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/update/api/SystemUpdateController.java @@ -0,0 +1,54 @@ +package com.kaidi.finance.update.api; + +import com.kaidi.finance.shared.api.ApiResponse; +import com.kaidi.finance.update.api.SystemUpdateContracts.DownloadUpdateRequest; +import com.kaidi.finance.update.api.SystemUpdateContracts.InstallUpdateRequest; +import com.kaidi.finance.update.application.SystemUpdateApplicationService; +import io.swagger.v3.oas.annotations.Operation; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping(value = "/api/v1/admin/system-update", produces = MediaType.APPLICATION_JSON_VALUE) +public class SystemUpdateController { + + private final SystemUpdateApplicationService service; + + public SystemUpdateController(SystemUpdateApplicationService service) { + this.service = service; + } + + @GetMapping + @PreAuthorize("@authorizationService.hasPermission('admin:update:view')") + @Operation(operationId = "getSystemUpdateStatus", summary = "读取系统版本与更新状态") + public ApiResponse status() { + return ApiResponse.ok(service.status()); + } + + @PostMapping("/check") + @PreAuthorize("@authorizationService.hasPermission('admin:update:view')") + @Operation(operationId = "checkSystemUpdate", summary = "检查最新签名发布版本") + public ApiResponse check() { + return ApiResponse.ok(service.check()); + } + + @PostMapping(value = "/download", consumes = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("@authorizationService.hasPermission('admin:update:execute')") + @Operation(operationId = "downloadSystemUpdate", summary = "下载并校验最新签名发布版本") + public ApiResponse download(@Valid @RequestBody DownloadUpdateRequest request) { + return ApiResponse.ok(service.download(request)); + } + + @PostMapping(value = "/install", consumes = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("@authorizationService.hasPermission('admin:update:execute')") + @Operation(operationId = "installSystemUpdate", summary = "排队安装最新签名发布版本") + public ApiResponse install(@Valid @RequestBody InstallUpdateRequest request) { + return ApiResponse.ok(service.install(request)); + } +} diff --git a/backend/src/main/java/com/kaidi/finance/update/api/SystemUpdateView.java b/backend/src/main/java/com/kaidi/finance/update/api/SystemUpdateView.java new file mode 100644 index 0000000..f5cc3d9 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/update/api/SystemUpdateView.java @@ -0,0 +1,19 @@ +package com.kaidi.finance.update.api; + +import java.time.Instant; +import java.util.List; + +public record SystemUpdateView( + boolean enabled, + String currentVersion, + String latestVersion, + boolean updateAvailable, + String state, + String message, + String releaseNotes, + Instant publishedAt, + Instant checkedAt, + Instant statusUpdatedAt, + List allowedActions +) { +} diff --git a/backend/src/main/java/com/kaidi/finance/update/application/SystemUpdateApplicationService.java b/backend/src/main/java/com/kaidi/finance/update/application/SystemUpdateApplicationService.java new file mode 100644 index 0000000..039d4f0 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/update/application/SystemUpdateApplicationService.java @@ -0,0 +1,562 @@ +package com.kaidi.finance.update.application; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.kaidi.finance.iam.domain.FinancePrincipal; +import com.kaidi.finance.shared.api.BusinessException; +import com.kaidi.finance.shared.api.ErrorCode; +import com.kaidi.finance.shared.audit.AuditService; +import com.kaidi.finance.shared.infrastructure.RequestContext; +import com.kaidi.finance.shared.security.AuthorizationService; +import com.kaidi.finance.shared.security.IdentityContext; +import com.kaidi.finance.update.api.SystemUpdateContracts.DownloadUpdateRequest; +import com.kaidi.finance.update.api.SystemUpdateContracts.InstallUpdateRequest; +import com.kaidi.finance.update.api.SystemUpdateView; +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.math.BigInteger; +import java.nio.channels.FileChannel; +import java.nio.channels.FileLock; +import java.nio.charset.StandardCharsets; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; + +@Service +public class SystemUpdateApplicationService { + + private static final int MAX_MANIFEST_BYTES = 64 * 1024; + private static final int MAX_RELEASE_API_BYTES = 256 * 1024; + private static final int MAX_STATUS_BYTES = 64 * 1024; + private static final int MAX_REDIRECTS = 3; + private static final String PRERELEASE_IDENTIFIER = + "(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)"; + private static final Pattern VERSION = Pattern.compile( + "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)" + + "(?:-(" + PRERELEASE_IDENTIFIER + "(?:\\." + PRERELEASE_IDENTIFIER + ")*))?" + + "(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?$"); + private static final Pattern ARTIFACT = Pattern.compile("^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}\\.tar\\.gz$"); + private static final Pattern SHA256 = Pattern.compile("^[0-9a-fA-F]{64}$"); + private static final List BUSY_STATES = List.of( + "QUEUED", "DOWNLOAD_QUEUED", "INSTALL_QUEUED", "VERIFYING", "DOWNLOADING", "BACKING_UP", + "INSTALLING", "RUNNING"); + + private final SystemUpdateProperties properties; + private final AuthorizationService authorizationService; + private final IdentityContext identityContext; + private final AuditService auditService; + private final ObjectMapper objectMapper; + private final HttpClient httpClient; + private volatile ReleaseManifest lastManifest; + private volatile Instant lastCheckedAt; + + public SystemUpdateApplicationService(SystemUpdateProperties properties, + AuthorizationService authorizationService, + IdentityContext identityContext, + AuditService auditService, + ObjectMapper objectMapper) { + this.properties = properties; + this.authorizationService = authorizationService; + this.identityContext = identityContext; + this.auditService = auditService; + this.objectMapper = objectMapper; + this.httpClient = HttpClient.newBuilder() + .connectTimeout(properties.connectTimeout()) + .followRedirects(HttpClient.Redirect.NEVER) + .build(); + } + + public SystemUpdateView status() { + requireIsolatedSystemAdministrator(); + return view(lastManifest, readStatus()); + } + + public SystemUpdateView check() { + requireIsolatedSystemAdministrator(); + requireConfigured(); + ReleaseManifest manifest = fetchManifest(); + lastManifest = manifest; + lastCheckedAt = Instant.now(); + SystemUpdateView result = view(manifest, readStatus()); + auditService.record("SYSTEM_UPDATE_CHECK", "SYSTEM_UPDATE", "SYSTEM_UPDATE", "SUCCESS", null, null, + result); + return result; + } + + public synchronized SystemUpdateView download(DownloadUpdateRequest request) { + requireIsolatedSystemAdministrator(); + authorizationService.requirePermission("admin:update:execute"); + requireConfigured(); + ReleaseManifest manifest = fetchManifest(); + lastManifest = manifest; + lastCheckedAt = Instant.now(); + String requested = request.version().trim(); + if (!manifest.version().equals(requested)) { + throw validation("请求版本已不是当前最新发布版本,请重新检查更新"); + } + if (compareVersions(manifest.version(), currentVersion()) <= 0) { + throw validation("当前已是相同或更高版本"); + } + UpdateStatus currentStatus = readStatus(); + if ("READY".equals(currentStatus.state()) && requested.equals(currentStatus.targetVersion())) { + throw validation("该版本已经下载并通过校验,请确认安装"); + } + QueueTransition transition = enqueue(properties.requestFile().toAbsolutePath().normalize(), requested, null, + "DOWNLOAD", "DOWNLOAD_QUEUED", "下载请求已排队,当前版本会继续运行"); + SystemUpdateView result = view(manifest, transition.queued()); + auditService.record("SYSTEM_UPDATE_DOWNLOAD_REQUEST", "SYSTEM_UPDATE", "SYSTEM_UPDATE", "SUCCESS", null, + transition.previous(), transition.queued()); + return result; + } + + public synchronized SystemUpdateView install(InstallUpdateRequest request) { + requireIsolatedSystemAdministrator(); + authorizationService.requirePermission("admin:update:execute"); + requireConfigured(); + String requested = request.version().trim(); + UpdateStatus ready = readStatus(); + if (!"READY".equals(ready.state()) || !requested.equals(ready.targetVersion())) { + throw validation("该版本尚未完成下载和签名校验"); + } + if (compareVersions(requested, currentVersion()) <= 0) { + throw validation("当前已是相同或更高版本"); + } + Path requestFile = properties.requestFile().toAbsolutePath().normalize(); + QueueTransition transition = enqueue(requestFile, requested, request.reason(), "INSTALL", "INSTALL_QUEUED", + "安装请求已确认,服务将在备份后切换版本"); + UpdateStatus currentStatus = transition.previous(); + UpdateStatus queued = transition.queued(); + SystemUpdateView result = view(lastManifest, queued); + auditService.record("SYSTEM_UPDATE_REQUEST", "SYSTEM_UPDATE", "SYSTEM_UPDATE", "SUCCESS", request.reason(), + currentStatus, queued); + return result; + } + + private void requireConfigured() { + if (!effectiveEnabled()) { + throw validation("在线更新尚未配置 Release 地址"); + } + } + + private boolean effectiveEnabled() { + if (!properties.enabled()) { + return false; + } + try { + configuredReleaseSourceUri(); + return true; + } catch (BusinessException exception) { + return false; + } + } + + private ReleaseManifest fetchManifest() { + try { + byte[] bytes = fetchReleaseBytes(releaseManifestUri(), MAX_MANIFEST_BYTES, "发布清单"); + JsonNode root = objectMapper.readTree(bytes); + String version = requiredText(root, "version"); + String artifact = requiredText(root, "artifact"); + String sha256 = requiredText(root, "sha256"); + if (!VERSION.matcher(version).matches()) throw releaseUnavailable("发布版本号格式无效"); + if (!ARTIFACT.matcher(artifact).matches()) throw releaseUnavailable("发布包名称格式无效"); + if (!SHA256.matcher(sha256).matches()) throw releaseUnavailable("发布包 SHA-256 格式无效"); + Instant publishedAt = parseInstant(root.path("publishedAt").asText(null)); + String notes = root.path("releaseNotes").asText(""); + if (notes.length() > 4000) notes = notes.substring(0, 4000); + return new ReleaseManifest(version, artifact, sha256.toLowerCase(Locale.ROOT), publishedAt, notes); + } catch (IOException | IllegalArgumentException exception) { + throw releaseUnavailable("发布清单读取失败"); + } + } + + private URI releaseManifestUri() { + if (blank(properties.releaseApiUrl()) != null) { + return giteaReleaseAssetUri("release-manifest.json"); + } + return releaseBaseUri().resolve("release-manifest.json"); + } + + private URI giteaReleaseAssetUri(String assetName) { + URI api = releaseApiUri(); + try { + JsonNode release = objectMapper.readTree(fetchReleaseBytes(api, MAX_RELEASE_API_BYTES, "Gitea Release")); + for (JsonNode asset : release.path("assets")) { + if (!assetName.equals(asset.path("name").asText())) continue; + URI uri = validatedReleaseUri(asset.path("browser_download_url").asText(null), "Release 资源地址"); + if (!sameOrigin(api, uri)) { + throw releaseUnavailable("Gitea Release 资源必须与 API 使用同一来源"); + } + return uri; + } + throw releaseUnavailable("Gitea Release 缺少 " + assetName); + } catch (IOException exception) { + throw releaseUnavailable("Gitea Release 响应读取失败"); + } + } + + private byte[] fetchReleaseBytes(URI uri, int maximumBytes, String resourceName) { + String token = releaseToken(); + URI current = uri; + for (int redirects = 0; redirects <= MAX_REDIRECTS; redirects++) { + HttpRequest.Builder builder = HttpRequest.newBuilder(current) + .timeout(properties.readTimeout()) + .header("Accept", "application/json") + .GET(); + if (token != null && authorizedReleaseOrigin(current)) { + builder.header("Authorization", "token " + token); + } + try { + HttpResponse response = httpClient.send(builder.build(), HttpResponse.BodyHandlers.ofInputStream()); + int statusCode = response.statusCode(); + if (isRedirect(statusCode)) { + String location = response.headers().firstValue("Location").orElse(null); + response.body().close(); + if (redirects == MAX_REDIRECTS || location == null || location.isBlank()) { + throw releaseUnavailable(resourceName + "重定向无效"); + } + URI redirected = validatedReleaseUri(current.resolve(location).toString(), resourceName + "重定向地址"); + if (token != null && !authorizedReleaseOrigin(redirected)) { + throw releaseUnavailable(resourceName + "不允许携带凭据跨来源重定向"); + } + current = redirected; + continue; + } + if (statusCode != 200) { + response.body().close(); + throw releaseUnavailable(resourceName + "服务返回 HTTP " + statusCode); + } + try (InputStream input = response.body()) { + byte[] bytes = input.readNBytes(maximumBytes + 1); + if (bytes.length > maximumBytes) throw releaseUnavailable(resourceName + "超过大小限制"); + return bytes; + } + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw releaseUnavailable("检查更新被中断"); + } catch (IOException | IllegalArgumentException exception) { + throw releaseUnavailable(resourceName + "读取失败"); + } + } + throw releaseUnavailable(resourceName + "重定向无效"); + } + + private boolean authorizedReleaseOrigin(URI uri) { + URI configured = blank(properties.releaseApiUrl()) == null ? releaseBaseUri() : releaseApiUri(); + return sameOrigin(configured, uri); + } + + private static boolean isRedirect(int statusCode) { + return statusCode == 301 || statusCode == 302 || statusCode == 303 + || statusCode == 307 || statusCode == 308; + } + + private static boolean sameOrigin(URI left, URI right) { + return left.getScheme().equalsIgnoreCase(right.getScheme()) + && left.getHost().equalsIgnoreCase(right.getHost()) + && effectivePort(left) == effectivePort(right); + } + + private static int effectivePort(URI uri) { + if (uri.getPort() >= 0) return uri.getPort(); + return "https".equalsIgnoreCase(uri.getScheme()) ? 443 : 80; + } + + private URI configuredReleaseSourceUri() { + return blank(properties.releaseApiUrl()) == null ? releaseBaseUri() : releaseApiUri(); + } + + private URI releaseBaseUri() { + String value = blank(properties.releaseBaseUrl()); + if (value == null) throw validation("Release 下载地址未配置"); + URI uri = validatedReleaseUri(value.endsWith("/") ? value : value + "/", "Release 下载地址"); + return uri; + } + + private URI releaseApiUri() { + String value = blank(properties.releaseApiUrl()); + if (value == null) throw validation("Gitea Release API 地址未配置"); + return validatedReleaseUri(value, "Gitea Release API 地址"); + } + + private URI validatedReleaseUri(String value, String label) { + try { + URI uri = URI.create(value == null ? "" : value.trim()); + String scheme = uri.getScheme() == null ? "" : uri.getScheme().toLowerCase(Locale.ROOT); + if (!"https".equals(scheme) && !(properties.allowInsecureUrl() && "http".equals(scheme))) { + throw validation(label + "必须使用 HTTPS"); + } + if (uri.getHost() == null || uri.getUserInfo() != null) throw validation(label + "格式无效"); + return uri; + } catch (IllegalArgumentException exception) { + throw validation(label + "格式无效"); + } + } + + private String releaseToken() { + String token = blank(properties.releaseToken()); + if (token == null) return null; + if (token.length() > 512 || token.indexOf('\r') >= 0 || token.indexOf('\n') >= 0) { + throw validation("Gitea Release Token 格式无效"); + } + return token; + } + + private QueueTransition enqueue(Path requestFile, String version, String reason, String action, String queuedState, + String queuedMessage) { + Path parent = requestFile.getParent(); + if (parent == null || !Files.isDirectory(parent, LinkOption.NOFOLLOW_LINKS) + || Files.isSymbolicLink(parent) || !Files.isWritable(parent)) { + throw storage("更新请求目录不存在或不可写"); + } + Path lockFile = parent.resolve(".enqueue.lock"); + try (FileChannel channel = FileChannel.open(lockFile, StandardOpenOption.CREATE, StandardOpenOption.WRITE); + FileLock ignored = channel.lock()) { + UpdateStatus currentStatus = readStatus(); + if (BUSY_STATES.contains(currentStatus.state())) { + throw new BusinessException(HttpStatus.CONFLICT, ErrorCode.COMMAND_IN_PROGRESS, + "已有系统更新任务正在执行"); + } + if (hasPendingRequest(requestFile)) { + throw new BusinessException(HttpStatus.CONFLICT, ErrorCode.COMMAND_IN_PROGRESS, + "已有系统更新请求等待执行"); + } + writeRequest(requestFile, version, reason, action); + UpdateStatus queued = new UpdateStatus(queuedState, queuedMessage, version, Instant.now()); + return new QueueTransition(currentStatus, queued); + } catch (IOException exception) { + throw storage("系统更新队列锁定失败"); + } + } + + private void writeRequest(Path requestFile, String version, String reason, String action) { + Path parent = requestFile.getParent(); + Path temporary = null; + try { + temporary = Files.createTempFile(parent, ".request-", ".json"); + JsonNode request = objectMapper.createObjectNode() + .put("action", action) + .put("version", version) + .put("reason", reason == null ? "" : reason.trim()) + .put("requestId", RequestContext.requestId()) + .put("requestedAt", Instant.now().toString()); + Files.writeString(temporary, objectMapper.writeValueAsString(request), StandardCharsets.UTF_8); + try { + Files.move(temporary, requestFile, StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException exception) { + Files.move(temporary, requestFile); + } + } catch (IOException exception) { + if (temporary != null) { + try { + Files.deleteIfExists(temporary); + } catch (IOException ignored) { + // Preserve the original storage failure. + } + } + throw storage("系统更新请求写入失败"); + } + } + + private UpdateStatus readStatus() { + if (!effectiveEnabled()) { + return new UpdateStatus("DISABLED", "在线更新未配置", null, null); + } + UpdateStatus persisted = readPersistedStatus(); + if (persisted != null && BUSY_STATES.contains(persisted.state())) return persisted; + if (persisted != null && "FAILED".equals(persisted.state()) && hasProcessingRequest()) return persisted; + UpdateStatus queued = pendingStatus(); + if (queued != null) return queued; + return persisted == null ? new UpdateStatus("IDLE", "尚未执行在线更新", null, null) : persisted; + } + + private UpdateStatus readPersistedStatus() { + Path path = properties.statusFile().toAbsolutePath().normalize(); + if (!Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(path)) { + return null; + } + try { + if (Files.size(path) > MAX_STATUS_BYTES) return new UpdateStatus("UNKNOWN", "更新状态文件无效", null, null); + JsonNode root = objectMapper.readTree(Files.readAllBytes(path)); + String state = root.path("state").asText("UNKNOWN").toUpperCase(Locale.ROOT); + if (!state.matches("^[A-Z_]{2,32}$")) state = "UNKNOWN"; + return new UpdateStatus(state, root.path("message").asText(""), + blank(root.path("targetVersion").asText(null)), parseInstant(root.path("updatedAt").asText(null))); + } catch (IOException exception) { + return new UpdateStatus("UNKNOWN", "更新状态读取失败", null, null); + } + } + + private SystemUpdateView view(ReleaseManifest manifest, UpdateStatus status) { + String current = currentVersion(); + boolean enabled = effectiveEnabled(); + String candidateVersion = manifest == null ? status.targetVersion() : manifest.version(); + boolean available = candidateVersion != null && compareVersions(candidateVersion, current) > 0; + boolean busy = BUSY_STATES.contains(status.state()); + boolean pending = hasPendingRequest(properties.requestFile().toAbsolutePath().normalize()); + boolean ready = "READY".equals(status.state()) && candidateVersion != null + && candidateVersion.equals(status.targetVersion()); + List actions = new ArrayList<>(); + if (enabled && !busy && !pending) actions.add("CHECK"); + if (enabled && available && !busy && !pending && authorizationService.hasPermission("admin:update:execute")) { + actions.add(ready ? "INSTALL" : "DOWNLOAD"); + } + return new SystemUpdateView(enabled, current, candidateVersion, + available, status.state(), status.message(), manifest == null ? null : manifest.releaseNotes(), + manifest == null ? null : manifest.publishedAt(), lastCheckedAt, status.updatedAt(), List.copyOf(actions)); + } + + private boolean hasPendingRequest(Path requestFile) { + if (Files.exists(requestFile, LinkOption.NOFOLLOW_LINKS)) return true; + Path processing = processingRequestFile(requestFile); + return processing != null && Files.exists(processing, LinkOption.NOFOLLOW_LINKS); + } + + private boolean hasProcessingRequest() { + Path requestFile = properties.requestFile().toAbsolutePath().normalize(); + Path processing = processingRequestFile(requestFile); + return processing != null && Files.exists(processing, LinkOption.NOFOLLOW_LINKS); + } + + private UpdateStatus pendingStatus() { + Path requestFile = properties.requestFile().toAbsolutePath().normalize(); + Path pending = Files.exists(requestFile, LinkOption.NOFOLLOW_LINKS) + ? requestFile : processingRequestFile(requestFile); + if (pending == null || !Files.isRegularFile(pending, LinkOption.NOFOLLOW_LINKS) + || Files.isSymbolicLink(pending)) { + return null; + } + try { + if (Files.size(pending) > MAX_STATUS_BYTES) return new UpdateStatus("UNKNOWN", "更新请求文件无效", null, null); + JsonNode root = objectMapper.readTree(Files.readAllBytes(pending)); + String version = blank(root.path("version").asText(null)); + String action = root.path("action").asText("INSTALL").toUpperCase(Locale.ROOT); + Instant requestedAt = parseInstant(root.path("requestedAt").asText(null)); + if ("DOWNLOAD".equals(action)) { + return new UpdateStatus("DOWNLOAD_QUEUED", "下载请求已排队,等待更新服务处理", version, requestedAt); + } + return new UpdateStatus("INSTALL_QUEUED", "安装请求已排队,等待更新服务处理", version, requestedAt); + } catch (IOException exception) { + return new UpdateStatus("UNKNOWN", "更新请求读取失败", null, null); + } + } + + private Path processingRequestFile(Path requestFile) { + Path inbox = requestFile.getParent(); + Path stateRoot = inbox == null ? null : inbox.getParent(); + return stateRoot == null ? null : stateRoot.resolve("processing/request.json").normalize(); + } + + private String currentVersion() { + Path path = properties.currentVersionFile(); + if (path != null) { + Path normalized = path.toAbsolutePath().normalize(); + try { + if (Files.isRegularFile(normalized, LinkOption.NOFOLLOW_LINKS) && !Files.isSymbolicLink(normalized)) { + String value = Files.readString(normalized, StandardCharsets.UTF_8).trim(); + if (VERSION.matcher(value).matches()) return value; + } + } catch (IOException ignored) { + // Fall back to the immutable build version from configuration. + } + } + String configured = blank(properties.currentVersion()); + return configured == null ? "0.0.0" : configured; + } + + private void requireIsolatedSystemAdministrator() { + FinancePrincipal principal = identityContext.requirePrincipal(); + boolean isolated = principal.roles().size() == 1 + && "SYSTEM_ADMIN".equals(principal.roles().get(0).code()); + if (!"SYSTEM_ADMIN".equals(identityContext.requireActiveRole()) || !isolated) { + throw new BusinessException(HttpStatus.FORBIDDEN, ErrorCode.PERMISSION_DENIED, + "系统更新仅允许隔离的系统管理员账号访问"); + } + } + + static int compareVersions(String left, String right) { + Matcher leftMatcher = VERSION.matcher(left); + Matcher rightMatcher = VERSION.matcher(right); + if (!leftMatcher.matches() || !rightMatcher.matches()) return left.compareTo(right); + for (int index = 1; index <= 3; index++) { + int compared = new BigInteger(leftMatcher.group(index)).compareTo(new BigInteger(rightMatcher.group(index))); + if (compared != 0) return compared; + } + String leftPre = leftMatcher.group(4); + String rightPre = rightMatcher.group(4); + if (leftPre == null && rightPre == null) return 0; + if (leftPre == null) return 1; + if (rightPre == null) return -1; + String[] leftParts = leftPre.split("\\."); + String[] rightParts = rightPre.split("\\."); + for (int index = 0; index < Math.max(leftParts.length, rightParts.length); index++) { + if (index >= leftParts.length) return -1; + if (index >= rightParts.length) return 1; + String leftPart = leftParts[index]; + String rightPart = rightParts[index]; + boolean leftNumber = leftPart.matches("\\d+"); + boolean rightNumber = rightPart.matches("\\d+"); + int compared; + if (leftNumber && rightNumber) compared = new BigInteger(leftPart).compareTo(new BigInteger(rightPart)); + else if (leftNumber) compared = -1; + else if (rightNumber) compared = 1; + else compared = leftPart.compareTo(rightPart); + if (compared != 0) return compared; + } + return 0; + } + + private static String requiredText(JsonNode root, String field) { + String value = root.path(field).asText(null); + if (value == null || value.isBlank()) throw new IllegalArgumentException("Missing " + field); + return value.trim(); + } + + private static String blank(String value) { + return value == null || value.isBlank() ? null : value.trim(); + } + + private static Instant parseInstant(String value) { + try { + return value == null || value.isBlank() ? null : Instant.parse(value); + } catch (RuntimeException exception) { + return null; + } + } + + private BusinessException validation(String message) { + return new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.VALIDATION_FAILED, message); + } + + private BusinessException releaseUnavailable(String message) { + return new BusinessException(HttpStatus.BAD_GATEWAY, ErrorCode.INTERNAL_ERROR, message); + } + + private BusinessException storage(String message) { + return new BusinessException(HttpStatus.SERVICE_UNAVAILABLE, ErrorCode.FILE_STORAGE_ERROR, message); + } + + private record ReleaseManifest(String version, String artifact, String sha256, Instant publishedAt, + String releaseNotes) { + } + + private record UpdateStatus(String state, String message, String targetVersion, Instant updatedAt) { + } + + private record QueueTransition(UpdateStatus previous, UpdateStatus queued) { + } +} diff --git a/backend/src/main/java/com/kaidi/finance/update/application/SystemUpdateProperties.java b/backend/src/main/java/com/kaidi/finance/update/application/SystemUpdateProperties.java new file mode 100644 index 0000000..6e90127 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/update/application/SystemUpdateProperties.java @@ -0,0 +1,21 @@ +package com.kaidi.finance.update.application; + +import java.nio.file.Path; +import java.time.Duration; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "finance.update") +public record SystemUpdateProperties( + boolean enabled, + String currentVersion, + Path currentVersionFile, + String releaseBaseUrl, + String releaseApiUrl, + String releaseToken, + Path requestFile, + Path statusFile, + Duration connectTimeout, + Duration readTimeout, + boolean allowInsecureUrl +) { +} diff --git a/backend/src/main/java/com/kaidi/finance/workbench/api/WorkbenchActivityView.java b/backend/src/main/java/com/kaidi/finance/workbench/api/WorkbenchActivityView.java new file mode 100644 index 0000000..e0e2e52 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/workbench/api/WorkbenchActivityView.java @@ -0,0 +1,15 @@ +package com.kaidi.finance.workbench.api; + +import java.time.Instant; + +public record WorkbenchActivityView( + String publicId, + String actionCode, + String objectType, + String objectPublicId, + String title, + String resultCode, + Instant occurredAt, + String targetRoute +) { +} diff --git a/backend/src/main/java/com/kaidi/finance/workbench/api/WorkbenchArchiveItemView.java b/backend/src/main/java/com/kaidi/finance/workbench/api/WorkbenchArchiveItemView.java new file mode 100644 index 0000000..942cceb --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/workbench/api/WorkbenchArchiveItemView.java @@ -0,0 +1,17 @@ +package com.kaidi.finance.workbench.api; + +import java.time.Instant; + +public record WorkbenchArchiveItemView( + String kind, + String publicId, + String title, + String subtitle, + String status, + String completeness, + long missingCount, + Instant dueAt, + Instant updatedAt, + String targetRoute +) { +} diff --git a/backend/src/main/java/com/kaidi/finance/workbench/api/WorkbenchController.java b/backend/src/main/java/com/kaidi/finance/workbench/api/WorkbenchController.java new file mode 100644 index 0000000..9283d20 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/workbench/api/WorkbenchController.java @@ -0,0 +1,33 @@ +package com.kaidi.finance.workbench.api; + +import com.kaidi.finance.shared.api.ApiResponse; +import com.kaidi.finance.workbench.application.WorkbenchApplicationService; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/workbenches") +public class WorkbenchController { + + private final WorkbenchApplicationService workbenchService; + + public WorkbenchController(WorkbenchApplicationService workbenchService) { + this.workbenchService = workbenchService; + } + + @GetMapping("/project") + public ApiResponse project() { + return ApiResponse.ok(workbenchService.project()); + } + + @GetMapping("/finance") + public ApiResponse finance() { + return ApiResponse.ok(workbenchService.finance()); + } + + @GetMapping("/archive") + public ApiResponse archive() { + return ApiResponse.ok(workbenchService.archive()); + } +} diff --git a/backend/src/main/java/com/kaidi/finance/workbench/api/WorkbenchFundView.java b/backend/src/main/java/com/kaidi/finance/workbench/api/WorkbenchFundView.java new file mode 100644 index 0000000..d3ec2c7 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/workbench/api/WorkbenchFundView.java @@ -0,0 +1,14 @@ +package com.kaidi.finance.workbench.api; + +public record WorkbenchFundView( + String projectPublicId, + String projectName, + String currency, + String confirmedReceipt, + String approvedUnpaid, + String paidAmount, + String frozenAmount, + String availableAmount, + String targetRoute +) { +} diff --git a/backend/src/main/java/com/kaidi/finance/workbench/api/WorkbenchIssueView.java b/backend/src/main/java/com/kaidi/finance/workbench/api/WorkbenchIssueView.java new file mode 100644 index 0000000..b41bfe7 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/workbench/api/WorkbenchIssueView.java @@ -0,0 +1,11 @@ +package com.kaidi.finance.workbench.api; + +public record WorkbenchIssueView( + String code, + String label, + String severity, + long count, + String detail, + String targetRoute +) { +} diff --git a/backend/src/main/java/com/kaidi/finance/workbench/api/WorkbenchMetricView.java b/backend/src/main/java/com/kaidi/finance/workbench/api/WorkbenchMetricView.java new file mode 100644 index 0000000..b063748 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/workbench/api/WorkbenchMetricView.java @@ -0,0 +1,4 @@ +package com.kaidi.finance.workbench.api; + +public record WorkbenchMetricView(String code, String label, long value, String targetRoute) { +} diff --git a/backend/src/main/java/com/kaidi/finance/workbench/api/WorkbenchProjectView.java b/backend/src/main/java/com/kaidi/finance/workbench/api/WorkbenchProjectView.java new file mode 100644 index 0000000..6319afb --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/workbench/api/WorkbenchProjectView.java @@ -0,0 +1,16 @@ +package com.kaidi.finance.workbench.api; + +import java.time.Instant; + +public record WorkbenchProjectView( + String publicId, + String businessNo, + String name, + String companyName, + String stage, + String status, + String contractAmount, + Instant updatedAt, + String targetRoute +) { +} diff --git a/backend/src/main/java/com/kaidi/finance/workbench/api/WorkbenchTodoView.java b/backend/src/main/java/com/kaidi/finance/workbench/api/WorkbenchTodoView.java new file mode 100644 index 0000000..87f906a --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/workbench/api/WorkbenchTodoView.java @@ -0,0 +1,19 @@ +package com.kaidi.finance.workbench.api; + +import java.time.Instant; + +/** A single actionable item shown on a role workbench. */ +public record WorkbenchTodoView( + String publicId, + String formType, + String formName, + String businessNo, + String projectPublicId, + String projectName, + String nodeCode, + String status, + Instant arrivedAt, + Instant dueAt, + String targetRoute +) { +} diff --git a/backend/src/main/java/com/kaidi/finance/workbench/api/WorkbenchView.java b/backend/src/main/java/com/kaidi/finance/workbench/api/WorkbenchView.java new file mode 100644 index 0000000..5308ba1 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/workbench/api/WorkbenchView.java @@ -0,0 +1,21 @@ +package com.kaidi.finance.workbench.api; + +import java.time.Instant; +import java.util.List; + +public record WorkbenchView(String roleCode, String title, Instant refreshedAt, + List metrics, + List todos, + List projects, + List issues, + List funds, + List archiveItems, + List activities) { + + /** Compatibility constructor for callers that only need the KPI cards. */ + public WorkbenchView(String roleCode, String title, Instant refreshedAt, + List metrics) { + this(roleCode, title, refreshedAt, metrics, List.of(), List.of(), List.of(), + List.of(), List.of(), List.of()); + } +} diff --git a/backend/src/main/java/com/kaidi/finance/workbench/application/WorkbenchApplicationService.java b/backend/src/main/java/com/kaidi/finance/workbench/application/WorkbenchApplicationService.java new file mode 100644 index 0000000..e48e4e0 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/workbench/application/WorkbenchApplicationService.java @@ -0,0 +1,227 @@ +package com.kaidi.finance.workbench.application; + +import com.kaidi.finance.iam.domain.FinancePrincipal; +import com.kaidi.finance.shared.api.BusinessException; +import com.kaidi.finance.shared.api.ErrorCode; +import com.kaidi.finance.shared.security.AuthorizationService; +import com.kaidi.finance.shared.security.IdentityContext; +import com.kaidi.finance.workbench.api.WorkbenchActivityView; +import com.kaidi.finance.workbench.api.WorkbenchArchiveItemView; +import com.kaidi.finance.workbench.api.WorkbenchFundView; +import com.kaidi.finance.workbench.api.WorkbenchIssueView; +import com.kaidi.finance.workbench.api.WorkbenchMetricView; +import com.kaidi.finance.workbench.api.WorkbenchProjectView; +import com.kaidi.finance.workbench.api.WorkbenchTodoView; +import com.kaidi.finance.workbench.api.WorkbenchView; +import com.kaidi.finance.workbench.infrastructure.WorkbenchMapper; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.List; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class WorkbenchApplicationService { + + private final WorkbenchMapper mapper; + private final IdentityContext identityContext; + private final AuthorizationService authorizationService; + + public WorkbenchApplicationService(WorkbenchMapper mapper, IdentityContext identityContext, + AuthorizationService authorizationService) { + this.mapper = mapper; + this.identityContext = identityContext; + this.authorizationService = authorizationService; + } + + @Transactional(readOnly = true) + public WorkbenchView project() { + FinancePrincipal actor = requireRole("PROJECT_MANAGER"); + String accessRole = identityContext.requireActiveRole(); + boolean systemAdministrator = identityContext.isSystemAdministrator(); + String returnedFormsRoute = systemAdministrator + ? "/forms?status=RETURNED" : "/forms?status=RETURNED&createdByMe=true"; + String draftFormsRoute = systemAdministrator + ? "/forms?status=DRAFT" : "/forms?status=DRAFT&createdByMe=true"; + authorizationService.requirePermission("project:project:view"); + List todos = mapper.listTodos(actor.userId(), accessRole, 8).stream() + .map(row -> todo(row, "/tasks?view=todo")) + .toList(); + List projects = mapper.listOwnedProjects(actor.userId(), accessRole, 8).stream() + .map(row -> new WorkbenchProjectView(row.publicId(), row.businessNo(), row.name(), row.companyName(), + row.stage(), row.status(), row.contractAmount(), instant(row.updatedAt()), + "/projects/%s".formatted(row.publicId()))) + .toList(); + List issues = List.of( + new WorkbenchIssueView("RETURNED_FORM", + systemAdministrator ? "全部退回表单" : "被退回表单", "WARNING", + mapper.countOwnedDocuments("RETURNED", actor.userId(), accessRole), + "需要补充资料后重新提交", returnedFormsRoute), + new WorkbenchIssueView("MISSING_ARCHIVE_ITEMS", "缺资料", "WARNING", + mapper.countOwnedArchiveMissing(actor.userId(), accessRole), + "请在档案包中补充缺失资料", "/archives/projects?completeness=INCOMPLETE"), + new WorkbenchIssueView("OVERDUE_TASK", "已超期待办", "DANGER", + mapper.countOverdueTasks(actor.userId(), accessRole), + "请优先处理已超过时限的审批任务", "/tasks?view=TODO"), + new WorkbenchIssueView("DRAFT_FORM", + systemAdministrator ? "全部未提交草稿" : "未提交草稿", "INFO", + mapper.countOwnedDocuments("DRAFT", actor.userId(), accessRole), + "继续编辑并完成校验后提交", draftFormsRoute) + ); + List activities = mapper.listRecentActivities(actor.publicId(), 8).stream() + .map(this::activity) + .toList(); + return view("PROJECT_MANAGER", "项目管理工作台", List.of( + metric("ACTIVE_PROJECTS", systemAdministrator ? "全部进行中项目" : "我的进行中项目", + mapper.countOwnedOpenProjects(actor.userId(), accessRole), "/projects"), + metric("PENDING_TASKS", systemAdministrator ? "全部待处理" : "待我处理", + mapper.countPendingTasks(actor.userId(), accessRole), "/tasks"), + metric("RETURNED_FORMS", systemAdministrator ? "全部退回表单" : "被退回表单", + mapper.countOwnedDocuments("RETURNED", actor.userId(), accessRole), + returnedFormsRoute), + metric("DUE_SOON_TASKS", systemAdministrator ? "全部即将到期待办" : "即将到期待办", + mapper.countDueSoonTasks(actor.userId(), accessRole), "/tasks?view=TODO") + ), todos, projects, issues, List.of(), List.of(), activities); + } + + @Transactional(readOnly = true) + public WorkbenchView finance() { + FinancePrincipal actor = requireRole("FINANCE_MANAGER"); + String accessRole = identityContext.requireActiveRole(); + boolean systemAdministrator = identityContext.isSystemAdministrator(); + authorizationService.requirePermission("workflow:task:view"); + List todos = mapper.listTodos(actor.userId(), accessRole, 8).stream() + .map(row -> todo(row, "/tasks?view=todo")) + .toList(); + List issues = mapper.listFinanceIssues(actor.userId(), accessRole).stream() + .map(row -> new WorkbenchIssueView(row.code(), row.label(), row.severity(), row.issueCount(), + row.detail(), row.targetRoute())) + .toList(); + List funds = mapper.listFunds(actor.userId(), accessRole, 8).stream() + .map(row -> new WorkbenchFundView(row.projectPublicId(), row.projectName(), row.currency(), + row.confirmedReceipt(), row.approvedUnpaid(), row.paidAmount(), row.frozenAmount(), + row.availableAmount(), "/finance/payments?projectId=%s&view=fund-ledger".formatted(row.projectPublicId()))) + .toList(); + List activities = mapper.listRecentActivities(actor.publicId(), 8).stream() + .map(this::activity) + .toList(); + return view("FINANCE_MANAGER", "财务管理工作台", List.of( + metric("PENDING_TASKS", systemAdministrator ? "全部待处理" : "财务待办", + mapper.countPendingTasks(actor.userId(), accessRole), "/tasks"), + metric("MASTERDATA_REVIEW", "主数据待复核", + mapper.countMasterDataReview(actor.userId(), accessRole), + "/finance/master-data?status=REVIEWING"), + metric("FINANCE_REVIEW", "表单待财务复核", + mapper.countSourceDocuments("FINANCE_REVIEW", actor.userId(), accessRole, + "source:form:view"), + "/forms?status=FINANCE_REVIEW"), + metric("RESULT_PENDING", "待登记结果", + mapper.countSourceDocuments("RESULT_PENDING", actor.userId(), accessRole, + "source:form:view"), + "/forms?status=RESULT_PENDING") + ), todos, List.of(), issues, funds, List.of(), activities); + } + + @Transactional(readOnly = true) + public WorkbenchView archive() { + FinancePrincipal actor = requireRole("ARCHIVE_MANAGER"); + String accessRole = identityContext.requireActiveRole(); + authorizationService.requirePermission("archive:package:view"); + List archiveItems = mapper.listArchivePackages(actor.userId(), + accessRole, 6).stream() + .map(row -> new WorkbenchArchiveItemView("PACKAGE", row.publicId(), row.businessNo(), + row.projectName(), row.status(), row.completeness(), row.missingCount(), null, + instant(row.updatedAt()), "/archives/projects?view=check")) + .collect(java.util.stream.Collectors.toCollection(java.util.ArrayList::new)); + mapper.listArchiveMissing(actor.userId(), accessRole, 6).stream() + .map(row -> new WorkbenchArchiveItemView("MISSING", row.publicId(), row.itemName(), + row.projectName(), row.status(), row.completeness(), row.missingCount(), null, + instant(row.updatedAt()), "/archives/projects?view=check")) + .forEach(archiveItems::add); + mapper.listArchiveBorrows(actor.userId(), accessRole, 6).stream() + .map(row -> new WorkbenchArchiveItemView("BORROW", row.publicId(), row.itemName(), + row.projectName(), row.status(), row.completeness(), row.missingCount(), + instant(row.dueAt()), instant(row.updatedAt()), "/archives/files?resource=borrows")) + .forEach(archiveItems::add); + mapper.listRecentArchiveDownloads(actor.userId(), accessRole, 6).stream() + .map(row -> new WorkbenchArchiveItemView("DOWNLOAD", row.publicId(), row.fileName(), + row.projectName(), "DOWNLOADED", null, 0, null, instant(row.occurredAt()), + "/archives/files")) + .forEach(archiveItems::add); + archiveItems.sort(java.util.Comparator.comparing(WorkbenchArchiveItemView::updatedAt, + java.util.Comparator.nullsLast(java.util.Comparator.reverseOrder())) + .thenComparing(WorkbenchArchiveItemView::publicId)); + List activities = mapper.listRecentActivities(actor.publicId(), 8).stream() + .map(this::activity) + .toList(); + return view("ARCHIVE_MANAGER", "资料管理工作台", List.of( + metric("ARCHIVE_PENDING", "待归档档案包", + mapper.countArchivePackages(false, actor.userId(), accessRole), + "/archives/projects?view=prepare"), + metric("MISSING_ARCHIVE_ITEMS", "缺资料", + mapper.countArchiveMissing(actor.userId(), accessRole), + "/archives/projects?completeness=INCOMPLETE"), + metric("BORROWS_DUE", "借阅即将到期", + mapper.countArchiveBorrowsDue(actor.userId(), accessRole), + "/archives/files?resource=borrows"), + metric("ARCHIVED", "已归档档案包", + mapper.countArchivePackages(true, actor.userId(), accessRole), + "/archives/projects?view=archived"), + metric("QUARANTINED_FILES", "隔离文件", + mapper.countFiles("QUARANTINED", actor.userId(), accessRole), + "/archives/files?scanStatus=QUARANTINED") + ), List.of(), List.of(), List.of(), List.of(), archiveItems, activities); + } + + private FinancePrincipal requireRole(String expectedRole) { + FinancePrincipal actor = identityContext.requirePrincipal(); + String activeRole = identityContext.requireActiveRole(); + if (!expectedRole.equals(activeRole) && !"SYSTEM_ADMIN".equals(activeRole)) { + throw new BusinessException(HttpStatus.FORBIDDEN, ErrorCode.PERMISSION_DENIED, + "当前身份不能访问该工作台"); + } + return actor; + } + + private WorkbenchView view(String roleCode, String title, List metrics, + List todos, List projects, + List issues, List funds, + List archiveItems, + List activities) { + return new WorkbenchView(roleCode, title, Instant.now(), metrics, todos, projects, issues, + funds, archiveItems, activities); + } + + private WorkbenchMetricView metric(String code, String label, long value, String targetRoute) { + return new WorkbenchMetricView(code, label, value, targetRoute); + } + + private WorkbenchTodoView todo(WorkbenchMapper.TodoRow row, String targetRoute) { + return new WorkbenchTodoView(row.publicId(), row.formType(), row.formName(), row.businessNo(), + row.projectPublicId(), row.projectName(), row.nodeCode(), row.status(), + instant(row.arrivedAt()), instant(row.dueAt()), targetRoute); + } + + private WorkbenchActivityView activity(WorkbenchMapper.ActivityRow row) { + return new WorkbenchActivityView(row.publicId(), row.actionCode(), row.objectType(), + row.objectPublicId(), row.title(), row.resultCode(), instant(row.occurredAt()), + activityRoute(row.objectType(), row.objectPublicId())); + } + + private String activityRoute(String objectType, String objectPublicId) { + if (objectPublicId == null || objectPublicId.isBlank()) return "/reports"; + return switch (objectType) { + case "PROJECT" -> "/projects/%s".formatted(objectPublicId); + case "PAYMENT", "PAYMENT_REQUEST" -> "/finance/payments?keyword=%s".formatted(objectPublicId); + case "VOUCHER", "ACCOUNTING" -> "/finance/accounting?keyword=%s".formatted(objectPublicId); + case "ARCHIVE_PACKAGE", "ARCHIVE" -> "/archives/projects"; + default -> "/reports"; + }; + } + + private Instant instant(LocalDateTime value) { + return value == null ? null : value.toInstant(ZoneOffset.UTC); + } +} diff --git a/backend/src/main/java/com/kaidi/finance/workbench/infrastructure/WorkbenchMapper.java b/backend/src/main/java/com/kaidi/finance/workbench/infrastructure/WorkbenchMapper.java new file mode 100644 index 0000000..0450b31 --- /dev/null +++ b/backend/src/main/java/com/kaidi/finance/workbench/infrastructure/WorkbenchMapper.java @@ -0,0 +1,680 @@ +package com.kaidi.finance.workbench.infrastructure; + +import java.time.LocalDateTime; +import java.util.List; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@org.apache.ibatis.annotations.Mapper +public interface WorkbenchMapper { + + @Select(""" + SELECT COUNT(*) + FROM md_project project + JOIN md_company company ON company.id = project.company_id + WHERE (#{roleCode} = 'SYSTEM_ADMIN' OR project.owner_user_id = #{userId}) + AND project.status IN ('ACTIVE', 'SUSPENDED') + AND EXISTS ( + SELECT 1 FROM iam_scope scope + JOIN iam_role role ON role.id = scope.role_id + JOIN iam_permission permission ON permission.id = scope.permission_id + WHERE scope.user_id = #{userId} AND role.code = #{roleCode} + AND permission.code = 'project:project:view' AND scope.status = 'ACTIVE' + AND scope.valid_from <= UTC_TIMESTAMP(3) + AND (scope.valid_to IS NULL OR scope.valid_to >= UTC_TIMESTAMP(3)) + AND (scope.scope_type = 'GLOBAL' + OR (scope.scope_type = 'COMPANY' AND scope.company_public_id = company.public_id) + OR (scope.scope_type = 'PROJECT' AND scope.project_public_id = project.public_id)) + ) + """) + long countOwnedOpenProjects(@Param("userId") long userId, @Param("roleCode") String roleCode); + + @Select(""" + SELECT COUNT(*) + FROM wf_task task + JOIN wf_instance instance ON instance.id = task.instance_id + JOIN src_document_version document_version ON document_version.id = instance.source_version_id + JOIN src_source_document document ON document.id = document_version.source_document_id + JOIN md_company company ON company.id = document.company_id + LEFT JOIN md_project project ON project.id = document.project_id + WHERE (#{roleCode} = 'SYSTEM_ADMIN' OR task.assignee_id = #{userId}) + AND task.status = 'PENDING' + AND ((#{roleCode} = 'PROJECT_MANAGER' AND task.node_code = 'PROJECT_REVIEW') + OR (#{roleCode} = 'FINANCE_MANAGER' AND task.node_code = 'FINANCE_REVIEW') + OR #{roleCode} = 'SYSTEM_ADMIN') + AND EXISTS ( + SELECT 1 + FROM iam_scope scope + JOIN iam_role role ON role.id = scope.role_id + JOIN iam_permission permission ON permission.id = scope.permission_id + WHERE scope.user_id = #{userId} + AND role.code = #{roleCode} + AND permission.code = 'workflow:task:view' + AND scope.status = 'ACTIVE' + AND scope.valid_from <= UTC_TIMESTAMP(3) + AND (scope.valid_to IS NULL OR scope.valid_to >= UTC_TIMESTAMP(3)) + AND (scope.scope_type = 'GLOBAL' + OR (scope.scope_type = 'COMPANY' AND scope.company_public_id = company.public_id) + OR (scope.scope_type = 'PROJECT' AND scope.project_public_id = project.public_id)) + ) + """) + long countPendingTasks(@Param("userId") long userId, @Param("roleCode") String roleCode); + + @Select(""" + SELECT COUNT(*) + FROM src_source_document document + JOIN md_company company ON company.id = document.company_id + LEFT JOIN md_project project ON project.id = document.project_id + WHERE (#{roleCode} = 'SYSTEM_ADMIN' OR document.created_by = #{userId}) + AND document.status = #{status} + AND EXISTS ( + SELECT 1 FROM iam_scope scope + JOIN iam_role role ON role.id = scope.role_id + JOIN iam_permission permission ON permission.id = scope.permission_id + WHERE scope.user_id = #{userId} AND role.code = #{roleCode} + AND permission.code = 'source:form:view' AND scope.status = 'ACTIVE' + AND scope.valid_from <= UTC_TIMESTAMP(3) + AND (scope.valid_to IS NULL OR scope.valid_to >= UTC_TIMESTAMP(3)) + AND (scope.scope_type = 'GLOBAL' + OR (scope.scope_type = 'COMPANY' AND scope.company_public_id = company.public_id) + OR (scope.scope_type = 'PROJECT' AND scope.project_public_id = project.public_id)) + ) + """) + long countOwnedDocuments(@Param("status") String status, @Param("userId") long userId, + @Param("roleCode") String roleCode); + + @Select(""" + SELECT COUNT(*) + FROM arc_package_item item + JOIN arc_package package_record ON package_record.id = item.package_id + JOIN md_company company ON company.id = package_record.company_id + JOIN md_project project ON project.id = package_record.project_id + WHERE (#{roleCode} = 'SYSTEM_ADMIN' OR project.owner_user_id = #{userId}) + AND item.status IN ('MISSING', 'NA_REJECTED') + AND package_record.status IN ('PREPARING', 'CHECKED', 'REVIEWING', 'RETURNED') + AND EXISTS ( + SELECT 1 FROM iam_scope scope + JOIN iam_role role ON role.id = scope.role_id + JOIN iam_permission permission ON permission.id = scope.permission_id + WHERE scope.user_id = #{userId} AND role.code = #{roleCode} + AND permission.code = 'archive:package:view' AND scope.status = 'ACTIVE' + AND scope.valid_from <= UTC_TIMESTAMP(3) + AND (scope.valid_to IS NULL OR scope.valid_to >= UTC_TIMESTAMP(3)) + AND (scope.scope_type = 'GLOBAL' + OR (scope.scope_type = 'COMPANY' AND scope.company_public_id = company.public_id) + OR (scope.scope_type = 'PROJECT' AND scope.project_public_id = project.public_id)) + ) + """) + long countOwnedArchiveMissing(@Param("userId") long userId, @Param("roleCode") String roleCode); + + @Select(""" + SELECT COUNT(*) + FROM wf_task task + JOIN wf_instance instance ON instance.id = task.instance_id + JOIN src_document_version document_version ON document_version.id = instance.source_version_id + JOIN src_source_document document ON document.id = document_version.source_document_id + JOIN md_company company ON company.id = document.company_id + LEFT JOIN md_project project ON project.id = document.project_id + WHERE (#{roleCode} = 'SYSTEM_ADMIN' OR task.assignee_id = #{userId}) + AND task.status = 'PENDING' + AND task.due_at >= UTC_TIMESTAMP(3) + AND task.due_at <= DATE_ADD(UTC_TIMESTAMP(3), INTERVAL 7 DAY) + AND ((#{roleCode} = 'PROJECT_MANAGER' AND task.node_code = 'PROJECT_REVIEW') + OR (#{roleCode} = 'FINANCE_MANAGER' AND task.node_code = 'FINANCE_REVIEW') + OR #{roleCode} = 'SYSTEM_ADMIN') + AND EXISTS ( + SELECT 1 FROM iam_scope scope + JOIN iam_role role ON role.id = scope.role_id + JOIN iam_permission permission ON permission.id = scope.permission_id + WHERE scope.user_id = #{userId} AND role.code = #{roleCode} + AND permission.code = 'workflow:task:view' AND scope.status = 'ACTIVE' + AND scope.valid_from <= UTC_TIMESTAMP(3) + AND (scope.valid_to IS NULL OR scope.valid_to >= UTC_TIMESTAMP(3)) + AND (scope.scope_type = 'GLOBAL' + OR (scope.scope_type = 'COMPANY' AND scope.company_public_id = company.public_id) + OR (scope.scope_type = 'PROJECT' AND scope.project_public_id = project.public_id)) + ) + """) + long countDueSoonTasks(@Param("userId") long userId, @Param("roleCode") String roleCode); + + @Select(""" + SELECT COUNT(*) + FROM wf_task task + JOIN wf_instance instance ON instance.id = task.instance_id + JOIN src_document_version document_version ON document_version.id = instance.source_version_id + JOIN src_source_document document ON document.id = document_version.source_document_id + JOIN md_company company ON company.id = document.company_id + LEFT JOIN md_project project ON project.id = document.project_id + WHERE (#{roleCode} = 'SYSTEM_ADMIN' OR task.assignee_id = #{userId}) + AND task.status = 'PENDING' + AND task.due_at IS NOT NULL AND task.due_at < UTC_TIMESTAMP(3) + AND ((#{roleCode} = 'PROJECT_MANAGER' AND task.node_code = 'PROJECT_REVIEW') + OR (#{roleCode} = 'FINANCE_MANAGER' AND task.node_code = 'FINANCE_REVIEW') + OR #{roleCode} = 'SYSTEM_ADMIN') + AND EXISTS ( + SELECT 1 FROM iam_scope scope + JOIN iam_role role ON role.id = scope.role_id + JOIN iam_permission permission ON permission.id = scope.permission_id + WHERE scope.user_id = #{userId} AND role.code = #{roleCode} + AND permission.code = 'workflow:task:view' AND scope.status = 'ACTIVE' + AND scope.valid_from <= UTC_TIMESTAMP(3) + AND (scope.valid_to IS NULL OR scope.valid_to >= UTC_TIMESTAMP(3)) + AND (scope.scope_type = 'GLOBAL' + OR (scope.scope_type = 'COMPANY' AND scope.company_public_id = company.public_id) + OR (scope.scope_type = 'PROJECT' AND scope.project_public_id = project.public_id)) + ) + """) + long countOverdueTasks(@Param("userId") long userId, @Param("roleCode") String roleCode); + + @Select(""" + SELECT ( + SELECT COUNT(*) + FROM md_company company + WHERE company.status = 'REVIEWING' + AND EXISTS ( + SELECT 1 FROM iam_scope scope + JOIN iam_role role ON role.id = scope.role_id + JOIN iam_permission permission ON permission.id = scope.permission_id + WHERE scope.user_id = #{userId} AND role.code = #{roleCode} + AND permission.code = 'masterdata:company:view' AND scope.status = 'ACTIVE' + AND scope.valid_from <= UTC_TIMESTAMP(3) + AND (scope.valid_to IS NULL OR scope.valid_to >= UTC_TIMESTAMP(3)) + AND (scope.scope_type = 'GLOBAL' + OR (scope.scope_type = 'COMPANY' AND scope.company_public_id = company.public_id)) + ) + ) + ( + SELECT COUNT(*) + FROM md_counterparty counterparty + WHERE counterparty.status = 'REVIEWING' + AND EXISTS ( + SELECT 1 FROM iam_scope scope + JOIN iam_role role ON role.id = scope.role_id + JOIN iam_permission permission ON permission.id = scope.permission_id + WHERE scope.user_id = #{userId} AND role.code = #{roleCode} + AND permission.code = 'masterdata:counterparty:view' + AND scope.scope_type = 'GLOBAL' AND scope.status = 'ACTIVE' + AND scope.valid_from <= UTC_TIMESTAMP(3) + AND (scope.valid_to IS NULL OR scope.valid_to >= UTC_TIMESTAMP(3)) + ) + ) + """) + long countMasterDataReview(@Param("userId") long userId, @Param("roleCode") String roleCode); + + @Select(""" + SELECT COUNT(*) + FROM src_source_document document + JOIN md_company company ON company.id = document.company_id + LEFT JOIN md_project project ON project.id = document.project_id + WHERE document.status = #{status} + AND EXISTS ( + SELECT 1 FROM iam_scope scope + JOIN iam_role role ON role.id = scope.role_id + JOIN iam_permission permission ON permission.id = scope.permission_id + WHERE scope.user_id = #{userId} AND role.code = #{roleCode} + AND permission.code = #{permissionCode} AND scope.status = 'ACTIVE' + AND scope.valid_from <= UTC_TIMESTAMP(3) + AND (scope.valid_to IS NULL OR scope.valid_to >= UTC_TIMESTAMP(3)) + AND (scope.scope_type = 'GLOBAL' + OR (scope.scope_type = 'COMPANY' AND scope.company_public_id = company.public_id) + OR (scope.scope_type = 'PROJECT' AND scope.project_public_id = project.public_id)) + ) + """) + long countSourceDocuments(@Param("status") String status, @Param("userId") long userId, + @Param("roleCode") String roleCode, + @Param("permissionCode") String permissionCode); + + @Select(""" + SELECT COUNT(DISTINCT file.id) + FROM file_object file + JOIN file_link link ON link.file_id = file.id + WHERE file.scan_status = #{status} + AND EXISTS ( + SELECT 1 FROM iam_scope scope + JOIN iam_role role ON role.id = scope.role_id + JOIN iam_permission permission ON permission.id = scope.permission_id + WHERE scope.user_id = #{userId} AND role.code = #{roleCode} + AND permission.code = 'archive:file:view' AND scope.status = 'ACTIVE' + AND scope.valid_from <= UTC_TIMESTAMP(3) + AND (scope.valid_to IS NULL OR scope.valid_to >= UTC_TIMESTAMP(3)) + AND (scope.scope_type = 'GLOBAL' + OR (scope.scope_type = 'COMPANY' AND scope.company_public_id = link.company_public_id) + OR (scope.scope_type = 'PROJECT' AND scope.project_public_id = link.project_public_id)) + ) + """) + long countFiles(@Param("status") String status, @Param("userId") long userId, + @Param("roleCode") String roleCode); + + @Select(""" + SELECT COUNT(*) + FROM arc_package package_record + JOIN md_company company ON company.id = package_record.company_id + JOIN md_project project ON project.id = package_record.project_id + WHERE ((#{archived} = TRUE AND package_record.status = 'ARCHIVED') + OR (#{archived} = FALSE AND package_record.status IN ('PREPARING','CHECKED','REVIEWING','RETURNED'))) + AND EXISTS ( + SELECT 1 FROM iam_scope scope + JOIN iam_role role ON role.id = scope.role_id + JOIN iam_permission permission ON permission.id = scope.permission_id + WHERE scope.user_id = #{userId} AND role.code = #{roleCode} + AND permission.code = 'archive:package:view' AND scope.status = 'ACTIVE' + AND scope.valid_from <= UTC_TIMESTAMP(3) + AND (scope.valid_to IS NULL OR scope.valid_to >= UTC_TIMESTAMP(3)) + AND (scope.scope_type = 'GLOBAL' + OR (scope.scope_type = 'COMPANY' AND scope.company_public_id = company.public_id) + OR (scope.scope_type = 'PROJECT' AND scope.project_public_id = project.public_id)) + ) + """) + long countArchivePackages(@Param("archived") boolean archived, @Param("userId") long userId, + @Param("roleCode") String roleCode); + + @Select(""" + SELECT COUNT(*) + FROM arc_package_item item + JOIN arc_package package_record ON package_record.id = item.package_id + JOIN md_company company ON company.id = package_record.company_id + JOIN md_project project ON project.id = package_record.project_id + WHERE item.status IN ('MISSING','NA_REJECTED') + AND package_record.status IN ('PREPARING','CHECKED','REVIEWING','RETURNED') + AND EXISTS ( + SELECT 1 FROM iam_scope scope + JOIN iam_role role ON role.id = scope.role_id + JOIN iam_permission permission ON permission.id = scope.permission_id + WHERE scope.user_id = #{userId} AND role.code = #{roleCode} + AND permission.code = 'archive:package:view' AND scope.status = 'ACTIVE' + AND scope.valid_from <= UTC_TIMESTAMP(3) + AND (scope.valid_to IS NULL OR scope.valid_to >= UTC_TIMESTAMP(3)) + AND (scope.scope_type = 'GLOBAL' + OR (scope.scope_type = 'COMPANY' AND scope.company_public_id = company.public_id) + OR (scope.scope_type = 'PROJECT' AND scope.project_public_id = project.public_id)) + ) + """) + long countArchiveMissing(@Param("userId") long userId, @Param("roleCode") String roleCode); + + @Select(""" + SELECT COUNT(*) + FROM arc_borrow borrow + WHERE borrow.status IN ('BORROWED','OVERDUE') + AND (borrow.status = 'OVERDUE' OR borrow.due_at <= DATE_ADD(UTC_TIMESTAMP(3), INTERVAL 7 DAY)) + AND EXISTS ( + SELECT 1 FROM file_link scoped_link + JOIN iam_scope scope ON scope.user_id = #{userId} + JOIN iam_role role ON role.id = scope.role_id + JOIN iam_permission permission ON permission.id = scope.permission_id + WHERE scoped_link.file_id = borrow.file_id AND role.code = #{roleCode} + AND permission.code = 'archive:file:view' AND scope.status = 'ACTIVE' + AND scope.valid_from <= UTC_TIMESTAMP(3) + AND (scope.valid_to IS NULL OR scope.valid_to >= UTC_TIMESTAMP(3)) + AND (scope.scope_type = 'GLOBAL' + OR (scope.scope_type = 'COMPANY' AND scope.company_public_id = scoped_link.company_public_id) + OR (scope.scope_type = 'PROJECT' AND scope.project_public_id = scoped_link.project_public_id)) + ) + """) + long countArchiveBorrowsDue(@Param("userId") long userId, @Param("roleCode") String roleCode); + + @Select(""" + SELECT task.public_id, + document.form_type, + template.name AS form_name, + document.business_no, + project.public_id AS project_public_id, + project.name AS project_name, + task.node_code, + task.status, + task.created_at AS arrived_at, + task.due_at + FROM wf_task task + JOIN wf_instance instance ON instance.id = task.instance_id + JOIN src_document_version document_version ON document_version.id = instance.source_version_id + JOIN src_source_document document ON document.id = document_version.source_document_id + JOIN src_template template ON template.id = document.template_id + LEFT JOIN md_project project ON project.id = document.project_id + WHERE (#{roleCode} = 'SYSTEM_ADMIN' OR task.assignee_id = #{userId}) + AND task.status = 'PENDING' + AND ((#{roleCode} = 'PROJECT_MANAGER' AND task.node_code = 'PROJECT_REVIEW') + OR (#{roleCode} = 'FINANCE_MANAGER' AND task.node_code = 'FINANCE_REVIEW') + OR #{roleCode} = 'SYSTEM_ADMIN') + AND EXISTS ( + SELECT 1 + FROM iam_scope scope + JOIN iam_role role ON role.id = scope.role_id + JOIN iam_permission permission ON permission.id = scope.permission_id + WHERE scope.user_id = #{userId} + AND role.code = #{roleCode} + AND permission.code = 'workflow:task:view' + AND scope.status = 'ACTIVE' + AND scope.valid_from <= UTC_TIMESTAMP(3) + AND (scope.valid_to IS NULL OR scope.valid_to >= UTC_TIMESTAMP(3)) + AND ( + scope.scope_type = 'GLOBAL' + OR (scope.scope_type = 'COMPANY' AND scope.company_public_id = ( + SELECT company.public_id FROM md_company company WHERE company.id = document.company_id + )) + OR (scope.scope_type = 'PROJECT' AND scope.project_public_id = project.public_id) + ) + ) + ORDER BY task.due_at IS NULL, task.due_at, task.created_at + LIMIT #{limit} + """) + List listTodos(@Param("userId") long userId, @Param("roleCode") String roleCode, + @Param("limit") int limit); + + @Select(""" + SELECT project.public_id, + project.business_no, + project.name, + company.name AS company_name, + project.stage, + project.status, + CAST(COALESCE(( + SELECT SUM(contract.original_amount + contract.approved_change_amount) + FROM md_contract contract + WHERE contract.project_id = project.id + AND contract.status NOT IN ('VOID', 'DISABLED') + ), 0) AS CHAR) AS contract_amount, + project.updated_at + FROM md_project project + JOIN md_company company ON company.id = project.company_id + WHERE (#{roleCode} = 'SYSTEM_ADMIN' OR project.owner_user_id = #{userId}) + AND project.status IN ('ACTIVE', 'SUSPENDED') + AND EXISTS ( + SELECT 1 + FROM iam_scope scope + JOIN iam_role role ON role.id = scope.role_id + JOIN iam_permission permission ON permission.id = scope.permission_id + WHERE scope.user_id = #{userId} + AND role.code = #{roleCode} + AND permission.code = 'project:project:view' + AND scope.status = 'ACTIVE' + AND scope.valid_from <= UTC_TIMESTAMP(3) + AND (scope.valid_to IS NULL OR scope.valid_to >= UTC_TIMESTAMP(3)) + AND ( + scope.scope_type = 'GLOBAL' + OR (scope.scope_type = 'COMPANY' AND scope.company_public_id = company.public_id) + OR (scope.scope_type = 'PROJECT' AND scope.project_public_id = project.public_id) + ) + ) + ORDER BY project.updated_at DESC, project.public_id + LIMIT #{limit} + """) + List listOwnedProjects(@Param("userId") long userId, @Param("roleCode") String roleCode, + @Param("limit") int limit); + + @Select(""" + SELECT audit.public_id, + audit.action_code, + audit.object_type, + COALESCE(audit.object_public_id, '') AS object_public_id, + CONCAT(audit.action_code, ' · ', audit.object_type) AS title, + audit.result_code, + audit.created_at AS occurred_at + FROM audit_log audit + WHERE audit.user_public_id = #{userPublicId} + ORDER BY audit.created_at DESC, audit.id DESC + LIMIT #{limit} + """) + List listRecentActivities(@Param("userPublicId") String userPublicId, + @Param("limit") int limit); + + @Select(""" + SELECT 'PAYMENT_BLOCK' AS code, '付款阻断异常' AS label, 'DANGER' AS severity, + COUNT(DISTINCT payment.id) AS issue_count, '请查看检查结果并处理阻断项' AS detail, + '/finance/payments?tab=finance-check&riskCode=BLOCK' AS target_route + FROM fin_payment_check payment_check + JOIN fin_payment_request payment ON payment.id = payment_check.payment_id + JOIN md_company company ON company.id = payment.company_id + LEFT JOIN md_project project ON project.id = payment.project_id + WHERE payment_check.result = 'BLOCK' + AND payment.status NOT IN ('VOID', 'PAID', 'REFUNDED') + AND EXISTS ( + SELECT 1 FROM iam_scope scope + JOIN iam_role role ON role.id = scope.role_id + JOIN iam_permission permission ON permission.id = scope.permission_id + WHERE scope.user_id = #{userId} AND role.code = #{roleCode} + AND permission.code = 'payment:request:view' AND scope.status = 'ACTIVE' + AND scope.valid_from <= UTC_TIMESTAMP(3) + AND (scope.valid_to IS NULL OR scope.valid_to >= UTC_TIMESTAMP(3)) + AND (scope.scope_type = 'GLOBAL' + OR (scope.scope_type = 'COMPANY' AND scope.company_public_id = company.public_id) + OR (scope.scope_type = 'PROJECT' AND scope.project_public_id = project.public_id)) + ) + UNION ALL + SELECT 'ACCOUNTING_PENDING', '待生成记账草稿', 'WARNING', COUNT(*), '确认业务结果后生成凭证草稿', + '/finance/accounting?tab=pending' + FROM acc_event event + JOIN md_company company ON company.id = event.company_id + LEFT JOIN md_project project ON project.id = event.project_id + WHERE event.status = 'PENDING' + AND EXISTS ( + SELECT 1 FROM iam_scope scope + JOIN iam_role role ON role.id = scope.role_id + JOIN iam_permission permission ON permission.id = scope.permission_id + WHERE scope.user_id = #{userId} AND role.code = #{roleCode} + AND permission.code = 'accounting:event:view' AND scope.status = 'ACTIVE' + AND scope.valid_from <= UTC_TIMESTAMP(3) + AND (scope.valid_to IS NULL OR scope.valid_to >= UTC_TIMESTAMP(3)) + AND (scope.scope_type = 'GLOBAL' + OR (scope.scope_type = 'COMPANY' AND scope.company_public_id = company.public_id) + OR (scope.scope_type = 'PROJECT' AND scope.project_public_id = project.public_id)) + ) + UNION ALL + SELECT 'PAYMENT_RESULT', '待登记付款结果', 'INFO', COUNT(*), '登记线下付款结果并提交异人复核', + '/finance/payments?tab=result' + FROM fin_payment_request payment + JOIN md_company company ON company.id = payment.company_id + LEFT JOIN md_project project ON project.id = payment.project_id + WHERE payment.status = 'RESULT_RECORDED' + AND EXISTS ( + SELECT 1 FROM iam_scope scope + JOIN iam_role role ON role.id = scope.role_id + JOIN iam_permission permission ON permission.id = scope.permission_id + WHERE scope.user_id = #{userId} AND role.code = #{roleCode} + AND permission.code = 'payment:request:view' AND scope.status = 'ACTIVE' + AND scope.valid_from <= UTC_TIMESTAMP(3) + AND (scope.valid_to IS NULL OR scope.valid_to >= UTC_TIMESTAMP(3)) + AND (scope.scope_type = 'GLOBAL' + OR (scope.scope_type = 'COMPANY' AND scope.company_public_id = company.public_id) + OR (scope.scope_type = 'PROJECT' AND scope.project_public_id = project.public_id)) + ) + """) + List listFinanceIssues(@Param("userId") long userId, @Param("roleCode") String roleCode); + + @Select(""" + SELECT project.public_id AS project_public_id, + project.name AS project_name, + control.currency, + CAST(control.confirmed_receipt AS CHAR) AS confirmed_receipt, + CAST(control.approved_unpaid AS CHAR) AS approved_unpaid, + CAST(control.paid_amount AS CHAR) AS paid_amount, + CAST(control.frozen_amount AS CHAR) AS frozen_amount, + CAST(control.confirmed_receipt - control.confirmed_deduction + + control.approved_adjustment - control.frozen_amount + - control.approved_unpaid - control.paid_amount AS CHAR) + AS available_amount + FROM project_fund_control control + JOIN md_project project ON project.id = control.project_id + JOIN md_company company ON company.id = project.company_id + WHERE project.status IN ('ACTIVE', 'SUSPENDED') + AND EXISTS ( + SELECT 1 FROM iam_scope scope + JOIN iam_role role ON role.id = scope.role_id + JOIN iam_permission permission ON permission.id = scope.permission_id + WHERE scope.user_id = #{userId} AND role.code = #{roleCode} + AND permission.code = 'payment:request:view' AND scope.status = 'ACTIVE' + AND scope.valid_from <= UTC_TIMESTAMP(3) + AND (scope.valid_to IS NULL OR scope.valid_to >= UTC_TIMESTAMP(3)) + AND (scope.scope_type = 'GLOBAL' + OR (scope.scope_type = 'COMPANY' AND scope.company_public_id = company.public_id) + OR (scope.scope_type = 'PROJECT' AND scope.project_public_id = project.public_id)) + ) + ORDER BY project.updated_at DESC, project.public_id + LIMIT #{limit} + """) + List listFunds(@Param("userId") long userId, @Param("roleCode") String roleCode, + @Param("limit") int limit); + + @Select(""" + SELECT package_record.public_id, + package_record.business_no, + project.name AS project_name, + package_record.status, + CAST(package_record.completeness AS CHAR) AS completeness, + package_record.missing_count, + package_record.updated_at + FROM arc_package package_record + JOIN md_project project ON project.id = package_record.project_id + JOIN md_company company ON company.id = package_record.company_id + WHERE package_record.status IN ('PREPARING', 'CHECKED', 'REVIEWING', 'RETURNED') + AND EXISTS ( + SELECT 1 FROM iam_scope scope + JOIN iam_role role ON role.id = scope.role_id + JOIN iam_permission permission ON permission.id = scope.permission_id + WHERE scope.user_id = #{userId} AND role.code = #{roleCode} + AND permission.code = 'archive:package:view' AND scope.status = 'ACTIVE' + AND scope.valid_from <= UTC_TIMESTAMP(3) + AND (scope.valid_to IS NULL OR scope.valid_to >= UTC_TIMESTAMP(3)) + AND (scope.scope_type = 'GLOBAL' + OR (scope.scope_type = 'COMPANY' AND scope.company_public_id = company.public_id) + OR (scope.scope_type = 'PROJECT' AND scope.project_public_id = project.public_id)) + ) + ORDER BY package_record.updated_at DESC, package_record.public_id + LIMIT #{limit} + """) + List listArchivePackages(@Param("userId") long userId, + @Param("roleCode") String roleCode, + @Param("limit") int limit); + + @Select(""" + SELECT item.public_id, + item.item_name, + project.name AS project_name, + package_record.status, + CAST(package_record.completeness AS CHAR) AS completeness, + package_record.missing_count, + package_record.updated_at + FROM arc_package_item item + JOIN arc_package package_record ON package_record.id = item.package_id + JOIN md_project project ON project.id = package_record.project_id + JOIN md_company company ON company.id = package_record.company_id + WHERE item.status IN ('MISSING', 'NA_REJECTED') + AND package_record.status IN ('PREPARING', 'CHECKED', 'REVIEWING', 'RETURNED') + AND EXISTS ( + SELECT 1 FROM iam_scope scope + JOIN iam_role role ON role.id = scope.role_id + JOIN iam_permission permission ON permission.id = scope.permission_id + WHERE scope.user_id = #{userId} AND role.code = #{roleCode} + AND permission.code = 'archive:package:view' AND scope.status = 'ACTIVE' + AND scope.valid_from <= UTC_TIMESTAMP(3) + AND (scope.valid_to IS NULL OR scope.valid_to >= UTC_TIMESTAMP(3)) + AND (scope.scope_type = 'GLOBAL' + OR (scope.scope_type = 'COMPANY' AND scope.company_public_id = company.public_id) + OR (scope.scope_type = 'PROJECT' AND scope.project_public_id = project.public_id)) + ) + ORDER BY package_record.updated_at DESC, item.public_id + LIMIT #{limit} + """) + List listArchiveMissing(@Param("userId") long userId, + @Param("roleCode") String roleCode, + @Param("limit") int limit); + + @Select(""" + SELECT borrow.public_id, + file.original_name AS item_name, + '文件借阅' AS project_name, + borrow.status, + CAST(NULL AS CHAR) AS completeness, + 0 AS missing_count, + borrow.due_at, + borrow.updated_at + FROM arc_borrow borrow + JOIN file_object file ON file.id = borrow.file_id + WHERE borrow.status IN ('APPLIED', 'APPROVED', 'BORROWED', 'OVERDUE') + AND (borrow.due_at <= DATE_ADD(UTC_TIMESTAMP(3), INTERVAL 7 DAY) + OR borrow.status IN ('APPLIED', 'OVERDUE')) + AND EXISTS ( + SELECT 1 FROM file_link scoped_link + JOIN iam_scope scope ON scope.user_id = #{userId} + JOIN iam_role role ON role.id = scope.role_id + JOIN iam_permission permission ON permission.id = scope.permission_id + WHERE scoped_link.file_id = borrow.file_id AND role.code = #{roleCode} + AND permission.code = 'archive:file:view' AND scope.status = 'ACTIVE' + AND scope.valid_from <= UTC_TIMESTAMP(3) + AND (scope.valid_to IS NULL OR scope.valid_to >= UTC_TIMESTAMP(3)) + AND (scope.scope_type = 'GLOBAL' + OR (scope.scope_type = 'COMPANY' AND scope.company_public_id = scoped_link.company_public_id) + OR (scope.scope_type = 'PROJECT' AND scope.project_public_id = scoped_link.project_public_id)) + ) + ORDER BY borrow.due_at, borrow.updated_at DESC + LIMIT #{limit} + """) + List listArchiveBorrows(@Param("userId") long userId, + @Param("roleCode") String roleCode, + @Param("limit") int limit); + + @Select(""" + SELECT audit.public_id, + file.original_name AS file_name, + COALESCE(project.name, company.name, '未关联项目') AS project_name, + audit.created_at AS occurred_at + FROM audit_log audit + JOIN file_object file ON file.public_id = audit.object_public_id + LEFT JOIN md_company company ON company.public_id = audit.company_public_id + LEFT JOIN md_project project ON project.public_id = audit.project_public_id + WHERE audit.action_code = 'ARCHIVE_FILE_DOWNLOAD' AND audit.result_code = 'SUCCESS' + AND EXISTS ( + SELECT 1 FROM iam_scope scope + JOIN iam_role role ON role.id = scope.role_id + JOIN iam_permission permission ON permission.id = scope.permission_id + WHERE scope.user_id = #{userId} AND role.code = #{roleCode} + AND permission.code = 'archive:file:view' AND scope.status = 'ACTIVE' + AND scope.valid_from <= UTC_TIMESTAMP(3) + AND (scope.valid_to IS NULL OR scope.valid_to >= UTC_TIMESTAMP(3)) + AND (scope.scope_type = 'GLOBAL' + OR (scope.scope_type = 'COMPANY' AND scope.company_public_id = audit.company_public_id) + OR (scope.scope_type = 'PROJECT' AND scope.project_public_id = audit.project_public_id)) + ) + ORDER BY audit.created_at DESC, audit.public_id + LIMIT #{limit} + """) + List listRecentArchiveDownloads(@Param("userId") long userId, + @Param("roleCode") String roleCode, + @Param("limit") int limit); + + record TodoRow(String publicId, String formType, String formName, String businessNo, + String projectPublicId, String projectName, String nodeCode, String status, + LocalDateTime arrivedAt, LocalDateTime dueAt) { + } + + record ProjectRow(String publicId, String businessNo, String name, String companyName, + String stage, String status, String contractAmount, LocalDateTime updatedAt) { + } + + record IssueRow(String code, String label, String severity, long issueCount, String detail, + String targetRoute) { + } + + record ActivityRow(String publicId, String actionCode, String objectType, String objectPublicId, + String title, String resultCode, LocalDateTime occurredAt) { + } + + record FundRow(String projectPublicId, String projectName, String currency, + String confirmedReceipt, String approvedUnpaid, String paidAmount, + String frozenAmount, String availableAmount) { + } + + record ArchivePackageRow(String publicId, String businessNo, String projectName, String status, + String completeness, long missingCount, LocalDateTime updatedAt) { + } + + record ArchiveMissingRow(String publicId, String itemName, String projectName, String status, + String completeness, long missingCount, LocalDateTime updatedAt) { + } + + record ArchiveBorrowRow(String publicId, String itemName, String projectName, String status, + String completeness, long missingCount, LocalDateTime dueAt, + LocalDateTime updatedAt) { + } + + record ArchiveDownloadRow(String publicId, String fileName, String projectName, + LocalDateTime occurredAt) { + } +} diff --git a/backend/src/main/java/com/kaidi/setup/SetupApplication.java b/backend/src/main/java/com/kaidi/setup/SetupApplication.java new file mode 100644 index 0000000..5171310 --- /dev/null +++ b/backend/src/main/java/com/kaidi/setup/SetupApplication.java @@ -0,0 +1,38 @@ +package com.kaidi.setup; + +import org.mybatis.spring.boot.autoconfigure.MybatisAutoConfiguration; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration; +import org.springframework.boot.autoconfigure.jdbc.DataSourceTransactionManagerAutoConfiguration; +import org.springframework.boot.autoconfigure.jdbc.JdbcTemplateAutoConfiguration; +import org.springframework.boot.autoconfigure.flyway.FlywayAutoConfiguration; +import org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import com.kaidi.finance.setup.SetupProperties; + +/** + * Minimal first-run context. It deliberately does not create a business DataSource or run + * Flyway; the setup endpoints create a short-lived JDBC connection only after the operator + * submits and tests the target database settings. + */ +@SpringBootApplication( + scanBasePackages = "com.kaidi.finance.setup", + exclude = { + DataSourceAutoConfiguration.class, + DataSourceTransactionManagerAutoConfiguration.class, + JdbcTemplateAutoConfiguration.class, + FlywayAutoConfiguration.class, + MybatisAutoConfiguration.class, + UserDetailsServiceAutoConfiguration.class + } +) +@EnableConfigurationProperties(SetupProperties.class) +public class SetupApplication { + + public static void main(String[] args) { + SpringApplication application = new SpringApplication(SetupApplication.class); + application.setAdditionalProfiles("setup"); + application.run(args); + } +} diff --git a/backend/src/main/resources/application-local.yml b/backend/src/main/resources/application-local.yml new file mode 100644 index 0000000..5335b1c --- /dev/null +++ b/backend/src/main/resources/application-local.yml @@ -0,0 +1,18 @@ +server: + servlet: + session: + cookie: + secure: false + +finance: + bootstrap: + enabled: true + password: ${FINANCE_BOOTSTRAP_PASSWORD:LocalOnly@123} + demo-data: true + crypto: + field-key: ${FIELD_ENCRYPTION_KEY:MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=} + reject-default: false + +logging: + level: + com.kaidi.finance: INFO diff --git a/backend/src/main/resources/application-production.yml b/backend/src/main/resources/application-production.yml new file mode 100644 index 0000000..679998f --- /dev/null +++ b/backend/src/main/resources/application-production.yml @@ -0,0 +1,34 @@ +server: + forward-headers-strategy: framework + servlet: + session: + cookie: + secure: ${SESSION_COOKIE_SECURE:true} + +spring: + datasource: + url: ${DB_URL} + username: ${DB_USERNAME} + password: ${DB_PASSWORD} + +springdoc: + api-docs: + enabled: false + swagger-ui: + enabled: false + +finance: + bootstrap: + enabled: ${FINANCE_BOOTSTRAP_ENABLED:false} + password: ${FINANCE_BOOTSTRAP_PASSWORD:} + demo-data: false + crypto: + field-key: ${FIELD_ENCRYPTION_KEY} + reject-default: true + storage: + root: ${FILE_STORAGE_ROOT} + temp: ${FILE_STORAGE_TEMP} + scanner: + enabled: ${FILE_SCANNER_ENABLED:true} + host: ${FILE_SCANNER_HOST:127.0.0.1} + port: ${FILE_SCANNER_PORT:3310} diff --git a/backend/src/main/resources/application-setup.yml b/backend/src/main/resources/application-setup.yml new file mode 100644 index 0000000..59260a9 --- /dev/null +++ b/backend/src/main/resources/application-setup.yml @@ -0,0 +1,21 @@ +spring: + main: + web-application-type: servlet + session: + store-type: none + flyway: + enabled: false + mvc: + problemdetails: + enabled: true + +springdoc: + api-docs: + enabled: false + swagger-ui: + enabled: false + +finance: + setup: + enabled: true + restart-after-complete: ${FINANCE_SETUP_RESTART_AFTER_COMPLETE:true} diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml new file mode 100644 index 0000000..fa58885 --- /dev/null +++ b/backend/src/main/resources/application.yml @@ -0,0 +1,104 @@ +server: + port: ${SERVER_PORT:18080} + shutdown: graceful + servlet: + session: + cookie: + name: KAIDI_SESSION + http-only: true + same-site: lax + secure: ${SESSION_COOKIE_SECURE:false} + +spring: + application: + name: kaidi-finance + profiles: + default: local + datasource: + url: ${DB_URL:jdbc:mysql://127.0.0.1:3307/kaidi_finance?useUnicode=true&characterEncoding=utf8&connectionTimeZone=UTC&serverTimezone=UTC} + username: ${DB_USERNAME:kaidi} + password: ${DB_PASSWORD:kaidi_local_2026} + hikari: + maximum-pool-size: 20 + minimum-idle: 2 + connection-timeout: 10000 + # R1 fixes the transaction view used by amount/commitment checks. + transaction-isolation: TRANSACTION_READ_COMMITTED + connection-init-sql: SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED + flyway: + enabled: true + locations: classpath:db/migration + validate-migration-naming: true + session: + timeout: 30m + jdbc: + initialize-schema: never + cleanup-cron: "0 */5 * * * *" + servlet: + multipart: + max-file-size: 100MB + max-request-size: 500MB + jackson: + default-property-inclusion: non_null + time-zone: UTC + +mybatis: + configuration: + map-underscore-to-camel-case: true + default-fetch-size: 100 + default-statement-timeout: 10 + +management: + endpoints: + web: + exposure: + include: health,info,metrics + endpoint: + health: + probes: + enabled: true + +springdoc: + api-docs: + path: /api-docs + swagger-ui: + path: /swagger-ui.html + +finance: + setup: + enabled: ${FINANCE_SETUP_ENABLED:false} + token-sha256: ${FINANCE_SETUP_TOKEN_SHA256:} + env-file: ${FINANCE_SETUP_ENV_FILE:/var/lib/kaidi/setup/application.env} + marker-file: ${FINANCE_SETUP_MARKER_FILE:/var/lib/kaidi/setup/locked} + restart-after-complete: ${FINANCE_SETUP_RESTART_AFTER_COMPLETE:true} + session: + absolute-timeout: ${FINANCE_SESSION_ABSOLUTE_TIMEOUT:8h} + crypto: + field-key: ${FIELD_ENCRYPTION_KEY} + reject-default: ${FIELD_ENCRYPTION_KEY_REJECT_DEFAULT:false} + storage: + root: ${FILE_STORAGE_ROOT:../runtime/files} + temp: ${FILE_STORAGE_TEMP:../runtime/tmp} + max-bytes: 104857600 + scanner: + enabled: ${FILE_SCANNER_ENABLED:false} + host: ${FILE_SCANNER_HOST:127.0.0.1} + port: ${FILE_SCANNER_PORT:3310} + connect-timeout-millis: ${FILE_SCANNER_CONNECT_TIMEOUT_MS:2000} + read-timeout-millis: ${FILE_SCANNER_READ_TIMEOUT_MS:30000} + chunk-bytes: 65536 + bootstrap: + enabled: false + demo-data: true + update: + enabled: ${FINANCE_UPDATE_ENABLED:false} + current-version: ${APP_VERSION:1.0.0-SNAPSHOT} + current-version-file: ${UPDATE_CURRENT_VERSION_FILE:VERSION} + release-base-url: ${UPDATE_RELEASE_BASE_URL:} + release-api-url: ${UPDATE_RELEASE_API_URL:} + release-token: ${UPDATE_RELEASE_TOKEN:} + request-file: ${UPDATE_REQUEST_FILE:runtime/update/inbox/request.json} + status-file: ${UPDATE_STATUS_FILE:runtime/update/status.json} + connect-timeout: ${UPDATE_CONNECT_TIMEOUT:3s} + read-timeout: ${UPDATE_READ_TIMEOUT:8s} + allow-insecure-url: ${UPDATE_ALLOW_INSECURE_URL:false} diff --git a/backend/src/main/resources/db/migration/V001__iam_session_and_audit_baseline.sql b/backend/src/main/resources/db/migration/V001__iam_session_and_audit_baseline.sql new file mode 100644 index 0000000..78b1a6c --- /dev/null +++ b/backend/src/main/resources/db/migration/V001__iam_session_and_audit_baseline.sql @@ -0,0 +1,200 @@ +CREATE TABLE iam_user ( + id BIGINT NOT NULL AUTO_INCREMENT, + public_id CHAR(26) NOT NULL, + username VARCHAR(64) NOT NULL, + display_name VARCHAR(100) NOT NULL, + department_name VARCHAR(100) NULL, + password_hash VARCHAR(255) NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + must_change_password BOOLEAN NOT NULL DEFAULT FALSE, + failed_login_count INT NOT NULL DEFAULT 0, + locked_until TIMESTAMP(3) NULL, + version BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_iam_user_public_id (public_id), + UNIQUE KEY uk_iam_user_username (username), + CONSTRAINT ck_iam_user_failed_login_count CHECK (failed_login_count >= 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE iam_role ( + id BIGINT NOT NULL AUTO_INCREMENT, + code VARCHAR(40) NOT NULL, + name VARCHAR(80) NOT NULL, + description VARCHAR(255) NULL, + sort_order INT NOT NULL DEFAULT 0, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + PRIMARY KEY (id), + UNIQUE KEY uk_iam_role_code (code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE iam_permission ( + id BIGINT NOT NULL AUTO_INCREMENT, + code VARCHAR(100) NOT NULL, + name VARCHAR(100) NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_iam_permission_code (code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE iam_user_role ( + user_id BIGINT NOT NULL, + role_id BIGINT NOT NULL, + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (user_id, role_id), + CONSTRAINT fk_iam_user_role_user FOREIGN KEY (user_id) REFERENCES iam_user (id), + CONSTRAINT fk_iam_user_role_role FOREIGN KEY (role_id) REFERENCES iam_role (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE iam_role_permission ( + role_id BIGINT NOT NULL, + permission_id BIGINT NOT NULL, + PRIMARY KEY (role_id, permission_id), + CONSTRAINT fk_iam_role_permission_role FOREIGN KEY (role_id) REFERENCES iam_role (id), + CONSTRAINT fk_iam_role_permission_permission FOREIGN KEY (permission_id) REFERENCES iam_permission (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE iam_scope ( + id BIGINT NOT NULL AUTO_INCREMENT, + user_id BIGINT NOT NULL, + role_id BIGINT NOT NULL, + company_public_id CHAR(26) NULL, + project_public_id CHAR(26) NULL, + amount_limit DECIMAL(20, 2) NULL, + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + KEY idx_iam_scope_user_role (user_id, role_id), + CONSTRAINT fk_iam_scope_user FOREIGN KEY (user_id) REFERENCES iam_user (id), + CONSTRAINT fk_iam_scope_role FOREIGN KEY (role_id) REFERENCES iam_role (id), + CONSTRAINT ck_iam_scope_amount_limit CHECK (amount_limit IS NULL OR amount_limit >= 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE iam_sod_rule ( + id BIGINT NOT NULL AUTO_INCREMENT, + code VARCHAR(80) NOT NULL, + first_permission VARCHAR(100) NOT NULL, + second_permission VARCHAR(100) NOT NULL, + description VARCHAR(255) NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + PRIMARY KEY (id), + UNIQUE KEY uk_iam_sod_rule_code (code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE audit_log ( + id BIGINT NOT NULL AUTO_INCREMENT, + public_id CHAR(26) NOT NULL, + request_id CHAR(26) NOT NULL, + user_public_id CHAR(26) NULL, + username VARCHAR(64) NULL, + active_role VARCHAR(40) NULL, + company_public_id CHAR(26) NULL, + project_public_id CHAR(26) NULL, + action_code VARCHAR(100) NOT NULL, + object_type VARCHAR(80) NOT NULL, + object_public_id CHAR(26) NULL, + result_code VARCHAR(40) NOT NULL, + reason VARCHAR(500) NULL, + before_json JSON NULL, + after_json JSON NULL, + ip_address VARCHAR(64) NULL, + user_agent VARCHAR(500) NULL, + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_audit_log_public_id (public_id), + KEY idx_audit_log_request_id (request_id), + KEY idx_audit_log_user_created (user_public_id, created_at), + KEY idx_audit_log_object (object_type, object_public_id, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE SPRING_SESSION ( + PRIMARY_ID CHAR(36) NOT NULL, + SESSION_ID CHAR(36) NOT NULL, + CREATION_TIME BIGINT NOT NULL, + LAST_ACCESS_TIME BIGINT NOT NULL, + MAX_INACTIVE_INTERVAL INT NOT NULL, + EXPIRY_TIME BIGINT NOT NULL, + PRINCIPAL_NAME VARCHAR(100) NULL, + PRIMARY KEY (PRIMARY_ID), + UNIQUE KEY SPRING_SESSION_IX1 (SESSION_ID), + KEY SPRING_SESSION_IX2 (EXPIRY_TIME), + KEY SPRING_SESSION_IX3 (PRINCIPAL_NAME) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci ROW_FORMAT=DYNAMIC; + +CREATE TABLE SPRING_SESSION_ATTRIBUTES ( + SESSION_PRIMARY_ID CHAR(36) NOT NULL, + ATTRIBUTE_NAME VARCHAR(200) NOT NULL, + ATTRIBUTE_BYTES LONGBLOB NOT NULL, + PRIMARY KEY (SESSION_PRIMARY_ID, ATTRIBUTE_NAME), + CONSTRAINT SPRING_SESSION_ATTRIBUTES_FK FOREIGN KEY (SESSION_PRIMARY_ID) + REFERENCES SPRING_SESSION (PRIMARY_ID) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci ROW_FORMAT=DYNAMIC; + +INSERT INTO iam_role (code, name, description, sort_order) VALUES + ('PROJECT', '项目管理人员', '发起项目业务、补件并查看项目进度', 10), + ('FINANCE', '财务管理人员', '执行财务审核、付款检查和记账准备', 20), + ('ARCHIVE', '资料管理人员', '执行资料检查、归档、检索和借阅', 30), + ('ADMIN', '系统管理员', '维护用户、角色、范围、模板和参数', 90); + +INSERT INTO iam_permission (code, name) VALUES + ('profile:read', '查看个人资料'), + ('project:read', '查看项目'), + ('project:create', '创建项目'), + ('source:form:read', '查看来源表单'), + ('source:form:create', '创建来源表单'), + ('source:form:submit', '提交来源表单'), + ('workflow:task:read', '查看审批任务'), + ('workflow:task:approve', '审批任务'), + ('masterdata:read', '查看主数据'), + ('masterdata:write', '维护主数据'), + ('contract:read', '查看合同成本'), + ('contract:write', '维护合同成本'), + ('receivable:read', '查看收款发票'), + ('receivable:write', '维护收款发票'), + ('payment:read', '查看付款'), + ('payment:request', '申请付款'), + ('payment:review', '审核付款'), + ('payment:result:record', '登记付款结果'), + ('payment:result:verify', '复核付款结果'), + ('accounting:read', '查看记账准备'), + ('accounting:write', '制单和登记记账结果'), + ('accounting:verify', '复核凭证'), + ('archive:read', '查看档案'), + ('archive:write', '管理档案'), + ('report:read', '查看报表'), + ('audit:read', '查看审计日志'), + ('system:configure', '维护系统配置'), + ('file:upload', '上传文件'), + ('file:download', '下载文件'); + +INSERT INTO iam_role_permission (role_id, permission_id) +SELECT r.id, p.id FROM iam_role r JOIN iam_permission p +WHERE r.code = 'PROJECT' AND p.code IN ( + 'profile:read', 'project:read', 'project:create', 'source:form:read', 'source:form:create', + 'source:form:submit', 'workflow:task:read', 'contract:read', 'payment:read', 'payment:request', + 'archive:read', 'report:read', 'file:upload', 'file:download' +); + +INSERT INTO iam_role_permission (role_id, permission_id) +SELECT r.id, p.id FROM iam_role r JOIN iam_permission p +WHERE r.code = 'FINANCE' AND p.code IN ( + 'profile:read', 'project:read', 'source:form:read', 'source:form:create', 'source:form:submit', + 'workflow:task:read', 'workflow:task:approve', 'masterdata:read', 'masterdata:write', + 'contract:read', 'contract:write', 'receivable:read', 'receivable:write', 'payment:read', + 'payment:review', 'payment:result:record', 'payment:result:verify', 'accounting:read', + 'accounting:write', 'accounting:verify', 'archive:read', 'archive:write', 'report:read', + 'file:upload', 'file:download' +); + +INSERT INTO iam_role_permission (role_id, permission_id) +SELECT r.id, p.id FROM iam_role r JOIN iam_permission p +WHERE r.code = 'ARCHIVE' AND p.code IN ( + 'profile:read', 'project:read', 'source:form:read', 'archive:read', 'archive:write', + 'report:read', 'file:upload', 'file:download' +); + +INSERT INTO iam_role_permission (role_id, permission_id) +SELECT r.id, p.id FROM iam_role r CROSS JOIN iam_permission p WHERE r.code = 'ADMIN'; + +INSERT INTO iam_sod_rule (code, first_permission, second_permission, description) VALUES + ('PAYMENT_RESULT_SEPARATION', 'payment:result:record', 'payment:result:verify', '同一笔付款的结果登记人与复核人必须不同'), + ('VOUCHER_SEPARATION', 'accounting:write', 'accounting:verify', '同一张凭证的制单人与复核人必须不同'); diff --git a/backend/src/main/resources/db/migration/V002__shared_controls_and_files.sql b/backend/src/main/resources/db/migration/V002__shared_controls_and_files.sql new file mode 100644 index 0000000..424f0eb --- /dev/null +++ b/backend/src/main/resources/db/migration/V002__shared_controls_and_files.sql @@ -0,0 +1,71 @@ +CREATE TABLE file_object ( + id BIGINT NOT NULL AUTO_INCREMENT, + public_id CHAR(26) NOT NULL, + original_name VARCHAR(255) NOT NULL, + storage_key VARCHAR(500) NOT NULL, + media_type VARCHAR(150) NOT NULL, + extension VARCHAR(20) NOT NULL, + size_bytes BIGINT NOT NULL, + sha256 CHAR(64) NOT NULL, + scan_status VARCHAR(30) NOT NULL, + uploaded_by CHAR(26) NOT NULL, + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_file_object_public_id (public_id), + UNIQUE KEY uk_file_object_storage_key (storage_key), + KEY idx_file_object_sha256 (sha256), + CONSTRAINT ck_file_object_size CHECK (size_bytes > 0), + CONSTRAINT ck_file_object_scan_status CHECK (scan_status IN ('PENDING', 'CLEAN', 'REJECTED', 'NOT_CONFIGURED')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE file_link ( + id BIGINT NOT NULL AUTO_INCREMENT, + file_id BIGINT NOT NULL, + object_type VARCHAR(80) NOT NULL, + object_public_id CHAR(26) NOT NULL, + document_type VARCHAR(80) NOT NULL, + version_no INT NOT NULL DEFAULT 1, + active BOOLEAN NOT NULL DEFAULT TRUE, + linked_by CHAR(26) NOT NULL, + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_file_link_version (object_type, object_public_id, document_type, version_no), + KEY idx_file_link_file (file_id), + CONSTRAINT fk_file_link_file FOREIGN KEY (file_id) REFERENCES file_object (id), + CONSTRAINT ck_file_link_version CHECK (version_no > 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE sys_idempotency_record ( + id BIGINT NOT NULL AUTO_INCREMENT, + idempotency_key VARCHAR(100) NOT NULL, + user_public_id CHAR(26) NOT NULL, + command_name VARCHAR(100) NOT NULL, + request_hash CHAR(64) NOT NULL, + response_status INT NULL, + response_json JSON NULL, + state VARCHAR(20) NOT NULL, + expires_at TIMESTAMP(3) NOT NULL, + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_sys_idempotency_key (idempotency_key, user_public_id, command_name), + KEY idx_sys_idempotency_expiry (expires_at), + CONSTRAINT ck_sys_idempotency_state CHECK (state IN ('STARTED', 'COMPLETED', 'FAILED')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE sys_parameter_version ( + id BIGINT NOT NULL AUTO_INCREMENT, + public_id CHAR(26) NOT NULL, + parameter_group VARCHAR(80) NOT NULL, + version_no INT NOT NULL, + value_json JSON NOT NULL, + status VARCHAR(20) NOT NULL, + effective_at TIMESTAMP(3) NULL, + created_by CHAR(26) NOT NULL, + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_sys_parameter_public_id (public_id), + UNIQUE KEY uk_sys_parameter_version (parameter_group, version_no), + CONSTRAINT ck_sys_parameter_version CHECK (version_no > 0), + CONSTRAINT ck_sys_parameter_status CHECK (status IN ('DRAFT', 'ACTIVE', 'RETIRED')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; diff --git a/backend/src/main/resources/db/migration/V003__project_masterdata_source_workflow.sql b/backend/src/main/resources/db/migration/V003__project_masterdata_source_workflow.sql new file mode 100644 index 0000000..a4424fd --- /dev/null +++ b/backend/src/main/resources/db/migration/V003__project_masterdata_source_workflow.sql @@ -0,0 +1,551 @@ +CREATE TABLE md_company ( + id BIGINT NOT NULL AUTO_INCREMENT, + public_id CHAR(26) NOT NULL, + business_no VARCHAR(64) NOT NULL, + name VARCHAR(200) NOT NULL, + normalized_tax_no VARCHAR(64) NULL, + status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE', + version BIGINT NOT NULL DEFAULT 0, + created_by BIGINT NOT NULL, + updated_by BIGINT NOT NULL, + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_md_company_public_id (public_id), + UNIQUE KEY uk_md_company_business_no (business_no), + UNIQUE KEY uk_md_company_tax_no (normalized_tax_no), + KEY idx_md_company_name_status (name, status), + CONSTRAINT fk_md_company_created_by FOREIGN KEY (created_by) REFERENCES iam_user (id), + CONSTRAINT fk_md_company_updated_by FOREIGN KEY (updated_by) REFERENCES iam_user (id), + CONSTRAINT ck_md_company_status CHECK (status IN ('DRAFT', 'REVIEWING', 'RETURNED', 'ACTIVE', 'DISABLED')), + CONSTRAINT ck_md_company_version CHECK (version >= 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE md_counterparty ( + id BIGINT NOT NULL AUTO_INCREMENT, + public_id CHAR(26) NOT NULL, + business_no VARCHAR(64) NOT NULL, + counterparty_type VARCHAR(32) NOT NULL, + name VARCHAR(200) NOT NULL, + normalized_tax_no VARCHAR(64) NULL, + contact_name VARCHAR(100) NULL, + contact_phone VARCHAR(32) NULL, + status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE', + version BIGINT NOT NULL DEFAULT 0, + created_by BIGINT NOT NULL, + updated_by BIGINT NOT NULL, + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_md_counterparty_public_id (public_id), + UNIQUE KEY uk_md_counterparty_business_no (business_no), + UNIQUE KEY uk_md_counterparty_tax_no (normalized_tax_no), + KEY idx_md_counterparty_name_status (name, status), + CONSTRAINT fk_md_counterparty_created_by FOREIGN KEY (created_by) REFERENCES iam_user (id), + CONSTRAINT fk_md_counterparty_updated_by FOREIGN KEY (updated_by) REFERENCES iam_user (id), + CONSTRAINT ck_md_counterparty_type CHECK (counterparty_type IN ('CUSTOMER', 'SUPPLIER', 'BOTH')), + CONSTRAINT ck_md_counterparty_status CHECK (status IN ('DRAFT', 'REVIEWING', 'RETURNED', 'ACTIVE', 'DISABLED')), + CONSTRAINT ck_md_counterparty_version CHECK (version >= 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE md_project ( + id BIGINT NOT NULL AUTO_INCREMENT, + public_id CHAR(26) NOT NULL, + company_id BIGINT NOT NULL, + business_no VARCHAR(64) NOT NULL, + name VARCHAR(200) NOT NULL, + customer_id BIGINT NULL, + owner_user_id BIGINT NOT NULL, + start_date DATE NULL, + end_date DATE NULL, + currency CHAR(3) NOT NULL DEFAULT 'CNY', + stage VARCHAR(32) NOT NULL DEFAULT 'INITIATION', + finance_status VARCHAR(32) NOT NULL DEFAULT 'PENDING', + archive_status VARCHAR(32) NOT NULL DEFAULT 'NOT_READY', + status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE', + version BIGINT NOT NULL DEFAULT 0, + created_by BIGINT NOT NULL, + updated_by BIGINT NOT NULL, + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_md_project_public_id (public_id), + UNIQUE KEY uk_md_project_company_no (company_id, business_no), + KEY idx_md_project_company_status (company_id, status, updated_at), + KEY idx_md_project_owner_status (owner_user_id, status, updated_at), + KEY idx_md_project_customer (customer_id), + CONSTRAINT fk_md_project_company FOREIGN KEY (company_id) REFERENCES md_company (id), + CONSTRAINT fk_md_project_customer FOREIGN KEY (customer_id) REFERENCES md_counterparty (id), + CONSTRAINT fk_md_project_owner FOREIGN KEY (owner_user_id) REFERENCES iam_user (id), + CONSTRAINT fk_md_project_created_by FOREIGN KEY (created_by) REFERENCES iam_user (id), + CONSTRAINT fk_md_project_updated_by FOREIGN KEY (updated_by) REFERENCES iam_user (id), + CONSTRAINT ck_md_project_dates CHECK (end_date IS NULL OR start_date IS NULL OR end_date >= start_date), + CONSTRAINT ck_md_project_status CHECK (status IN ('DRAFT', 'ACTIVE', 'SUSPENDED', 'CLOSED', 'ARCHIVED')), + CONSTRAINT ck_md_project_stage CHECK (stage IN ('INITIATION', 'CONTRACTING', 'EXECUTION', 'ACCEPTANCE', 'SETTLEMENT', 'CLOSED')), + CONSTRAINT ck_md_project_finance_status CHECK (finance_status IN ('PENDING', 'IN_PROGRESS', 'COMPLETED', 'BLOCKED')), + CONSTRAINT ck_md_project_archive_status CHECK (archive_status IN ('NOT_READY', 'PREPARING', 'CHECKING', 'ARCHIVED', 'RETURNED', 'FROZEN')), + CONSTRAINT ck_md_project_version CHECK (version >= 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE project_risk_flag ( + id BIGINT NOT NULL AUTO_INCREMENT, + public_id CHAR(26) NOT NULL, + project_id BIGINT NOT NULL, + risk_type VARCHAR(32) NOT NULL, + reason VARCHAR(1000) NOT NULL, + evidence_file_link_id BIGINT NULL, + effective_from TIMESTAMP(3) NOT NULL, + effective_until TIMESTAMP(3) NULL, + blocking BOOLEAN NOT NULL DEFAULT TRUE, + status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE', + version BIGINT NOT NULL DEFAULT 0, + resolved_reason VARCHAR(1000) NULL, + resolved_by BIGINT NULL, + resolved_at TIMESTAMP(3) NULL, + created_by BIGINT NOT NULL, + updated_by BIGINT NOT NULL, + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_project_risk_public_id (public_id), + KEY idx_project_risk_active (project_id, status, effective_from, effective_until), + CONSTRAINT fk_project_risk_project FOREIGN KEY (project_id) REFERENCES md_project (id), + CONSTRAINT fk_project_risk_file FOREIGN KEY (evidence_file_link_id) REFERENCES file_link (id), + CONSTRAINT fk_project_risk_resolved_by FOREIGN KEY (resolved_by) REFERENCES iam_user (id), + CONSTRAINT fk_project_risk_created_by FOREIGN KEY (created_by) REFERENCES iam_user (id), + CONSTRAINT fk_project_risk_updated_by FOREIGN KEY (updated_by) REFERENCES iam_user (id), + CONSTRAINT ck_project_risk_period CHECK (effective_until IS NULL OR effective_until >= effective_from), + CONSTRAINT ck_project_risk_status CHECK (status IN ('ACTIVE', 'RESOLVED', 'EXPIRED')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE md_bank_account_version ( + id BIGINT NOT NULL AUTO_INCREMENT, + public_id CHAR(26) NOT NULL, + owner_type VARCHAR(32) NOT NULL, + company_id BIGINT NULL, + counterparty_id BIGINT NULL, + account_category VARCHAR(32) NOT NULL, + account_name VARCHAR(200) NOT NULL, + bank_name VARCHAR(200) NOT NULL, + account_no_ciphertext VARBINARY(1024) NOT NULL, + account_no_hash CHAR(64) NOT NULL, + masked_account_no VARCHAR(64) NOT NULL, + version_no INT NOT NULL, + valid_from TIMESTAMP(3) NOT NULL, + valid_to TIMESTAMP(3) NULL, + status VARCHAR(32) NOT NULL DEFAULT 'DRAFT', + version BIGINT NOT NULL DEFAULT 0, + created_by BIGINT NOT NULL, + updated_by BIGINT NOT NULL, + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + owner_key VARCHAR(64) GENERATED ALWAYS AS ( + CASE WHEN company_id IS NOT NULL THEN CONCAT('C:', company_id) ELSE CONCAT('P:', counterparty_id) END + ) STORED, + PRIMARY KEY (id), + UNIQUE KEY uk_md_bank_account_public_id (public_id), + UNIQUE KEY uk_md_bank_account_version (owner_key, account_no_hash, version_no), + KEY idx_md_bank_account_company (company_id, status), + KEY idx_md_bank_account_counterparty (counterparty_id, status), + CONSTRAINT fk_md_bank_account_company FOREIGN KEY (company_id) REFERENCES md_company (id), + CONSTRAINT fk_md_bank_account_counterparty FOREIGN KEY (counterparty_id) REFERENCES md_counterparty (id), + CONSTRAINT fk_md_bank_account_created_by FOREIGN KEY (created_by) REFERENCES iam_user (id), + CONSTRAINT fk_md_bank_account_updated_by FOREIGN KEY (updated_by) REFERENCES iam_user (id), + CONSTRAINT ck_md_bank_account_owner CHECK ((company_id IS NOT NULL) <> (counterparty_id IS NOT NULL)), + CONSTRAINT ck_md_bank_account_version_no CHECK (version_no > 0), + CONSTRAINT ck_md_bank_account_period CHECK (valid_to IS NULL OR valid_to >= valid_from), + CONSTRAINT ck_md_bank_account_status CHECK (status IN ('DRAFT', 'REVIEWING', 'RETURNED', 'ACTIVE', 'DISABLED')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE md_contract ( + id BIGINT NOT NULL AUTO_INCREMENT, + public_id CHAR(26) NOT NULL, + company_id BIGINT NOT NULL, + project_id BIGINT NOT NULL, + counterparty_id BIGINT NOT NULL, + business_no VARCHAR(64) NOT NULL, + name VARCHAR(200) NOT NULL, + original_amount DECIMAL(20, 2) NOT NULL, + approved_change_amount DECIMAL(20, 2) NOT NULL DEFAULT 0, + settlement_amount DECIMAL(20, 2) NULL, + currency CHAR(3) NOT NULL DEFAULT 'CNY', + status VARCHAR(32) NOT NULL DEFAULT 'DRAFT', + version BIGINT NOT NULL DEFAULT 0, + created_by BIGINT NOT NULL, + updated_by BIGINT NOT NULL, + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_md_contract_public_id (public_id), + UNIQUE KEY uk_md_contract_company_no (company_id, business_no), + KEY idx_md_contract_project_status (project_id, status), + KEY idx_md_contract_counterparty (counterparty_id, status), + CONSTRAINT fk_md_contract_company FOREIGN KEY (company_id) REFERENCES md_company (id), + CONSTRAINT fk_md_contract_project FOREIGN KEY (project_id) REFERENCES md_project (id), + CONSTRAINT fk_md_contract_counterparty FOREIGN KEY (counterparty_id) REFERENCES md_counterparty (id), + CONSTRAINT fk_md_contract_created_by FOREIGN KEY (created_by) REFERENCES iam_user (id), + CONSTRAINT fk_md_contract_updated_by FOREIGN KEY (updated_by) REFERENCES iam_user (id), + CONSTRAINT ck_md_contract_amounts CHECK (original_amount >= 0 AND approved_change_amount >= 0 AND (settlement_amount IS NULL OR settlement_amount >= 0)), + CONSTRAINT ck_md_contract_status CHECK (status IN ('DRAFT', 'REVIEWING', 'RETURNED', 'ACTIVE', 'SETTLED', 'DISABLED', 'VOID')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE md_cost_category ( + id BIGINT NOT NULL AUTO_INCREMENT, + public_id CHAR(26) NOT NULL, + code VARCHAR(64) NOT NULL, + name VARCHAR(128) NOT NULL, + parent_id BIGINT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE', + version BIGINT NOT NULL DEFAULT 0, + created_by BIGINT NULL, + updated_by BIGINT NULL, + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_md_cost_category_public_id (public_id), + UNIQUE KEY uk_md_cost_category_code (code), + KEY idx_md_cost_category_parent (parent_id, status), + CONSTRAINT fk_md_cost_category_parent FOREIGN KEY (parent_id) REFERENCES md_cost_category (id), + CONSTRAINT fk_md_cost_category_created_by FOREIGN KEY (created_by) REFERENCES iam_user (id), + CONSTRAINT fk_md_cost_category_updated_by FOREIGN KEY (updated_by) REFERENCES iam_user (id), + CONSTRAINT ck_md_cost_category_status CHECK (status IN ('ACTIVE', 'DISABLED')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE src_template ( + id BIGINT NOT NULL AUTO_INCREMENT, + public_id CHAR(26) NOT NULL, + form_type VARCHAR(16) NOT NULL, + template_version INT NOT NULL, + name VARCHAR(200) NOT NULL, + schema_version INT NOT NULL DEFAULT 1, + status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE', + published_at TIMESTAMP(3) NULL, + version BIGINT NOT NULL DEFAULT 0, + created_by BIGINT NULL, + updated_by BIGINT NULL, + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_src_template_public_id (public_id), + UNIQUE KEY uk_src_template_type_version (form_type, template_version), + KEY idx_src_template_status (form_type, status), + CONSTRAINT fk_src_template_created_by FOREIGN KEY (created_by) REFERENCES iam_user (id), + CONSTRAINT fk_src_template_updated_by FOREIGN KEY (updated_by) REFERENCES iam_user (id), + CONSTRAINT ck_src_template_versions CHECK (template_version > 0 AND schema_version > 0), + CONSTRAINT ck_src_template_status CHECK (status IN ('DRAFT', 'ACTIVE', 'RETIRED')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE src_template_field ( + id BIGINT NOT NULL AUTO_INCREMENT, + template_id BIGINT NOT NULL, + field_code VARCHAR(64) NOT NULL, + label VARCHAR(200) NOT NULL, + data_type VARCHAR(32) NOT NULL, + max_length INT NULL, + numeric_precision INT NULL, + numeric_scale INT NULL, + required_when_json JSON NULL, + enum_code VARCHAR(64) NULL, + sensitivity VARCHAR(32) NOT NULL DEFAULT 'NORMAL', + mapping_code VARCHAR(100) NULL, + sort_order INT NOT NULL DEFAULT 0, + PRIMARY KEY (id), + UNIQUE KEY uk_src_template_field (template_id, field_code), + KEY idx_src_template_field_order (template_id, sort_order), + CONSTRAINT fk_src_template_field_template FOREIGN KEY (template_id) REFERENCES src_template (id), + CONSTRAINT ck_src_template_field_type CHECK (data_type IN ('TEXT', 'LONG_TEXT', 'INTEGER', 'DECIMAL', 'DATE', 'DATETIME', 'BOOLEAN', 'ENUM', 'REFERENCE', 'FILE')), + CONSTRAINT ck_src_template_field_sensitivity CHECK (sensitivity IN ('NORMAL', 'INTERNAL', 'SENSITIVE', 'RESTRICTED')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE src_import_batch ( + id BIGINT NOT NULL AUTO_INCREMENT, + public_id CHAR(26) NOT NULL, + batch_no VARCHAR(64) NOT NULL, + form_type VARCHAR(16) NOT NULL, + source_system VARCHAR(32) NOT NULL, + source_file_id BIGINT NOT NULL, + file_sha256 CHAR(64) NOT NULL, + total_rows INT NOT NULL DEFAULT 0, + valid_rows INT NOT NULL DEFAULT 0, + error_rows INT NOT NULL DEFAULT 0, + validation_json JSON NULL, + status VARCHAR(32) NOT NULL DEFAULT 'VALIDATING', + version BIGINT NOT NULL DEFAULT 0, + created_by BIGINT NOT NULL, + updated_by BIGINT NOT NULL, + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_src_import_batch_public_id (public_id), + UNIQUE KEY uk_src_import_batch_no (batch_no), + KEY idx_src_import_batch_hash (form_type, source_system, file_sha256), + KEY idx_src_import_batch_status (status, created_at), + CONSTRAINT fk_src_import_batch_file FOREIGN KEY (source_file_id) REFERENCES file_object (id), + CONSTRAINT fk_src_import_batch_created_by FOREIGN KEY (created_by) REFERENCES iam_user (id), + CONSTRAINT fk_src_import_batch_updated_by FOREIGN KEY (updated_by) REFERENCES iam_user (id), + CONSTRAINT ck_src_import_counts CHECK (total_rows >= 0 AND valid_rows >= 0 AND error_rows >= 0 AND valid_rows + error_rows <= total_rows), + CONSTRAINT ck_src_import_status CHECK (status IN ('VALIDATING', 'INVALID', 'READY', 'CONFIRMED', 'CANCELLED')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE src_source_document ( + id BIGINT NOT NULL AUTO_INCREMENT, + public_id CHAR(26) NOT NULL, + business_no VARCHAR(64) NOT NULL, + form_type VARCHAR(16) NOT NULL, + source_system VARCHAR(32) NOT NULL, + source_no VARCHAR(100) NOT NULL, + normalized_source_no VARCHAR(100) NOT NULL, + source_version VARCHAR(32) NOT NULL, + normalized_source_version VARCHAR(32) NOT NULL, + template_id BIGINT NOT NULL, + company_id BIGINT NOT NULL, + project_id BIGINT NULL, + current_version_id BIGINT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'DRAFT', + version BIGINT NOT NULL DEFAULT 0, + created_by BIGINT NOT NULL, + updated_by BIGINT NOT NULL, + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_src_document_public_id (public_id), + UNIQUE KEY uk_src_document_business_no (business_no), + UNIQUE KEY uk_src_document_source (form_type, source_system, normalized_source_no, normalized_source_version), + KEY idx_src_document_scope_status (company_id, project_id, status), + KEY idx_src_document_type_status (form_type, status), + CONSTRAINT fk_src_document_template FOREIGN KEY (template_id) REFERENCES src_template (id), + CONSTRAINT fk_src_document_company FOREIGN KEY (company_id) REFERENCES md_company (id), + CONSTRAINT fk_src_document_project FOREIGN KEY (project_id) REFERENCES md_project (id), + CONSTRAINT fk_src_document_created_by FOREIGN KEY (created_by) REFERENCES iam_user (id), + CONSTRAINT fk_src_document_updated_by FOREIGN KEY (updated_by) REFERENCES iam_user (id), + CONSTRAINT ck_src_document_status CHECK (status IN ('DRAFT', 'SUBMITTED', 'PROJECT_REVIEW', 'FINANCE_REVIEW', 'RETURNED', 'REJECTED', 'APPROVED', 'RESULT_PENDING', 'ARCHIVE_PENDING', 'ARCHIVED', 'VOID')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE src_document_version ( + id BIGINT NOT NULL AUTO_INCREMENT, + public_id CHAR(26) NOT NULL, + source_document_id BIGINT NOT NULL, + version_no INT NOT NULL, + previous_version_id BIGINT NULL, + template_id BIGINT NOT NULL, + snapshot_sha256 CHAR(64) NOT NULL, + submitted_at TIMESTAMP(3) NULL, + created_by BIGINT NOT NULL, + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_src_document_version_public_id (public_id), + UNIQUE KEY uk_src_document_version_no (source_document_id, version_no), + CONSTRAINT fk_src_version_document FOREIGN KEY (source_document_id) REFERENCES src_source_document (id), + CONSTRAINT fk_src_version_previous FOREIGN KEY (previous_version_id) REFERENCES src_document_version (id), + CONSTRAINT fk_src_version_template FOREIGN KEY (template_id) REFERENCES src_template (id), + CONSTRAINT fk_src_version_created_by FOREIGN KEY (created_by) REFERENCES iam_user (id), + CONSTRAINT ck_src_document_version_no CHECK (version_no > 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +ALTER TABLE src_source_document + ADD CONSTRAINT fk_src_document_current_version FOREIGN KEY (current_version_id) REFERENCES src_document_version (id); + +CREATE TABLE src_field_value ( + id BIGINT NOT NULL AUTO_INCREMENT, + document_version_id BIGINT NOT NULL, + field_code VARCHAR(64) NOT NULL, + row_no INT NOT NULL DEFAULT 0, + typed_value_json JSON NOT NULL, + schema_version INT NOT NULL DEFAULT 1, + PRIMARY KEY (id), + UNIQUE KEY uk_src_field_value (document_version_id, field_code, row_no), + KEY idx_src_field_value_code (field_code), + CONSTRAINT fk_src_field_value_version FOREIGN KEY (document_version_id) REFERENCES src_document_version (id), + CONSTRAINT ck_src_field_value_row CHECK (row_no >= 0), + CONSTRAINT ck_src_field_value_schema CHECK (schema_version > 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE src_approval_snapshot ( + id BIGINT NOT NULL AUTO_INCREMENT, + public_id CHAR(26) NOT NULL, + document_version_id BIGINT NOT NULL, + node_code VARCHAR(64) NOT NULL, + actor_name VARCHAR(100) NOT NULL, + actor_id BIGINT NULL, + action VARCHAR(32) NOT NULL, + opinion VARCHAR(1000) NULL, + acted_at TIMESTAMP(3) NOT NULL, + source_snapshot_json JSON NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_src_approval_public_id (public_id), + UNIQUE KEY uk_src_approval_action (document_version_id, node_code, action, acted_at), + KEY idx_src_approval_timeline (document_version_id, acted_at), + CONSTRAINT fk_src_approval_version FOREIGN KEY (document_version_id) REFERENCES src_document_version (id), + CONSTRAINT fk_src_approval_actor FOREIGN KEY (actor_id) REFERENCES iam_user (id), + CONSTRAINT ck_src_approval_action CHECK (action IN ('SUBMIT', 'APPROVE', 'RETURN', 'REJECT', 'WITHDRAW', 'VOID', 'IMPORT')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE wf_definition ( + id BIGINT NOT NULL AUTO_INCREMENT, + public_id CHAR(26) NOT NULL, + code VARCHAR(64) NOT NULL, + name VARCHAR(200) NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE', + version BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_wf_definition_public_id (public_id), + UNIQUE KEY uk_wf_definition_code (code), + CONSTRAINT ck_wf_definition_status CHECK (status IN ('ACTIVE', 'DISABLED')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE wf_definition_version ( + id BIGINT NOT NULL AUTO_INCREMENT, + public_id CHAR(26) NOT NULL, + definition_id BIGINT NOT NULL, + version_no INT NOT NULL, + definition_json JSON NOT NULL, + schema_version INT NOT NULL DEFAULT 1, + status VARCHAR(32) NOT NULL DEFAULT 'DRAFT', + published_at TIMESTAMP(3) NULL, + created_by BIGINT NULL, + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_wf_definition_version_public_id (public_id), + UNIQUE KEY uk_wf_definition_version_no (definition_id, version_no), + KEY idx_wf_definition_version_status (definition_id, status), + CONSTRAINT fk_wf_definition_version_def FOREIGN KEY (definition_id) REFERENCES wf_definition (id), + CONSTRAINT fk_wf_definition_version_user FOREIGN KEY (created_by) REFERENCES iam_user (id), + CONSTRAINT ck_wf_definition_version_numbers CHECK (version_no > 0 AND schema_version > 0), + CONSTRAINT ck_wf_definition_version_status CHECK (status IN ('DRAFT', 'ACTIVE', 'RETIRED')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE wf_instance ( + id BIGINT NOT NULL AUTO_INCREMENT, + public_id CHAR(26) NOT NULL, + definition_version_id BIGINT NOT NULL, + source_version_id BIGINT NOT NULL, + current_node_code VARCHAR(64) NULL, + status VARCHAR(32) NOT NULL DEFAULT 'RUNNING', + version BIGINT NOT NULL DEFAULT 0, + created_by BIGINT NOT NULL, + updated_by BIGINT NOT NULL, + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + active_source_version_id BIGINT GENERATED ALWAYS AS ( + CASE WHEN status IN ('RUNNING', 'RETURNED') THEN source_version_id ELSE NULL END + ) STORED, + PRIMARY KEY (id), + UNIQUE KEY uk_wf_instance_public_id (public_id), + UNIQUE KEY uk_wf_instance_active_source (active_source_version_id), + KEY idx_wf_instance_source (source_version_id, status), + CONSTRAINT fk_wf_instance_definition FOREIGN KEY (definition_version_id) REFERENCES wf_definition_version (id), + CONSTRAINT fk_wf_instance_source FOREIGN KEY (source_version_id) REFERENCES src_document_version (id), + CONSTRAINT fk_wf_instance_created_by FOREIGN KEY (created_by) REFERENCES iam_user (id), + CONSTRAINT fk_wf_instance_updated_by FOREIGN KEY (updated_by) REFERENCES iam_user (id), + CONSTRAINT ck_wf_instance_status CHECK (status IN ('RUNNING', 'RETURNED', 'APPROVED', 'REJECTED', 'WITHDRAWN', 'VOID')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE wf_task ( + id BIGINT NOT NULL AUTO_INCREMENT, + public_id CHAR(26) NOT NULL, + instance_id BIGINT NOT NULL, + node_code VARCHAR(64) NOT NULL, + sequence_no INT NOT NULL, + assignee_id BIGINT NOT NULL, + due_at TIMESTAMP(3) NULL, + status VARCHAR(32) NOT NULL DEFAULT 'PENDING', + version BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_wf_task_public_id (public_id), + UNIQUE KEY uk_wf_task_node_sequence (instance_id, node_code, sequence_no), + KEY idx_wf_task_assignee (assignee_id, status, due_at), + KEY idx_wf_task_instance (instance_id, status), + CONSTRAINT fk_wf_task_instance FOREIGN KEY (instance_id) REFERENCES wf_instance (id), + CONSTRAINT fk_wf_task_assignee FOREIGN KEY (assignee_id) REFERENCES iam_user (id), + CONSTRAINT ck_wf_task_sequence CHECK (sequence_no > 0), + CONSTRAINT ck_wf_task_status CHECK (status IN ('PENDING', 'APPROVED', 'RETURNED', 'REJECTED', 'CANCELLED')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE wf_action ( + id BIGINT NOT NULL AUTO_INCREMENT, + public_id CHAR(26) NOT NULL, + task_id BIGINT NOT NULL, + sequence_no INT NOT NULL, + action VARCHAR(32) NOT NULL, + opinion VARCHAR(1000) NOT NULL, + return_points_json JSON NULL, + actor_id BIGINT NOT NULL, + acted_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + request_id CHAR(26) NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_wf_action_public_id (public_id), + UNIQUE KEY uk_wf_action_task_sequence (task_id, sequence_no), + UNIQUE KEY uk_wf_action_request (request_id, task_id), + CONSTRAINT fk_wf_action_task FOREIGN KEY (task_id) REFERENCES wf_task (id), + CONSTRAINT fk_wf_action_actor FOREIGN KEY (actor_id) REFERENCES iam_user (id), + CONSTRAINT ck_wf_action_sequence CHECK (sequence_no > 0), + CONSTRAINT ck_wf_action_type CHECK (action IN ('APPROVE', 'RETURN', 'REJECT', 'REASSIGN', 'CANCEL')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +INSERT INTO md_cost_category (public_id, code, name) VALUES + ('00000000000000000000000001', 'LABOR', '人工费'), + ('00000000000000000000000002', 'MATERIAL', '材料费'), + ('00000000000000000000000003', 'MACHINERY', '机械费'), + ('00000000000000000000000004', 'SUBCONTRACT', '专业分包'), + ('00000000000000000000000005', 'TRANSPORT', '运输费'), + ('00000000000000000000000006', 'SERVICE', '业务服务费'), + ('00000000000000000000000007', 'TEMPORARY_LABOR', '临时用工'), + ('00000000000000000000000008', 'EQUIPMENT', '设备采购'); + +INSERT INTO src_template (public_id, form_type, template_version, name, status, published_at) VALUES + ('00000000000000000000000101', 'OA-01', 1, '项目立项审批表', 'ACTIVE', UTC_TIMESTAMP(3)), + ('00000000000000000000000102', 'OA-02', 1, '供应商准入申请', 'ACTIVE', UTC_TIMESTAMP(3)), + ('00000000000000000000000103', 'OA-03', 1, '项目成本合同会签审批单', 'ACTIVE', UTC_TIMESTAMP(3)), + ('00000000000000000000000104', 'OA-04', 1, '人材机合同应付资金确认单', 'ACTIVE', UTC_TIMESTAMP(3)), + ('00000000000000000000000105', 'OA-05', 1, '项目收款确认单', 'ACTIVE', UTC_TIMESTAMP(3)), + ('00000000000000000000000106', 'OA-06', 1, '项目进度款支付', 'ACTIVE', UTC_TIMESTAMP(3)), + ('00000000000000000000000107', 'OA-07', 1, '银行账户开户审批及账户信息表', 'ACTIVE', UTC_TIMESTAMP(3)), + ('00000000000000000000000108', 'OA-08', 1, '发票开具申请表', 'ACTIVE', UTC_TIMESTAMP(3)), + ('00000000000000000000000109', 'OA-09', 1, '农民工班组实名入场', 'ACTIVE', UTC_TIMESTAMP(3)), + ('00000000000000000000000110', 'OA-10', 1, '项目履约进度申报表', 'ACTIVE', UTC_TIMESTAMP(3)), + ('00000000000000000000000111', 'OA-11', 1, '项目成本合同结算审批单', 'ACTIVE', UTC_TIMESTAMP(3)), + ('00000000000000000000000112', 'OA-12', 1, '项目验收信息登记表', 'ACTIVE', UTC_TIMESTAMP(3)), + ('00000000000000000000000113', 'OA-13', 1, '项目押证费用收取明细表', 'ACTIVE', UTC_TIMESTAMP(3)), + ('00000000000000000000000114', 'OA-14', 1, '中标项目人员信息表', 'ACTIVE', UTC_TIMESTAMP(3)); + +INSERT INTO iam_permission (code, name) VALUES + ('project:project:view', '查看项目'), + ('project:project:create', '创建项目'), + ('project:project:edit', '编辑项目'), + ('masterdata:company:view', '查看公司'), + ('masterdata:company:create', '创建公司'), + ('masterdata:company:edit', '编辑公司'), + ('masterdata:counterparty:view', '查看往来单位'), + ('masterdata:counterparty:create', '创建往来单位'), + ('masterdata:counterparty:edit', '编辑往来单位'); + +INSERT INTO iam_role_permission (role_id, permission_id) +SELECT r.id, p.id FROM iam_role r JOIN iam_permission p +WHERE r.code = 'PROJECT' AND p.code IN ( + 'project:project:view', 'project:project:create', 'project:project:edit', + 'masterdata:company:view', 'masterdata:counterparty:view' +); + +INSERT INTO iam_role_permission (role_id, permission_id) +SELECT r.id, p.id FROM iam_role r JOIN iam_permission p +WHERE r.code = 'FINANCE' AND p.code IN ( + 'project:project:view', 'project:project:create', 'project:project:edit', + 'masterdata:company:view', 'masterdata:company:create', 'masterdata:company:edit', + 'masterdata:counterparty:view', 'masterdata:counterparty:create', 'masterdata:counterparty:edit' +); + +INSERT INTO iam_role_permission (role_id, permission_id) +SELECT r.id, p.id FROM iam_role r JOIN iam_permission p +WHERE r.code = 'ARCHIVE' AND p.code = 'project:project:view'; + +INSERT INTO iam_role_permission (role_id, permission_id) +SELECT r.id, p.id FROM iam_role r CROSS JOIN iam_permission p +WHERE r.code = 'ADMIN' AND p.code IN ( + 'project:project:view', 'project:project:create', 'project:project:edit', + 'masterdata:company:view', 'masterdata:company:create', 'masterdata:company:edit', + 'masterdata:counterparty:view', 'masterdata:counterparty:create', 'masterdata:counterparty:edit' +); diff --git a/backend/src/main/resources/db/migration/V004__authorization_scope_and_masterdata_permissions.sql b/backend/src/main/resources/db/migration/V004__authorization_scope_and_masterdata_permissions.sql new file mode 100644 index 0000000..7ac1a4a --- /dev/null +++ b/backend/src/main/resources/db/migration/V004__authorization_scope_and_masterdata_permissions.sql @@ -0,0 +1,120 @@ +CREATE TABLE iam_scope_v2 ( + id BIGINT NOT NULL AUTO_INCREMENT, + user_id BIGINT NOT NULL, + role_id BIGINT NOT NULL, + permission_id BIGINT NOT NULL, + scope_type VARCHAR(16) NOT NULL, + company_public_id CHAR(26) NULL, + project_public_id CHAR(26) NULL, + amount_limit DECIMAL(20, 2) NULL, + valid_from TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + valid_to TIMESTAMP(3) NULL, + status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE', + version BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + scope_key VARCHAR(80) GENERATED ALWAYS AS ( + CASE scope_type + WHEN 'GLOBAL' THEN 'GLOBAL' + WHEN 'COMPANY' THEN CONCAT('COMPANY:', company_public_id) + WHEN 'PROJECT' THEN CONCAT('PROJECT:', project_public_id) + END + ) STORED, + PRIMARY KEY (id), + UNIQUE KEY uk_iam_scope_v2_grant (user_id, role_id, permission_id, scope_key), + KEY idx_iam_scope_v2_lookup (user_id, role_id, permission_id, status), + KEY idx_iam_scope_v2_company (company_public_id, status), + KEY idx_iam_scope_v2_project (project_public_id, status), + CONSTRAINT fk_iam_scope_v2_user FOREIGN KEY (user_id) REFERENCES iam_user (id), + CONSTRAINT fk_iam_scope_v2_role FOREIGN KEY (role_id) REFERENCES iam_role (id), + CONSTRAINT fk_iam_scope_v2_permission FOREIGN KEY (permission_id) REFERENCES iam_permission (id), + CONSTRAINT ck_iam_scope_v2_type CHECK (scope_type IN ('GLOBAL', 'COMPANY', 'PROJECT')), + CONSTRAINT ck_iam_scope_v2_target CHECK ( + (scope_type = 'GLOBAL' AND company_public_id IS NULL AND project_public_id IS NULL) + OR (scope_type = 'COMPANY' AND company_public_id IS NOT NULL AND project_public_id IS NULL) + OR (scope_type = 'PROJECT' AND project_public_id IS NOT NULL) + ), + CONSTRAINT ck_iam_scope_v2_amount CHECK (amount_limit IS NULL OR amount_limit >= 0), + CONSTRAINT ck_iam_scope_v2_period CHECK (valid_to IS NULL OR valid_to >= valid_from), + CONSTRAINT ck_iam_scope_v2_status CHECK (status IN ('ACTIVE', 'DISABLED')), + CONSTRAINT ck_iam_scope_v2_version CHECK (version >= 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +INSERT INTO iam_scope_v2 ( + user_id, role_id, permission_id, scope_type, company_public_id, project_public_id, + amount_limit, valid_from +) +SELECT legacy.user_id, + legacy.role_id, + role_permission.permission_id, + CASE + WHEN legacy.project_public_id IS NOT NULL THEN 'PROJECT' + WHEN legacy.company_public_id IS NOT NULL THEN 'COMPANY' + ELSE 'GLOBAL' + END, + legacy.company_public_id, + legacy.project_public_id, + legacy.amount_limit, + legacy.created_at +FROM iam_scope legacy +JOIN iam_role_permission role_permission ON role_permission.role_id = legacy.role_id; + +DROP TABLE iam_scope; +RENAME TABLE iam_scope_v2 TO iam_scope; + +UPDATE iam_role SET code = 'PROJECT_MANAGER' WHERE code = 'PROJECT'; +UPDATE iam_role SET code = 'FINANCE_MANAGER' WHERE code = 'FINANCE'; +UPDATE iam_role SET code = 'ARCHIVE_MANAGER' WHERE code = 'ARCHIVE'; + +INSERT IGNORE INTO iam_permission (code, name) VALUES + ('project:project:submit', '提交项目'), + ('project:project:export', '导出项目'), + ('project:risk-flag:view', '查看项目风险标记'), + ('project:risk-flag:create', '创建项目风险标记'), + ('project:risk-flag:resolve', '解除项目风险标记'), + ('masterdata:company:submit', '提交公司复核'), + ('masterdata:company:review', '复核公司'), + ('masterdata:company:disable', '停用公司'), + ('masterdata:counterparty:submit', '提交往来单位复核'), + ('masterdata:counterparty:review', '复核往来单位'), + ('masterdata:counterparty:disable', '停用往来单位'); + +INSERT INTO iam_role_permission (role_id, permission_id) +SELECT role.id, permission.id +FROM iam_role role +JOIN iam_permission permission +WHERE role.code = 'PROJECT_MANAGER' + AND permission.code IN ( + 'project:project:submit', 'project:project:export', 'project:risk-flag:view' + ) + AND NOT EXISTS ( + SELECT 1 FROM iam_role_permission existing + WHERE existing.role_id = role.id AND existing.permission_id = permission.id + ); + +INSERT INTO iam_role_permission (role_id, permission_id) +SELECT role.id, permission.id +FROM iam_role role +JOIN iam_permission permission +WHERE role.code = 'FINANCE_MANAGER' + AND permission.code IN ( + 'project:project:submit', 'project:project:export', + 'project:risk-flag:view', 'project:risk-flag:create', 'project:risk-flag:resolve', + 'masterdata:company:submit', 'masterdata:company:review', 'masterdata:company:disable', + 'masterdata:counterparty:submit', 'masterdata:counterparty:review', + 'masterdata:counterparty:disable' + ) + AND NOT EXISTS ( + SELECT 1 FROM iam_role_permission existing + WHERE existing.role_id = role.id AND existing.permission_id = permission.id + ); + +INSERT INTO iam_role_permission (role_id, permission_id) +SELECT role.id, permission.id +FROM iam_role role +JOIN iam_permission permission +WHERE role.code = 'ADMIN' + AND NOT EXISTS ( + SELECT 1 FROM iam_role_permission existing + WHERE existing.role_id = role.id AND existing.permission_id = permission.id + ); diff --git a/backend/src/main/resources/db/migration/V005__masterdata_review_metadata.sql b/backend/src/main/resources/db/migration/V005__masterdata_review_metadata.sql new file mode 100644 index 0000000..f46b296 --- /dev/null +++ b/backend/src/main/resources/db/migration/V005__masterdata_review_metadata.sql @@ -0,0 +1,17 @@ +ALTER TABLE md_company + ADD COLUMN submitted_by BIGINT NULL AFTER updated_by, + ADD COLUMN submitted_at TIMESTAMP(3) NULL AFTER submitted_by, + ADD COLUMN reviewed_by BIGINT NULL AFTER submitted_at, + ADD COLUMN reviewed_at TIMESTAMP(3) NULL AFTER reviewed_by, + ADD COLUMN review_opinion VARCHAR(1000) NULL AFTER reviewed_at, + ADD CONSTRAINT fk_md_company_submitted_by FOREIGN KEY (submitted_by) REFERENCES iam_user (id), + ADD CONSTRAINT fk_md_company_reviewed_by FOREIGN KEY (reviewed_by) REFERENCES iam_user (id); + +ALTER TABLE md_counterparty + ADD COLUMN submitted_by BIGINT NULL AFTER updated_by, + ADD COLUMN submitted_at TIMESTAMP(3) NULL AFTER submitted_by, + ADD COLUMN reviewed_by BIGINT NULL AFTER submitted_at, + ADD COLUMN reviewed_at TIMESTAMP(3) NULL AFTER reviewed_by, + ADD COLUMN review_opinion VARCHAR(1000) NULL AFTER reviewed_at, + ADD CONSTRAINT fk_md_counterparty_submitted_by FOREIGN KEY (submitted_by) REFERENCES iam_user (id), + ADD CONSTRAINT fk_md_counterparty_reviewed_by FOREIGN KEY (reviewed_by) REFERENCES iam_user (id); diff --git a/backend/src/main/resources/db/migration/V006__file_scan_state_policy.sql b/backend/src/main/resources/db/migration/V006__file_scan_state_policy.sql new file mode 100644 index 0000000..2fab99c --- /dev/null +++ b/backend/src/main/resources/db/migration/V006__file_scan_state_policy.sql @@ -0,0 +1,17 @@ +ALTER TABLE file_object + DROP CHECK ck_file_object_scan_status, + ADD COLUMN scan_detail VARCHAR(500) NULL AFTER scan_status, + ADD COLUMN scanned_at TIMESTAMP(3) NULL AFTER scan_detail; + +UPDATE file_object +SET scan_status = CASE scan_status + WHEN 'CLEAN' THEN 'AVAILABLE' + WHEN 'PENDING' THEN 'SCANNING' + WHEN 'NOT_CONFIGURED' THEN 'QUARANTINED' + ELSE scan_status +END, +scanned_at = COALESCE(scanned_at, created_at); + +ALTER TABLE file_object + ADD CONSTRAINT ck_file_object_scan_status + CHECK (scan_status IN ('SCANNING', 'AVAILABLE', 'REJECTED', 'QUARANTINED', 'SUPERSEDED')); diff --git a/backend/src/main/resources/db/migration/V007__project_management_foundation.sql b/backend/src/main/resources/db/migration/V007__project_management_foundation.sql new file mode 100644 index 0000000..4e0be46 --- /dev/null +++ b/backend/src/main/resources/db/migration/V007__project_management_foundation.sql @@ -0,0 +1,42 @@ +ALTER TABLE md_project + ADD COLUMN description VARCHAR(1000) NULL AFTER name, + ADD KEY idx_md_project_filters ( + company_id, stage, finance_status, archive_status, status, updated_at + ), + ADD KEY idx_md_project_dates (start_date, end_date); + +CREATE TABLE project_fund_control ( + id BIGINT NOT NULL AUTO_INCREMENT, + project_id BIGINT NOT NULL, + currency CHAR(3) NOT NULL, + confirmed_receipt DECIMAL(20, 2) NOT NULL DEFAULT 0, + confirmed_deduction DECIMAL(20, 2) NOT NULL DEFAULT 0, + approved_adjustment DECIMAL(20, 2) NOT NULL DEFAULT 0, + frozen_amount DECIMAL(20, 2) NOT NULL DEFAULT 0, + approved_unpaid DECIMAL(20, 2) NOT NULL DEFAULT 0, + paid_amount DECIMAL(20, 2) NOT NULL DEFAULT 0, + version BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_project_fund_control_project_currency (project_id, currency), + CONSTRAINT fk_project_fund_control_project FOREIGN KEY (project_id) REFERENCES md_project (id), + CONSTRAINT ck_project_fund_control_amounts CHECK ( + confirmed_receipt >= 0 + AND confirmed_deduction >= 0 + AND approved_adjustment >= 0 + AND frozen_amount >= 0 + AND approved_unpaid >= 0 + AND paid_amount >= 0 + ), + CONSTRAINT ck_project_fund_control_version CHECK (version >= 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +INSERT INTO project_fund_control (project_id, currency) +SELECT project.id, project.currency +FROM md_project project +WHERE NOT EXISTS ( + SELECT 1 + FROM project_fund_control existing + WHERE existing.project_id = project.id AND existing.currency = project.currency +); diff --git a/backend/src/main/resources/db/migration/V008__isolate_system_admin_role.sql b/backend/src/main/resources/db/migration/V008__isolate_system_admin_role.sql new file mode 100644 index 0000000..3b8869b --- /dev/null +++ b/backend/src/main/resources/db/migration/V008__isolate_system_admin_role.sql @@ -0,0 +1,6 @@ +-- Keep the administrator identity separate from business roles. V001-V007 +-- are already applied in existing environments, so the canonical role code is +-- changed through a forward-only migration. +UPDATE iam_role +SET code = 'SYSTEM_ADMIN' +WHERE code = 'ADMIN'; diff --git a/backend/src/main/resources/db/migration/V009__source_form_and_workflow_runtime.sql b/backend/src/main/resources/db/migration/V009__source_form_and_workflow_runtime.sql new file mode 100644 index 0000000..83c3425 --- /dev/null +++ b/backend/src/main/resources/db/migration/V009__source_form_and_workflow_runtime.sql @@ -0,0 +1,277 @@ +ALTER TABLE src_template + ADD COLUMN group_code VARCHAR(32) NULL AFTER name, + ADD COLUMN purpose VARCHAR(500) NULL AFTER group_code, + ADD COLUMN initiator_roles VARCHAR(100) NULL AFTER purpose; + +UPDATE src_template +SET group_code = CASE + WHEN form_type IN ('OA-01', 'OA-02', 'OA-03') THEN 'PROJECT_PREPARATION' + WHEN form_type IN ('OA-04', 'OA-05', 'OA-06', 'OA-08', 'OA-13') THEN 'INCOME_EXPENSE' + WHEN form_type IN ('OA-09', 'OA-10', 'OA-14') THEN 'PROJECT_PROCESS' + WHEN form_type IN ('OA-11', 'OA-12') THEN 'CLOSING_ARCHIVE' + ELSE 'BANK_INFORMATION' + END, + purpose = CASE form_type + WHEN 'OA-01' THEN '建立项目、预算和付款约定的来源依据' + WHEN 'OA-02' THEN '采集供应商证照、税号、资质和收款账户' + WHEN 'OA-03' THEN '完成成本合同会签并建立合同额度依据' + WHEN 'OA-04' THEN '确认人材机应付、发票和成本附件' + WHEN 'OA-05' THEN '登记项目收款证据和应收核销依据' + WHEN 'OA-06' THEN '提交进度款资料并完成财务付款检查' + WHEN 'OA-07' THEN '登记公司银行账户资料并进入双人复核' + WHEN 'OA-08' THEN '提交开票资料并登记线下开票结果依据' + WHEN 'OA-09' THEN '保存实名班组和受限人员资料版本' + WHEN 'OA-10' THEN '申报履约进度、产值、成本和现场照片' + WHEN 'OA-11' THEN '确认成本合同结算金额和结算附件' + WHEN 'OA-12' THEN '登记项目验收结论并触发归档检查' + WHEN 'OA-13' THEN '登记押证人员和费用明细' + WHEN 'OA-14' THEN '维护中标、变更和驻派人员名册版本' + END, + initiator_roles = CASE WHEN form_type = 'OA-07' + THEN 'PROJECT_MANAGER,FINANCE_MANAGER' + ELSE 'PROJECT_MANAGER,FINANCE_MANAGER' + END; + +ALTER TABLE src_template + MODIFY COLUMN group_code VARCHAR(32) NOT NULL, + MODIFY COLUMN purpose VARCHAR(500) NOT NULL, + MODIFY COLUMN initiator_roles VARCHAR(100) NOT NULL; + +ALTER TABLE src_template_field + DROP CHECK ck_src_template_field_type, + ADD COLUMN section_code VARCHAR(64) NOT NULL DEFAULT 'BUSINESS' AFTER label, + ADD COLUMN section_name VARCHAR(100) NOT NULL DEFAULT '业务信息' AFTER section_code, + ADD COLUMN required_flag BOOLEAN NOT NULL DEFAULT FALSE AFTER data_type, + ADD COLUMN repeatable BOOLEAN NOT NULL DEFAULT FALSE AFTER required_flag, + ADD COLUMN options_json JSON NULL AFTER enum_code, + ADD COLUMN unit VARCHAR(32) NULL AFTER options_json, + ADD COLUMN import_column VARCHAR(200) NULL AFTER unit, + ADD COLUMN masking_rule VARCHAR(32) NULL AFTER sensitivity, + ADD COLUMN help_text VARCHAR(500) NULL AFTER mapping_code, + ADD CONSTRAINT ck_src_template_field_type CHECK ( + data_type IN ('TEXT', 'LONG_TEXT', 'INTEGER', 'DECIMAL', 'DATE', 'DATETIME', + 'BOOLEAN', 'ENUM', 'REFERENCE', 'FILE', 'TABLE') + ); + +ALTER TABLE src_source_document + ADD COLUMN applicant_name VARCHAR(100) NOT NULL DEFAULT '' AFTER project_id, + ADD COLUMN business_date DATE NULL AFTER applicant_name; + +ALTER TABLE src_document_version + ADD COLUMN payload_json JSON NULL AFTER snapshot_sha256; + +CREATE TABLE src_import_row ( + id BIGINT NOT NULL AUTO_INCREMENT, + batch_id BIGINT NOT NULL, + row_no INT NOT NULL, + source_no VARCHAR(100) NULL, + source_version VARCHAR(32) NULL, + payload_json JSON NOT NULL, + validation_json JSON NULL, + status VARCHAR(16) NOT NULL, + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_src_import_row (batch_id, row_no), + CONSTRAINT fk_src_import_row_batch FOREIGN KEY (batch_id) REFERENCES src_import_batch (id), + CONSTRAINT ck_src_import_row_no CHECK (row_no > 0), + CONSTRAINT ck_src_import_row_status CHECK (status IN ('VALID', 'INVALID', 'CONFIRMED')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +INSERT IGNORE INTO iam_permission (code, name) VALUES + ('source:template:view', '查看来源模板'), + ('source:form:view', '查看来源表单'), + ('source:form:edit', '编辑来源表单'), + ('source:form:import', '导入来源表单'), + ('source:form:validate', '校验来源表单'), + ('source:form:withdraw', '撤回来源表单'), + ('source:form:void', '作废来源表单'), + ('source:sensitive:view', '查看来源表单敏感字段'), + ('workflow:task:view', '查看审批任务'), + ('workflow:task:return', '退回审批任务'), + ('workflow:task:reject', '驳回审批任务'), + ('workflow:instance:withdraw', '撤回审批流程'), + ('workflow:instance:void', '作废审批流程'); + +INSERT IGNORE INTO iam_role_permission (role_id, permission_id) +SELECT role.id, permission.id +FROM iam_role role +JOIN iam_permission permission +WHERE role.code IN ('PROJECT_MANAGER', 'FINANCE_MANAGER') + AND permission.code IN ( + 'source:template:view', 'source:form:view', 'source:form:create', 'source:form:edit', + 'source:form:import', 'source:form:validate', 'source:form:submit', + 'source:form:withdraw', 'source:form:void', 'workflow:task:view', + 'workflow:instance:withdraw', 'workflow:instance:void' + ); + +INSERT IGNORE INTO iam_role_permission (role_id, permission_id) +SELECT role.id, permission.id +FROM iam_role role +JOIN iam_permission permission +WHERE role.code = 'FINANCE_MANAGER' + AND permission.code = 'source:sensitive:view'; + +INSERT IGNORE INTO iam_role_permission (role_id, permission_id) +SELECT role.id, permission.id +FROM iam_role role +JOIN iam_permission permission +WHERE role.code IN ('PROJECT_MANAGER', 'FINANCE_MANAGER') + AND permission.code IN ('workflow:task:approve', 'workflow:task:return', 'workflow:task:reject'); + +INSERT IGNORE INTO iam_role_permission (role_id, permission_id) +SELECT role.id, permission.id +FROM iam_role role +CROSS JOIN iam_permission permission +WHERE role.code = 'SYSTEM_ADMIN'; + +INSERT INTO wf_definition (public_id, code, name, status) +VALUES ('00000000000000000000000201', 'SOURCE_STANDARD_REVIEW', '来源表单标准审批', 'ACTIVE'); + +INSERT INTO wf_definition_version ( + public_id, definition_id, version_no, definition_json, schema_version, status, published_at +) +SELECT '00000000000000000000000202', definition.id, 1, + JSON_OBJECT( + 'nodes', JSON_ARRAY( + JSON_OBJECT('code', 'PROJECT_REVIEW', 'name', '项目复核', 'role', 'PROJECT_MANAGER'), + JSON_OBJECT('code', 'FINANCE_REVIEW', 'name', '财务复核', 'role', 'FINANCE_MANAGER') + ), + 'returnTo', 'APPLICANT', + 'allowWithdrawBeforeFirstAction', TRUE + ), + 1, 'ACTIVE', UTC_TIMESTAMP(3) +FROM wf_definition definition +WHERE definition.code = 'SOURCE_STANDARD_REVIEW'; + +SET @kaidi_previous_sql_mode = @@SESSION.sql_mode; +SET SESSION sql_mode = CONCAT_WS(',', @@SESSION.sql_mode, 'NO_BACKSLASH_ESCAPES'); + +INSERT INTO src_template_field ( + template_id, field_code, label, section_code, section_name, data_type, + required_flag, repeatable, max_length, numeric_precision, numeric_scale, + required_when_json, enum_code, options_json, unit, sensitivity, masking_rule, + mapping_code, import_column, sort_order +) +SELECT template.id, field.field_code, field.label, field.section_code, field.section_name, + field.data_type, field.required_flag, field.repeatable, field.max_length, + field.numeric_precision, field.numeric_scale, + IF(field.required_flag, JSON_OBJECT('required', TRUE), NULL), + NULL, CAST(field.options_text AS JSON), field.unit, field.sensitivity, + field.masking_rule, field.field_code, field.label, field.sort_order +FROM src_template template +JOIN JSON_TABLE( + '[ + {"formType":"OA-01","code":"awardDate","label":"中标时间","sectionCode":"PROJECT","sectionName":"项目信息","type":"DATE","required":1,"repeatable":0,"maxLength":null,"precision":null,"scale":null,"options":"[]","unit":null,"sensitivity":"NORMAL","masking":null,"order":10}, + {"formType":"OA-01","code":"constructionUnit","label":"建设单位","sectionCode":"PROJECT","sectionName":"项目信息","type":"TEXT","required":1,"repeatable":0,"maxLength":200,"precision":null,"scale":null,"options":"[]","unit":null,"sensitivity":"NORMAL","masking":null,"order":20}, + {"formType":"OA-01","code":"projectAddress","label":"项目地址","sectionCode":"PROJECT","sectionName":"项目信息","type":"TEXT","required":1,"repeatable":0,"maxLength":300,"precision":null,"scale":null,"options":"[]","unit":null,"sensitivity":"NORMAL","masking":null,"order":30}, + {"formType":"OA-01","code":"awardAmount","label":"中标金额","sectionCode":"PROJECT","sectionName":"项目信息","type":"DECIMAL","required":1,"repeatable":0,"maxLength":null,"precision":20,"scale":2,"options":"[]","unit":"元","sensitivity":"INTERNAL","masking":null,"order":40}, + {"formType":"OA-01","code":"isEpc","label":"是否 EPC","sectionCode":"PROJECT","sectionName":"项目信息","type":"BOOLEAN","required":1,"repeatable":0,"maxLength":null,"precision":null,"scale":null,"options":"[]","unit":null,"sensitivity":"NORMAL","masking":null,"order":50}, + {"formType":"OA-01","code":"winningPeople","label":"中标人员","sectionCode":"PEOPLE","sectionName":"人员信息","type":"TABLE","required":0,"repeatable":1,"maxLength":null,"precision":null,"scale":null,"options":"[{\"code\":\"position\",\"label\":\"岗位\"},{\"code\":\"name\",\"label\":\"姓名\"},{\"code\":\"category\",\"label\":\"类别\"},{\"code\":\"phone\",\"label\":\"电话\"},{\"code\":\"certificateType\",\"label\":\"证件类型\"},{\"code\":\"onDuty\",\"label\":\"是否上岗\"}]","unit":null,"sensitivity":"SENSITIVE","masking":"PARTIAL","order":60}, + {"formType":"OA-01","code":"constructionDetails","label":"施工及合同信息","sectionCode":"CONSTRUCTION","sectionName":"施工信息","type":"TABLE","required":0,"repeatable":1,"maxLength":null,"precision":null,"scale":null,"options":"[{\"code\":\"contractName\",\"label\":\"合同名称\"},{\"code\":\"counterparty\",\"label\":\"对方单位\"},{\"code\":\"amount\",\"label\":\"金额\"},{\"code\":\"fundSource\",\"label\":\"资金来源\"},{\"code\":\"workContent\",\"label\":\"工程内容\"},{\"code\":\"startDate\",\"label\":\"开工日\"},{\"code\":\"endDate\",\"label\":\"竣工日\"}]","unit":null,"sensitivity":"INTERNAL","masking":null,"order":70}, + {"formType":"OA-01","code":"costBudget","label":"成本预算","sectionCode":"COST","sectionName":"成本预算","type":"TABLE","required":1,"repeatable":0,"maxLength":null,"precision":null,"scale":null,"options":"[{\"code\":\"originalAmount\",\"label\":\"原额\"},{\"code\":\"incrementAmount\",\"label\":\"增量\"},{\"code\":\"totalBudget\",\"label\":\"预算总成本\"},{\"code\":\"labor\",\"label\":\"人工\"},{\"code\":\"material\",\"label\":\"材料\"},{\"code\":\"machinery\",\"label\":\"机械\"},{\"code\":\"tax\",\"label\":\"税金\"},{\"code\":\"other\",\"label\":\"其他成本\"}]","unit":"元","sensitivity":"INTERNAL","masking":null,"order":80}, + {"formType":"OA-01","code":"paymentTerms","label":"付款约定","sectionCode":"FINANCE","sectionName":"财务信息","type":"TABLE","required":1,"repeatable":1,"maxLength":null,"precision":null,"scale":null,"options":"[{\"code\":\"description\",\"label\":\"付款描述\"},{\"code\":\"ratio\",\"label\":\"比率\"},{\"code\":\"amount\",\"label\":\"金额\"},{\"code\":\"settlementMethod\",\"label\":\"结算方式\"}]","unit":null,"sensitivity":"INTERNAL","masking":null,"order":90}, + + {"formType":"OA-02","code":"supplierName","label":"供应商名称","sectionCode":"SUPPLIER","sectionName":"供应商信息","type":"TEXT","required":1,"repeatable":0,"maxLength":200,"precision":null,"scale":null,"options":"[]","unit":null,"sensitivity":"NORMAL","masking":null,"order":10}, + {"formType":"OA-02","code":"taxNo","label":"统一社会信用代码/税号","sectionCode":"SUPPLIER","sectionName":"供应商信息","type":"TEXT","required":1,"repeatable":0,"maxLength":64,"precision":null,"scale":null,"options":"[]","unit":null,"sensitivity":"SENSITIVE","masking":"PARTIAL","order":20}, + {"formType":"OA-02","code":"contact","label":"联系人及电话","sectionCode":"SUPPLIER","sectionName":"供应商信息","type":"TABLE","required":1,"repeatable":0,"maxLength":null,"precision":null,"scale":null,"options":"[{\"code\":\"name\",\"label\":\"联系人\"},{\"code\":\"phone\",\"label\":\"电话\"},{\"code\":\"product\",\"label\":\"产品\"}]","unit":null,"sensitivity":"SENSITIVE","masking":"PARTIAL","order":30}, + {"formType":"OA-02","code":"supplierType","label":"供应商类型","sectionCode":"SUPPLIER","sectionName":"供应商信息","type":"TEXT","required":1,"repeatable":0,"maxLength":64,"precision":null,"scale":null,"options":"[]","unit":null,"sensitivity":"NORMAL","masking":null,"order":40}, + {"formType":"OA-02","code":"purchaseAmount","label":"预计采购额","sectionCode":"BUSINESS","sectionName":"业务信息","type":"DECIMAL","required":0,"repeatable":0,"maxLength":null,"precision":20,"scale":2,"options":"[]","unit":"元","sensitivity":"INTERNAL","masking":null,"order":50}, + {"formType":"OA-02","code":"bankAccounts","label":"账户明细","sectionCode":"ACCOUNT","sectionName":"账户信息","type":"TABLE","required":1,"repeatable":1,"maxLength":null,"precision":null,"scale":null,"options":"[{\"code\":\"accountName\",\"label\":\"户名\"},{\"code\":\"bankName\",\"label\":\"开户行\"},{\"code\":\"accountNo\",\"label\":\"账号\"},{\"code\":\"reason\",\"label\":\"准入原因\"}]","unit":null,"sensitivity":"RESTRICTED","masking":"BANK_ACCOUNT","order":60}, + {"formType":"OA-02","code":"qualifications","label":"资质明细","sectionCode":"QUALIFICATION","sectionName":"资质信息","type":"TABLE","required":1,"repeatable":1,"maxLength":null,"precision":null,"scale":null,"options":"[{\"code\":\"name\",\"label\":\"资质\"},{\"code\":\"certificateNo\",\"label\":\"证件号码\"},{\"code\":\"validUntil\",\"label\":\"有效期\"},{\"code\":\"fileId\",\"label\":\"附件\"}]","unit":null,"sensitivity":"SENSITIVE","masking":"PARTIAL","order":70}, + + {"formType":"OA-03","code":"contractName","label":"成本合同名称","sectionCode":"CONTRACT","sectionName":"合同信息","type":"TEXT","required":1,"repeatable":0,"maxLength":200,"precision":null,"scale":null,"options":"[]","unit":null,"sensitivity":"NORMAL","masking":null,"order":10}, + {"formType":"OA-03","code":"contractNo","label":"成本合同编号","sectionCode":"CONTRACT","sectionName":"合同信息","type":"TEXT","required":1,"repeatable":0,"maxLength":64,"precision":null,"scale":null,"options":"[]","unit":null,"sensitivity":"NORMAL","masking":null,"order":20}, + {"formType":"OA-03","code":"contractAmount","label":"合同金额","sectionCode":"CONTRACT","sectionName":"合同信息","type":"DECIMAL","required":1,"repeatable":0,"maxLength":null,"precision":20,"scale":2,"options":"[]","unit":"元","sensitivity":"INTERNAL","masking":null,"order":30}, + {"formType":"OA-03","code":"supplierSnapshot","label":"供应商及账户快照","sectionCode":"SUPPLIER","sectionName":"供应商信息","type":"TABLE","required":1,"repeatable":0,"maxLength":null,"precision":null,"scale":null,"options":"[{\"code\":\"supplier\",\"label\":\"供应商\"},{\"code\":\"accountName\",\"label\":\"户名\"},{\"code\":\"bankName\",\"label\":\"开户行\"},{\"code\":\"accountNo\",\"label\":\"账号\"}]","unit":null,"sensitivity":"RESTRICTED","masking":"BANK_ACCOUNT","order":40}, + {"formType":"OA-03","code":"realtimeCost","label":"项目实时成本","sectionCode":"COST","sectionName":"项目实时成本","type":"TABLE","required":1,"repeatable":0,"maxLength":null,"precision":null,"scale":null,"options":"[{\"code\":\"targetAmount\",\"label\":\"目标合同金额\"},{\"code\":\"signedAmount\",\"label\":\"累计签订\"},{\"code\":\"paidAmount\",\"label\":\"累计支付\"},{\"code\":\"receivedAmount\",\"label\":\"累计收款\"},{\"code\":\"deductionAmount\",\"label\":\"累计扣费\"},{\"code\":\"balance\",\"label\":\"项目余额\"}]","unit":"元","sensitivity":"INTERNAL","masking":null,"order":50}, + {"formType":"OA-03","code":"quantityPriceReview","label":"量价审核明细","sectionCode":"REVIEW","sectionName":"量价审核","type":"TABLE","required":1,"repeatable":1,"maxLength":null,"precision":null,"scale":null,"options":"[{\"code\":\"quantity\",\"label\":\"数量\"},{\"code\":\"unitPrice\",\"label\":\"单价\"},{\"code\":\"costPlan\",\"label\":\"成本计划\"},{\"code\":\"overrunAmount\",\"label\":\"预计超清单\"},{\"code\":\"opinion\",\"label\":\"审核意见\"}]","unit":null,"sensitivity":"INTERNAL","masking":null,"order":60}, + + {"formType":"OA-04","code":"costContractNo","label":"成本合同编号","sectionCode":"PAYABLE","sectionName":"应付信息","type":"TEXT","required":1,"repeatable":0,"maxLength":64,"precision":null,"scale":null,"options":"[]","unit":null,"sensitivity":"NORMAL","masking":null,"order":10}, + {"formType":"OA-04","code":"supplierName","label":"供应商","sectionCode":"PAYABLE","sectionName":"应付信息","type":"TEXT","required":1,"repeatable":0,"maxLength":200,"precision":null,"scale":null,"options":"[]","unit":null,"sensitivity":"NORMAL","masking":null,"order":20}, + {"formType":"OA-04","code":"costType","label":"本次费用成本类型","sectionCode":"PAYABLE","sectionName":"应付信息","type":"TEXT","required":1,"repeatable":0,"maxLength":64,"precision":null,"scale":null,"options":"[]","unit":null,"sensitivity":"NORMAL","masking":null,"order":30}, + {"formType":"OA-04","code":"payableAmount","label":"本次应付金额","sectionCode":"PAYABLE","sectionName":"应付信息","type":"DECIMAL","required":1,"repeatable":0,"maxLength":null,"precision":20,"scale":2,"options":"[]","unit":"元","sensitivity":"INTERNAL","masking":null,"order":40}, + {"formType":"OA-04","code":"invoiceDetails","label":"发票明细","sectionCode":"INVOICE","sectionName":"发票信息","type":"TABLE","required":1,"repeatable":1,"maxLength":null,"precision":null,"scale":null,"options":"[{\"code\":\"invoiceNo\",\"label\":\"发票号码\"},{\"code\":\"invoiceType\",\"label\":\"发票类型\"},{\"code\":\"invoiceDate\",\"label\":\"发票日期\"},{\"code\":\"taxInclusiveAmount\",\"label\":\"含税金额\"},{\"code\":\"taxExclusiveAmount\",\"label\":\"不含税金额\"},{\"code\":\"taxRate\",\"label\":\"税率\"},{\"code\":\"taxAmount\",\"label\":\"税额\"}]","unit":null,"sensitivity":"INTERNAL","masking":null,"order":50}, + {"formType":"OA-04","code":"attachmentMatrix","label":"条件附件矩阵","sectionCode":"ATTACHMENT","sectionName":"附件信息","type":"TABLE","required":1,"repeatable":1,"maxLength":null,"precision":null,"scale":null,"options":"[{\"code\":\"attachmentType\",\"label\":\"附件类型\"},{\"code\":\"required\",\"label\":\"是否必需\"},{\"code\":\"fileId\",\"label\":\"文件\"},{\"code\":\"notApplicableReason\",\"label\":\"不涉及原因\"}]","unit":null,"sensitivity":"INTERNAL","masking":null,"order":60}, + + {"formType":"OA-05","code":"ownerUnit","label":"业主单位","sectionCode":"RECEIPT","sectionName":"收款信息","type":"TEXT","required":1,"repeatable":0,"maxLength":200,"precision":null,"scale":null,"options":"[]","unit":null,"sensitivity":"NORMAL","masking":null,"order":10}, + {"formType":"OA-05","code":"receiptMethod","label":"收款方式","sectionCode":"RECEIPT","sectionName":"收款信息","type":"TEXT","required":1,"repeatable":0,"maxLength":64,"precision":null,"scale":null,"options":"[]","unit":null,"sensitivity":"NORMAL","masking":null,"order":20}, + {"formType":"OA-05","code":"receiptAmount","label":"本次收款金额","sectionCode":"RECEIPT","sectionName":"收款信息","type":"DECIMAL","required":1,"repeatable":0,"maxLength":null,"precision":20,"scale":2,"options":"[]","unit":"元","sensitivity":"INTERNAL","masking":null,"order":30}, + {"formType":"OA-05","code":"receiptAccount","label":"收款账户","sectionCode":"RECEIPT","sectionName":"收款信息","type":"TABLE","required":1,"repeatable":0,"maxLength":null,"precision":null,"scale":null,"options":"[{\"code\":\"accountName\",\"label\":\"户名\"},{\"code\":\"bankName\",\"label\":\"开户行\"},{\"code\":\"accountNo\",\"label\":\"账号\"}]","unit":null,"sensitivity":"RESTRICTED","masking":"BANK_ACCOUNT","order":40}, + {"formType":"OA-05","code":"receiptDescription","label":"收款内容描述","sectionCode":"RECEIPT","sectionName":"收款信息","type":"LONG_TEXT","required":1,"repeatable":0,"maxLength":1000,"precision":null,"scale":null,"options":"[]","unit":null,"sensitivity":"INTERNAL","masking":null,"order":50}, + {"formType":"OA-05","code":"evidenceFiles","label":"收款证据附件","sectionCode":"ATTACHMENT","sectionName":"附件信息","type":"FILE","required":1,"repeatable":1,"maxLength":null,"precision":null,"scale":null,"options":"[]","unit":null,"sensitivity":"INTERNAL","masking":null,"order":60}, + + {"formType":"OA-06","code":"contractAmount","label":"合同金额","sectionCode":"FUND","sectionName":"项目资金","type":"DECIMAL","required":1,"repeatable":0,"maxLength":null,"precision":20,"scale":2,"options":"[]","unit":"元","sensitivity":"INTERNAL","masking":null,"order":10}, + {"formType":"OA-06","code":"availableFund","label":"可用资金","sectionCode":"FUND","sectionName":"项目资金","type":"DECIMAL","required":1,"repeatable":0,"maxLength":null,"precision":20,"scale":2,"options":"[]","unit":"元","sensitivity":"INTERNAL","masking":null,"order":20}, + {"formType":"OA-06","code":"requestedAmount","label":"本次申请金额","sectionCode":"PAYMENT","sectionName":"付款申请","type":"DECIMAL","required":1,"repeatable":0,"maxLength":null,"precision":20,"scale":2,"options":"[]","unit":"元","sensitivity":"INTERNAL","masking":null,"order":30}, + {"formType":"OA-06","code":"payeeAccount","label":"收款账户","sectionCode":"PAYMENT","sectionName":"付款申请","type":"TABLE","required":1,"repeatable":0,"maxLength":null,"precision":null,"scale":null,"options":"[{\"code\":\"payee\",\"label\":\"收款对象\"},{\"code\":\"accountName\",\"label\":\"户名\"},{\"code\":\"bankName\",\"label\":\"开户行\"},{\"code\":\"accountNo\",\"label\":\"账号\"}]","unit":null,"sensitivity":"RESTRICTED","masking":"BANK_ACCOUNT","order":40}, + {"formType":"OA-06","code":"paymentDetails","label":"支付明细","sectionCode":"PAYMENT","sectionName":"付款申请","type":"TABLE","required":1,"repeatable":1,"maxLength":null,"precision":null,"scale":null,"options":"[{\"code\":\"costContractNo\",\"label\":\"成本合同编号\"},{\"code\":\"subject\",\"label\":\"标的物\"},{\"code\":\"supplier\",\"label\":\"供应商\"},{\"code\":\"payableAmount\",\"label\":\"累计应付\"},{\"code\":\"paidAmount\",\"label\":\"累计已付\"},{\"code\":\"requestAmount\",\"label\":\"本次申请\"}]","unit":null,"sensitivity":"INTERNAL","masking":null,"order":50}, + {"formType":"OA-06","code":"attachmentMatrix","label":"付款附件矩阵","sectionCode":"ATTACHMENT","sectionName":"附件信息","type":"TABLE","required":1,"repeatable":1,"maxLength":null,"precision":null,"scale":null,"options":"[{\"code\":\"attachmentType\",\"label\":\"附件类型\"},{\"code\":\"required\",\"label\":\"是否必需\"},{\"code\":\"fileId\",\"label\":\"文件\"},{\"code\":\"notApplicableReason\",\"label\":\"不涉及原因\"}]","unit":null,"sensitivity":"INTERNAL","masking":null,"order":60}, + + {"formType":"OA-07","code":"openingReason","label":"开户事由","sectionCode":"ACCOUNT","sectionName":"账户信息","type":"LONG_TEXT","required":1,"repeatable":0,"maxLength":1000,"precision":null,"scale":null,"options":"[]","unit":null,"sensitivity":"INTERNAL","masking":null,"order":10}, + {"formType":"OA-07","code":"reconciliationCycle","label":"对账周期","sectionCode":"ACCOUNT","sectionName":"账户信息","type":"TEXT","required":1,"repeatable":0,"maxLength":64,"precision":null,"scale":null,"options":"[]","unit":null,"sensitivity":"NORMAL","masking":null,"order":20}, + {"formType":"OA-07","code":"accountName","label":"户名","sectionCode":"ACCOUNT","sectionName":"账户信息","type":"TEXT","required":1,"repeatable":0,"maxLength":200,"precision":null,"scale":null,"options":"[]","unit":null,"sensitivity":"SENSITIVE","masking":"PARTIAL","order":30}, + {"formType":"OA-07","code":"bankName","label":"开户行","sectionCode":"ACCOUNT","sectionName":"账户信息","type":"TEXT","required":1,"repeatable":0,"maxLength":200,"precision":null,"scale":null,"options":"[]","unit":null,"sensitivity":"SENSITIVE","masking":"PARTIAL","order":40}, + {"formType":"OA-07","code":"accountNo","label":"账号","sectionCode":"ACCOUNT","sectionName":"账户信息","type":"TEXT","required":1,"repeatable":0,"maxLength":64,"precision":null,"scale":null,"options":"[]","unit":null,"sensitivity":"RESTRICTED","masking":"BANK_ACCOUNT","order":50}, + {"formType":"OA-07","code":"accountCategory","label":"账户类别","sectionCode":"ACCOUNT","sectionName":"账户信息","type":"TEXT","required":1,"repeatable":0,"maxLength":64,"precision":null,"scale":null,"options":"[]","unit":null,"sensitivity":"NORMAL","masking":null,"order":60}, + {"formType":"OA-07","code":"openingMaterials","label":"开户资料清单","sectionCode":"ATTACHMENT","sectionName":"附件信息","type":"TABLE","required":1,"repeatable":1,"maxLength":null,"precision":null,"scale":null,"options":"[{\"code\":\"materialType\",\"label\":\"资料类型\"},{\"code\":\"complete\",\"label\":\"是否齐全\"},{\"code\":\"fileId\",\"label\":\"打码附件\"},{\"code\":\"remark\",\"label\":\"备注\"}]","unit":null,"sensitivity":"RESTRICTED","masking":"PARTIAL","order":70}, + + {"formType":"OA-08","code":"contractAmount","label":"总承包合同金额","sectionCode":"INVOICE","sectionName":"开票信息","type":"DECIMAL","required":1,"repeatable":0,"maxLength":null,"precision":20,"scale":2,"options":"[]","unit":"元","sensitivity":"INTERNAL","masking":null,"order":10}, + {"formType":"OA-08","code":"invoicedAmount","label":"已开票金额","sectionCode":"INVOICE","sectionName":"开票信息","type":"DECIMAL","required":1,"repeatable":0,"maxLength":null,"precision":20,"scale":2,"options":"[]","unit":"元","sensitivity":"INTERNAL","masking":null,"order":20}, + {"formType":"OA-08","code":"invoiceAmount","label":"本次开票金额","sectionCode":"INVOICE","sectionName":"开票信息","type":"DECIMAL","required":1,"repeatable":0,"maxLength":null,"precision":20,"scale":2,"options":"[]","unit":"元","sensitivity":"INTERNAL","masking":null,"order":30}, + {"formType":"OA-08","code":"customerName","label":"客户全称","sectionCode":"CUSTOMER","sectionName":"客户信息","type":"TEXT","required":1,"repeatable":0,"maxLength":200,"precision":null,"scale":null,"options":"[]","unit":null,"sensitivity":"NORMAL","masking":null,"order":40}, + {"formType":"OA-08","code":"invoiceHeader","label":"开票抬头及税务信息","sectionCode":"CUSTOMER","sectionName":"客户信息","type":"TABLE","required":1,"repeatable":0,"maxLength":null,"precision":null,"scale":null,"options":"[{\"code\":\"title\",\"label\":\"开票抬头\"},{\"code\":\"taxNo\",\"label\":\"纳税识别号\"},{\"code\":\"addressPhone\",\"label\":\"注册地址/电话\"},{\"code\":\"bankAccount\",\"label\":\"开户行/账号\"}]","unit":null,"sensitivity":"SENSITIVE","masking":"PARTIAL","order":50}, + {"formType":"OA-08","code":"invoiceRequirements","label":"开票要求","sectionCode":"INVOICE","sectionName":"开票信息","type":"TABLE","required":1,"repeatable":0,"maxLength":null,"precision":null,"scale":null,"options":"[{\"code\":\"invoiceType\",\"label\":\"发票类型\"},{\"code\":\"taxRate\",\"label\":\"税率\"},{\"code\":\"content\",\"label\":\"开票内容\"},{\"code\":\"settlementMethod\",\"label\":\"结算方式\"},{\"code\":\"expectedReceiptDate\",\"label\":\"预计到账时间\"}]","unit":null,"sensitivity":"INTERNAL","masking":null,"order":60}, + + {"formType":"OA-09","code":"teamName","label":"班组名称","sectionCode":"TEAM","sectionName":"班组信息","type":"TEXT","required":1,"repeatable":0,"maxLength":200,"precision":null,"scale":null,"options":"[]","unit":null,"sensitivity":"NORMAL","masking":null,"order":10}, + {"formType":"OA-09","code":"teamLeader","label":"班组负责人及联系方式","sectionCode":"TEAM","sectionName":"班组信息","type":"TABLE","required":1,"repeatable":0,"maxLength":null,"precision":null,"scale":null,"options":"[{\"code\":\"name\",\"label\":\"负责人\"},{\"code\":\"phone\",\"label\":\"联系方式\"}]","unit":null,"sensitivity":"SENSITIVE","masking":"PARTIAL","order":20}, + {"formType":"OA-09","code":"workers","label":"实名人员明细","sectionCode":"PEOPLE","sectionName":"人员信息","type":"TABLE","required":1,"repeatable":1,"maxLength":null,"precision":null,"scale":null,"options":"[{\"code\":\"name\",\"label\":\"姓名\"},{\"code\":\"idCardNo\",\"label\":\"身份证号\"},{\"code\":\"phone\",\"label\":\"联系方式\"},{\"code\":\"monthlySalary\",\"label\":\"月度薪酬标准\"},{\"code\":\"workType\",\"label\":\"工种\"},{\"code\":\"entryDate\",\"label\":\"进场时间\"},{\"code\":\"emergencyContact\",\"label\":\"紧急联系方式\"},{\"code\":\"bankName\",\"label\":\"工资卡开户行\"},{\"code\":\"bankAccountNo\",\"label\":\"工资卡账号\"}]","unit":null,"sensitivity":"RESTRICTED","masking":"IDENTITY","order":30}, + {"formType":"OA-09","code":"workerFiles","label":"人员附件","sectionCode":"ATTACHMENT","sectionName":"附件信息","type":"TABLE","required":1,"repeatable":1,"maxLength":null,"precision":null,"scale":null,"options":"[{\"code\":\"workerName\",\"label\":\"人员\"},{\"code\":\"laborContractFileId\",\"label\":\"劳动合同\"},{\"code\":\"idCardFileId\",\"label\":\"身份证\"}]","unit":null,"sensitivity":"RESTRICTED","masking":"IDENTITY","order":40}, + + {"formType":"OA-10","code":"contractAmount","label":"合同额","sectionCode":"PROGRESS","sectionName":"履约进度","type":"DECIMAL","required":1,"repeatable":0,"maxLength":null,"precision":20,"scale":2,"options":"[]","unit":"元","sensitivity":"INTERNAL","masking":null,"order":10}, + {"formType":"OA-10","code":"reportPeriod","label":"报告年度/季度","sectionCode":"PROGRESS","sectionName":"履约进度","type":"TEXT","required":1,"repeatable":0,"maxLength":32,"precision":null,"scale":null,"options":"[]","unit":null,"sensitivity":"NORMAL","masking":null,"order":20}, + {"formType":"OA-10","code":"progressMetrics","label":"进度及产值指标","sectionCode":"PROGRESS","sectionName":"履约进度","type":"TABLE","required":1,"repeatable":0,"maxLength":null,"precision":null,"scale":null,"options":"[{\"code\":\"currentOutput\",\"label\":\"本期完成产值\"},{\"code\":\"cumulativeOutput\",\"label\":\"累计完成产值\"},{\"code\":\"currentCost\",\"label\":\"本期投入成本\"},{\"code\":\"cumulativeCost\",\"label\":\"累计投入成本\"},{\"code\":\"vatRevenue\",\"label\":\"本期应确认增值税收入\"},{\"code\":\"completionRate\",\"label\":\"完成进度\"},{\"code\":\"remainingDays\",\"label\":\"剩余工期\"}]","unit":null,"sensitivity":"INTERNAL","masking":null,"order":30}, + {"formType":"OA-10","code":"progressSummary","label":"进度概述","sectionCode":"PROGRESS","sectionName":"履约进度","type":"LONG_TEXT","required":1,"repeatable":0,"maxLength":2000,"precision":null,"scale":null,"options":"[]","unit":null,"sensitivity":"INTERNAL","masking":null,"order":40}, + {"formType":"OA-10","code":"taskDetails","label":"任务明细","sectionCode":"TASK","sectionName":"任务明细","type":"TABLE","required":1,"repeatable":1,"maxLength":null,"precision":null,"scale":null,"options":"[{\"code\":\"name\",\"label\":\"任务名称\"},{\"code\":\"startDate\",\"label\":\"开始日\"},{\"code\":\"endDate\",\"label\":\"完成日\"},{\"code\":\"progress\",\"label\":\"进度\"},{\"code\":\"owner\",\"label\":\"负责人\"},{\"code\":\"fileId\",\"label\":\"文件\"}]","unit":null,"sensitivity":"INTERNAL","masking":null,"order":50}, + {"formType":"OA-10","code":"sitePhotos","label":"项目形象照片","sectionCode":"ATTACHMENT","sectionName":"附件信息","type":"FILE","required":1,"repeatable":1,"maxLength":null,"precision":null,"scale":null,"options":"[]","unit":null,"sensitivity":"INTERNAL","masking":null,"order":60}, + + {"formType":"OA-11","code":"costContractNo","label":"成本合同编号","sectionCode":"SETTLEMENT","sectionName":"合同结算","type":"TEXT","required":1,"repeatable":0,"maxLength":64,"precision":null,"scale":null,"options":"[]","unit":null,"sensitivity":"NORMAL","masking":null,"order":10}, + {"formType":"OA-11","code":"supplierName","label":"供应商","sectionCode":"SETTLEMENT","sectionName":"合同结算","type":"TEXT","required":1,"repeatable":0,"maxLength":200,"precision":null,"scale":null,"options":"[]","unit":null,"sensitivity":"NORMAL","masking":null,"order":20}, + {"formType":"OA-11","code":"amounts","label":"结算金额汇总","sectionCode":"SETTLEMENT","sectionName":"合同结算","type":"TABLE","required":1,"repeatable":0,"maxLength":null,"precision":null,"scale":null,"options":"[{\"code\":\"contractAmount\",\"label\":\"合同金额\"},{\"code\":\"settlementAmount\",\"label\":\"结算金额\"},{\"code\":\"invoicedAmount\",\"label\":\"累计已开票\"},{\"code\":\"paidAmount\",\"label\":\"累计已付款\"}]","unit":"元","sensitivity":"INTERNAL","masking":null,"order":30}, + {"formType":"OA-11","code":"settlementFiles","label":"结算附件","sectionCode":"ATTACHMENT","sectionName":"附件信息","type":"TABLE","required":1,"repeatable":1,"maxLength":null,"precision":null,"scale":null,"options":"[{\"code\":\"fileType\",\"label\":\"附件类型\"},{\"code\":\"fileId\",\"label\":\"文件\"},{\"code\":\"remark\",\"label\":\"备注\"}]","unit":null,"sensitivity":"INTERNAL","masking":null,"order":40}, + + {"formType":"OA-12","code":"acceptanceDetails","label":"验收明细","sectionCode":"ACCEPTANCE","sectionName":"验收信息","type":"TABLE","required":1,"repeatable":1,"maxLength":null,"precision":null,"scale":null,"options":"[{\"code\":\"stage\",\"label\":\"验收阶段\"},{\"code\":\"acceptedAt\",\"label\":\"验收时间\"},{\"code\":\"conclusion\",\"label\":\"验收结论\"},{\"code\":\"fileId\",\"label\":\"附件\"},{\"code\":\"remark\",\"label\":\"备注\"}]","unit":null,"sensitivity":"INTERNAL","masking":null,"order":10}, + {"formType":"OA-12","code":"acceptanceSummary","label":"验收总结","sectionCode":"ACCEPTANCE","sectionName":"验收信息","type":"LONG_TEXT","required":1,"repeatable":0,"maxLength":2000,"precision":null,"scale":null,"options":"[]","unit":null,"sensitivity":"INTERNAL","masking":null,"order":20}, + + {"formType":"OA-13","code":"chargeDate","label":"收费时间","sectionCode":"FEE","sectionName":"押证费用","type":"DATE","required":1,"repeatable":0,"maxLength":null,"precision":null,"scale":null,"options":"[]","unit":null,"sensitivity":"NORMAL","masking":null,"order":10}, + {"formType":"OA-13","code":"feeDetails","label":"押证费用明细","sectionCode":"FEE","sectionName":"押证费用","type":"TABLE","required":1,"repeatable":1,"maxLength":null,"precision":null,"scale":null,"options":"[{\"code\":\"position\",\"label\":\"岗位\"},{\"code\":\"name\",\"label\":\"姓名\"},{\"code\":\"personOwner\",\"label\":\"人员归属\"},{\"code\":\"certificateStartDate\",\"label\":\"证件开始时间\"},{\"code\":\"feeStandard\",\"label\":\"收费标准\"},{\"code\":\"monthlyAmount\",\"label\":\"本月收费金额\"}]","unit":null,"sensitivity":"SENSITIVE","masking":"PARTIAL","order":20}, + {"formType":"OA-13","code":"monthlyReceivable","label":"公司/办事处每月应收费用","sectionCode":"FEE","sectionName":"押证费用","type":"DECIMAL","required":1,"repeatable":0,"maxLength":null,"precision":20,"scale":2,"options":"[]","unit":"元","sensitivity":"INTERNAL","masking":null,"order":30}, + + {"formType":"OA-14","code":"constructionPermitRequired","label":"是否办理施工许可证","sectionCode":"PROJECT","sectionName":"项目信息","type":"BOOLEAN","required":1,"repeatable":0,"maxLength":null,"precision":null,"scale":null,"options":"[]","unit":null,"sensitivity":"NORMAL","masking":null,"order":10}, + {"formType":"OA-14","code":"projectLeaders","label":"项目负责人信息","sectionCode":"PROJECT","sectionName":"项目信息","type":"TABLE","required":1,"repeatable":0,"maxLength":null,"precision":null,"scale":null,"options":"[{\"code\":\"businessLeaderName\",\"label\":\"经营负责人\"},{\"code\":\"businessLeaderPhone\",\"label\":\"经营负责人电话\"},{\"code\":\"projectLeaderName\",\"label\":\"项目负责人\"},{\"code\":\"projectLeaderPhone\",\"label\":\"项目负责人电话\"}]","unit":null,"sensitivity":"SENSITIVE","masking":"PARTIAL","order":20}, + {"formType":"OA-14","code":"projectDates","label":"项目金额及日期","sectionCode":"PROJECT","sectionName":"项目信息","type":"TABLE","required":1,"repeatable":0,"maxLength":null,"precision":null,"scale":null,"options":"[{\"code\":\"awardAmount\",\"label\":\"中标金额\"},{\"code\":\"contractDuration\",\"label\":\"合同工期\"},{\"code\":\"awardPublicDate\",\"label\":\"中标公示日\"},{\"code\":\"contractCompletionDate\",\"label\":\"合同竣工日\"},{\"code\":\"actualAcceptanceDate\",\"label\":\"实际验收日\"}]","unit":null,"sensitivity":"INTERNAL","masking":null,"order":30}, + {"formType":"OA-14","code":"certificatePeople","label":"证件人员新增","sectionCode":"PEOPLE","sectionName":"人员信息","type":"TABLE","required":0,"repeatable":1,"maxLength":null,"precision":null,"scale":null,"options":"[{\"code\":\"position\",\"label\":\"岗位\"},{\"code\":\"name\",\"label\":\"姓名\"},{\"code\":\"category\",\"label\":\"类别\"},{\"code\":\"phone\",\"label\":\"电话\"},{\"code\":\"address\",\"label\":\"地址\"},{\"code\":\"certificateType\",\"label\":\"证件类型\"},{\"code\":\"online\",\"label\":\"是否上网/上岗\"}]","unit":null,"sensitivity":"SENSITIVE","masking":"PARTIAL","order":40}, + {"formType":"OA-14","code":"personChanges","label":"人员变更","sectionCode":"PEOPLE","sectionName":"人员信息","type":"TABLE","required":0,"repeatable":1,"maxLength":null,"precision":null,"scale":null,"options":"[{\"code\":\"position\",\"label\":\"岗位\"},{\"code\":\"name\",\"label\":\"姓名\"},{\"code\":\"phone\",\"label\":\"电话\"},{\"code\":\"reason\",\"label\":\"变更原因\"},{\"code\":\"changeDate\",\"label\":\"变更日期\"},{\"code\":\"online\",\"label\":\"是否上网/上岗\"}]","unit":null,"sensitivity":"SENSITIVE","masking":"PARTIAL","order":50}, + {"formType":"OA-14","code":"residentPeople","label":"驻派人员","sectionCode":"PEOPLE","sectionName":"人员信息","type":"TABLE","required":0,"repeatable":1,"maxLength":null,"precision":null,"scale":null,"options":"[{\"code\":\"name\",\"label\":\"姓名\"},{\"code\":\"phone\",\"label\":\"电话\"},{\"code\":\"appointmentDate\",\"label\":\"任命时间\"},{\"code\":\"transferDate\",\"label\":\"调离时间\"},{\"code\":\"salary\",\"label\":\"工资\"},{\"code\":\"remark\",\"label\":\"备注\"}]","unit":null,"sensitivity":"RESTRICTED","masking":"PARTIAL","order":60} + ]', + '$[*]' COLUMNS ( + form_type VARCHAR(16) PATH '$.formType', + field_code VARCHAR(64) PATH '$.code', + label VARCHAR(200) PATH '$.label', + section_code VARCHAR(64) PATH '$.sectionCode', + section_name VARCHAR(100) PATH '$.sectionName', + data_type VARCHAR(32) PATH '$.type', + required_flag BOOLEAN PATH '$.required', + repeatable BOOLEAN PATH '$.repeatable', + max_length INT PATH '$.maxLength', + numeric_precision INT PATH '$.precision', + numeric_scale INT PATH '$.scale', + options_text LONGTEXT PATH '$.options', + unit VARCHAR(32) PATH '$.unit', + sensitivity VARCHAR(32) PATH '$.sensitivity', + masking_rule VARCHAR(32) PATH '$.masking', + sort_order INT PATH '$.order' + ) +) field ON field.form_type = template.form_type +WHERE template.template_version = 1; + +SET SESSION sql_mode = @kaidi_previous_sql_mode; diff --git a/backend/src/main/resources/db/migration/V010__finance_operations_archive_and_governance.sql b/backend/src/main/resources/db/migration/V010__finance_operations_archive_and_governance.sql new file mode 100644 index 0000000..c71cf43 --- /dev/null +++ b/backend/src/main/resources/db/migration/V010__finance_operations_archive_and_governance.sql @@ -0,0 +1,734 @@ +ALTER TABLE md_contract + ADD COLUMN submitted_by BIGINT NULL AFTER updated_by, + ADD COLUMN submitted_at TIMESTAMP(3) NULL AFTER submitted_by, + ADD COLUMN reviewed_by BIGINT NULL AFTER submitted_at, + ADD COLUMN reviewed_at TIMESTAMP(3) NULL AFTER reviewed_by, + ADD COLUMN review_opinion VARCHAR(1000) NULL AFTER reviewed_at, + ADD CONSTRAINT fk_md_contract_submitted_by FOREIGN KEY (submitted_by) REFERENCES iam_user (id), + ADD CONSTRAINT fk_md_contract_reviewed_by FOREIGN KEY (reviewed_by) REFERENCES iam_user (id); + +ALTER TABLE md_bank_account_version + ADD COLUMN submitted_by BIGINT NULL AFTER updated_by, + ADD COLUMN submitted_at TIMESTAMP(3) NULL AFTER submitted_by, + ADD COLUMN reviewed_by BIGINT NULL AFTER submitted_at, + ADD COLUMN reviewed_at TIMESTAMP(3) NULL AFTER reviewed_by, + ADD COLUMN review_opinion VARCHAR(1000) NULL AFTER reviewed_at, + ADD CONSTRAINT fk_md_bank_account_submitted_by FOREIGN KEY (submitted_by) REFERENCES iam_user (id), + ADD CONSTRAINT fk_md_bank_account_reviewed_by FOREIGN KEY (reviewed_by) REFERENCES iam_user (id); + +ALTER TABLE md_cost_category + DROP CHECK ck_md_cost_category_status, + ADD COLUMN submitted_by BIGINT NULL AFTER updated_by, + ADD COLUMN submitted_at TIMESTAMP(3) NULL AFTER submitted_by, + ADD COLUMN reviewed_by BIGINT NULL AFTER submitted_at, + ADD COLUMN reviewed_at TIMESTAMP(3) NULL AFTER reviewed_by, + ADD COLUMN review_opinion VARCHAR(1000) NULL AFTER reviewed_at, + ADD CONSTRAINT fk_md_cost_category_submitted_by FOREIGN KEY (submitted_by) REFERENCES iam_user (id), + ADD CONSTRAINT fk_md_cost_category_reviewed_by FOREIGN KEY (reviewed_by) REFERENCES iam_user (id), + ADD CONSTRAINT ck_md_cost_category_status CHECK (status IN ('DRAFT', 'REVIEWING', 'RETURNED', 'ACTIVE', 'DISABLED')); + +CREATE TABLE md_account ( + id BIGINT NOT NULL AUTO_INCREMENT, + public_id CHAR(26) NOT NULL, + code VARCHAR(64) NOT NULL, + name VARCHAR(128) NOT NULL, + account_type VARCHAR(32) NOT NULL, + auxiliary_required BOOLEAN NOT NULL DEFAULT FALSE, + status VARCHAR(32) NOT NULL DEFAULT 'DRAFT', + version BIGINT NOT NULL DEFAULT 0, + created_by BIGINT NOT NULL, + updated_by BIGINT NOT NULL, + submitted_by BIGINT NULL, + submitted_at TIMESTAMP(3) NULL, + reviewed_by BIGINT NULL, + reviewed_at TIMESTAMP(3) NULL, + review_opinion VARCHAR(1000) NULL, + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_md_account_public_id (public_id), + UNIQUE KEY uk_md_account_code (code), + KEY idx_md_account_status (status, code), + CONSTRAINT fk_md_account_created_by FOREIGN KEY (created_by) REFERENCES iam_user (id), + CONSTRAINT fk_md_account_updated_by FOREIGN KEY (updated_by) REFERENCES iam_user (id), + CONSTRAINT fk_md_account_submitted_by FOREIGN KEY (submitted_by) REFERENCES iam_user (id), + CONSTRAINT fk_md_account_reviewed_by FOREIGN KEY (reviewed_by) REFERENCES iam_user (id), + CONSTRAINT ck_md_account_type CHECK (account_type IN ('ASSET', 'LIABILITY', 'EQUITY', 'INCOME', 'COST', 'EXPENSE')), + CONSTRAINT ck_md_account_status CHECK (status IN ('DRAFT', 'REVIEWING', 'RETURNED', 'ACTIVE', 'DISABLED')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE md_tax_rate ( + id BIGINT NOT NULL AUTO_INCREMENT, + public_id CHAR(26) NOT NULL, + code VARCHAR(64) NOT NULL, + name VARCHAR(128) NOT NULL, + rate DECIMAL(10, 6) NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'DRAFT', + version BIGINT NOT NULL DEFAULT 0, + created_by BIGINT NOT NULL, + updated_by BIGINT NOT NULL, + submitted_by BIGINT NULL, + submitted_at TIMESTAMP(3) NULL, + reviewed_by BIGINT NULL, + reviewed_at TIMESTAMP(3) NULL, + review_opinion VARCHAR(1000) NULL, + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_md_tax_rate_public_id (public_id), + UNIQUE KEY uk_md_tax_rate_code (code), + KEY idx_md_tax_rate_status (status, code), + CONSTRAINT fk_md_tax_rate_created_by FOREIGN KEY (created_by) REFERENCES iam_user (id), + CONSTRAINT fk_md_tax_rate_updated_by FOREIGN KEY (updated_by) REFERENCES iam_user (id), + CONSTRAINT fk_md_tax_rate_submitted_by FOREIGN KEY (submitted_by) REFERENCES iam_user (id), + CONSTRAINT fk_md_tax_rate_reviewed_by FOREIGN KEY (reviewed_by) REFERENCES iam_user (id), + CONSTRAINT ck_md_tax_rate_value CHECK (rate >= 0 AND rate <= 1), + CONSTRAINT ck_md_tax_rate_status CHECK (status IN ('DRAFT', 'REVIEWING', 'RETURNED', 'ACTIVE', 'DISABLED')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE fin_contract_change ( + id BIGINT NOT NULL AUTO_INCREMENT, + public_id CHAR(26) NOT NULL, + contract_id BIGINT NOT NULL, + change_no VARCHAR(64) NOT NULL, + change_amount DECIMAL(20, 2) NOT NULL, + effective_date DATE NOT NULL, + reason VARCHAR(1000) NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'APPROVED', + version BIGINT NOT NULL DEFAULT 0, + created_by BIGINT NOT NULL, + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_fin_contract_change_public_id (public_id), + UNIQUE KEY uk_fin_contract_change_no (contract_id, change_no), + KEY idx_fin_contract_change_contract (contract_id, status, effective_date), + CONSTRAINT fk_fin_contract_change_contract FOREIGN KEY (contract_id) REFERENCES md_contract (id), + CONSTRAINT fk_fin_contract_change_created_by FOREIGN KEY (created_by) REFERENCES iam_user (id), + CONSTRAINT ck_fin_contract_change_amount CHECK (change_amount <> 0), + CONSTRAINT ck_fin_contract_change_status CHECK (status IN ('APPROVED', 'VOID')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE fin_contract_settlement ( + id BIGINT NOT NULL AUTO_INCREMENT, + public_id CHAR(26) NOT NULL, + contract_id BIGINT NOT NULL, + settlement_no VARCHAR(64) NOT NULL, + settlement_amount DECIMAL(20, 2) NOT NULL, + settlement_date DATE NOT NULL, + source_document_id BIGINT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'CONFIRMED', + version BIGINT NOT NULL DEFAULT 0, + created_by BIGINT NOT NULL, + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_fin_contract_settlement_public_id (public_id), + UNIQUE KEY uk_fin_contract_settlement_no (contract_id, settlement_no), + KEY idx_fin_contract_settlement_contract (contract_id, status), + CONSTRAINT fk_fin_contract_settlement_contract FOREIGN KEY (contract_id) REFERENCES md_contract (id), + CONSTRAINT fk_fin_contract_settlement_source FOREIGN KEY (source_document_id) REFERENCES src_source_document (id), + CONSTRAINT fk_fin_contract_settlement_created_by FOREIGN KEY (created_by) REFERENCES iam_user (id), + CONSTRAINT ck_fin_contract_settlement_amount CHECK (settlement_amount >= 0), + CONSTRAINT ck_fin_contract_settlement_status CHECK (status IN ('CONFIRMED', 'VOID')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE fin_payable ( + id BIGINT NOT NULL AUTO_INCREMENT, + public_id CHAR(26) NOT NULL, + business_no VARCHAR(64) NOT NULL, + company_id BIGINT NOT NULL, + project_id BIGINT NOT NULL, + contract_id BIGINT NOT NULL, + counterparty_id BIGINT NOT NULL, + source_document_id BIGINT NULL, + cost_category_id BIGINT NULL, + business_date DATE NOT NULL, + amount DECIMAL(20, 2) NOT NULL, + invoiced_amount DECIMAL(20, 2) NOT NULL DEFAULT 0, + paid_amount DECIMAL(20, 2) NOT NULL DEFAULT 0, + status VARCHAR(32) NOT NULL DEFAULT 'PENDING', + confirmed_by BIGINT NULL, + confirmed_at TIMESTAMP(3) NULL, + version BIGINT NOT NULL DEFAULT 0, + created_by BIGINT NOT NULL, + updated_by BIGINT NOT NULL, + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_fin_payable_public_id (public_id), + UNIQUE KEY uk_fin_payable_business_no (business_no), + KEY idx_fin_payable_contract_status (contract_id, status, business_date), + KEY idx_fin_payable_project_status (project_id, status), + CONSTRAINT fk_fin_payable_company FOREIGN KEY (company_id) REFERENCES md_company (id), + CONSTRAINT fk_fin_payable_project FOREIGN KEY (project_id) REFERENCES md_project (id), + CONSTRAINT fk_fin_payable_contract FOREIGN KEY (contract_id) REFERENCES md_contract (id), + CONSTRAINT fk_fin_payable_counterparty FOREIGN KEY (counterparty_id) REFERENCES md_counterparty (id), + CONSTRAINT fk_fin_payable_source FOREIGN KEY (source_document_id) REFERENCES src_source_document (id), + CONSTRAINT fk_fin_payable_category FOREIGN KEY (cost_category_id) REFERENCES md_cost_category (id), + CONSTRAINT fk_fin_payable_confirmed_by FOREIGN KEY (confirmed_by) REFERENCES iam_user (id), + CONSTRAINT fk_fin_payable_created_by FOREIGN KEY (created_by) REFERENCES iam_user (id), + CONSTRAINT fk_fin_payable_updated_by FOREIGN KEY (updated_by) REFERENCES iam_user (id), + CONSTRAINT ck_fin_payable_amounts CHECK (amount >= 0 AND invoiced_amount >= 0 AND paid_amount >= 0 AND paid_amount <= amount), + CONSTRAINT ck_fin_payable_status CHECK (status IN ('PENDING', 'CONFIRMED', 'PART_PAID', 'PAID', 'VOID')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE fin_receipt ( + id BIGINT NOT NULL AUTO_INCREMENT, + public_id CHAR(26) NOT NULL, + business_no VARCHAR(64) NOT NULL, + company_id BIGINT NOT NULL, + project_id BIGINT NULL, + customer_id BIGINT NULL, + source_document_id BIGINT NULL, + receipt_date DATE NOT NULL, + amount DECIMAL(20, 2) NOT NULL, + allocated_amount DECIMAL(20, 2) NOT NULL DEFAULT 0, + currency CHAR(3) NOT NULL DEFAULT 'CNY', + payer_name VARCHAR(200) NOT NULL, + reference_no VARCHAR(100) NULL, + status VARCHAR(32) NOT NULL DEFAULT 'DRAFT', + confirmed_by BIGINT NULL, + confirmed_at TIMESTAMP(3) NULL, + version BIGINT NOT NULL DEFAULT 0, + created_by BIGINT NOT NULL, + updated_by BIGINT NOT NULL, + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_fin_receipt_public_id (public_id), + UNIQUE KEY uk_fin_receipt_business_no (business_no), + KEY idx_fin_receipt_project_status (project_id, status, receipt_date), + CONSTRAINT fk_fin_receipt_company FOREIGN KEY (company_id) REFERENCES md_company (id), + CONSTRAINT fk_fin_receipt_project FOREIGN KEY (project_id) REFERENCES md_project (id), + CONSTRAINT fk_fin_receipt_customer FOREIGN KEY (customer_id) REFERENCES md_counterparty (id), + CONSTRAINT fk_fin_receipt_source FOREIGN KEY (source_document_id) REFERENCES src_source_document (id), + CONSTRAINT fk_fin_receipt_confirmed_by FOREIGN KEY (confirmed_by) REFERENCES iam_user (id), + CONSTRAINT fk_fin_receipt_created_by FOREIGN KEY (created_by) REFERENCES iam_user (id), + CONSTRAINT fk_fin_receipt_updated_by FOREIGN KEY (updated_by) REFERENCES iam_user (id), + CONSTRAINT ck_fin_receipt_amounts CHECK (amount > 0 AND allocated_amount >= 0 AND allocated_amount <= amount), + CONSTRAINT ck_fin_receipt_status CHECK (status IN ('DRAFT', 'CONFIRMED', 'PART_ALLOCATED', 'ALLOCATED', 'UNALLOCATED', 'VOID')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE fin_receipt_allocation ( + id BIGINT NOT NULL AUTO_INCREMENT, + public_id CHAR(26) NOT NULL, + receipt_id BIGINT NOT NULL, + project_id BIGINT NOT NULL, + contract_id BIGINT NULL, + amount DECIMAL(20, 2) NOT NULL, + note VARCHAR(500) NULL, + created_by BIGINT NOT NULL, + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_fin_receipt_allocation_public_id (public_id), + KEY idx_fin_receipt_allocation_receipt (receipt_id), + CONSTRAINT fk_fin_receipt_allocation_receipt FOREIGN KEY (receipt_id) REFERENCES fin_receipt (id), + CONSTRAINT fk_fin_receipt_allocation_project FOREIGN KEY (project_id) REFERENCES md_project (id), + CONSTRAINT fk_fin_receipt_allocation_contract FOREIGN KEY (contract_id) REFERENCES md_contract (id), + CONSTRAINT fk_fin_receipt_allocation_created_by FOREIGN KEY (created_by) REFERENCES iam_user (id), + CONSTRAINT ck_fin_receipt_allocation_amount CHECK (amount > 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE fin_invoice ( + id BIGINT NOT NULL AUTO_INCREMENT, + public_id CHAR(26) NOT NULL, + business_no VARCHAR(64) NOT NULL, + company_id BIGINT NOT NULL, + project_id BIGINT NOT NULL, + contract_id BIGINT NULL, + counterparty_id BIGINT NOT NULL, + source_document_id BIGINT NULL, + invoice_type VARCHAR(32) NOT NULL, + amount DECIMAL(20, 2) NOT NULL, + tax_rate DECIMAL(10, 6) NOT NULL, + requested_date DATE NOT NULL, + invoice_no VARCHAR(100) NULL, + issued_date DATE NULL, + result_file_id BIGINT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'DRAFT', + submitted_by BIGINT NULL, + reviewed_by BIGINT NULL, + result_recorded_by BIGINT NULL, + version BIGINT NOT NULL DEFAULT 0, + created_by BIGINT NOT NULL, + updated_by BIGINT NOT NULL, + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_fin_invoice_public_id (public_id), + UNIQUE KEY uk_fin_invoice_business_no (business_no), + KEY idx_fin_invoice_project_status (project_id, status, requested_date), + CONSTRAINT fk_fin_invoice_company FOREIGN KEY (company_id) REFERENCES md_company (id), + CONSTRAINT fk_fin_invoice_project FOREIGN KEY (project_id) REFERENCES md_project (id), + CONSTRAINT fk_fin_invoice_contract FOREIGN KEY (contract_id) REFERENCES md_contract (id), + CONSTRAINT fk_fin_invoice_counterparty FOREIGN KEY (counterparty_id) REFERENCES md_counterparty (id), + CONSTRAINT fk_fin_invoice_source FOREIGN KEY (source_document_id) REFERENCES src_source_document (id), + CONSTRAINT fk_fin_invoice_file FOREIGN KEY (result_file_id) REFERENCES file_object (id), + CONSTRAINT fk_fin_invoice_submitted_by FOREIGN KEY (submitted_by) REFERENCES iam_user (id), + CONSTRAINT fk_fin_invoice_reviewed_by FOREIGN KEY (reviewed_by) REFERENCES iam_user (id), + CONSTRAINT fk_fin_invoice_result_by FOREIGN KEY (result_recorded_by) REFERENCES iam_user (id), + CONSTRAINT fk_fin_invoice_created_by FOREIGN KEY (created_by) REFERENCES iam_user (id), + CONSTRAINT fk_fin_invoice_updated_by FOREIGN KEY (updated_by) REFERENCES iam_user (id), + CONSTRAINT ck_fin_invoice_amount CHECK (amount > 0 AND tax_rate >= 0 AND tax_rate <= 1), + CONSTRAINT ck_fin_invoice_type CHECK (invoice_type IN ('SPECIAL', 'NORMAL', 'RECEIPT')), + CONSTRAINT ck_fin_invoice_status CHECK (status IN ('DRAFT', 'REVIEWING', 'RETURNED', 'APPROVED', 'ISSUED', 'VOID', 'RED_LETTER')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE fin_payment_request ( + id BIGINT NOT NULL AUTO_INCREMENT, + public_id CHAR(26) NOT NULL, + business_no VARCHAR(64) NOT NULL, + company_id BIGINT NOT NULL, + project_id BIGINT NOT NULL, + contract_id BIGINT NOT NULL, + payable_id BIGINT NULL, + supplier_id BIGINT NOT NULL, + bank_account_version_id BIGINT NOT NULL, + source_document_id BIGINT NULL, + requested_amount DECIMAL(20, 2) NOT NULL, + approved_amount DECIMAL(20, 2) NULL, + currency CHAR(3) NOT NULL DEFAULT 'CNY', + requested_date DATE NOT NULL, + purpose VARCHAR(500) NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'DRAFT', + submitted_by BIGINT NULL, + checked_by BIGINT NULL, + approved_by BIGINT NULL, + version BIGINT NOT NULL DEFAULT 0, + created_by BIGINT NOT NULL, + updated_by BIGINT NOT NULL, + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_fin_payment_public_id (public_id), + UNIQUE KEY uk_fin_payment_business_no (business_no), + KEY idx_fin_payment_project_status (project_id, status, requested_date), + KEY idx_fin_payment_contract_status (contract_id, status), + CONSTRAINT fk_fin_payment_company FOREIGN KEY (company_id) REFERENCES md_company (id), + CONSTRAINT fk_fin_payment_project FOREIGN KEY (project_id) REFERENCES md_project (id), + CONSTRAINT fk_fin_payment_contract FOREIGN KEY (contract_id) REFERENCES md_contract (id), + CONSTRAINT fk_fin_payment_payable FOREIGN KEY (payable_id) REFERENCES fin_payable (id), + CONSTRAINT fk_fin_payment_supplier FOREIGN KEY (supplier_id) REFERENCES md_counterparty (id), + CONSTRAINT fk_fin_payment_account FOREIGN KEY (bank_account_version_id) REFERENCES md_bank_account_version (id), + CONSTRAINT fk_fin_payment_source FOREIGN KEY (source_document_id) REFERENCES src_source_document (id), + CONSTRAINT fk_fin_payment_submitted_by FOREIGN KEY (submitted_by) REFERENCES iam_user (id), + CONSTRAINT fk_fin_payment_checked_by FOREIGN KEY (checked_by) REFERENCES iam_user (id), + CONSTRAINT fk_fin_payment_approved_by FOREIGN KEY (approved_by) REFERENCES iam_user (id), + CONSTRAINT fk_fin_payment_created_by FOREIGN KEY (created_by) REFERENCES iam_user (id), + CONSTRAINT fk_fin_payment_updated_by FOREIGN KEY (updated_by) REFERENCES iam_user (id), + CONSTRAINT ck_fin_payment_amount CHECK (requested_amount > 0 AND (approved_amount IS NULL OR (approved_amount > 0 AND approved_amount <= requested_amount))), + CONSTRAINT ck_fin_payment_status CHECK (status IN ('DRAFT', 'FINANCE_CHECK', 'RETURNED', 'APPROVED', 'OFFLINE_PENDING', 'RESULT_PENDING', 'PAID', 'FAILED', 'REFUNDED', 'VOID')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE fin_payment_check ( + id BIGINT NOT NULL AUTO_INCREMENT, + public_id CHAR(26) NOT NULL, + payment_id BIGINT NOT NULL, + check_code VARCHAR(64) NOT NULL, + result VARCHAR(16) NOT NULL, + message VARCHAR(500) NOT NULL, + checked_by BIGINT NOT NULL, + checked_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_fin_payment_check_public_id (public_id), + KEY idx_fin_payment_check_payment (payment_id, checked_at), + CONSTRAINT fk_fin_payment_check_payment FOREIGN KEY (payment_id) REFERENCES fin_payment_request (id), + CONSTRAINT fk_fin_payment_check_checked_by FOREIGN KEY (checked_by) REFERENCES iam_user (id), + CONSTRAINT ck_fin_payment_check_result CHECK (result IN ('PASS', 'WARNING', 'BLOCK')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE fin_payment_result ( + id BIGINT NOT NULL AUTO_INCREMENT, + public_id CHAR(26) NOT NULL, + payment_id BIGINT NOT NULL, + result_type VARCHAR(32) NOT NULL, + paid_date DATE NULL, + amount DECIMAL(20, 2) NULL, + transaction_no VARCHAR(100) NULL, + receipt_file_id BIGINT NULL, + failure_reason VARCHAR(1000) NULL, + recorded_by BIGINT NOT NULL, + recorded_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + verified_by BIGINT NULL, + verified_at TIMESTAMP(3) NULL, + status VARCHAR(32) NOT NULL DEFAULT 'PENDING_VERIFY', + version BIGINT NOT NULL DEFAULT 0, + PRIMARY KEY (id), + UNIQUE KEY uk_fin_payment_result_public_id (public_id), + KEY idx_fin_payment_result_payment (payment_id, status), + CONSTRAINT fk_fin_payment_result_payment FOREIGN KEY (payment_id) REFERENCES fin_payment_request (id), + CONSTRAINT fk_fin_payment_result_file FOREIGN KEY (receipt_file_id) REFERENCES file_object (id), + CONSTRAINT fk_fin_payment_result_recorded_by FOREIGN KEY (recorded_by) REFERENCES iam_user (id), + CONSTRAINT fk_fin_payment_result_verified_by FOREIGN KEY (verified_by) REFERENCES iam_user (id), + CONSTRAINT ck_fin_payment_result_type CHECK (result_type IN ('SUCCESS', 'FAILURE', 'REFUND')), + CONSTRAINT ck_fin_payment_result_status CHECK (status IN ('PENDING_VERIFY', 'VERIFIED', 'RETURNED')), + CONSTRAINT ck_fin_payment_result_amount CHECK (amount IS NULL OR amount > 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE acc_event ( + id BIGINT NOT NULL AUTO_INCREMENT, + public_id CHAR(26) NOT NULL, + business_no VARCHAR(64) NOT NULL, + event_type VARCHAR(32) NOT NULL, + source_type VARCHAR(32) NOT NULL, + source_public_id CHAR(26) NOT NULL, + company_id BIGINT NOT NULL, + project_id BIGINT NULL, + counterparty_id BIGINT NULL, + business_date DATE NOT NULL, + period CHAR(7) NOT NULL, + amount DECIMAL(20, 2) NOT NULL, + currency CHAR(3) NOT NULL DEFAULT 'CNY', + status VARCHAR(32) NOT NULL DEFAULT 'PENDING', + voucher_id BIGINT NULL, + version BIGINT NOT NULL DEFAULT 0, + created_by BIGINT NOT NULL, + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_acc_event_public_id (public_id), + UNIQUE KEY uk_acc_event_source (event_type, source_type, source_public_id), + KEY idx_acc_event_period_status (period, status, business_date), + CONSTRAINT fk_acc_event_company FOREIGN KEY (company_id) REFERENCES md_company (id), + CONSTRAINT fk_acc_event_project FOREIGN KEY (project_id) REFERENCES md_project (id), + CONSTRAINT fk_acc_event_counterparty FOREIGN KEY (counterparty_id) REFERENCES md_counterparty (id), + CONSTRAINT fk_acc_event_created_by FOREIGN KEY (created_by) REFERENCES iam_user (id), + CONSTRAINT ck_acc_event_type CHECK (event_type IN ('RECEIPT', 'PAYABLE', 'PAYMENT', 'INVOICE')), + CONSTRAINT ck_acc_event_source_type CHECK (source_type IN ('RECEIPT', 'PAYABLE', 'PAYMENT', 'INVOICE')), + CONSTRAINT ck_acc_event_amount CHECK (amount > 0), + CONSTRAINT ck_acc_event_status CHECK (status IN ('PENDING', 'DRAFTED', 'COMPLETED', 'VOID')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE acc_voucher ( + id BIGINT NOT NULL AUTO_INCREMENT, + public_id CHAR(26) NOT NULL, + business_no VARCHAR(64) NOT NULL, + event_id BIGINT NOT NULL, + company_id BIGINT NOT NULL, + project_id BIGINT NULL, + period CHAR(7) NOT NULL, + business_date DATE NOT NULL, + summary VARCHAR(500) NOT NULL, + debit_total DECIMAL(20, 2) NOT NULL DEFAULT 0, + credit_total DECIMAL(20, 2) NOT NULL DEFAULT 0, + status VARCHAR(32) NOT NULL DEFAULT 'DRAFT', + submitted_by BIGINT NULL, + reviewed_by BIGINT NULL, + exported_at TIMESTAMP(3) NULL, + export_sha256 CHAR(64) NULL, + external_voucher_no VARCHAR(100) NULL, + result_recorded_by BIGINT NULL, + result_verified_by BIGINT NULL, + version BIGINT NOT NULL DEFAULT 0, + created_by BIGINT NOT NULL, + updated_by BIGINT NOT NULL, + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_acc_voucher_public_id (public_id), + UNIQUE KEY uk_acc_voucher_business_no (business_no), + UNIQUE KEY uk_acc_voucher_event (event_id), + KEY idx_acc_voucher_period_status (period, status, business_date), + CONSTRAINT fk_acc_voucher_event FOREIGN KEY (event_id) REFERENCES acc_event (id), + CONSTRAINT fk_acc_voucher_company FOREIGN KEY (company_id) REFERENCES md_company (id), + CONSTRAINT fk_acc_voucher_project FOREIGN KEY (project_id) REFERENCES md_project (id), + CONSTRAINT fk_acc_voucher_submitted_by FOREIGN KEY (submitted_by) REFERENCES iam_user (id), + CONSTRAINT fk_acc_voucher_reviewed_by FOREIGN KEY (reviewed_by) REFERENCES iam_user (id), + CONSTRAINT fk_acc_voucher_result_recorded_by FOREIGN KEY (result_recorded_by) REFERENCES iam_user (id), + CONSTRAINT fk_acc_voucher_result_verified_by FOREIGN KEY (result_verified_by) REFERENCES iam_user (id), + CONSTRAINT fk_acc_voucher_created_by FOREIGN KEY (created_by) REFERENCES iam_user (id), + CONSTRAINT fk_acc_voucher_updated_by FOREIGN KEY (updated_by) REFERENCES iam_user (id), + CONSTRAINT ck_acc_voucher_totals CHECK (debit_total >= 0 AND credit_total >= 0), + CONSTRAINT ck_acc_voucher_status CHECK (status IN ('DRAFT', 'REVIEWING', 'RETURNED', 'APPROVED', 'EXPORTED', 'RESULT_PENDING', 'COMPLETED', 'VOID')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +ALTER TABLE acc_event + ADD CONSTRAINT fk_acc_event_voucher FOREIGN KEY (voucher_id) REFERENCES acc_voucher (id); + +CREATE TABLE acc_voucher_entry ( + id BIGINT NOT NULL AUTO_INCREMENT, + public_id CHAR(26) NOT NULL, + voucher_id BIGINT NOT NULL, + line_no INT NOT NULL, + direction VARCHAR(8) NOT NULL, + account_id BIGINT NOT NULL, + amount DECIMAL(20, 2) NOT NULL, + summary VARCHAR(500) NOT NULL, + auxiliary_json JSON NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_acc_voucher_entry_public_id (public_id), + UNIQUE KEY uk_acc_voucher_entry_line (voucher_id, line_no), + CONSTRAINT fk_acc_voucher_entry_voucher FOREIGN KEY (voucher_id) REFERENCES acc_voucher (id), + CONSTRAINT fk_acc_voucher_entry_account FOREIGN KEY (account_id) REFERENCES md_account (id), + CONSTRAINT ck_acc_voucher_entry_line CHECK (line_no > 0), + CONSTRAINT ck_acc_voucher_entry_direction CHECK (direction IN ('DEBIT', 'CREDIT')), + CONSTRAINT ck_acc_voucher_entry_amount CHECK (amount > 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE arc_package ( + id BIGINT NOT NULL AUTO_INCREMENT, + public_id CHAR(26) NOT NULL, + business_no VARCHAR(64) NOT NULL, + company_id BIGINT NOT NULL, + project_id BIGINT NOT NULL, + package_version INT NOT NULL, + completeness DECIMAL(5, 2) NOT NULL DEFAULT 0, + missing_count INT NOT NULL DEFAULT 0, + retention_years INT NOT NULL DEFAULT 10, + physical_location VARCHAR(500) NULL, + frozen BOOLEAN NOT NULL DEFAULT FALSE, + freeze_reason VARCHAR(1000) NULL, + status VARCHAR(32) NOT NULL DEFAULT 'PREPARING', + submitted_by BIGINT NULL, + reviewed_by BIGINT NULL, + archived_at TIMESTAMP(3) NULL, + version BIGINT NOT NULL DEFAULT 0, + created_by BIGINT NOT NULL, + updated_by BIGINT NOT NULL, + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_arc_package_public_id (public_id), + UNIQUE KEY uk_arc_package_business_no (business_no), + UNIQUE KEY uk_arc_package_project_version (project_id, package_version), + KEY idx_arc_package_status (status, updated_at), + CONSTRAINT fk_arc_package_company FOREIGN KEY (company_id) REFERENCES md_company (id), + CONSTRAINT fk_arc_package_project FOREIGN KEY (project_id) REFERENCES md_project (id), + CONSTRAINT fk_arc_package_submitted_by FOREIGN KEY (submitted_by) REFERENCES iam_user (id), + CONSTRAINT fk_arc_package_reviewed_by FOREIGN KEY (reviewed_by) REFERENCES iam_user (id), + CONSTRAINT fk_arc_package_created_by FOREIGN KEY (created_by) REFERENCES iam_user (id), + CONSTRAINT fk_arc_package_updated_by FOREIGN KEY (updated_by) REFERENCES iam_user (id), + CONSTRAINT ck_arc_package_completeness CHECK (completeness >= 0 AND completeness <= 100 AND missing_count >= 0), + CONSTRAINT ck_arc_package_version CHECK (package_version > 0 AND retention_years > 0), + CONSTRAINT ck_arc_package_status CHECK (status IN ('PREPARING', 'CHECKED', 'REVIEWING', 'RETURNED', 'ARCHIVED', 'FROZEN')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE arc_package_item ( + id BIGINT NOT NULL AUTO_INCREMENT, + public_id CHAR(26) NOT NULL, + package_id BIGINT NOT NULL, + item_type VARCHAR(64) NOT NULL, + item_name VARCHAR(200) NOT NULL, + object_type VARCHAR(80) NULL, + object_public_id CHAR(26) NULL, + file_id BIGINT NULL, + file_sha256 CHAR(64) NULL, + required_flag BOOLEAN NOT NULL DEFAULT TRUE, + status VARCHAR(32) NOT NULL, + missing_reason VARCHAR(500) NULL, + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_arc_package_item_public_id (public_id), + KEY idx_arc_package_item_package (package_id, status), + CONSTRAINT fk_arc_package_item_package FOREIGN KEY (package_id) REFERENCES arc_package (id), + CONSTRAINT fk_arc_package_item_file FOREIGN KEY (file_id) REFERENCES file_object (id), + CONSTRAINT ck_arc_package_item_status CHECK (status IN ('PRESENT', 'MISSING', 'NOT_APPLICABLE', 'SUPERSEDED')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE arc_borrow ( + id BIGINT NOT NULL AUTO_INCREMENT, + public_id CHAR(26) NOT NULL, + business_no VARCHAR(64) NOT NULL, + file_id BIGINT NOT NULL, + applicant_id BIGINT NOT NULL, + purpose VARCHAR(500) NOT NULL, + due_at TIMESTAMP(3) NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'APPLIED', + approved_by BIGINT NULL, + approved_at TIMESTAMP(3) NULL, + returned_by BIGINT NULL, + returned_at TIMESTAMP(3) NULL, + version BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_arc_borrow_public_id (public_id), + UNIQUE KEY uk_arc_borrow_business_no (business_no), + KEY idx_arc_borrow_status_due (status, due_at), + CONSTRAINT fk_arc_borrow_file FOREIGN KEY (file_id) REFERENCES file_object (id), + CONSTRAINT fk_arc_borrow_applicant FOREIGN KEY (applicant_id) REFERENCES iam_user (id), + CONSTRAINT fk_arc_borrow_approved_by FOREIGN KEY (approved_by) REFERENCES iam_user (id), + CONSTRAINT fk_arc_borrow_returned_by FOREIGN KEY (returned_by) REFERENCES iam_user (id), + CONSTRAINT ck_arc_borrow_status CHECK (status IN ('APPLIED', 'APPROVED', 'BORROWED', 'RETURNED', 'REJECTED', 'OVERDUE')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +INSERT INTO iam_permission (code, name) +SELECT item.code, item.name +FROM JSON_TABLE( + '[ + {"code":"masterdata:bank-account:view","name":"查看银行账户主数据"}, + {"code":"masterdata:bank-account:create","name":"创建银行账户主数据"}, + {"code":"masterdata:bank-account:edit","name":"编辑银行账户主数据"}, + {"code":"masterdata:bank-account:submit","name":"提交银行账户复核"}, + {"code":"masterdata:bank-account:review","name":"复核银行账户"}, + {"code":"masterdata:bank-account:disable","name":"停用银行账户"}, + {"code":"masterdata:contract:view","name":"查看合同主数据"}, + {"code":"masterdata:contract:create","name":"创建合同主数据"}, + {"code":"masterdata:contract:edit","name":"编辑合同主数据"}, + {"code":"masterdata:contract:submit","name":"提交合同复核"}, + {"code":"masterdata:contract:review","name":"复核合同"}, + {"code":"masterdata:contract:disable","name":"停用合同"}, + {"code":"masterdata:dictionary:view","name":"查看财务字典"}, + {"code":"masterdata:dictionary:create","name":"创建财务字典"}, + {"code":"masterdata:dictionary:edit","name":"编辑财务字典"}, + {"code":"masterdata:dictionary:submit","name":"提交财务字典复核"}, + {"code":"masterdata:dictionary:review","name":"复核财务字典"}, + {"code":"masterdata:dictionary:disable","name":"停用财务字典"}, + {"code":"contractcost:ledger:view","name":"查看合同成本台账"}, + {"code":"contractcost:ledger:export","name":"导出合同成本台账"}, + {"code":"contractcost:contract:maintain","name":"维护合同变更和结算"}, + {"code":"contractcost:payable:view","name":"查看应付"}, + {"code":"contractcost:payable:confirm","name":"确认应付"}, + {"code":"receivable:receipt:view","name":"查看收款"}, + {"code":"receivable:receipt:confirm","name":"确认收款"}, + {"code":"receivable:receipt:allocate","name":"分配收款"}, + {"code":"receivable:invoice:view","name":"查看发票"}, + {"code":"receivable:invoice:create","name":"创建开票申请"}, + {"code":"receivable:invoice:review","name":"复核开票申请"}, + {"code":"receivable:invoice:record-result","name":"登记人工开票结果"}, + {"code":"receivable:invoice:void","name":"作废或红冲发票"}, + {"code":"payment:request:view","name":"查看付款申请"}, + {"code":"payment:request:create","name":"创建付款申请"}, + {"code":"payment:request:review","name":"财务检查付款"}, + {"code":"payment:request:approve","name":"批准付款"}, + {"code":"payment:request:record-result","name":"登记线下付款结果"}, + {"code":"payment:request:verify-result","name":"复核线下付款结果"}, + {"code":"payment:request:refund","name":"登记退汇"}, + {"code":"accounting:event:view","name":"查看财务事件"}, + {"code":"accounting:event:generate","name":"生成凭证草稿"}, + {"code":"accounting:voucher:view","name":"查看凭证草稿"}, + {"code":"accounting:voucher:edit","name":"编辑凭证草稿"}, + {"code":"accounting:voucher:review","name":"复核凭证草稿"}, + {"code":"accounting:voucher:export","name":"导出凭证"}, + {"code":"accounting:voucher:record-result","name":"登记人工记账结果"}, + {"code":"accounting:voucher:verify-result","name":"复核人工记账结果"}, + {"code":"accounting:voucher:void","name":"作废凭证"}, + {"code":"archive:package:view","name":"查看档案包"}, + {"code":"archive:package:create","name":"创建档案包"}, + {"code":"archive:package:submit","name":"提交档案包"}, + {"code":"archive:package:review","name":"审核档案包"}, + {"code":"archive:package:archive","name":"确认归档"}, + {"code":"archive:file:view","name":"检索文件"}, + {"code":"archive:file:preview","name":"预览文件"}, + {"code":"archive:file:download","name":"下载档案文件"}, + {"code":"archive:borrow:apply","name":"申请借阅"}, + {"code":"archive:borrow:approve","name":"批准借阅"}, + {"code":"archive:borrow:return","name":"登记归还"}, + {"code":"report:finance:view","name":"查看财务报表"}, + {"code":"report:finance:export","name":"导出财务报表"}, + {"code":"audit:log:view","name":"查看审计日志"}, + {"code":"audit:log:export","name":"导出审计日志"}, + {"code":"admin:user:view","name":"查看用户配置"}, + {"code":"admin:user:create","name":"创建用户配置"}, + {"code":"admin:user:edit","name":"编辑用户配置"}, + {"code":"admin:user:enable","name":"启用用户"}, + {"code":"admin:user:disable","name":"停用用户"}, + {"code":"admin:role:view","name":"查看角色配置"}, + {"code":"admin:role:edit","name":"编辑角色配置"}, + {"code":"admin:scope:view","name":"查看数据范围"}, + {"code":"admin:scope:create","name":"创建数据范围"}, + {"code":"admin:scope:edit","name":"编辑数据范围"}, + {"code":"admin:template:view","name":"查看模板配置"}, + {"code":"admin:template:publish","name":"发布模板配置"}, + {"code":"admin:parameter:view","name":"查看参数版本"}, + {"code":"admin:parameter:create","name":"创建参数版本"}, + {"code":"admin:parameter:publish","name":"发布参数版本"} + ]', + '$[*]' COLUMNS (code VARCHAR(100) PATH '$.code', name VARCHAR(100) PATH '$.name') +) item +WHERE NOT EXISTS (SELECT 1 FROM iam_permission existing WHERE existing.code = item.code); + +INSERT INTO iam_role_permission (role_id, permission_id) +SELECT role.id, permission.id +FROM iam_role role +JOIN iam_permission permission +WHERE role.code = 'PROJECT_MANAGER' + AND (permission.code LIKE 'contractcost:%' + OR permission.code IN ('payment:request:view', 'payment:request:create', + 'archive:package:view', 'archive:package:create', 'archive:package:submit', + 'archive:file:view', 'archive:file:preview', 'archive:file:download', + 'report:finance:view', 'report:finance:export')) + AND NOT EXISTS (SELECT 1 FROM iam_role_permission existing WHERE existing.role_id = role.id AND existing.permission_id = permission.id); + +INSERT INTO iam_role_permission (role_id, permission_id) +SELECT role.id, permission.id +FROM iam_role role +JOIN iam_permission permission +WHERE role.code = 'FINANCE_MANAGER' + AND (permission.code LIKE 'masterdata:%' + OR permission.code LIKE 'contractcost:%' + OR permission.code LIKE 'receivable:%' + OR permission.code LIKE 'payment:request:%' + OR permission.code LIKE 'accounting:%' + OR permission.code LIKE 'archive:%' + OR permission.code LIKE 'report:finance:%' + OR permission.code LIKE 'audit:log:%') + AND NOT EXISTS (SELECT 1 FROM iam_role_permission existing WHERE existing.role_id = role.id AND existing.permission_id = permission.id); + +INSERT INTO iam_role_permission (role_id, permission_id) +SELECT role.id, permission.id +FROM iam_role role +JOIN iam_permission permission +WHERE role.code = 'ARCHIVE_MANAGER' + AND (permission.code LIKE 'archive:%' OR permission.code LIKE 'report:finance:%') + AND NOT EXISTS (SELECT 1 FROM iam_role_permission existing WHERE existing.role_id = role.id AND existing.permission_id = permission.id); + +DELETE assignment +FROM iam_role_permission assignment +JOIN iam_role role ON role.id = assignment.role_id AND role.code = 'SYSTEM_ADMIN' +JOIN iam_permission permission ON permission.id = assignment.permission_id +WHERE permission.code NOT IN ('profile:read', 'audit:read', 'audit:log:view', 'audit:log:export', 'system:configure') + AND permission.code NOT LIKE 'admin:%'; + +INSERT INTO iam_role_permission (role_id, permission_id) +SELECT role.id, permission.id +FROM iam_role role +JOIN iam_permission permission +WHERE role.code = 'SYSTEM_ADMIN' + AND (permission.code LIKE 'admin:%' OR permission.code LIKE 'audit:log:%') + AND NOT EXISTS (SELECT 1 FROM iam_role_permission existing WHERE existing.role_id = role.id AND existing.permission_id = permission.id); + +INSERT INTO md_cost_category (public_id, code, name, status) +SELECT seed.public_id, seed.code, seed.name, 'ACTIVE' +FROM JSON_TABLE( + '[ + {"publicId":"00000000000000000000000301","code":"LABOR","name":"人工"}, + {"publicId":"00000000000000000000000302","code":"MATERIAL","name":"材料"}, + {"publicId":"00000000000000000000000303","code":"MACHINERY","name":"机械"}, + {"publicId":"00000000000000000000000304","code":"SUBCONTRACT","name":"专业分包"}, + {"publicId":"00000000000000000000000305","code":"TRANSPORT","name":"运输"}, + {"publicId":"00000000000000000000000306","code":"EQUIPMENT","name":"设备"}, + {"publicId":"00000000000000000000000307","code":"INDIRECT","name":"间接费"}, + {"publicId":"00000000000000000000000308","code":"OTHER","name":"其他成本"} + ]', + '$[*]' COLUMNS (public_id CHAR(26) PATH '$.publicId', code VARCHAR(64) PATH '$.code', name VARCHAR(128) PATH '$.name') +) seed +WHERE NOT EXISTS (SELECT 1 FROM md_cost_category existing WHERE existing.code = seed.code); + +INSERT INTO md_account (public_id, code, name, account_type, auxiliary_required, status, created_by, updated_by) +SELECT seed.public_id, seed.code, seed.name, seed.account_type, seed.auxiliary_required, 'ACTIVE', user_account.id, user_account.id +FROM JSON_TABLE( + '[ + {"publicId":"00000000000000000000000311","code":"1002","name":"银行存款","type":"ASSET","aux":false}, + {"publicId":"00000000000000000000000312","code":"1122","name":"应收账款","type":"ASSET","aux":true}, + {"publicId":"00000000000000000000000313","code":"2202","name":"应付账款","type":"LIABILITY","aux":true}, + {"publicId":"00000000000000000000000314","code":"5401","name":"工程施工成本","type":"COST","aux":true}, + {"publicId":"00000000000000000000000315","code":"6001","name":"主营业务收入","type":"INCOME","aux":true} + ]', + '$[*]' COLUMNS (public_id CHAR(26) PATH '$.publicId', code VARCHAR(64) PATH '$.code', name VARCHAR(128) PATH '$.name', account_type VARCHAR(32) PATH '$.type', auxiliary_required BOOLEAN PATH '$.aux') +) seed +JOIN iam_user user_account ON user_account.username = 'finance' +WHERE NOT EXISTS (SELECT 1 FROM md_account existing WHERE existing.code = seed.code); + +INSERT INTO md_tax_rate (public_id, code, name, rate, status, created_by, updated_by) +SELECT seed.public_id, seed.code, seed.name, seed.rate, 'ACTIVE', user_account.id, user_account.id +FROM JSON_TABLE( + '[ + {"publicId":"00000000000000000000000321","code":"VAT-0","name":"免税/零税率","rate":0}, + {"publicId":"00000000000000000000000322","code":"VAT-3","name":"增值税 3%","rate":0.03}, + {"publicId":"00000000000000000000000323","code":"VAT-6","name":"增值税 6%","rate":0.06}, + {"publicId":"00000000000000000000000324","code":"VAT-9","name":"增值税 9%","rate":0.09}, + {"publicId":"00000000000000000000000325","code":"VAT-13","name":"增值税 13%","rate":0.13} + ]', + '$[*]' COLUMNS (public_id CHAR(26) PATH '$.publicId', code VARCHAR(64) PATH '$.code', name VARCHAR(128) PATH '$.name', rate DECIMAL(10,6) PATH '$.rate') +) seed +JOIN iam_user user_account ON user_account.username = 'finance' +WHERE NOT EXISTS (SELECT 1 FROM md_tax_rate existing WHERE existing.code = seed.code); diff --git a/backend/src/main/resources/db/migration/V011__receivable_workflow_controls.sql b/backend/src/main/resources/db/migration/V011__receivable_workflow_controls.sql new file mode 100644 index 0000000..f1bdcef --- /dev/null +++ b/backend/src/main/resources/db/migration/V011__receivable_workflow_controls.sql @@ -0,0 +1,52 @@ +ALTER TABLE fin_invoice + ADD COLUMN original_invoice_id BIGINT NULL AFTER result_file_id, + ADD KEY idx_fin_invoice_original (original_invoice_id), + ADD CONSTRAINT fk_fin_invoice_original FOREIGN KEY (original_invoice_id) REFERENCES fin_invoice (id); + +UPDATE fin_receipt SET status = 'PARTIALLY_ALLOCATED' WHERE status = 'PART_ALLOCATED'; +UPDATE fin_receipt SET status = 'CONFIRMED' WHERE status = 'UNALLOCATED'; + +ALTER TABLE fin_receipt + DROP CHECK ck_fin_receipt_status, + ADD CONSTRAINT ck_fin_receipt_status CHECK ( + status IN ('DRAFT', 'CONFIRMED', 'PARTIALLY_ALLOCATED', 'ALLOCATED', 'VOID') + ); + +UPDATE fin_invoice SET status = 'RED_LETTERED' WHERE status = 'RED_LETTER'; + +ALTER TABLE fin_invoice + DROP CHECK ck_fin_invoice_type, + DROP CHECK ck_fin_invoice_status, + ADD CONSTRAINT ck_fin_invoice_type CHECK ( + invoice_type IN ('SPECIAL', 'NORMAL', 'RECEIPT', 'RED_LETTER') + ), + ADD CONSTRAINT ck_fin_invoice_status CHECK ( + status IN ( + 'DRAFT', 'REVIEWING', 'RETURNED', 'REJECTED', 'APPROVED', + 'RESULT_PENDING', 'ISSUED', 'VOID', 'RED_LETTERED' + ) + ); + +INSERT IGNORE INTO iam_permission (code, name) VALUES + ('receivable:receipt:create', '登记收款确认单'), + ('receivable:receipt:edit', '编辑收款草稿'), + ('receivable:receipt:export', '导出收款台账'), + ('receivable:invoice:edit', '编辑开票申请草稿'), + ('receivable:invoice:submit', '提交开票申请'), + ('receivable:invoice:export', '导出发票台账'), + ('receivable:invoice:red-letter', '登记发票红冲'); + +INSERT INTO iam_role_permission (role_id, permission_id) +SELECT role.id, permission.id +FROM iam_role role +JOIN iam_permission permission +WHERE role.code = 'FINANCE_MANAGER' + AND permission.code IN ( + 'receivable:receipt:create', 'receivable:receipt:edit', 'receivable:receipt:export', + 'receivable:invoice:edit', 'receivable:invoice:submit', + 'receivable:invoice:export', 'receivable:invoice:red-letter' + ) + AND NOT EXISTS ( + SELECT 1 FROM iam_role_permission existing + WHERE existing.role_id = role.id AND existing.permission_id = permission.id + ); diff --git a/backend/src/main/resources/db/migration/V012__payment_workflow_controls.sql b/backend/src/main/resources/db/migration/V012__payment_workflow_controls.sql new file mode 100644 index 0000000..c305ac8 --- /dev/null +++ b/backend/src/main/resources/db/migration/V012__payment_workflow_controls.sql @@ -0,0 +1,119 @@ +UPDATE fin_payment_request SET status = 'REVIEWING' WHERE status = 'FINANCE_CHECK'; +UPDATE fin_payment_request SET status = 'RESULT_RECORDED' WHERE status = 'RESULT_PENDING'; + +ALTER TABLE fin_payment_request + DROP CHECK ck_fin_payment_status, + ADD COLUMN source_event_key CHAR(64) NULL AFTER source_document_id, + ADD COLUMN original_payment_id BIGINT NULL AFTER source_event_key, + ADD COLUMN repayment_sequence INT NOT NULL DEFAULT 0 AFTER original_payment_id, + ADD COLUMN frozen_amount DECIMAL(20, 2) NOT NULL DEFAULT 0 AFTER approved_amount, + ADD COLUMN check_snapshot_json JSON NULL AFTER purpose, + ADD COLUMN check_rule_version VARCHAR(32) NULL AFTER check_snapshot_json, + ADD COLUMN submitted_at TIMESTAMP(3) NULL AFTER submitted_by, + ADD COLUMN checked_at TIMESTAMP(3) NULL AFTER checked_by, + ADD COLUMN approved_at TIMESTAMP(3) NULL AFTER approved_by, + ADD COLUMN returned_by BIGINT NULL AFTER approved_at, + ADD COLUMN returned_at TIMESTAMP(3) NULL AFTER returned_by, + ADD COLUMN return_reason VARCHAR(1000) NULL AFTER returned_at, + ADD UNIQUE KEY uk_fin_payment_source_event (source_event_key), + ADD UNIQUE KEY uk_fin_payment_repayment (original_payment_id, repayment_sequence), + ADD KEY idx_fin_payment_account_status (bank_account_version_id, status), + ADD CONSTRAINT fk_fin_payment_original FOREIGN KEY (original_payment_id) REFERENCES fin_payment_request (id), + ADD CONSTRAINT fk_fin_payment_returned_by FOREIGN KEY (returned_by) REFERENCES iam_user (id), + ADD CONSTRAINT ck_fin_payment_frozen CHECK ( + frozen_amount >= 0 AND frozen_amount <= COALESCE(approved_amount, requested_amount) + ), + ADD CONSTRAINT ck_fin_payment_repayment_sequence CHECK ( + (original_payment_id IS NULL AND repayment_sequence = 0) + OR (original_payment_id IS NOT NULL AND repayment_sequence > 0) + ), + ADD CONSTRAINT ck_fin_payment_status CHECK ( + status IN ( + 'DRAFT', 'REVIEWING', 'RETURNED', 'REJECTED', 'APPROVED', + 'OFFLINE_PENDING', 'RESULT_RECORDED', 'RESULT_VERIFIED', + 'PAID', 'FAILED', 'REFUNDED', 'VOID' + ) + ); + +ALTER TABLE fin_payment_result + ADD COLUMN result_no VARCHAR(64) NULL AFTER public_id, + ADD COLUMN remark VARCHAR(1000) NULL AFTER failure_reason, + ADD COLUMN verification_opinion VARCHAR(1000) NULL AFTER verified_at, + ADD CONSTRAINT ck_fin_payment_result_sod CHECK (verified_by IS NULL OR verified_by <> recorded_by); + +UPDATE fin_payment_result +SET result_no = CONCAT('PAY-RESULT-', public_id) +WHERE result_no IS NULL; + +ALTER TABLE fin_payment_result + MODIFY COLUMN result_no VARCHAR(64) NOT NULL, + ADD UNIQUE KEY uk_fin_payment_result_no (result_no); + +CREATE TABLE fin_payment_attachment ( + id BIGINT NOT NULL AUTO_INCREMENT, + public_id CHAR(26) NOT NULL, + payment_id BIGINT NOT NULL, + file_id BIGINT NOT NULL, + document_type VARCHAR(64) NOT NULL DEFAULT 'PAYMENT_SUPPORT', + active BOOLEAN NOT NULL DEFAULT TRUE, + uploaded_by BIGINT NOT NULL, + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_fin_payment_attachment_public_id (public_id), + UNIQUE KEY uk_fin_payment_attachment_file (payment_id, file_id, document_type), + KEY idx_fin_payment_attachment_payment (payment_id, active), + CONSTRAINT fk_fin_payment_attachment_payment FOREIGN KEY (payment_id) REFERENCES fin_payment_request (id), + CONSTRAINT fk_fin_payment_attachment_file FOREIGN KEY (file_id) REFERENCES file_object (id), + CONSTRAINT fk_fin_payment_attachment_user FOREIGN KEY (uploaded_by) REFERENCES iam_user (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE fin_payment_result_file ( + id BIGINT NOT NULL AUTO_INCREMENT, + public_id CHAR(26) NOT NULL, + payment_result_id BIGINT NOT NULL, + file_id BIGINT NOT NULL, + created_by BIGINT NOT NULL, + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_fin_payment_result_file_public_id (public_id), + UNIQUE KEY uk_fin_payment_result_file (payment_result_id, file_id), + KEY idx_fin_payment_result_file_result (payment_result_id), + CONSTRAINT fk_fin_payment_result_file_result FOREIGN KEY (payment_result_id) REFERENCES fin_payment_result (id), + CONSTRAINT fk_fin_payment_result_file_object FOREIGN KEY (file_id) REFERENCES file_object (id), + CONSTRAINT fk_fin_payment_result_file_user FOREIGN KEY (created_by) REFERENCES iam_user (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE fin_payment_refund ( + id BIGINT NOT NULL AUTO_INCREMENT, + public_id CHAR(26) NOT NULL, + payment_result_id BIGINT NOT NULL, + sequence_no INT NOT NULL, + amount DECIMAL(20, 2) NOT NULL, + reason VARCHAR(1000) NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'RECORDED', + created_by BIGINT NOT NULL, + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_fin_payment_refund_public_id (public_id), + UNIQUE KEY uk_fin_payment_refund_sequence (payment_result_id, sequence_no), + KEY idx_fin_payment_refund_status (status, created_at), + CONSTRAINT fk_fin_payment_refund_result FOREIGN KEY (payment_result_id) REFERENCES fin_payment_result (id), + CONSTRAINT fk_fin_payment_refund_user FOREIGN KEY (created_by) REFERENCES iam_user (id), + CONSTRAINT ck_fin_payment_refund_amount CHECK (amount > 0), + CONSTRAINT ck_fin_payment_refund_sequence CHECK (sequence_no > 0), + CONSTRAINT ck_fin_payment_refund_status CHECK (status IN ('RECORDED', 'VERIFIED', 'VOID')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +INSERT IGNORE INTO iam_permission (code, name) VALUES + ('payment:request:export', '导出付款进度台账'); + +INSERT INTO iam_role_permission (role_id, permission_id) +SELECT role.id, permission.id +FROM iam_role role +JOIN iam_permission permission +WHERE role.code IN ('PROJECT_MANAGER', 'FINANCE_MANAGER') + AND permission.code = 'payment:request:export' + AND NOT EXISTS ( + SELECT 1 FROM iam_role_permission existing + WHERE existing.role_id = role.id AND existing.permission_id = permission.id + ); diff --git a/backend/src/main/resources/db/migration/V013__accounting_preparation_workflow.sql b/backend/src/main/resources/db/migration/V013__accounting_preparation_workflow.sql new file mode 100644 index 0000000..24be4d3 --- /dev/null +++ b/backend/src/main/resources/db/migration/V013__accounting_preparation_workflow.sql @@ -0,0 +1,33 @@ +ALTER TABLE acc_voucher + ADD COLUMN export_batch_no VARCHAR(64) NULL AFTER export_sha256, + ADD COLUMN result_recorded_at TIMESTAMP(3) NULL AFTER result_recorded_by, + ADD COLUMN result_verified_at TIMESTAMP(3) NULL AFTER result_verified_by, + ADD COLUMN result_remark VARCHAR(1000) NULL AFTER result_verified_at, + ADD COLUMN void_reason VARCHAR(1000) NULL AFTER result_remark, + ADD COLUMN last_change_reason VARCHAR(1000) NULL AFTER void_reason; + +ALTER TABLE acc_voucher + ADD UNIQUE KEY uk_acc_voucher_external_no (company_id, period, external_voucher_no); + +ALTER TABLE acc_voucher + DROP CHECK ck_acc_voucher_status, + ADD CONSTRAINT ck_acc_voucher_status CHECK ( + status IN ('DRAFT', 'REVIEWING', 'RETURNED', 'APPROVED', 'EXPORTED', + 'RESULT_PENDING', 'RESULT_RECORDED', 'COMPLETED', 'VOID') + ); + +CREATE TABLE acc_voucher_change ( + id BIGINT NOT NULL AUTO_INCREMENT, + public_id CHAR(26) NOT NULL, + voucher_id BIGINT NOT NULL, + reason VARCHAR(1000) NOT NULL, + before_json JSON NOT NULL, + after_json JSON NOT NULL, + changed_by BIGINT NOT NULL, + changed_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_acc_voucher_change_public_id (public_id), + KEY idx_acc_voucher_change_voucher (voucher_id, changed_at), + CONSTRAINT fk_acc_voucher_change_voucher FOREIGN KEY (voucher_id) REFERENCES acc_voucher (id), + CONSTRAINT fk_acc_voucher_change_user FOREIGN KEY (changed_by) REFERENCES iam_user (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; diff --git a/backend/src/main/resources/db/migration/V014__archive_reporting_governance_contract.sql b/backend/src/main/resources/db/migration/V014__archive_reporting_governance_contract.sql new file mode 100644 index 0000000..b3ebadc --- /dev/null +++ b/backend/src/main/resources/db/migration/V014__archive_reporting_governance_contract.sql @@ -0,0 +1,130 @@ +ALTER TABLE arc_package + ADD COLUMN rule_version VARCHAR(64) NOT NULL DEFAULT 'ARCHIVE-RULE-V1' AFTER package_version, + ADD COLUMN manifest_json JSON NULL AFTER missing_count, + ADD COLUMN manifest_sha256 CHAR(64) NULL AFTER manifest_json, + ADD COLUMN submitted_at TIMESTAMP(3) NULL AFTER submitted_by, + ADD COLUMN review_opinion VARCHAR(1000) NULL AFTER reviewed_by, + ADD COLUMN freeze_status_before VARCHAR(32) NULL AFTER freeze_reason; + +ALTER TABLE arc_package_item + ADD COLUMN file_link_id BIGINT NULL AFTER file_id, + ADD COLUMN not_applicable_reason VARCHAR(500) NULL AFTER missing_reason, + ADD CONSTRAINT fk_arc_package_item_file_link FOREIGN KEY (file_link_id) REFERENCES file_link (id); + +ALTER TABLE arc_borrow + ADD COLUMN package_id BIGINT NULL AFTER file_id, + ADD COLUMN review_opinion VARCHAR(1000) NULL AFTER approved_at, + ADD COLUMN return_condition VARCHAR(500) NULL AFTER returned_at, + ADD CONSTRAINT fk_arc_borrow_package FOREIGN KEY (package_id) REFERENCES arc_package (id); + +ALTER TABLE file_link + ADD COLUMN company_public_id CHAR(26) NULL AFTER object_public_id, + ADD COLUMN project_public_id CHAR(26) NULL AFTER company_public_id, + ADD COLUMN contract_public_id CHAR(26) NULL AFTER project_public_id, + ADD COLUMN counterparty_public_id CHAR(26) NULL AFTER contract_public_id, + ADD COLUMN form_type VARCHAR(32) NULL AFTER counterparty_public_id, + ADD COLUMN original_type VARCHAR(64) NULL AFTER form_type, + ADD COLUMN archive_status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE' AFTER original_type, + ADD COLUMN supersedes_link_id BIGINT NULL AFTER archive_status, + ADD COLUMN not_applicable_reason VARCHAR(500) NULL AFTER supersedes_link_id, + ADD CONSTRAINT fk_file_link_supersedes FOREIGN KEY (supersedes_link_id) REFERENCES file_link (id), + ADD CONSTRAINT ck_file_link_archive_status CHECK (archive_status IN ('ACTIVE', 'SUPERSEDED', 'ARCHIVED', 'FROZEN')); + +CREATE INDEX idx_file_link_archive_project ON file_link (project_public_id, archive_status, created_at); +CREATE INDEX idx_file_link_archive_dimensions ON file_link (company_public_id, form_type, original_type, created_at); + +CREATE TABLE arc_package_action ( + id BIGINT NOT NULL AUTO_INCREMENT, + public_id CHAR(26) NOT NULL, + package_id BIGINT NOT NULL, + sequence_no INT NOT NULL, + action_code VARCHAR(32) NOT NULL, + from_status VARCHAR(32) NULL, + to_status VARCHAR(32) NOT NULL, + opinion VARCHAR(1000) NULL, + missing_json JSON NULL, + manifest_sha256 CHAR(64) NULL, + actor_id BIGINT NOT NULL, + occurred_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_arc_package_action_public_id (public_id), + UNIQUE KEY uk_arc_package_action_sequence (package_id, sequence_no), + KEY idx_arc_package_action_package (package_id, occurred_at), + CONSTRAINT fk_arc_package_action_package FOREIGN KEY (package_id) REFERENCES arc_package (id), + CONSTRAINT fk_arc_package_action_actor FOREIGN KEY (actor_id) REFERENCES iam_user (id), + CONSTRAINT ck_arc_package_action_code CHECK (action_code IN ('GENERATE', 'CHECK', 'SUBMIT', 'RETURN', 'ARCHIVE', 'FREEZE', 'UNFREEZE')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE export_log ( + id BIGINT NOT NULL AUTO_INCREMENT, + public_id CHAR(26) NOT NULL, + request_id CHAR(26) NOT NULL, + actor_public_id CHAR(26) NOT NULL, + identity_code VARCHAR(40) NULL, + resource_type VARCHAR(64) NOT NULL, + filter_json JSON NOT NULL, + columns_json JSON NOT NULL, + row_count INT NOT NULL, + sha256 CHAR(64) NOT NULL, + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_export_log_public_id (public_id), + UNIQUE KEY uk_export_log_request_id (request_id), + KEY idx_export_log_actor_created (actor_public_id, created_at), + KEY idx_export_log_resource_created (resource_type, created_at), + CONSTRAINT ck_export_log_row_count CHECK (row_count >= 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +ALTER TABLE iam_role + ADD COLUMN version BIGINT NOT NULL DEFAULT 0 AFTER enabled, + ADD COLUMN updated_by BIGINT NULL AFTER version, + ADD COLUMN updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + ADD CONSTRAINT fk_iam_role_updated_by FOREIGN KEY (updated_by) REFERENCES iam_user (id); + +INSERT INTO iam_permission (code, name) VALUES + ('archive:package:freeze', '冻结档案包'), + ('archive:package:unfreeze', '解除档案包冻结'), + ('archive:file:upload', '上传归档文件'), + ('archive:borrow:reject', '拒绝借阅'), + ('admin:role:create', '创建角色模板'), + ('admin:role:enable', '启用角色模板'), + ('admin:role:disable', '停用角色模板'), + ('admin:scope:enable', '启用数据范围'), + ('admin:scope:disable', '停用数据范围'), + ('admin:template:create', '创建表单模板'), + ('admin:template:edit', '编辑表单模板'), + ('admin:template:enable', '启用表单模板'), + ('admin:template:disable', '停用表单模板'), + ('admin:parameter:edit', '编辑参数版本'), + ('admin:parameter:enable', '启用参数版本'), + ('admin:parameter:disable', '停用参数版本') +ON DUPLICATE KEY UPDATE name = VALUES(name); + +INSERT INTO iam_role_permission (role_id, permission_id) +SELECT role.id, permission.id +FROM iam_role role +JOIN iam_permission permission +WHERE role.code = 'SYSTEM_ADMIN' + AND permission.code IN ( + 'archive:package:freeze', 'archive:package:unfreeze', 'archive:file:upload', + 'archive:borrow:reject', 'admin:role:create', 'admin:role:enable', 'admin:role:disable', + 'admin:scope:enable', 'admin:scope:disable', 'admin:template:create', 'admin:template:edit', + 'admin:template:enable', 'admin:template:disable', 'admin:parameter:edit', + 'admin:parameter:enable', 'admin:parameter:disable' + ) + AND NOT EXISTS ( + SELECT 1 FROM iam_role_permission existing + WHERE existing.role_id = role.id AND existing.permission_id = permission.id + ); + +INSERT INTO iam_role_permission (role_id, permission_id) +SELECT role.id, permission.id +FROM iam_role role +JOIN iam_permission permission +WHERE role.code IN ('ARCHIVE_MANAGER', 'FINANCE_MANAGER') + AND permission.code IN ('archive:package:freeze', 'archive:package:unfreeze', 'archive:file:upload', + 'archive:borrow:reject') + AND NOT EXISTS ( + SELECT 1 FROM iam_role_permission existing + WHERE existing.role_id = role.id AND existing.permission_id = permission.id + ); diff --git a/backend/src/main/resources/db/migration/V015__governance_parameter_version.sql b/backend/src/main/resources/db/migration/V015__governance_parameter_version.sql new file mode 100644 index 0000000..c71dd7a --- /dev/null +++ b/backend/src/main/resources/db/migration/V015__governance_parameter_version.sql @@ -0,0 +1,6 @@ +ALTER TABLE sys_parameter_version + ADD COLUMN version BIGINT NOT NULL DEFAULT 0 AFTER status, + ADD COLUMN updated_by CHAR(26) NULL AFTER created_by, + ADD COLUMN updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) + ON UPDATE CURRENT_TIMESTAMP(3) AFTER created_at, + ADD CONSTRAINT ck_sys_parameter_optimistic_version CHECK (version >= 0); diff --git a/backend/src/main/resources/db/migration/V016__contract_cost_command_controls.sql b/backend/src/main/resources/db/migration/V016__contract_cost_command_controls.sql new file mode 100644 index 0000000..543e080 --- /dev/null +++ b/backend/src/main/resources/db/migration/V016__contract_cost_command_controls.sql @@ -0,0 +1,7 @@ +ALTER TABLE md_contract + DROP CHECK ck_md_contract_amounts, + ADD CONSTRAINT ck_md_contract_amounts CHECK ( + original_amount >= 0 + AND original_amount + approved_change_amount >= 0 + AND (settlement_amount IS NULL OR settlement_amount >= 0) + ); diff --git a/backend/src/main/resources/db/migration/V017__receivable_ledger_and_invoice_controls.sql b/backend/src/main/resources/db/migration/V017__receivable_ledger_and_invoice_controls.sql new file mode 100644 index 0000000..8d9207c --- /dev/null +++ b/backend/src/main/resources/db/migration/V017__receivable_ledger_and_invoice_controls.sql @@ -0,0 +1,92 @@ +CREATE TABLE fin_receivable ( + id BIGINT NOT NULL AUTO_INCREMENT, + public_id CHAR(26) NOT NULL, + business_no VARCHAR(64) NOT NULL, + source_document_id BIGINT NULL, + source_invoice_id BIGINT NULL, + company_id BIGINT NOT NULL, + project_id BIGINT NOT NULL, + contract_id BIGINT NULL, + counterparty_id BIGINT NOT NULL, + business_date DATE NOT NULL, + amount DECIMAL(20, 2) NOT NULL, + outstanding_amount DECIMAL(20, 2) NOT NULL, + currency CHAR(3) NOT NULL DEFAULT 'CNY', + status VARCHAR(32) NOT NULL DEFAULT 'OPEN', + version BIGINT NOT NULL DEFAULT 0, + created_by BIGINT NOT NULL, + updated_by BIGINT NOT NULL, + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_fin_receivable_public_id (public_id), + UNIQUE KEY uk_fin_receivable_business_no (business_no), + UNIQUE KEY uk_fin_receivable_source_invoice (source_invoice_id), + KEY idx_fin_receivable_project_status (project_id, status, business_date), + KEY idx_fin_receivable_counterparty_status (counterparty_id, status), + CONSTRAINT fk_fin_receivable_source FOREIGN KEY (source_document_id) REFERENCES src_source_document (id), + CONSTRAINT fk_fin_receivable_invoice FOREIGN KEY (source_invoice_id) REFERENCES fin_invoice (id), + CONSTRAINT fk_fin_receivable_company FOREIGN KEY (company_id) REFERENCES md_company (id), + CONSTRAINT fk_fin_receivable_project FOREIGN KEY (project_id) REFERENCES md_project (id), + CONSTRAINT fk_fin_receivable_contract FOREIGN KEY (contract_id) REFERENCES md_contract (id), + CONSTRAINT fk_fin_receivable_counterparty FOREIGN KEY (counterparty_id) REFERENCES md_counterparty (id), + CONSTRAINT fk_fin_receivable_created_by FOREIGN KEY (created_by) REFERENCES iam_user (id), + CONSTRAINT fk_fin_receivable_updated_by FOREIGN KEY (updated_by) REFERENCES iam_user (id), + CONSTRAINT ck_fin_receivable_amounts CHECK ( + amount > 0 AND outstanding_amount >= 0 AND outstanding_amount <= amount + ), + CONSTRAINT ck_fin_receivable_status CHECK ( + status IN ('OPEN', 'PARTIALLY_SETTLED', 'SETTLED', 'VOID') + ) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +ALTER TABLE fin_receipt + ADD COLUMN void_reason VARCHAR(1000) NULL AFTER confirmed_at, + ADD UNIQUE KEY uk_fin_receipt_company_reference (company_id, reference_no); + +ALTER TABLE fin_receipt_allocation + ADD COLUMN receivable_id BIGINT NULL AFTER receipt_id, + ADD COLUMN sequence_no INT NULL AFTER receivable_id, + ADD UNIQUE KEY uk_fin_receipt_allocation_sequence (receipt_id, receivable_id, sequence_no), + ADD KEY idx_fin_receipt_allocation_receivable (receivable_id), + ADD CONSTRAINT fk_fin_receipt_allocation_receivable + FOREIGN KEY (receivable_id) REFERENCES fin_receivable (id), + ADD CONSTRAINT ck_fin_receipt_allocation_sequence CHECK (sequence_no IS NULL OR sequence_no > 0); + +ALTER TABLE fin_invoice + ADD COLUMN submitted_at TIMESTAMP(3) NULL AFTER submitted_by, + ADD COLUMN reviewed_at TIMESTAMP(3) NULL AFTER reviewed_by, + ADD COLUMN review_opinion VARCHAR(1000) NULL AFTER reviewed_at, + ADD COLUMN result_recorded_at TIMESTAMP(3) NULL AFTER result_recorded_by, + ADD COLUMN termination_reason VARCHAR(1000) NULL AFTER result_recorded_at, + ADD UNIQUE KEY uk_fin_invoice_company_no (company_id, invoice_no); + +INSERT IGNORE INTO iam_permission (code, name) VALUES + ('receivable:receipt:void', '作废收款记录'); + +INSERT INTO iam_role_permission (role_id, permission_id) +SELECT role.id, permission.id +FROM iam_role role +JOIN iam_permission permission +WHERE role.code = 'FINANCE_MANAGER' + AND permission.code = 'receivable:receipt:void' + AND NOT EXISTS ( + SELECT 1 FROM iam_role_permission existing + WHERE existing.role_id = role.id AND existing.permission_id = permission.id + ); + +-- Preserve the same scope boundaries when upgrading existing non-bootstrap environments. +INSERT IGNORE INTO iam_scope ( + user_id, role_id, permission_id, scope_type, company_public_id, project_public_id, + amount_limit, valid_from, valid_to, status +) +SELECT source_scope.user_id, source_scope.role_id, target_permission.id, + source_scope.scope_type, source_scope.company_public_id, source_scope.project_public_id, + source_scope.amount_limit, source_scope.valid_from, source_scope.valid_to, source_scope.status +FROM iam_scope source_scope +JOIN iam_permission source_permission ON source_permission.id = source_scope.permission_id +JOIN iam_permission target_permission ON target_permission.code = 'receivable:receipt:void' +JOIN iam_role_permission target_assignment + ON target_assignment.role_id = source_scope.role_id + AND target_assignment.permission_id = target_permission.id +WHERE source_permission.code = 'receivable:receipt:confirm'; diff --git a/backend/src/main/resources/db/migration/V018__archive_workflow_hardening.sql b/backend/src/main/resources/db/migration/V018__archive_workflow_hardening.sql new file mode 100644 index 0000000..f60225b --- /dev/null +++ b/backend/src/main/resources/db/migration/V018__archive_workflow_hardening.sql @@ -0,0 +1,2 @@ +ALTER TABLE arc_borrow + MODIFY COLUMN due_at DATETIME(3) NOT NULL; diff --git a/backend/src/main/resources/db/migration/V019__reporting_scope_backfill.sql b/backend/src/main/resources/db/migration/V019__reporting_scope_backfill.sql new file mode 100644 index 0000000..7d0d27c --- /dev/null +++ b/backend/src/main/resources/db/migration/V019__reporting_scope_backfill.sql @@ -0,0 +1,26 @@ +-- Report permissions were introduced after the original per-user scopes. +-- Start PAGE-19 with the same boundary as project visibility; administrators +-- can narrow the generated report scopes independently after the upgrade. +INSERT IGNORE INTO iam_scope ( + user_id, role_id, permission_id, scope_type, company_public_id, project_public_id, + amount_limit, valid_from, valid_to, status +) +SELECT source_scope.user_id, + source_scope.role_id, + target_permission.id, + source_scope.scope_type, + source_scope.company_public_id, + source_scope.project_public_id, + source_scope.amount_limit, + source_scope.valid_from, + source_scope.valid_to, + source_scope.status +FROM iam_scope source_scope +JOIN iam_permission source_permission + ON source_permission.id = source_scope.permission_id +JOIN iam_permission target_permission + ON target_permission.code IN ('report:finance:view', 'report:finance:export') +JOIN iam_role_permission target_assignment + ON target_assignment.role_id = source_scope.role_id + AND target_assignment.permission_id = target_permission.id +WHERE source_permission.code = 'project:project:view'; diff --git a/backend/src/main/resources/db/migration/V020__project_timeline_scope_index.sql b/backend/src/main/resources/db/migration/V020__project_timeline_scope_index.sql new file mode 100644 index 0000000..f489eca --- /dev/null +++ b/backend/src/main/resources/db/migration/V020__project_timeline_scope_index.sql @@ -0,0 +1,17 @@ +-- PAGE-08 timeline reads by project scope. Business events now persist the +-- owning company/project in audit_log; keep the historical PROJECT-object +-- fallback in the query while making scoped lookups index-backed. +ALTER TABLE audit_log + ADD KEY idx_audit_log_project_created (project_public_id, created_at, id); + +-- Backfill the project-owned events written before scoped audit recording was +-- introduced. This is deliberately limited to PROJECT objects: inferring +-- scope for old cross-domain events can be ambiguous when an object has been +-- superseded or red-lettered. +UPDATE audit_log audit +JOIN md_project project ON project.public_id = audit.object_public_id +JOIN md_company company ON company.id = project.company_id +SET audit.company_public_id = company.public_id, + audit.project_public_id = project.public_id +WHERE audit.object_type = 'PROJECT' + AND (audit.company_public_id IS NULL OR audit.project_public_id IS NULL); diff --git a/backend/src/main/resources/db/migration/V021__project_import_workflow.sql b/backend/src/main/resources/db/migration/V021__project_import_workflow.sql new file mode 100644 index 0000000..12799b9 --- /dev/null +++ b/backend/src/main/resources/db/migration/V021__project_import_workflow.sql @@ -0,0 +1,46 @@ +CREATE TABLE project_import_batch ( + id BIGINT NOT NULL AUTO_INCREMENT, + public_id CHAR(26) NOT NULL, + batch_no VARCHAR(64) NOT NULL, + source_file_id BIGINT NOT NULL, + file_sha256 CHAR(64) NOT NULL, + total_rows INT NOT NULL DEFAULT 0, + valid_rows INT NOT NULL DEFAULT 0, + error_rows INT NOT NULL DEFAULT 0, + validation_json JSON NULL, + status VARCHAR(32) NOT NULL DEFAULT 'VALIDATING', + version BIGINT NOT NULL DEFAULT 0, + created_by BIGINT NOT NULL, + updated_by BIGINT NOT NULL, + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_project_import_batch_public_id (public_id), + UNIQUE KEY uk_project_import_batch_no (batch_no), + KEY idx_project_import_batch_hash (file_sha256), + KEY idx_project_import_batch_status (status, created_at), + CONSTRAINT fk_project_import_batch_file FOREIGN KEY (source_file_id) REFERENCES file_object (id), + CONSTRAINT fk_project_import_batch_created_by FOREIGN KEY (created_by) REFERENCES iam_user (id), + CONSTRAINT fk_project_import_batch_updated_by FOREIGN KEY (updated_by) REFERENCES iam_user (id), + CONSTRAINT ck_project_import_batch_counts CHECK ( + total_rows >= 0 AND valid_rows >= 0 AND error_rows >= 0 AND valid_rows + error_rows <= total_rows + ), + CONSTRAINT ck_project_import_batch_status CHECK ( + status IN ('VALIDATING', 'INVALID', 'READY', 'CONFIRMED', 'CANCELLED') + ) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE project_import_row ( + id BIGINT NOT NULL AUTO_INCREMENT, + batch_id BIGINT NOT NULL, + row_no INT NOT NULL, + payload_json JSON NOT NULL, + validation_json JSON NULL, + status VARCHAR(16) NOT NULL, + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_project_import_row (batch_id, row_no), + CONSTRAINT fk_project_import_row_batch FOREIGN KEY (batch_id) REFERENCES project_import_batch (id), + CONSTRAINT ck_project_import_row_no CHECK (row_no > 0), + CONSTRAINT ck_project_import_row_status CHECK (status IN ('VALID', 'INVALID', 'CONFIRMED')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; diff --git a/backend/src/main/resources/db/migration/V022__project_import_source_file_guard.sql b/backend/src/main/resources/db/migration/V022__project_import_source_file_guard.sql new file mode 100644 index 0000000..08fce89 --- /dev/null +++ b/backend/src/main/resources/db/migration/V022__project_import_source_file_guard.sql @@ -0,0 +1,4 @@ +-- A source file represents one user intent. Prevent multiple READY/CONFIRMED +-- project batches from being built from the same immutable upload. +ALTER TABLE project_import_batch + ADD UNIQUE KEY uk_project_import_batch_source_file (source_file_id); diff --git a/backend/src/main/resources/db/migration/V023__payment_partial_result.sql b/backend/src/main/resources/db/migration/V023__payment_partial_result.sql new file mode 100644 index 0000000..9e6db0c --- /dev/null +++ b/backend/src/main/resources/db/migration/V023__payment_partial_result.sql @@ -0,0 +1,42 @@ +-- PAGE-15: preserve paid and still-frozen portions of a payment request. +ALTER TABLE fin_payment_request + ADD COLUMN paid_amount DECIMAL(20, 2) NOT NULL DEFAULT 0 AFTER frozen_amount; + +-- Reconstruct the net verified amount for rows created before PART_PAID existed. +UPDATE fin_payment_request payment +LEFT JOIN ( + SELECT payment_id, + SUM(CASE result_type + WHEN 'SUCCESS' THEN amount + WHEN 'REFUND' THEN -amount + ELSE 0 END) AS net_paid + FROM fin_payment_result + WHERE status = 'VERIFIED' + GROUP BY payment_id +) result ON result.payment_id = payment.id +SET payment.paid_amount = CASE + WHEN payment.status IN ('PAID', 'PART_PAID', 'REFUNDED') + THEN GREATEST(0, LEAST(COALESCE(payment.approved_amount, payment.requested_amount), + COALESCE(result.net_paid, + CASE WHEN payment.status = 'PAID' + THEN COALESCE(payment.approved_amount, payment.requested_amount) + ELSE 0 END))) + ELSE 0 +END; + +ALTER TABLE fin_payment_request + DROP CHECK ck_fin_payment_status, + ADD CONSTRAINT ck_fin_payment_paid_amount CHECK ( + paid_amount >= 0 + AND paid_amount + frozen_amount <= COALESCE(approved_amount, requested_amount) + ), + ADD CONSTRAINT ck_fin_payment_status CHECK ( + status IN ( + 'DRAFT', 'REVIEWING', 'RETURNED', 'REJECTED', 'APPROVED', + 'OFFLINE_PENDING', 'RESULT_RECORDED', 'RESULT_VERIFIED', + 'PART_PAID', 'PAID', 'FAILED', 'REFUNDED', 'VOID' + ) + ); + +CREATE INDEX idx_fin_payment_paid_status + ON fin_payment_request (status, paid_amount, frozen_amount); diff --git a/backend/src/main/resources/db/migration/V024__payment_refund_accounting_event.sql b/backend/src/main/resources/db/migration/V024__payment_refund_accounting_event.sql new file mode 100644 index 0000000..124976f --- /dev/null +++ b/backend/src/main/resources/db/migration/V024__payment_refund_accounting_event.sql @@ -0,0 +1,12 @@ +-- A refund/return must be represented as a reversing accounting event rather +-- than another positive PAYMENT event. Keep the original event type intact +-- so the accounting rule can generate the opposite debit/credit pair. +ALTER TABLE acc_event + DROP CHECK ck_acc_event_type, + DROP CHECK ck_acc_event_source_type, + ADD CONSTRAINT ck_acc_event_type CHECK ( + event_type IN ('RECEIPT', 'PAYABLE', 'PAYMENT', 'PAYMENT_REFUND', 'INVOICE') + ), + ADD CONSTRAINT ck_acc_event_source_type CHECK ( + source_type IN ('RECEIPT', 'PAYABLE', 'PAYMENT', 'PAYMENT_REFUND', 'INVOICE') + ); diff --git a/backend/src/main/resources/db/migration/V025__payment_result_return_workflow.sql b/backend/src/main/resources/db/migration/V025__payment_result_return_workflow.sql new file mode 100644 index 0000000..5047146 --- /dev/null +++ b/backend/src/main/resources/db/migration/V025__payment_result_return_workflow.sql @@ -0,0 +1,33 @@ +-- PAGE-15: keep the original payment state and an auditable result-return decision. +ALTER TABLE fin_payment_result + ADD COLUMN recorded_from_status VARCHAR(32) NULL AFTER payment_id, + ADD COLUMN returned_by BIGINT NULL AFTER verification_opinion, + ADD COLUMN returned_at TIMESTAMP(3) NULL AFTER returned_by, + ADD COLUMN return_opinion VARCHAR(1000) NULL AFTER returned_at, + ADD CONSTRAINT fk_fin_payment_result_returned_by + FOREIGN KEY (returned_by) REFERENCES iam_user (id), + ADD CONSTRAINT ck_fin_payment_result_recorded_from_status CHECK ( + recorded_from_status IS NULL + OR recorded_from_status IN ('OFFLINE_PENDING', 'PART_PAID', 'PAID') + ), + ADD CONSTRAINT ck_fin_payment_result_return_metadata CHECK ( + (status = 'RETURNED' AND returned_by IS NOT NULL AND returned_at IS NOT NULL + AND return_opinion IS NOT NULL) + OR (status <> 'RETURNED' AND returned_by IS NULL AND returned_at IS NULL + AND return_opinion IS NULL) + ); + +-- Existing verified history cannot be reconstructed with certainty. Pending rows can, +-- because registration has not yet changed paid/frozen balances. +UPDATE fin_payment_result result +JOIN fin_payment_request payment ON payment.id = result.payment_id +SET result.recorded_from_status = CASE + WHEN result.result_type = 'REFUND' AND payment.paid_amount > 0 AND payment.frozen_amount = 0 THEN 'PAID' + WHEN payment.paid_amount > 0 THEN 'PART_PAID' + ELSE 'OFFLINE_PENDING' +END +WHERE result.status = 'PENDING_VERIFY' + AND result.recorded_from_status IS NULL; + +CREATE INDEX idx_fin_payment_result_returned + ON fin_payment_result (payment_id, status, returned_at); diff --git a/backend/src/main/resources/db/migration/V026__accounting_export_evidence_and_regeneration.sql b/backend/src/main/resources/db/migration/V026__accounting_export_evidence_and_regeneration.sql new file mode 100644 index 0000000..ca3effb --- /dev/null +++ b/backend/src/main/resources/db/migration/V026__accounting_export_evidence_and_regeneration.sql @@ -0,0 +1,63 @@ +ALTER TABLE acc_voucher + ADD KEY idx_acc_voucher_event_fk (event_id), + DROP INDEX uk_acc_voucher_event, + ADD COLUMN active_event_id BIGINT + GENERATED ALWAYS AS (CASE WHEN status = 'VOID' THEN NULL ELSE event_id END) STORED AFTER event_id, + ADD UNIQUE KEY uk_acc_voucher_active_event (active_event_id), + ADD COLUMN rule_version VARCHAR(64) NOT NULL DEFAULT 'ACCOUNTING-RULE-V1' AFTER project_id, + ADD COLUMN result_at DATE NULL AFTER external_voucher_no; + +UPDATE acc_voucher +SET result_at = DATE(result_recorded_at) +WHERE status IN ('RESULT_RECORDED', 'COMPLETED') + AND result_at IS NULL + AND result_recorded_at IS NOT NULL; + +CREATE TABLE acc_export_batch ( + id BIGINT NOT NULL AUTO_INCREMENT, + public_id CHAR(26) NOT NULL, + batch_no VARCHAR(64) NOT NULL, + voucher_id BIGINT NOT NULL, + rule_version VARCHAR(64) NOT NULL, + range_json JSON NOT NULL, + file_id BIGINT NOT NULL, + sha256 CHAR(64) NOT NULL, + row_count INT NOT NULL, + exported_by BIGINT NOT NULL, + exported_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_acc_export_batch_public_id (public_id), + UNIQUE KEY uk_acc_export_batch_no (batch_no), + UNIQUE KEY uk_acc_export_batch_voucher (voucher_id), + KEY idx_acc_export_batch_exported (exported_at), + CONSTRAINT fk_acc_export_batch_voucher FOREIGN KEY (voucher_id) REFERENCES acc_voucher (id), + CONSTRAINT fk_acc_export_batch_file FOREIGN KEY (file_id) REFERENCES file_object (id), + CONSTRAINT fk_acc_export_batch_user FOREIGN KEY (exported_by) REFERENCES iam_user (id), + CONSTRAINT ck_acc_export_batch_rows CHECK (row_count > 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE acc_voucher_result_file ( + id BIGINT NOT NULL AUTO_INCREMENT, + public_id CHAR(26) NOT NULL, + voucher_id BIGINT NOT NULL, + file_id BIGINT NOT NULL, + created_by BIGINT NOT NULL, + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_acc_voucher_result_file_public_id (public_id), + UNIQUE KEY uk_acc_voucher_result_file (voucher_id, file_id), + KEY idx_acc_voucher_result_file_voucher (voucher_id), + CONSTRAINT fk_acc_voucher_result_file_voucher FOREIGN KEY (voucher_id) REFERENCES acc_voucher (id), + CONSTRAINT fk_acc_voucher_result_file_object FOREIGN KEY (file_id) REFERENCES file_object (id), + CONSTRAINT fk_acc_voucher_result_file_user FOREIGN KEY (created_by) REFERENCES iam_user (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +ALTER TABLE acc_voucher + ADD CONSTRAINT ck_acc_voucher_result_fields CHECK ( + (status IN ('RESULT_RECORDED', 'COMPLETED') + AND external_voucher_no IS NOT NULL + AND result_at IS NOT NULL + AND result_recorded_by IS NOT NULL + AND result_recorded_at IS NOT NULL) + OR status NOT IN ('RESULT_RECORDED', 'COMPLETED') + ); diff --git a/backend/src/main/resources/db/migration/V027__archive_package_revision_and_na_review.sql b/backend/src/main/resources/db/migration/V027__archive_package_revision_and_na_review.sql new file mode 100644 index 0000000..285e3f3 --- /dev/null +++ b/backend/src/main/resources/db/migration/V027__archive_package_revision_and_na_review.sql @@ -0,0 +1,93 @@ +ALTER TABLE arc_package + ADD COLUMN root_package_id BIGINT NULL AFTER project_id, + ADD COLUMN supersedes_package_id BIGINT NULL AFTER root_package_id, + ADD COLUMN revision_reason VARCHAR(1000) NULL AFTER supersedes_package_id, + ADD CONSTRAINT fk_arc_package_root FOREIGN KEY (root_package_id) REFERENCES arc_package (id) ON DELETE SET NULL, + ADD CONSTRAINT fk_arc_package_supersedes FOREIGN KEY (supersedes_package_id) REFERENCES arc_package (id) ON DELETE SET NULL, + ADD UNIQUE KEY uk_arc_package_supersedes (supersedes_package_id); + +ALTER TABLE arc_package_item + DROP CHECK ck_arc_package_item_status, + ADD COLUMN not_applicable_requested_by BIGINT NULL AFTER not_applicable_reason, + ADD COLUMN not_applicable_requested_at TIMESTAMP(3) NULL AFTER not_applicable_requested_by, + ADD COLUMN not_applicable_reviewed_by BIGINT NULL AFTER not_applicable_requested_at, + ADD COLUMN not_applicable_reviewed_at TIMESTAMP(3) NULL AFTER not_applicable_reviewed_by, + ADD COLUMN not_applicable_review_opinion VARCHAR(1000) NULL AFTER not_applicable_reviewed_at, + ADD CONSTRAINT fk_arc_package_item_na_requested_by + FOREIGN KEY (not_applicable_requested_by) REFERENCES iam_user (id), + ADD CONSTRAINT fk_arc_package_item_na_reviewed_by + FOREIGN KEY (not_applicable_reviewed_by) REFERENCES iam_user (id), + ADD CONSTRAINT ck_arc_package_item_status CHECK ( + status IN ('PRESENT', 'MISSING', 'NA_REVIEWING', 'NOT_APPLICABLE', 'NA_REJECTED', 'SUPERSEDED') + ); + +ALTER TABLE arc_package_action + DROP CHECK ck_arc_package_action_code, + ADD CONSTRAINT ck_arc_package_action_code CHECK ( + action_code IN ( + 'GENERATE', 'CHECK', 'SUBMIT', 'RETURN', 'REVISE', 'ARCHIVE', 'FREEZE', 'UNFREEZE', + 'NA_REQUEST', 'NA_APPROVE', 'NA_REJECT' + ) + ); + +CREATE TABLE arc_package_return_item ( + package_id BIGINT NOT NULL, + item_id BIGINT NOT NULL, + reason VARCHAR(1000) NOT NULL, + created_by BIGINT NOT NULL, + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (package_id, item_id), + CONSTRAINT fk_arc_package_return_item_package + FOREIGN KEY (package_id) REFERENCES arc_package (id) ON DELETE CASCADE, + CONSTRAINT fk_arc_package_return_item_item + FOREIGN KEY (item_id) REFERENCES arc_package_item (id) ON DELETE CASCADE, + CONSTRAINT fk_arc_package_return_item_actor + FOREIGN KEY (created_by) REFERENCES iam_user (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +INSERT INTO iam_permission (code, name) VALUES + ('archive:package:na-review', '复核档案清单不涉及申请') +ON DUPLICATE KEY UPDATE name = VALUES(name); + +INSERT INTO iam_role_permission (role_id, permission_id) +SELECT role.id, permission.id +FROM iam_role role +JOIN iam_permission permission +WHERE role.code = 'FINANCE_MANAGER' + AND permission.code = 'archive:package:na-review' + AND NOT EXISTS ( + SELECT 1 FROM iam_role_permission existing + WHERE existing.role_id = role.id AND existing.permission_id = permission.id + ); + +INSERT IGNORE INTO iam_scope ( + user_id, role_id, permission_id, scope_type, company_public_id, project_public_id, + amount_limit, valid_from, valid_to, status +) +SELECT source_scope.user_id, source_scope.role_id, target_permission.id, + source_scope.scope_type, source_scope.company_public_id, source_scope.project_public_id, + source_scope.amount_limit, source_scope.valid_from, source_scope.valid_to, source_scope.status +FROM iam_scope source_scope +JOIN iam_permission source_permission ON source_permission.id = source_scope.permission_id +JOIN iam_permission target_permission ON target_permission.code = 'archive:package:na-review' +JOIN iam_role_permission target_assignment + ON target_assignment.role_id = source_scope.role_id + AND target_assignment.permission_id = target_permission.id +WHERE source_permission.code = 'archive:package:review'; + +UPDATE md_project project +JOIN arc_package package_record ON package_record.project_id = project.id +SET project.archive_status = CASE + WHEN package_record.status IN ('PREPARING', 'CHECKED') THEN 'PREPARING' + WHEN package_record.status = 'REVIEWING' THEN 'CHECKING' + WHEN package_record.status = 'RETURNED' THEN 'RETURNED' + ELSE project.archive_status + END +WHERE package_record.id = ( + SELECT latest.id + FROM arc_package latest + WHERE latest.project_id = project.id + ORDER BY latest.package_version DESC, latest.id DESC + LIMIT 1 +) + AND package_record.status IN ('PREPARING', 'CHECKED', 'REVIEWING', 'RETURNED'); diff --git a/backend/src/main/resources/db/migration/V028__archive_package_object_snapshot.sql b/backend/src/main/resources/db/migration/V028__archive_package_object_snapshot.sql new file mode 100644 index 0000000..c08faf0 --- /dev/null +++ b/backend/src/main/resources/db/migration/V028__archive_package_object_snapshot.sql @@ -0,0 +1,25 @@ +CREATE TABLE arc_package_object_snapshot ( + id BIGINT NOT NULL AUTO_INCREMENT, + public_id CHAR(26) NOT NULL, + package_id BIGINT NOT NULL, + item_id BIGINT NOT NULL, + object_type VARCHAR(80) NOT NULL, + object_public_id CHAR(26) NOT NULL, + object_business_no VARCHAR(100) NULL, + object_name VARCHAR(200) NULL, + object_status VARCHAR(32) NOT NULL, + object_version BIGINT NULL, + source_version_public_id CHAR(26) NULL, + snapshot_json JSON NOT NULL, + snapshot_sha256 CHAR(64) NOT NULL, + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_arc_object_snapshot_public_id (public_id), + KEY idx_arc_object_snapshot_package (package_id, item_id), + KEY idx_arc_object_snapshot_lookup (package_id, object_type, object_public_id), + CONSTRAINT fk_arc_object_snapshot_package + FOREIGN KEY (package_id) REFERENCES arc_package (id), + CONSTRAINT fk_arc_object_snapshot_item + FOREIGN KEY (item_id) REFERENCES arc_package_item (id), + CONSTRAINT ck_arc_object_snapshot_version CHECK (object_version IS NULL OR object_version >= 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; diff --git a/backend/src/main/resources/db/migration/V029__archive_file_series_and_borrow_controls.sql b/backend/src/main/resources/db/migration/V029__archive_file_series_and_borrow_controls.sql new file mode 100644 index 0000000..de9b05a --- /dev/null +++ b/backend/src/main/resources/db/migration/V029__archive_file_series_and_borrow_controls.sql @@ -0,0 +1,137 @@ +DROP TABLE IF EXISTS migration_v029_file_series_work; +DROP PROCEDURE IF EXISTS assert_and_map_v029_file_series; + +CREATE TABLE migration_v029_file_series_work ( + id BIGINT NOT NULL, + parent_id BIGINT NULL, + root_id BIGINT NULL, + depth_no INT NULL, + version_no INT NOT NULL, + PRIMARY KEY (id), + KEY idx_tmp_v029_parent (parent_id), + KEY idx_tmp_v029_root_version (root_id, version_no) +) ENGINE=InnoDB; + +INSERT INTO migration_v029_file_series_work (id, parent_id, root_id, depth_no, version_no) +SELECT id, supersedes_link_id, + CASE WHEN supersedes_link_id IS NULL THEN id ELSE NULL END, + CASE WHEN supersedes_link_id IS NULL THEN 0 ELSE NULL END, + version_no +FROM file_link; + +DELIMITER $$ +CREATE PROCEDURE assert_and_map_v029_file_series() +BEGIN + DECLARE changed_rows INT DEFAULT 1; + + IF EXISTS ( + SELECT 1 + FROM migration_v029_file_series_work child + LEFT JOIN migration_v029_file_series_work parent ON parent.id = child.parent_id + WHERE child.parent_id IS NOT NULL AND parent.id IS NULL + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'V029: file_link contains a missing supersedes parent'; + END IF; + + IF EXISTS ( + SELECT 1 FROM migration_v029_file_series_work + WHERE parent_id IS NOT NULL + GROUP BY parent_id HAVING COUNT(*) > 1 + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'V029: file_link contains a branched replacement chain'; + END IF; + + WHILE changed_rows > 0 DO + UPDATE migration_v029_file_series_work child + JOIN migration_v029_file_series_work parent ON parent.id = child.parent_id + SET child.root_id = parent.root_id, + child.depth_no = parent.depth_no + 1 + WHERE child.root_id IS NULL AND parent.root_id IS NOT NULL; + SET changed_rows = ROW_COUNT(); + END WHILE; + + IF EXISTS (SELECT 1 FROM migration_v029_file_series_work WHERE root_id IS NULL) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'V029: file_link contains a replacement cycle'; + END IF; + + IF EXISTS ( + SELECT 1 FROM migration_v029_file_series_work + GROUP BY root_id, version_no HAVING COUNT(*) > 1 + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'V029: a file series contains duplicate version numbers'; + END IF; + + IF EXISTS ( + SELECT 1 + FROM file_link child + JOIN file_link parent ON parent.id = child.supersedes_link_id + WHERE child.version_no <= parent.version_no + OR NOT (child.company_public_id <=> parent.company_public_id) + OR NOT (child.project_public_id <=> parent.project_public_id) + OR NOT (child.contract_public_id <=> parent.contract_public_id) + OR NOT (child.counterparty_public_id <=> parent.counterparty_public_id) + OR NOT (child.form_type <=> parent.form_type) + OR child.document_type <> parent.document_type + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'V029: a file replacement chain has inconsistent dimensions or versions'; + END IF; +END$$ +DELIMITER ; + +CALL assert_and_map_v029_file_series(); +DROP PROCEDURE assert_and_map_v029_file_series; + +ALTER TABLE file_link + ADD COLUMN series_public_id CHAR(26) NULL AFTER object_public_id; + +ALTER TABLE arc_borrow + ADD COLUMN file_link_id BIGINT NULL AFTER file_id, + ADD COLUMN series_public_id CHAR(26) NULL AFTER file_link_id; + +UPDATE file_link link_record +JOIN migration_v029_file_series_work map ON map.id = link_record.id +SET link_record.series_public_id = LPAD(HEX(map.root_id), 26, '0'); + +UPDATE file_link parent +JOIN file_link child ON child.supersedes_link_id = parent.id +SET parent.active = FALSE, + parent.archive_status = 'SUPERSEDED'; + +DROP TABLE IF EXISTS migration_v029_borrow_choice_work; + +CREATE TABLE migration_v029_borrow_choice_work ( + borrow_id BIGINT NOT NULL, + file_link_id BIGINT NOT NULL, + PRIMARY KEY (borrow_id) +) ENGINE=InnoDB; + +INSERT INTO migration_v029_borrow_choice_work (borrow_id, file_link_id) +SELECT borrow.id, MIN(item.file_link_id) +FROM arc_borrow borrow +JOIN arc_package_item item + ON item.package_id = borrow.package_id + AND item.file_id = borrow.file_id +JOIN file_link link_record + ON link_record.id = item.file_link_id + AND link_record.file_id = borrow.file_id +WHERE borrow.package_id IS NOT NULL +GROUP BY borrow.id +HAVING COUNT(DISTINCT item.file_link_id) = 1; + +INSERT INTO migration_v029_borrow_choice_work (borrow_id, file_link_id) +SELECT borrow.id, MIN(link_record.id) +FROM arc_borrow borrow +JOIN file_link link_record ON link_record.file_id = borrow.file_id +LEFT JOIN migration_v029_borrow_choice_work chosen ON chosen.borrow_id = borrow.id +WHERE chosen.borrow_id IS NULL +GROUP BY borrow.id +HAVING COUNT(*) = 1; + +UPDATE arc_borrow borrow +JOIN migration_v029_borrow_choice_work chosen ON chosen.borrow_id = borrow.id +JOIN file_link link_record ON link_record.id = chosen.file_link_id +SET borrow.file_link_id = link_record.id, + borrow.series_public_id = link_record.series_public_id; + +DROP TABLE migration_v029_borrow_choice_work; +DROP TABLE migration_v029_file_series_work; diff --git a/backend/src/main/resources/db/migration/V030__archive_file_series_constraints.sql b/backend/src/main/resources/db/migration/V030__archive_file_series_constraints.sql new file mode 100644 index 0000000..d363f23 --- /dev/null +++ b/backend/src/main/resources/db/migration/V030__archive_file_series_constraints.sql @@ -0,0 +1,75 @@ +DROP PROCEDURE IF EXISTS assert_v030_file_series_constraints; + +DELIMITER $$ +CREATE PROCEDURE assert_v030_file_series_constraints() +BEGIN + IF EXISTS (SELECT 1 FROM file_link WHERE series_public_id IS NULL) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'V030: file_link series_public_id must be repaired before constraints'; + END IF; + + IF EXISTS ( + SELECT 1 FROM file_link + GROUP BY series_public_id, version_no HAVING COUNT(*) > 1 + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'V030: a file series contains duplicate version numbers'; + END IF; + + IF EXISTS ( + SELECT 1 FROM file_link + WHERE active = TRUE + GROUP BY series_public_id HAVING COUNT(*) > 1 + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'V030: a file series contains multiple current versions'; + END IF; + + IF EXISTS ( + SELECT 1 FROM file_link + WHERE supersedes_link_id IS NOT NULL + GROUP BY supersedes_link_id HAVING COUNT(*) > 1 + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'V030: a file version is superseded more than once'; + END IF; + + IF EXISTS ( + SELECT 1 + FROM file_link child + JOIN file_link parent ON parent.id = child.supersedes_link_id + WHERE child.series_public_id <> parent.series_public_id + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'V030: a replacement crosses file series'; + END IF; + + IF EXISTS ( + SELECT 1 FROM file_link + WHERE (active = TRUE AND archive_status = 'SUPERSEDED') + OR (active = FALSE AND archive_status <> 'SUPERSEDED') + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'V030: file_link active and archive status are inconsistent'; + END IF; +END$$ +DELIMITER ; + +CALL assert_v030_file_series_constraints(); +DROP PROCEDURE assert_v030_file_series_constraints; + +ALTER TABLE file_link + MODIFY COLUMN series_public_id CHAR(26) NOT NULL, + DROP INDEX uk_file_link_version, + DROP FOREIGN KEY fk_file_link_supersedes, + ADD COLUMN current_series_public_id CHAR(26) + GENERATED ALWAYS AS (CASE WHEN active = TRUE THEN series_public_id ELSE NULL END) STORED, + ADD UNIQUE KEY uk_file_link_series_version (series_public_id, version_no), + ADD UNIQUE KEY uk_file_link_superseded_once (supersedes_link_id), + ADD UNIQUE KEY uk_file_link_id_series (id, series_public_id), + ADD UNIQUE KEY uk_file_link_borrow_identity (id, series_public_id, file_id), + ADD UNIQUE KEY uk_file_link_current_series (current_series_public_id), + ADD KEY idx_file_link_supersedes_series (supersedes_link_id, series_public_id), + ADD KEY idx_file_link_series_current (series_public_id, active, version_no), + ADD CONSTRAINT ck_file_link_active_status CHECK ( + (active = TRUE AND archive_status IN ('ACTIVE', 'ARCHIVED', 'FROZEN')) + OR (active = FALSE AND archive_status = 'SUPERSEDED') + ); + +ALTER TABLE file_link + ADD CONSTRAINT fk_file_link_supersedes_series + FOREIGN KEY (supersedes_link_id, series_public_id) REFERENCES file_link (id, series_public_id); diff --git a/backend/src/main/resources/db/migration/V031__archive_borrow_series_constraints.sql b/backend/src/main/resources/db/migration/V031__archive_borrow_series_constraints.sql new file mode 100644 index 0000000..9966d41 --- /dev/null +++ b/backend/src/main/resources/db/migration/V031__archive_borrow_series_constraints.sql @@ -0,0 +1,47 @@ +DROP PROCEDURE IF EXISTS assert_v031_archive_borrow_constraints; + +DELIMITER $$ +CREATE PROCEDURE assert_v031_archive_borrow_constraints() +BEGIN + IF EXISTS ( + SELECT 1 + FROM arc_borrow borrow + LEFT JOIN file_link link_record ON link_record.id = borrow.file_link_id + WHERE borrow.file_link_id IS NULL + OR borrow.series_public_id IS NULL + OR link_record.id IS NULL + OR link_record.file_id <> borrow.file_id + OR link_record.series_public_id <> borrow.series_public_id + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'V031: archive borrow file association requires manual repair'; + END IF; + + IF EXISTS ( + SELECT 1 + FROM arc_borrow + WHERE status IN ('APPLIED', 'APPROVED', 'BORROWED', 'OVERDUE') + GROUP BY series_public_id HAVING COUNT(*) > 1 + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'V031: a file series contains multiple active borrows'; + END IF; +END$$ +DELIMITER ; + +CALL assert_v031_archive_borrow_constraints(); +DROP PROCEDURE assert_v031_archive_borrow_constraints; + +ALTER TABLE arc_borrow + MODIFY COLUMN file_link_id BIGINT NOT NULL, + MODIFY COLUMN series_public_id CHAR(26) NOT NULL, + ADD COLUMN active_series_public_id CHAR(26) + GENERATED ALWAYS AS ( + CASE + WHEN status IN ('APPLIED', 'APPROVED', 'BORROWED', 'OVERDUE') THEN series_public_id + ELSE NULL + END + ) STORED, + ADD CONSTRAINT fk_arc_borrow_file_link_identity + FOREIGN KEY (file_link_id, series_public_id, file_id) + REFERENCES file_link (id, series_public_id, file_id), + ADD UNIQUE KEY uk_arc_borrow_active_series (active_series_public_id), + ADD KEY idx_arc_borrow_series_history (series_public_id, created_at); diff --git a/backend/src/main/resources/db/migration/V032__report_export_two_phase.sql b/backend/src/main/resources/db/migration/V032__report_export_two_phase.sql new file mode 100644 index 0000000..5378454 --- /dev/null +++ b/backend/src/main/resources/db/migration/V032__report_export_two_phase.sql @@ -0,0 +1,26 @@ +CREATE TABLE report_export_request ( + id BIGINT NOT NULL AUTO_INCREMENT, + public_id CHAR(26) NOT NULL, + actor_public_id CHAR(26) NOT NULL, + identity_code VARCHAR(40) NOT NULL, + scope_fingerprint CHAR(64) NOT NULL, + report_code VARCHAR(40) NOT NULL, + definition_version VARCHAR(64) NOT NULL, + filter_hash CHAR(64) NOT NULL, + filter_json JSON NOT NULL, + columns_json JSON NOT NULL, + sort_code VARCHAR(64) NOT NULL, + estimated_rows INT NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'PREPARED', + export_log_public_id CHAR(26) NULL, + expires_at TIMESTAMP(3) NOT NULL, + completed_at TIMESTAMP(3) NULL, + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_report_export_request_public_id (public_id), + KEY idx_report_export_request_actor (actor_public_id, created_at), + KEY idx_report_export_request_expiry (status, expires_at), + CONSTRAINT ck_report_export_request_rows CHECK (estimated_rows >= 0), + CONSTRAINT ck_report_export_request_status CHECK (status IN ('PREPARED', 'COMPLETED', 'EXPIRED')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; diff --git a/backend/src/main/resources/db/migration/V033__payable_accounting_event_backfill.sql b/backend/src/main/resources/db/migration/V033__payable_accounting_event_backfill.sql new file mode 100644 index 0000000..c076db9 --- /dev/null +++ b/backend/src/main/resources/db/migration/V033__payable_accounting_event_backfill.sql @@ -0,0 +1,25 @@ +-- Confirmed payables are accounting sources. Backfill pre-existing rows so an +-- upgrade does not leave historical liabilities outside PAGE-16. +INSERT INTO acc_event ( + public_id, business_no, event_type, source_type, source_public_id, + company_id, project_id, counterparty_id, business_date, period, + amount, currency, status, created_by +) +SELECT UPPER(SUBSTRING(SHA2(CONCAT('PAYABLE_EVENT:', payable.public_id), 256), 1, 26)), + CONCAT('EVT-', payable.public_id), + 'PAYABLE', 'PAYABLE', payable.public_id, + payable.company_id, payable.project_id, payable.counterparty_id, + payable.business_date, DATE_FORMAT(payable.business_date, '%Y-%m'), + payable.amount, contract.currency, 'PENDING', + COALESCE(payable.confirmed_by, payable.updated_by, payable.created_by) +FROM fin_payable payable +JOIN md_contract contract ON contract.id = payable.contract_id +WHERE payable.status IN ('CONFIRMED', 'PART_PAID', 'PAID') + AND payable.amount > 0 + AND NOT EXISTS ( + SELECT 1 + FROM acc_event event + WHERE event.event_type = 'PAYABLE' + AND event.source_type = 'PAYABLE' + AND event.source_public_id = payable.public_id + ); diff --git a/backend/src/main/resources/db/migration/V034__payable_create_confirm_separation.sql b/backend/src/main/resources/db/migration/V034__payable_create_confirm_separation.sql new file mode 100644 index 0000000..b657646 --- /dev/null +++ b/backend/src/main/resources/db/migration/V034__payable_create_confirm_separation.sql @@ -0,0 +1,75 @@ +-- Registering and confirming a payable are separate duties. Preserve each +-- user's existing boundary for registration before withdrawing project-role +-- confirmation grants. +INSERT IGNORE INTO iam_permission (code, name) VALUES + ('contractcost:payable:create', '登记应付'); + +UPDATE iam_permission +SET name = '登记应付' +WHERE code = 'contractcost:payable:create'; + +INSERT INTO iam_role_permission (role_id, permission_id) +SELECT role.id, permission.id +FROM iam_role role +JOIN iam_permission permission +WHERE role.code IN ('PROJECT_MANAGER', 'FINANCE_MANAGER') + AND permission.code = 'contractcost:payable:create' + AND NOT EXISTS ( + SELECT 1 + FROM iam_role_permission existing + WHERE existing.role_id = role.id + AND existing.permission_id = permission.id + ); + +INSERT IGNORE INTO iam_scope ( + user_id, role_id, permission_id, scope_type, company_public_id, project_public_id, + amount_limit, valid_from, valid_to, status +) +SELECT source_scope.user_id, source_scope.role_id, target_permission.id, + source_scope.scope_type, source_scope.company_public_id, source_scope.project_public_id, + source_scope.amount_limit, source_scope.valid_from, source_scope.valid_to, source_scope.status +FROM iam_scope source_scope +JOIN iam_permission source_permission + ON source_permission.id = source_scope.permission_id +JOIN iam_permission target_permission + ON target_permission.code = 'contractcost:payable:create' +JOIN iam_role_permission target_assignment + ON target_assignment.role_id = source_scope.role_id + AND target_assignment.permission_id = target_permission.id +JOIN iam_role role + ON role.id = source_scope.role_id +WHERE source_permission.code = 'contractcost:payable:confirm' + AND role.code IN ('PROJECT_MANAGER', 'FINANCE_MANAGER'); + +DELETE scope +FROM iam_scope scope +JOIN iam_role role + ON role.id = scope.role_id +JOIN iam_permission permission + ON permission.id = scope.permission_id +WHERE role.code = 'PROJECT_MANAGER' + AND permission.code = 'contractcost:payable:confirm'; + +DELETE assignment +FROM iam_role_permission assignment +JOIN iam_role role + ON role.id = assignment.role_id +JOIN iam_permission permission + ON permission.id = assignment.permission_id +WHERE role.code = 'PROJECT_MANAGER' + AND permission.code = 'contractcost:payable:confirm'; + +INSERT IGNORE INTO iam_sod_rule (code, first_permission, second_permission, description) +VALUES ( + 'PAYABLE_CREATE_CONFIRM_SEPARATION', + 'contractcost:payable:create', + 'contractcost:payable:confirm', + '同一笔应付的登记人与确认人必须不同' +); + +UPDATE iam_sod_rule +SET first_permission = 'contractcost:payable:create', + second_permission = 'contractcost:payable:confirm', + description = '同一笔应付的登记人与确认人必须不同', + enabled = TRUE +WHERE code = 'PAYABLE_CREATE_CONFIRM_SEPARATION'; diff --git a/backend/src/main/resources/db/migration/V035__contract_cost_approved_source_constraints.sql b/backend/src/main/resources/db/migration/V035__contract_cost_approved_source_constraints.sql new file mode 100644 index 0000000..f067b2f --- /dev/null +++ b/backend/src/main/resources/db/migration/V035__contract_cost_approved_source_constraints.sql @@ -0,0 +1,5 @@ +ALTER TABLE fin_payable + ADD UNIQUE KEY uk_fin_payable_source_document (source_document_id); + +ALTER TABLE fin_contract_settlement + ADD UNIQUE KEY uk_fin_contract_settlement_source_document (source_document_id); diff --git a/backend/src/main/resources/db/migration/V036__payment_attachment_matrix.sql b/backend/src/main/resources/db/migration/V036__payment_attachment_matrix.sql new file mode 100644 index 0000000..c4fae77 --- /dev/null +++ b/backend/src/main/resources/db/migration/V036__payment_attachment_matrix.sql @@ -0,0 +1,90 @@ +ALTER TABLE fin_payment_request + ADD COLUMN attachment_rule_parameter_id BIGINT NULL AFTER check_rule_version, + ADD COLUMN attachment_rule_version_no INT NULL AFTER attachment_rule_parameter_id, + ADD COLUMN attachment_rule_snapshot_json JSON NULL AFTER attachment_rule_version_no, + ADD KEY idx_fin_payment_attachment_rule (attachment_rule_parameter_id), + ADD CONSTRAINT fk_fin_payment_attachment_rule + FOREIGN KEY (attachment_rule_parameter_id) REFERENCES sys_parameter_version (id), + ADD CONSTRAINT ck_fin_payment_attachment_rule_snapshot CHECK ( + (attachment_rule_parameter_id IS NULL + AND attachment_rule_version_no IS NULL + AND attachment_rule_snapshot_json IS NULL) + OR + (attachment_rule_parameter_id IS NOT NULL + AND attachment_rule_version_no > 0 + AND attachment_rule_snapshot_json IS NOT NULL) + ); + +INSERT INTO sys_parameter_version + (public_id, parameter_group, version_no, value_json, status, created_by) +VALUES + ( + '00000000000000000000000360', + 'PAYMENT_ATTACHMENT_MATRIX', + 1, + CAST('{ + "schemaVersion": 1, + "categories": [ + {"costCategoryCode":"MATERIAL","costCategoryName":"材料费","requirements":[ + {"documentType":"MATERIAL_INVOICE","name":"材料发票","required":true,"allowNotApplicable":false}, + {"documentType":"DELIVERY_NOTE","name":"送货单","required":true,"allowNotApplicable":false}, + {"documentType":"MATERIAL_SETTLEMENT","name":"材料结算单","required":true,"allowNotApplicable":false}, + {"documentType":"WAREHOUSE_RECEIPT","name":"入库单","required":true,"allowNotApplicable":true}, + {"documentType":"WAREHOUSE_ISSUE","name":"出库单或领料单","required":true,"allowNotApplicable":true}, + {"documentType":"SITE_ACCEPTANCE_PHOTO","name":"现场验收照片","required":true,"allowNotApplicable":false}, + {"documentType":"MATERIAL_CERTIFICATE","name":"检验检测报告或合格证","required":true,"allowNotApplicable":true} + ]}, + {"costCategoryCode":"MACHINERY","costCategoryName":"机械费","requirements":[ + {"documentType":"MACHINERY_INVOICE","name":"机械租赁或服务发票","required":true,"allowNotApplicable":false}, + {"documentType":"MACHINERY_ACCEPTANCE","name":"机械设备进出场验收表","required":true,"allowNotApplicable":true}, + {"documentType":"MACHINERY_SETTLEMENT","name":"机械费用结算单","required":true,"allowNotApplicable":false}, + {"documentType":"MACHINERY_OWNERSHIP","name":"设备权属或租赁进项证明","required":true,"allowNotApplicable":true}, + {"documentType":"MACHINERY_SITE_PHOTO","name":"机械设备现场照片","required":true,"allowNotApplicable":true} + ]}, + {"costCategoryCode":"LABOR","costCategoryName":"人工费","requirements":[ + {"documentType":"LABOR_INVOICE","name":"劳务发票","required":true,"allowNotApplicable":false}, + {"documentType":"ATTENDANCE_SHEET","name":"考勤表","required":true,"allowNotApplicable":false}, + {"documentType":"PAYROLL_SHEET","name":"工资表","required":true,"allowNotApplicable":false}, + {"documentType":"TAX_DECLARATION","name":"个税申报及完税证明","required":true,"allowNotApplicable":true}, + {"documentType":"LABOR_COST_CONFIRMATION","name":"人工成本确认单","required":true,"allowNotApplicable":false}, + {"documentType":"WAGE_PAYMENT_RECORD","name":"工资发放记录","required":true,"allowNotApplicable":false} + ]}, + {"costCategoryCode":"SERVICE","costCategoryName":"业务服务费","requirements":[ + {"documentType":"SERVICE_INVOICE","name":"业务发票","required":true,"allowNotApplicable":false}, + {"documentType":"SERVICE_SETTLEMENT","name":"业务结算单","required":true,"allowNotApplicable":false}, + {"documentType":"SERVICE_REPORT","name":"业务报告","required":true,"allowNotApplicable":false} + ]}, + {"costCategoryCode":"TRANSPORT","costCategoryName":"运输费","requirements":[ + {"documentType":"TRANSPORT_INVOICE","name":"运输服务发票","required":true,"allowNotApplicable":false}, + {"documentType":"TRANSPORT_SETTLEMENT","name":"运输结算单","required":true,"allowNotApplicable":false}, + {"documentType":"VEHICLE_OWNERSHIP","name":"运输车辆权属证明","required":true,"allowNotApplicable":true}, + {"documentType":"TRANSPORT_QUALIFICATION","name":"运输资质","required":true,"allowNotApplicable":true}, + {"documentType":"TRANSPORT_SITE_PHOTO","name":"运输现场照片","required":true,"allowNotApplicable":true} + ]}, + {"costCategoryCode":"TEMPORARY_LABOR","costCategoryName":"临时用工","requirements":[ + {"documentType":"TEMPORARY_LABOR_ROSTER","name":"临时用工花名册","required":true,"allowNotApplicable":false}, + {"documentType":"TEMPORARY_LABOR_AGREEMENT","name":"临时用工协议","required":true,"allowNotApplicable":false}, + {"documentType":"PROJECT_DAILY_REPORT","name":"项目日报表","required":true,"allowNotApplicable":false}, + {"documentType":"ATTENDANCE_SHEET","name":"考勤表","required":true,"allowNotApplicable":false}, + {"documentType":"TAX_DECLARATION","name":"个税申报表","required":true,"allowNotApplicable":true} + ]}, + {"costCategoryCode":"SUBCONTRACT","costCategoryName":"专业分包","requirements":[ + {"documentType":"SUBCONTRACT_INVOICE","name":"工程服务发票","required":true,"allowNotApplicable":false}, + {"documentType":"SUBCONTRACT_BOQ","name":"专业分包工程量清单","required":true,"allowNotApplicable":false}, + {"documentType":"SUBCONTRACT_PROGRESS_APPROVAL","name":"专业分包进度款审批单","required":true,"allowNotApplicable":false}, + {"documentType":"SUBCONTRACT_SETTLEMENT","name":"专业分包结算或验收单","required":true,"allowNotApplicable":false}, + {"documentType":"SUBCONTRACT_ACCEPTANCE_REPORT","name":"专业分包验收报告","required":true,"allowNotApplicable":false} + ]}, + {"costCategoryCode":"EQUIPMENT","costCategoryName":"设备采购","requirements":[ + {"documentType":"EQUIPMENT_INVOICE","name":"设备采购发票","required":true,"allowNotApplicable":false}, + {"documentType":"EQUIPMENT_DELIVERY_NOTE","name":"设备发货或送货单","required":true,"allowNotApplicable":false}, + {"documentType":"EQUIPMENT_SETTLEMENT","name":"设备结算单","required":true,"allowNotApplicable":false}, + {"documentType":"EQUIPMENT_ACCEPTANCE_PHOTO","name":"现场验收设备照片","required":true,"allowNotApplicable":false}, + {"documentType":"EQUIPMENT_CERTIFICATE","name":"设备检验检测报告或合格证","required":true,"allowNotApplicable":true}, + {"documentType":"EQUIPMENT_INSTALLATION_PHOTO","name":"设备安装照片","required":true,"allowNotApplicable":true} + ]} + ] + }' AS JSON), + 'DRAFT', + '00000000000000000000000000' + ); diff --git a/backend/src/main/resources/db/migration/V037__fund_ledger.sql b/backend/src/main/resources/db/migration/V037__fund_ledger.sql new file mode 100644 index 0000000..f0f16de --- /dev/null +++ b/backend/src/main/resources/db/migration/V037__fund_ledger.sql @@ -0,0 +1,77 @@ +CREATE TABLE fund_ledger ( + id BIGINT NOT NULL AUTO_INCREMENT, + public_id CHAR(26) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + company_id BIGINT NOT NULL, + project_id BIGINT NOT NULL, + currency CHAR(3) NOT NULL, + source_event_key VARCHAR(160) NOT NULL, + entry_type VARCHAR(64) NOT NULL, + source_type VARCHAR(32) NOT NULL, + source_public_id VARCHAR(26) NULL, + source_business_no VARCHAR(80) NULL, + description VARCHAR(500) NOT NULL, + event_amount DECIMAL(20, 2) NOT NULL DEFAULT 0, + delta_amount DECIMAL(20, 2) NOT NULL, + confirmed_receipt_delta DECIMAL(20, 2) NOT NULL DEFAULT 0, + confirmed_deduction_delta DECIMAL(20, 2) NOT NULL DEFAULT 0, + approved_adjustment_delta DECIMAL(20, 2) NOT NULL DEFAULT 0, + frozen_amount_delta DECIMAL(20, 2) NOT NULL DEFAULT 0, + approved_unpaid_delta DECIMAL(20, 2) NOT NULL DEFAULT 0, + paid_amount_delta DECIMAL(20, 2) NOT NULL DEFAULT 0, + confirmed_receipt_after DECIMAL(20, 2) NOT NULL, + confirmed_deduction_after DECIMAL(20, 2) NOT NULL, + approved_adjustment_after DECIMAL(20, 2) NOT NULL, + frozen_amount_after DECIMAL(20, 2) NOT NULL, + approved_unpaid_after DECIMAL(20, 2) NOT NULL, + paid_amount_after DECIMAL(20, 2) NOT NULL, + balance_after DECIMAL(20, 2) NOT NULL, + control_version_after BIGINT NOT NULL, + occurred_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + created_by BIGINT NULL, + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_fund_ledger_public_id (public_id), + UNIQUE KEY uk_fund_ledger_source_event (source_event_key), + KEY idx_fund_ledger_project_time (project_id, currency, occurred_at DESC, id DESC), + KEY idx_fund_ledger_entry_type (project_id, entry_type, occurred_at DESC), + KEY idx_fund_ledger_source (source_type, source_public_id), + CONSTRAINT fk_fund_ledger_company FOREIGN KEY (company_id) REFERENCES md_company (id), + CONSTRAINT fk_fund_ledger_project FOREIGN KEY (project_id) REFERENCES md_project (id), + CONSTRAINT fk_fund_ledger_creator FOREIGN KEY (created_by) REFERENCES iam_user (id), + CONSTRAINT ck_fund_ledger_event_amount CHECK (event_amount >= 0), + CONSTRAINT ck_fund_ledger_after_amounts CHECK ( + confirmed_receipt_after >= 0 + AND confirmed_deduction_after >= 0 + AND approved_adjustment_after >= 0 + AND frozen_amount_after >= 0 + AND approved_unpaid_after >= 0 + AND paid_amount_after >= 0 + ), + CONSTRAINT ck_fund_ledger_control_version CHECK (control_version_after >= 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +INSERT INTO fund_ledger ( + public_id, company_id, project_id, currency, source_event_key, entry_type, + source_type, source_public_id, source_business_no, description, event_amount, + delta_amount, confirmed_receipt_delta, confirmed_deduction_delta, + approved_adjustment_delta, frozen_amount_delta, approved_unpaid_delta, paid_amount_delta, + confirmed_receipt_after, confirmed_deduction_after, approved_adjustment_after, + frozen_amount_after, approved_unpaid_after, paid_amount_after, balance_after, + control_version_after, occurred_at, created_by, created_at +) +SELECT + UPPER(LEFT(REPLACE(UUID(), '-', ''), 26)), project.company_id, control.project_id, + control.currency, CONCAT('FUND_OPENING:', project.public_id, ':', control.currency), + 'OPENING_BALANCE', 'PROJECT', project.public_id, project.business_no, + '资金流水启用时的期初快照', 0, + control.confirmed_receipt - control.confirmed_deduction + control.approved_adjustment + - control.frozen_amount - control.approved_unpaid - control.paid_amount, + control.confirmed_receipt, control.confirmed_deduction, control.approved_adjustment, + control.frozen_amount, control.approved_unpaid, control.paid_amount, + control.confirmed_receipt, control.confirmed_deduction, control.approved_adjustment, + control.frozen_amount, control.approved_unpaid, control.paid_amount, + control.confirmed_receipt - control.confirmed_deduction + control.approved_adjustment + - control.frozen_amount - control.approved_unpaid - control.paid_amount, + control.version, control.updated_at, project.updated_by, control.updated_at +FROM project_fund_control control +JOIN md_project project ON project.id = control.project_id; diff --git a/backend/src/main/resources/db/migration/V038__payment_attachment_exceptions.sql b/backend/src/main/resources/db/migration/V038__payment_attachment_exceptions.sql new file mode 100644 index 0000000..a39e4d0 --- /dev/null +++ b/backend/src/main/resources/db/migration/V038__payment_attachment_exceptions.sql @@ -0,0 +1,48 @@ +CREATE TABLE fin_payment_attachment_exception ( + id BIGINT NOT NULL AUTO_INCREMENT, + public_id CHAR(26) NOT NULL, + payment_id BIGINT NOT NULL, + document_type VARCHAR(64) NOT NULL, + version_no INT NOT NULL, + reason VARCHAR(500) NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'PENDING', + active BOOLEAN NOT NULL DEFAULT TRUE, + version BIGINT NOT NULL DEFAULT 0, + requested_by BIGINT NOT NULL, + requested_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + reviewed_by BIGINT NULL, + reviewed_at TIMESTAMP(3) NULL, + review_opinion VARCHAR(1000) NULL, + consumed_by BIGINT NULL, + consumed_at TIMESTAMP(3) NULL, + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_fin_payment_attachment_exception_public_id (public_id), + UNIQUE KEY uk_fin_payment_attachment_exception_version (payment_id, document_type, version_no), + KEY idx_fin_payment_attachment_exception_status (payment_id, active, status), + CONSTRAINT fk_fin_payment_attachment_exception_payment + FOREIGN KEY (payment_id) REFERENCES fin_payment_request (id), + CONSTRAINT fk_fin_payment_attachment_exception_requester + FOREIGN KEY (requested_by) REFERENCES iam_user (id), + CONSTRAINT fk_fin_payment_attachment_exception_reviewer + FOREIGN KEY (reviewed_by) REFERENCES iam_user (id), + CONSTRAINT fk_fin_payment_attachment_exception_consumer + FOREIGN KEY (consumed_by) REFERENCES iam_user (id), + CONSTRAINT ck_fin_payment_attachment_exception_status + CHECK (status IN ('PENDING', 'APPROVED', 'REJECTED')), + CONSTRAINT ck_fin_payment_attachment_exception_version CHECK (version_no > 0), + CONSTRAINT ck_fin_payment_attachment_exception_row_version CHECK (version >= 0), + CONSTRAINT ck_fin_payment_attachment_exception_reason + CHECK (CHAR_LENGTH(TRIM(reason)) BETWEEN 1 AND 500), + CONSTRAINT ck_fin_payment_attachment_exception_review CHECK ( + (status = 'PENDING' AND reviewed_by IS NULL AND reviewed_at IS NULL) + OR + (status IN ('APPROVED', 'REJECTED') AND reviewed_by IS NOT NULL AND reviewed_at IS NOT NULL) + ), + CONSTRAINT ck_fin_payment_attachment_exception_consumed CHECK ( + (consumed_by IS NULL AND consumed_at IS NULL) + OR + (consumed_by IS NOT NULL AND consumed_at IS NOT NULL AND status = 'APPROVED') + ) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; diff --git a/backend/src/main/resources/db/migration/V039__payment_attachment_exception_names.sql b/backend/src/main/resources/db/migration/V039__payment_attachment_exception_names.sql new file mode 100644 index 0000000..68bbe5c --- /dev/null +++ b/backend/src/main/resources/db/migration/V039__payment_attachment_exception_names.sql @@ -0,0 +1,2 @@ +ALTER TABLE fin_payment_attachment_exception + ADD COLUMN document_name VARCHAR(128) NOT NULL DEFAULT '' AFTER document_type; diff --git a/backend/src/main/resources/db/migration/V040__payment_attachment_exception_name_backfill.sql b/backend/src/main/resources/db/migration/V040__payment_attachment_exception_name_backfill.sql new file mode 100644 index 0000000..e7b57f8 --- /dev/null +++ b/backend/src/main/resources/db/migration/V040__payment_attachment_exception_name_backfill.sql @@ -0,0 +1,20 @@ +UPDATE fin_payment_attachment_exception exception_item +JOIN fin_payment_request payment ON payment.id = exception_item.payment_id +LEFT JOIN JSON_TABLE( + payment.attachment_rule_snapshot_json, + '$.requirements[*]' COLUMNS ( + document_type VARCHAR(64) PATH '$.documentType', + document_name VARCHAR(128) PATH '$.name' + ) +) snapshot_requirement + ON snapshot_requirement.document_type = exception_item.document_type +SET exception_item.document_name = COALESCE( + NULLIF(TRIM(snapshot_requirement.document_name), ''), + exception_item.document_type +) +WHERE TRIM(exception_item.document_name) = ''; + +ALTER TABLE fin_payment_attachment_exception + MODIFY COLUMN document_name VARCHAR(128) NOT NULL, + ADD CONSTRAINT ck_fin_payment_attachment_exception_document_name + CHECK (CHAR_LENGTH(TRIM(document_name)) BETWEEN 1 AND 128); diff --git a/backend/src/main/resources/db/migration/V041__accounting_result_pending_state_backfill.sql b/backend/src/main/resources/db/migration/V041__accounting_result_pending_state_backfill.sql new file mode 100644 index 0000000..0026bfe --- /dev/null +++ b/backend/src/main/resources/db/migration/V041__accounting_result_pending_state_backfill.sql @@ -0,0 +1,19 @@ +-- RESULT_PENDING was a pre-result state. Retain the point reached by each +-- historical voucher using persisted export evidence instead of its legacy label. +UPDATE acc_voucher voucher +LEFT JOIN acc_export_batch export_batch ON export_batch.voucher_id = voucher.id +SET voucher.status = CASE + WHEN voucher.exported_at IS NOT NULL + OR voucher.export_sha256 IS NOT NULL + OR voucher.export_batch_no IS NOT NULL + OR export_batch.voucher_id IS NOT NULL THEN 'EXPORTED' + ELSE 'APPROVED' +END +WHERE voucher.status = 'RESULT_PENDING'; + +ALTER TABLE acc_voucher + DROP CHECK ck_acc_voucher_status, + ADD CONSTRAINT ck_acc_voucher_status CHECK ( + status IN ('DRAFT', 'REVIEWING', 'RETURNED', 'APPROVED', 'EXPORTED', + 'RESULT_RECORDED', 'COMPLETED', 'VOID') + ); diff --git a/backend/src/main/resources/db/migration/V043__report_export_view_scope_snapshot.sql b/backend/src/main/resources/db/migration/V043__report_export_view_scope_snapshot.sql new file mode 100644 index 0000000..caab75b --- /dev/null +++ b/backend/src/main/resources/db/migration/V043__report_export_view_scope_snapshot.sql @@ -0,0 +1,13 @@ +ALTER TABLE report_export_request + ADD COLUMN view_scope_fingerprint CHAR(64) NULL AFTER scope_fingerprint; + +-- A request prepared before this migration did not freeze the view permission +-- scope and therefore cannot be confirmed under the new contract. +UPDATE report_export_request +SET status = 'EXPIRED', + view_scope_fingerprint = REPEAT('0', 64), + updated_at = UTC_TIMESTAMP(3) +WHERE view_scope_fingerprint IS NULL; + +ALTER TABLE report_export_request + MODIFY COLUMN view_scope_fingerprint CHAR(64) NOT NULL AFTER scope_fingerprint; diff --git a/backend/src/main/resources/db/migration/V044__governance_version_restore_permissions.sql b/backend/src/main/resources/db/migration/V044__governance_version_restore_permissions.sql new file mode 100644 index 0000000..380fe07 --- /dev/null +++ b/backend/src/main/resources/db/migration/V044__governance_version_restore_permissions.sql @@ -0,0 +1,16 @@ +-- V010 predates the final PAGE-21 action set. Keep the historical seed immutable +-- and grant the explicit restoration action through a forward-only migration. +INSERT INTO iam_permission (code, name) VALUES + ('admin:template:restore', '恢复已停用表单模板'), + ('admin:parameter:restore', '恢复已停用参数版本') +ON DUPLICATE KEY UPDATE name = VALUES(name); + +INSERT INTO iam_role_permission (role_id, permission_id) +SELECT role.id, permission.id +FROM iam_role role +JOIN iam_permission permission ON permission.code IN ('admin:template:restore', 'admin:parameter:restore') +WHERE role.code = 'SYSTEM_ADMIN' + AND NOT EXISTS ( + SELECT 1 FROM iam_role_permission existing + WHERE existing.role_id = role.id AND existing.permission_id = permission.id + ); diff --git a/backend/src/main/resources/db/migration/V045__contract_cost_source_version_snapshots.sql b/backend/src/main/resources/db/migration/V045__contract_cost_source_version_snapshots.sql new file mode 100644 index 0000000..1818c31 --- /dev/null +++ b/backend/src/main/resources/db/migration/V045__contract_cost_source_version_snapshots.sql @@ -0,0 +1,11 @@ +ALTER TABLE fin_payable + ADD COLUMN source_version_id BIGINT NULL AFTER source_document_id, + ADD KEY idx_fin_payable_source_version (source_version_id), + ADD CONSTRAINT fk_fin_payable_source_version + FOREIGN KEY (source_version_id) REFERENCES src_document_version (id); + +ALTER TABLE fin_contract_settlement + ADD COLUMN source_version_id BIGINT NULL AFTER source_document_id, + ADD KEY idx_fin_contract_settlement_source_version (source_version_id), + ADD CONSTRAINT fk_fin_contract_settlement_source_version + FOREIGN KEY (source_version_id) REFERENCES src_document_version (id); diff --git a/backend/src/main/resources/db/migration/V046__restrict_audit_log_permissions_to_system_admin.sql b/backend/src/main/resources/db/migration/V046__restrict_audit_log_permissions_to_system_admin.sql new file mode 100644 index 0000000..2ec82b4 --- /dev/null +++ b/backend/src/main/resources/db/migration/V046__restrict_audit_log_permissions_to_system_admin.sql @@ -0,0 +1,15 @@ +-- Audit logs are a governance-only capability. Remove the historic V010 +-- grants from every business or custom role while retaining SYSTEM_ADMIN. +DELETE scope +FROM iam_scope scope +JOIN iam_role role ON role.id = scope.role_id +JOIN iam_permission permission ON permission.id = scope.permission_id +WHERE role.code <> 'SYSTEM_ADMIN' + AND permission.code IN ('audit:log:view', 'audit:log:export'); + +DELETE assignment +FROM iam_role_permission assignment +JOIN iam_role role ON role.id = assignment.role_id +JOIN iam_permission permission ON permission.id = assignment.permission_id +WHERE role.code <> 'SYSTEM_ADMIN' + AND permission.code IN ('audit:log:view', 'audit:log:export'); diff --git a/backend/src/main/resources/db/migration/V047__project_disabled_status.sql b/backend/src/main/resources/db/migration/V047__project_disabled_status.sql new file mode 100644 index 0000000..43e067c --- /dev/null +++ b/backend/src/main/resources/db/migration/V047__project_disabled_status.sql @@ -0,0 +1,4 @@ +ALTER TABLE md_project + DROP CHECK ck_md_project_status, + ADD CONSTRAINT ck_md_project_status + CHECK (status IN ('DRAFT', 'ACTIVE', 'SUSPENDED', 'CLOSED', 'ARCHIVED', 'DISABLED')); diff --git a/backend/src/main/resources/db/migration/V048__approved_source_masterdata_projections.sql b/backend/src/main/resources/db/migration/V048__approved_source_masterdata_projections.sql new file mode 100644 index 0000000..e989596 --- /dev/null +++ b/backend/src/main/resources/db/migration/V048__approved_source_masterdata_projections.sql @@ -0,0 +1,153 @@ +ALTER TABLE src_document_version + ADD UNIQUE KEY uk_src_document_version_id_document (id, source_document_id); + +ALTER TABLE md_counterparty + ADD COLUMN source_document_id BIGINT NULL AFTER review_opinion, + ADD COLUMN source_version_id BIGINT NULL AFTER source_document_id, + ADD UNIQUE KEY uk_md_counterparty_source_document (source_document_id), + ADD UNIQUE KEY uk_md_counterparty_id_source_tuple (id, source_document_id, source_version_id), + ADD KEY idx_md_counterparty_source_version (source_version_id), + ADD CONSTRAINT fk_md_counterparty_source_document + FOREIGN KEY (source_document_id) REFERENCES src_source_document (id), + ADD CONSTRAINT fk_md_counterparty_source_version_document + FOREIGN KEY (source_version_id, source_document_id) + REFERENCES src_document_version (id, source_document_id), + ADD CONSTRAINT ck_md_counterparty_source_pair CHECK ( + (source_document_id IS NULL AND source_version_id IS NULL) + OR (source_document_id IS NOT NULL AND source_version_id IS NOT NULL) + ); + +ALTER TABLE md_bank_account_version + ADD COLUMN source_document_id BIGINT NULL AFTER review_opinion, + ADD COLUMN source_version_id BIGINT NULL AFTER source_document_id, + ADD COLUMN source_row_no INT NULL AFTER source_version_id, + ADD UNIQUE KEY uk_md_bank_account_source_row (source_document_id, source_row_no), + ADD KEY idx_md_bank_account_source_version (source_version_id), + ADD KEY idx_md_bank_account_counterparty_source (counterparty_id, source_document_id, source_version_id), + ADD CONSTRAINT fk_md_bank_account_source_document + FOREIGN KEY (source_document_id) REFERENCES src_source_document (id), + ADD CONSTRAINT fk_md_bank_account_source_version_document + FOREIGN KEY (source_version_id, source_document_id) + REFERENCES src_document_version (id, source_document_id), + ADD CONSTRAINT fk_md_bank_account_counterparty_source + FOREIGN KEY (counterparty_id, source_document_id, source_version_id) + REFERENCES md_counterparty (id, source_document_id, source_version_id), + ADD CONSTRAINT ck_md_bank_account_source_tuple CHECK ( + (source_document_id IS NULL AND source_version_id IS NULL AND source_row_no IS NULL) + OR (source_document_id IS NOT NULL AND source_version_id IS NOT NULL AND source_row_no > 0) + ); + +ALTER TABLE md_contract + ADD COLUMN source_document_id BIGINT NULL AFTER review_opinion, + ADD COLUMN source_version_id BIGINT NULL AFTER source_document_id, + ADD UNIQUE KEY uk_md_contract_source_document (source_document_id), + ADD KEY idx_md_contract_source_version (source_version_id), + ADD CONSTRAINT fk_md_contract_source_document + FOREIGN KEY (source_document_id) REFERENCES src_source_document (id), + ADD CONSTRAINT fk_md_contract_source_version_document + FOREIGN KEY (source_version_id, source_document_id) + REFERENCES src_document_version (id, source_document_id), + ADD CONSTRAINT ck_md_contract_source_pair CHECK ( + (source_document_id IS NULL AND source_version_id IS NULL) + OR (source_document_id IS NOT NULL AND source_version_id IS NOT NULL) + ); + +INSERT INTO src_template ( + public_id, form_type, template_version, name, group_code, purpose, initiator_roles, + schema_version, status, published_at, created_by, updated_by +) +SELECT CASE source.form_type + WHEN 'OA-02' THEN '00000000000000000000004802' + WHEN 'OA-03' THEN '00000000000000000000004803' + END, + source.form_type, 2, source.name, source.group_code, source.purpose, source.initiator_roles, + source.schema_version + 1, 'ACTIVE', UTC_TIMESTAMP(3), source.created_by, source.updated_by +FROM src_template source +WHERE source.form_type IN ('OA-02', 'OA-03') + AND source.template_version = 1 + AND source.status = 'ACTIVE'; + +INSERT INTO src_template_field ( + template_id, field_code, label, section_code, section_name, data_type, + required_flag, repeatable, max_length, numeric_precision, numeric_scale, + required_when_json, enum_code, options_json, unit, sensitivity, masking_rule, + mapping_code, import_column, sort_order, help_text +) +SELECT target.id, field.field_code, field.label, field.section_code, field.section_name, + field.data_type, field.required_flag, field.repeatable, field.max_length, + field.numeric_precision, field.numeric_scale, field.required_when_json, field.enum_code, + field.options_json, field.unit, field.sensitivity, field.masking_rule, + field.mapping_code, field.import_column, field.sort_order, field.help_text +FROM src_template source +JOIN src_template target + ON target.form_type = source.form_type AND target.template_version = 2 +JOIN src_template_field field ON field.template_id = source.id +WHERE source.form_type IN ('OA-02', 'OA-03') + AND source.template_version = 1; + +UPDATE src_template_field field +JOIN src_template template ON template.id = field.template_id +SET field.options_json = CAST('[ + {"code":"accountName","label":"户名","dataType":"TEXT","required":true,"maxLength":200}, + {"code":"bankName","label":"开户行","dataType":"TEXT","required":true,"maxLength":200}, + {"code":"accountNo","label":"账号","dataType":"TEXT","required":true,"maxLength":64}, + {"code":"accountCategory","label":"账户类别","dataType":"ENUM","required":true, + "options":[ + {"code":"BASIC","label":"基本户"}, + {"code":"GENERAL","label":"一般户"}, + {"code":"SPECIAL","label":"专用户"}, + {"code":"PROJECT","label":"项目户"}, + {"code":"OTHER","label":"其他"} + ]}, + {"code":"validFrom","label":"生效日期","dataType":"DATE","required":true}, + {"code":"validTo","label":"失效日期","dataType":"DATE","required":false}, + {"code":"reason","label":"准入原因","dataType":"TEXT","required":false} +]' AS JSON) +WHERE template.form_type = 'OA-02' + AND template.template_version = 2 + AND field.field_code = 'bankAccounts'; + +UPDATE src_template_field field +JOIN src_template template ON template.id = field.template_id +SET field.options_json = CAST('[ + {"code":"name","label":"联系人","dataType":"TEXT","required":true,"maxLength":100}, + {"code":"phone","label":"电话","dataType":"TEXT","required":true,"maxLength":32}, + {"code":"product","label":"产品","dataType":"TEXT","required":false} +]' AS JSON) +WHERE template.form_type = 'OA-02' + AND template.template_version = 2 + AND field.field_code = 'contact'; + +INSERT INTO src_template_field ( + template_id, field_code, label, section_code, section_name, data_type, + required_flag, repeatable, max_length, required_when_json, options_json, + sensitivity, mapping_code, import_column, sort_order +) +SELECT template.id, 'supplierId', '生效供应商', 'SUPPLIER', '供应商信息', 'REFERENCE', + TRUE, FALSE, 26, JSON_OBJECT('required', TRUE), JSON_ARRAY(), + 'INTERNAL', 'supplierId', '生效供应商', 35 +FROM src_template template +WHERE template.form_type = 'OA-03' AND template.template_version = 2; + +INSERT INTO src_template_field ( + template_id, field_code, label, section_code, section_name, data_type, + required_flag, repeatable, max_length, required_when_json, enum_code, options_json, + sensitivity, mapping_code, import_column, sort_order +) +SELECT template.id, 'currency', '币种', 'CONTRACT', '合同信息', 'ENUM', + TRUE, FALSE, 3, JSON_OBJECT('required', TRUE), 'CURRENCY', + CAST('[ + {"code":"CNY","label":"人民币"}, + {"code":"USD","label":"美元"}, + {"code":"EUR","label":"欧元"}, + {"code":"HKD","label":"港币"} + ]' AS JSON), + 'NORMAL', 'currency', '币种', 36 +FROM src_template template +WHERE template.form_type = 'OA-03' AND template.template_version = 2; + +UPDATE src_template +SET status = 'RETIRED', updated_at = UTC_TIMESTAMP(3), version = version + 1 +WHERE form_type IN ('OA-02', 'OA-03') + AND template_version = 1 + AND status = 'ACTIVE'; diff --git a/backend/src/main/resources/db/migration/V049_1__approved_oa05_receipt_source_preflight.sql b/backend/src/main/resources/db/migration/V049_1__approved_oa05_receipt_source_preflight.sql new file mode 100644 index 0000000..10f52b1 --- /dev/null +++ b/backend/src/main/resources/db/migration/V049_1__approved_oa05_receipt_source_preflight.sql @@ -0,0 +1,37 @@ +CREATE TEMPORARY TABLE v049_1_oa05_receipt_preflight ( + issue_code VARCHAR(64) NOT NULL, + issue_count BIGINT NOT NULL, + CONSTRAINT ck_v049_1_oa05_receipt_preflight_clear CHECK (issue_count = 0) +); + +INSERT INTO v049_1_oa05_receipt_preflight (issue_code, issue_count) +SELECT 'DUPLICATE_SOURCE_DOCUMENT', COUNT(*) +FROM ( + SELECT source_document_id + FROM fin_receipt + WHERE source_document_id IS NOT NULL + GROUP BY source_document_id + HAVING COUNT(*) > 1 +) duplicate_source; + +INSERT INTO v049_1_oa05_receipt_preflight (issue_code, issue_count) +SELECT 'AMBIGUOUS_SOURCE_VERSION', COUNT(*) +FROM fin_receipt receipt +JOIN src_source_document document ON document.id = receipt.source_document_id +LEFT JOIN ( + SELECT source_document_id, COUNT(*) AS version_count + FROM src_document_version + GROUP BY source_document_id +) source_versions ON source_versions.source_document_id = document.id +LEFT JOIN src_document_version current_version + ON current_version.id = document.current_version_id + AND current_version.source_document_id = document.id +WHERE receipt.source_document_id IS NOT NULL + AND ( + document.current_version_id IS NULL + OR current_version.id IS NULL + OR source_versions.version_count IS NULL + OR source_versions.version_count <> 1 + ); + +DROP TEMPORARY TABLE v049_1_oa05_receipt_preflight; diff --git a/backend/src/main/resources/db/migration/V049__approved_oa07_company_bank_account.sql b/backend/src/main/resources/db/migration/V049__approved_oa07_company_bank_account.sql new file mode 100644 index 0000000..8f67c6d --- /dev/null +++ b/backend/src/main/resources/db/migration/V049__approved_oa07_company_bank_account.sql @@ -0,0 +1,51 @@ +INSERT INTO src_template ( + public_id, form_type, template_version, name, group_code, purpose, initiator_roles, + schema_version, status, published_at, created_by, updated_by +) +SELECT '00000000000000000000004907', source.form_type, 2, source.name, source.group_code, + source.purpose, source.initiator_roles, source.schema_version + 1, 'ACTIVE', + UTC_TIMESTAMP(3), source.created_by, source.updated_by +FROM src_template source +WHERE source.form_type = 'OA-07' + AND source.template_version = 1 + AND source.status = 'ACTIVE'; + +INSERT INTO src_template_field ( + template_id, field_code, label, section_code, section_name, data_type, + required_flag, repeatable, max_length, numeric_precision, numeric_scale, + required_when_json, enum_code, options_json, unit, sensitivity, masking_rule, + mapping_code, import_column, sort_order, help_text +) +SELECT target.id, field.field_code, field.label, field.section_code, field.section_name, + field.data_type, field.required_flag, field.repeatable, field.max_length, + field.numeric_precision, field.numeric_scale, field.required_when_json, field.enum_code, + field.options_json, field.unit, field.sensitivity, field.masking_rule, + field.mapping_code, field.import_column, field.sort_order, field.help_text +FROM src_template source +JOIN src_template target + ON target.form_type = source.form_type AND target.template_version = 2 +JOIN src_template_field field ON field.template_id = source.id +WHERE source.form_type = 'OA-07' + AND source.template_version = 1; + +UPDATE src_template_field field +JOIN src_template template ON template.id = field.template_id +SET field.data_type = 'ENUM', + field.max_length = 32, + field.enum_code = 'BANK_ACCOUNT_CATEGORY', + field.options_json = CAST('[ + {"code":"BASIC","label":"基本户"}, + {"code":"GENERAL","label":"一般户"}, + {"code":"SPECIAL","label":"专用户"}, + {"code":"PROJECT","label":"项目户"}, + {"code":"OTHER","label":"其他"} + ]' AS JSON) +WHERE template.form_type = 'OA-07' + AND template.template_version = 2 + AND field.field_code = 'accountCategory'; + +UPDATE src_template +SET status = 'RETIRED', updated_at = UTC_TIMESTAMP(3), version = version + 1 +WHERE form_type = 'OA-07' + AND template_version = 1 + AND status = 'ACTIVE'; diff --git a/backend/src/main/resources/db/migration/V050__approved_oa05_receipt_projection.sql b/backend/src/main/resources/db/migration/V050__approved_oa05_receipt_projection.sql new file mode 100644 index 0000000..0532953 --- /dev/null +++ b/backend/src/main/resources/db/migration/V050__approved_oa05_receipt_projection.sql @@ -0,0 +1,55 @@ +ALTER TABLE fin_receipt + ADD COLUMN source_version_id BIGINT NULL AFTER source_document_id; + +UPDATE fin_receipt receipt +JOIN src_source_document document ON document.id = receipt.source_document_id +SET receipt.source_version_id = document.current_version_id +WHERE receipt.source_document_id IS NOT NULL + AND receipt.source_version_id IS NULL; + +ALTER TABLE fin_receipt + ADD UNIQUE KEY uk_fin_receipt_source_document (source_document_id), + ADD KEY idx_fin_receipt_source_version (source_version_id), + ADD CONSTRAINT fk_fin_receipt_source_version_document + FOREIGN KEY (source_version_id, source_document_id) + REFERENCES src_document_version (id, source_document_id), + ADD CONSTRAINT ck_fin_receipt_source_pair CHECK ( + (source_document_id IS NULL AND source_version_id IS NULL) + OR (source_document_id IS NOT NULL AND source_version_id IS NOT NULL) + ); + +INSERT INTO src_template ( + public_id, form_type, template_version, name, group_code, purpose, initiator_roles, + schema_version, status, published_at, created_by, updated_by +) +SELECT '00000000000000000000005005', source.form_type, 2, source.name, source.group_code, + source.purpose, source.initiator_roles, source.schema_version + 1, 'ACTIVE', + UTC_TIMESTAMP(3), source.created_by, source.updated_by +FROM src_template source +WHERE source.form_type = 'OA-05' + AND source.template_version = 1 + AND source.status = 'ACTIVE'; + +INSERT INTO src_template_field ( + template_id, field_code, label, section_code, section_name, data_type, + required_flag, repeatable, max_length, numeric_precision, numeric_scale, + required_when_json, enum_code, options_json, unit, sensitivity, masking_rule, + mapping_code, import_column, sort_order, help_text +) +SELECT target.id, field.field_code, field.label, field.section_code, field.section_name, + field.data_type, field.required_flag, field.repeatable, field.max_length, + field.numeric_precision, field.numeric_scale, field.required_when_json, field.enum_code, + field.options_json, field.unit, field.sensitivity, field.masking_rule, + field.mapping_code, field.import_column, field.sort_order, field.help_text +FROM src_template source +JOIN src_template target + ON target.form_type = source.form_type AND target.template_version = 2 +JOIN src_template_field field ON field.template_id = source.id +WHERE source.form_type = 'OA-05' + AND source.template_version = 1; + +UPDATE src_template +SET status = 'RETIRED', updated_at = UTC_TIMESTAMP(3), version = version + 1 +WHERE form_type = 'OA-05' + AND template_version = 1 + AND status = 'ACTIVE'; diff --git a/backend/src/main/resources/db/migration/V051__approved_oa08_invoice_projection.sql b/backend/src/main/resources/db/migration/V051__approved_oa08_invoice_projection.sql new file mode 100644 index 0000000..230af23 --- /dev/null +++ b/backend/src/main/resources/db/migration/V051__approved_oa08_invoice_projection.sql @@ -0,0 +1,152 @@ +CREATE TEMPORARY TABLE v051_invoice_preflight ( + issue_code VARCHAR(64) NOT NULL, + issue_count BIGINT NOT NULL, + CONSTRAINT ck_v051_invoice_preflight_clear CHECK (issue_count = 0) +); + +INSERT INTO v051_invoice_preflight (issue_code, issue_count) +SELECT 'DUPLICATE_SOURCE_DOCUMENT', COUNT(*) +FROM ( + SELECT source_document_id + FROM fin_invoice + WHERE source_document_id IS NOT NULL + GROUP BY source_document_id + HAVING COUNT(*) > 1 +) duplicate_source; + +INSERT INTO v051_invoice_preflight (issue_code, issue_count) +SELECT 'AMBIGUOUS_SOURCE_VERSION', COUNT(*) +FROM fin_invoice invoice +JOIN src_source_document document ON document.id = invoice.source_document_id +LEFT JOIN ( + SELECT source_document_id, COUNT(*) AS version_count + FROM src_document_version + GROUP BY source_document_id +) source_versions ON source_versions.source_document_id = document.id +WHERE invoice.source_document_id IS NOT NULL + AND (document.current_version_id IS NULL OR source_versions.version_count <> 1); + +DROP TEMPORARY TABLE v051_invoice_preflight; + +ALTER TABLE fin_invoice + ADD COLUMN source_version_id BIGINT NULL AFTER source_document_id, + ADD COLUMN currency CHAR(3) NULL AFTER amount; + +UPDATE fin_invoice invoice +JOIN md_project project ON project.id = invoice.project_id +SET invoice.currency = project.currency +WHERE invoice.currency IS NULL; + +UPDATE fin_invoice invoice +JOIN src_source_document document ON document.id = invoice.source_document_id +SET invoice.source_version_id = document.current_version_id +WHERE invoice.source_document_id IS NOT NULL + AND invoice.source_version_id IS NULL; + +ALTER TABLE fin_invoice + MODIFY COLUMN currency CHAR(3) NOT NULL DEFAULT 'CNY', + ADD UNIQUE KEY uk_fin_invoice_source_document (source_document_id), + ADD KEY idx_fin_invoice_source_version (source_version_id), + ADD CONSTRAINT fk_fin_invoice_source_version_document + FOREIGN KEY (source_version_id, source_document_id) + REFERENCES src_document_version (id, source_document_id), + ADD CONSTRAINT ck_fin_invoice_source_pair CHECK ( + (source_document_id IS NULL AND source_version_id IS NULL) + OR (source_document_id IS NOT NULL AND source_version_id IS NOT NULL) + ); + +INSERT INTO src_template ( + public_id, form_type, template_version, name, group_code, purpose, initiator_roles, + schema_version, status, published_at, created_by, updated_by +) +SELECT '00000000000000000000005108', source.form_type, 2, source.name, source.group_code, + source.purpose, source.initiator_roles, source.schema_version + 1, 'ACTIVE', + UTC_TIMESTAMP(3), source.created_by, source.updated_by +FROM src_template source +WHERE source.form_type = 'OA-08' + AND source.template_version = 1 + AND source.status = 'ACTIVE'; + +INSERT INTO src_template_field ( + template_id, field_code, label, section_code, section_name, data_type, + required_flag, repeatable, max_length, numeric_precision, numeric_scale, + required_when_json, enum_code, options_json, unit, sensitivity, masking_rule, + mapping_code, import_column, sort_order, help_text +) +SELECT target.id, field.field_code, field.label, field.section_code, field.section_name, + field.data_type, field.required_flag, field.repeatable, field.max_length, + field.numeric_precision, field.numeric_scale, field.required_when_json, field.enum_code, + field.options_json, field.unit, field.sensitivity, field.masking_rule, + field.mapping_code, field.import_column, field.sort_order, field.help_text +FROM src_template source +JOIN src_template target + ON target.form_type = source.form_type AND target.template_version = 2 +JOIN src_template_field field ON field.template_id = source.id +WHERE source.form_type = 'OA-08' + AND source.template_version = 1; + +INSERT INTO src_template_field ( + template_id, field_code, label, section_code, section_name, data_type, + required_flag, repeatable, max_length, required_when_json, options_json, + sensitivity, mapping_code, import_column, sort_order +) +SELECT template.id, 'contractId', '总承包合同', 'INVOICE', '开票信息', 'REFERENCE', + TRUE, FALSE, 26, JSON_OBJECT('required', TRUE), JSON_ARRAY(), + 'INTERNAL', 'contractId', '总承包合同', 34 +FROM src_template template +WHERE template.form_type = 'OA-08' AND template.template_version = 2; + +INSERT INTO src_template_field ( + template_id, field_code, label, section_code, section_name, data_type, + required_flag, repeatable, max_length, required_when_json, options_json, + sensitivity, mapping_code, import_column, sort_order +) +SELECT template.id, 'counterpartyId', '生效客户', 'CUSTOMER', '客户信息', 'REFERENCE', + TRUE, FALSE, 26, JSON_OBJECT('required', TRUE), JSON_ARRAY(), + 'INTERNAL', 'counterpartyId', '生效客户', 44 +FROM src_template template +WHERE template.form_type = 'OA-08' AND template.template_version = 2; + +INSERT INTO src_template_field ( + template_id, field_code, label, section_code, section_name, data_type, + required_flag, repeatable, max_length, required_when_json, enum_code, options_json, + sensitivity, mapping_code, import_column, sort_order +) +SELECT template.id, 'invoiceType', '发票类型', 'INVOICE', '开票信息', 'ENUM', + TRUE, FALSE, 32, JSON_OBJECT('required', TRUE), 'INVOICE_TYPE', + CAST('[ + {"code":"SPECIAL","label":"增值税专用发票"}, + {"code":"NORMAL","label":"增值税普通发票"}, + {"code":"RECEIPT","label":"收据"} + ]' AS JSON), + 'NORMAL', 'invoiceType', '发票类型', 35 +FROM src_template template +WHERE template.form_type = 'OA-08' AND template.template_version = 2; + +INSERT INTO src_template_field ( + template_id, field_code, label, section_code, section_name, data_type, + required_flag, repeatable, numeric_precision, numeric_scale, required_when_json, + options_json, unit, sensitivity, mapping_code, import_column, sort_order +) +SELECT template.id, 'taxRate', '税率', 'INVOICE', '开票信息', 'DECIMAL', + TRUE, FALSE, 10, 6, JSON_OBJECT('required', TRUE), JSON_ARRAY(), + '比例', 'INTERNAL', 'taxRate', '税率', 36 +FROM src_template template +WHERE template.form_type = 'OA-08' AND template.template_version = 2; + +UPDATE src_template_field field +JOIN src_template template ON template.id = field.template_id +SET field.options_json = CAST('[ + {"code":"content","label":"开票内容","dataType":"TEXT","required":true,"maxLength":500}, + {"code":"settlementMethod","label":"结算方式","dataType":"TEXT","required":true,"maxLength":100}, + {"code":"expectedReceiptDate","label":"预计到账时间","dataType":"DATE","required":true} +]' AS JSON) +WHERE template.form_type = 'OA-08' + AND template.template_version = 2 + AND field.field_code = 'invoiceRequirements'; + +UPDATE src_template +SET status = 'RETIRED', updated_at = UTC_TIMESTAMP(3), version = version + 1 +WHERE form_type = 'OA-08' + AND template_version = 1 + AND status = 'ACTIVE'; diff --git a/backend/src/main/resources/db/migration/V052__approved_oa04_payable_projection.sql b/backend/src/main/resources/db/migration/V052__approved_oa04_payable_projection.sql new file mode 100644 index 0000000..eea7291 --- /dev/null +++ b/backend/src/main/resources/db/migration/V052__approved_oa04_payable_projection.sql @@ -0,0 +1,84 @@ +INSERT INTO src_template ( + public_id, form_type, template_version, name, group_code, purpose, initiator_roles, + schema_version, status, published_at, created_by, updated_by +) +SELECT '00000000000000000000005204', source.form_type, 2, source.name, source.group_code, + source.purpose, source.initiator_roles, source.schema_version + 1, 'ACTIVE', + UTC_TIMESTAMP(3), source.created_by, source.updated_by +FROM src_template source +WHERE source.form_type = 'OA-04' + AND source.template_version = 1 + AND source.status = 'ACTIVE'; + +INSERT INTO src_template_field ( + template_id, field_code, label, section_code, section_name, data_type, + required_flag, repeatable, max_length, numeric_precision, numeric_scale, + required_when_json, enum_code, options_json, unit, sensitivity, masking_rule, + mapping_code, import_column, sort_order, help_text +) +SELECT target.id, field.field_code, field.label, field.section_code, field.section_name, + field.data_type, field.required_flag, field.repeatable, field.max_length, + field.numeric_precision, field.numeric_scale, field.required_when_json, field.enum_code, + field.options_json, field.unit, field.sensitivity, field.masking_rule, + field.mapping_code, field.import_column, field.sort_order, field.help_text +FROM src_template source +JOIN src_template target + ON target.form_type = source.form_type AND target.template_version = 2 +JOIN src_template_field field ON field.template_id = source.id +WHERE source.form_type = 'OA-04' + AND source.template_version = 1; + +INSERT INTO src_template_field ( + template_id, field_code, label, section_code, section_name, data_type, + required_flag, repeatable, max_length, required_when_json, options_json, + sensitivity, mapping_code, import_column, sort_order +) +SELECT template.id, 'contractId', '生效成本合同', 'PAYABLE', '应付信息', 'REFERENCE', + TRUE, FALSE, 26, JSON_OBJECT('required', TRUE), JSON_ARRAY(), + 'INTERNAL', 'contractId', '生效成本合同', 14 +FROM src_template template +WHERE template.form_type = 'OA-04' AND template.template_version = 2; + +INSERT INTO src_template_field ( + template_id, field_code, label, section_code, section_name, data_type, + required_flag, repeatable, max_length, required_when_json, options_json, + sensitivity, mapping_code, import_column, sort_order +) +SELECT template.id, 'supplierId', '生效供应商', 'PAYABLE', '应付信息', 'REFERENCE', + TRUE, FALSE, 26, JSON_OBJECT('required', TRUE), JSON_ARRAY(), + 'INTERNAL', 'supplierId', '生效供应商', 24 +FROM src_template template +WHERE template.form_type = 'OA-04' AND template.template_version = 2; + +INSERT INTO src_template_field ( + template_id, field_code, label, section_code, section_name, data_type, + required_flag, repeatable, max_length, required_when_json, options_json, + sensitivity, mapping_code, import_column, sort_order +) +SELECT template.id, 'costCategoryId', '成本分类', 'PAYABLE', '应付信息', 'REFERENCE', + TRUE, FALSE, 26, JSON_OBJECT('required', TRUE), JSON_ARRAY(), + 'INTERNAL', 'costCategoryId', '成本分类', 34 +FROM src_template template +WHERE template.form_type = 'OA-04' AND template.template_version = 2; + +UPDATE src_template_field field +JOIN src_template template ON template.id = field.template_id +SET field.options_json = CAST('[ + {"code":"invoiceNo","label":"发票号码","dataType":"TEXT","required":true,"maxLength":100}, + {"code":"invoiceType","label":"发票类型","dataType":"ENUM","required":true, + "options":[{"code":"SPECIAL","label":"增值税专用发票"},{"code":"NORMAL","label":"增值税普通发票"},{"code":"RECEIPT","label":"收据"}]}, + {"code":"invoiceDate","label":"发票日期","dataType":"DATE","required":true}, + {"code":"taxInclusiveAmount","label":"含税金额","dataType":"DECIMAL","required":true}, + {"code":"taxExclusiveAmount","label":"不含税金额","dataType":"DECIMAL","required":true}, + {"code":"taxRate","label":"税率","dataType":"DECIMAL","required":true}, + {"code":"taxAmount","label":"税额","dataType":"DECIMAL","required":true} +]' AS JSON) +WHERE template.form_type = 'OA-04' + AND template.template_version = 2 + AND field.field_code = 'invoiceDetails'; + +UPDATE src_template +SET status = 'RETIRED', updated_at = UTC_TIMESTAMP(3), version = version + 1 +WHERE form_type = 'OA-04' + AND template_version = 1 + AND status = 'ACTIVE'; diff --git a/backend/src/main/resources/db/migration/V053__payment_control_exceptions.sql b/backend/src/main/resources/db/migration/V053__payment_control_exceptions.sql new file mode 100644 index 0000000..cd43835 --- /dev/null +++ b/backend/src/main/resources/db/migration/V053__payment_control_exceptions.sql @@ -0,0 +1,65 @@ +CREATE TABLE fin_payment_control_exception ( + id BIGINT NOT NULL AUTO_INCREMENT, + public_id CHAR(26) NOT NULL, + payment_id BIGINT NOT NULL, + control_code VARCHAR(32) NOT NULL, + finding_fingerprint CHAR(64) NOT NULL, + finding_summary VARCHAR(1000) NOT NULL, + finding_snapshot_json JSON NOT NULL, + version_no INT NOT NULL, + reason VARCHAR(500) NOT NULL, + valid_until DATE NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'PENDING', + active BOOLEAN NOT NULL DEFAULT TRUE, + active_control_code VARCHAR(32) + GENERATED ALWAYS AS (CASE WHEN active THEN control_code ELSE NULL END) STORED, + version BIGINT NOT NULL DEFAULT 0, + requested_by BIGINT NOT NULL, + requested_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + reviewed_by BIGINT NULL, + reviewed_at TIMESTAMP(3) NULL, + review_opinion VARCHAR(1000) NULL, + consumed_by BIGINT NULL, + consumed_at TIMESTAMP(3) NULL, + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_fin_payment_control_exception_public_id (public_id), + UNIQUE KEY uk_fin_payment_control_exception_version ( + payment_id, control_code, finding_fingerprint, version_no + ), + UNIQUE KEY uk_fin_payment_control_exception_active (payment_id, active_control_code), + KEY idx_fin_payment_control_exception_status (payment_id, active, status, valid_until), + CONSTRAINT fk_fin_payment_control_exception_payment + FOREIGN KEY (payment_id) REFERENCES fin_payment_request (id), + CONSTRAINT fk_fin_payment_control_exception_requester + FOREIGN KEY (requested_by) REFERENCES iam_user (id), + CONSTRAINT fk_fin_payment_control_exception_reviewer + FOREIGN KEY (reviewed_by) REFERENCES iam_user (id), + CONSTRAINT fk_fin_payment_control_exception_consumer + FOREIGN KEY (consumed_by) REFERENCES iam_user (id), + CONSTRAINT ck_fin_payment_control_exception_code + CHECK (control_code IN ('DUPLICATE', 'PROJECT_RISK')), + CONSTRAINT ck_fin_payment_control_exception_fingerprint + CHECK (finding_fingerprint REGEXP '^[0-9a-f]{64}$'), + CONSTRAINT ck_fin_payment_control_exception_version CHECK (version_no > 0), + CONSTRAINT ck_fin_payment_control_exception_row_version CHECK (version >= 0), + CONSTRAINT ck_fin_payment_control_exception_reason + CHECK (CHAR_LENGTH(TRIM(reason)) BETWEEN 1 AND 500), + CONSTRAINT ck_fin_payment_control_exception_summary + CHECK (CHAR_LENGTH(TRIM(finding_summary)) BETWEEN 1 AND 1000), + CONSTRAINT ck_fin_payment_control_exception_review CHECK ( + (status = 'PENDING' AND reviewed_by IS NULL AND reviewed_at IS NULL AND review_opinion IS NULL) + OR + (status IN ('APPROVED', 'REJECTED') + AND reviewed_by IS NOT NULL AND reviewed_at IS NOT NULL + AND CHAR_LENGTH(TRIM(review_opinion)) BETWEEN 1 AND 1000) + ), + CONSTRAINT ck_fin_payment_control_exception_status + CHECK (status IN ('PENDING', 'APPROVED', 'REJECTED')), + CONSTRAINT ck_fin_payment_control_exception_consumed CHECK ( + (consumed_by IS NULL AND consumed_at IS NULL) + OR + (consumed_by IS NOT NULL AND consumed_at IS NOT NULL AND status = 'APPROVED') + ) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; diff --git a/backend/src/main/resources/db/migration/V054__payment_control_exception_revision_key.sql b/backend/src/main/resources/db/migration/V054__payment_control_exception_revision_key.sql new file mode 100644 index 0000000..7553775 --- /dev/null +++ b/backend/src/main/resources/db/migration/V054__payment_control_exception_revision_key.sql @@ -0,0 +1,3 @@ +ALTER TABLE fin_payment_control_exception + DROP INDEX uk_fin_payment_control_exception_version, + ADD UNIQUE KEY uk_fin_payment_control_exception_version (payment_id, control_code, version_no); diff --git a/backend/src/main/resources/db/migration/V055__source_projection_integrity_preflight.sql b/backend/src/main/resources/db/migration/V055__source_projection_integrity_preflight.sql new file mode 100644 index 0000000..e3034f0 --- /dev/null +++ b/backend/src/main/resources/db/migration/V055__source_projection_integrity_preflight.sql @@ -0,0 +1,29 @@ +-- Existing projection migrations add a composite source-version foreign key. +-- Keep a forward-only guard for databases that were upgraded before the +-- source-version preflight was installed; invalid historical rows must stop +-- startup instead of being silently exposed as valid financial records. +CREATE TEMPORARY TABLE v055_source_projection_preflight ( + issue_code VARCHAR(64) NOT NULL, + issue_count BIGINT NOT NULL, + CONSTRAINT ck_v055_source_projection_preflight_clear CHECK (issue_count = 0) +); + +INSERT INTO v055_source_projection_preflight (issue_code, issue_count) +SELECT 'RECEIPT_SOURCE_VERSION_MISMATCH', COUNT(*) +FROM fin_receipt receipt +LEFT JOIN src_document_version source_version + ON source_version.id = receipt.source_version_id + AND source_version.source_document_id = receipt.source_document_id +WHERE receipt.source_document_id IS NOT NULL + AND source_version.id IS NULL; + +INSERT INTO v055_source_projection_preflight (issue_code, issue_count) +SELECT 'INVOICE_SOURCE_VERSION_MISMATCH', COUNT(*) +FROM fin_invoice invoice +LEFT JOIN src_document_version source_version + ON source_version.id = invoice.source_version_id + AND source_version.source_document_id = invoice.source_document_id +WHERE invoice.source_document_id IS NOT NULL + AND source_version.id IS NULL; + +DROP TEMPORARY TABLE v055_source_projection_preflight; diff --git a/backend/src/main/resources/db/migration/V056__source_document_current_version_integrity.sql b/backend/src/main/resources/db/migration/V056__source_document_current_version_integrity.sql new file mode 100644 index 0000000..39b39cd --- /dev/null +++ b/backend/src/main/resources/db/migration/V056__source_document_current_version_integrity.sql @@ -0,0 +1,27 @@ +-- A source document's current version must belong to that same document. +-- V003 used a scalar foreign key, so old databases may contain a cross-document +-- pointer even though the version id itself exists. Fail before tightening the +-- constraint rather than allowing an invalid history to become unreadable. +CREATE TEMPORARY TABLE v056_source_document_preflight ( + issue_code VARCHAR(64) NOT NULL, + issue_count BIGINT NOT NULL, + CONSTRAINT ck_v056_source_document_preflight_clear CHECK (issue_count = 0) +); + +INSERT INTO v056_source_document_preflight (issue_code, issue_count) +SELECT 'CURRENT_VERSION_DOCUMENT_MISMATCH', COUNT(*) +FROM src_source_document document +LEFT JOIN src_document_version source_version + ON source_version.id = document.current_version_id + AND source_version.source_document_id = document.id +WHERE document.current_version_id IS NOT NULL + AND source_version.id IS NULL; + +DROP TEMPORARY TABLE v056_source_document_preflight; + +ALTER TABLE src_source_document + DROP FOREIGN KEY fk_src_document_current_version, + ADD KEY idx_src_document_current_version_document (current_version_id, id), + ADD CONSTRAINT fk_src_document_current_version_document + FOREIGN KEY (current_version_id, id) + REFERENCES src_document_version (id, source_document_id); diff --git a/backend/src/main/resources/db/migration/V057__accounting_source_event_key.sql b/backend/src/main/resources/db/migration/V057__accounting_source_event_key.sql new file mode 100644 index 0000000..a0d357d --- /dev/null +++ b/backend/src/main/resources/db/migration/V057__accounting_source_event_key.sql @@ -0,0 +1,15 @@ +-- Finance events are identified by the source revision, event type and sequence. +-- Existing rows are retained under an explicit legacy revision before the old +-- source-only uniqueness rule is replaced. +ALTER TABLE acc_event + ADD COLUMN source_version VARCHAR(128) NOT NULL DEFAULT 'LEGACY-1' AFTER source_public_id, + ADD COLUMN event_sequence INT NOT NULL DEFAULT 1 AFTER source_version, + ADD COLUMN source_event_key CHAR(64) + GENERATED ALWAYS AS ( + SHA2(CONCAT(source_type, '|', source_public_id, '|', source_version, '|', + event_type, '|', CAST(event_sequence AS CHAR)), 256) + ) STORED AFTER event_sequence, + DROP INDEX uk_acc_event_source, + ADD UNIQUE KEY uk_acc_event_source_event_key (source_event_key), + ADD KEY idx_acc_event_source (event_type, source_type, source_public_id), + ADD CONSTRAINT ck_acc_event_sequence CHECK (event_sequence > 0); diff --git a/backend/src/main/resources/db/migration/V058__accounting_rule_template_snapshot.sql b/backend/src/main/resources/db/migration/V058__accounting_rule_template_snapshot.sql new file mode 100644 index 0000000..9354546 --- /dev/null +++ b/backend/src/main/resources/db/migration/V058__accounting_rule_template_snapshot.sql @@ -0,0 +1,45 @@ +ALTER TABLE acc_voucher + ADD COLUMN rule_snapshot_json JSON NULL AFTER rule_version; + +-- Preserve a replayable V1 template for vouchers created before rule templates +-- became data. The snapshot remains on each voucher even when the active rule +-- is later replaced. +UPDATE acc_voucher +SET rule_snapshot_json = JSON_OBJECT( + 'schemaVersion', 1, + 'ruleVersion', rule_version, + 'eventTypes', JSON_OBJECT( + 'RECEIPT', JSON_OBJECT('debitAccount', '1002', 'creditAccount', '1122'), + 'INVOICE', JSON_OBJECT('debitAccount', '1122', 'creditAccount', '6001'), + 'PAYABLE', JSON_OBJECT('debitAccount', '5401', 'creditAccount', '2202'), + 'PAYMENT', JSON_OBJECT('debitAccount', '2202', 'creditAccount', '1002'), + 'PAYMENT_REFUND', JSON_OBJECT('debitAccount', '1002', 'creditAccount', '2202') + ) +) +WHERE rule_snapshot_json IS NULL; + +INSERT INTO sys_parameter_version ( + public_id, parameter_group, version_no, value_json, status, effective_at, created_by +) +SELECT + '00000000000000000000000381', + 'ACCOUNTING_RULE_TEMPLATE', + 1, + JSON_OBJECT( + 'schemaVersion', 1, + 'ruleVersion', 'ACCOUNTING-RULE-V1', + 'eventTypes', JSON_OBJECT( + 'RECEIPT', JSON_OBJECT('debitAccount', '1002', 'creditAccount', '1122'), + 'INVOICE', JSON_OBJECT('debitAccount', '1122', 'creditAccount', '6001'), + 'PAYABLE', JSON_OBJECT('debitAccount', '5401', 'creditAccount', '2202'), + 'PAYMENT', JSON_OBJECT('debitAccount', '2202', 'creditAccount', '1002'), + 'PAYMENT_REFUND', JSON_OBJECT('debitAccount', '1002', 'creditAccount', '2202') + ) + ), + 'ACTIVE', + UTC_TIMESTAMP(3), + '00000000000000000000000000' +WHERE NOT EXISTS ( + SELECT 1 FROM sys_parameter_version + WHERE parameter_group = 'ACCOUNTING_RULE_TEMPLATE' AND version_no = 1 +); diff --git a/backend/src/main/resources/db/migration/V059__file_rescan_promotion_intent.sql b/backend/src/main/resources/db/migration/V059__file_rescan_promotion_intent.sql new file mode 100644 index 0000000..4600da4 --- /dev/null +++ b/backend/src/main/resources/db/migration/V059__file_rescan_promotion_intent.sql @@ -0,0 +1,2 @@ +ALTER TABLE file_object + ADD COLUMN promotion_storage_key VARCHAR(500) NULL AFTER storage_key; diff --git a/backend/src/main/resources/db/migration/V060__project_finance_completion_permission.sql b/backend/src/main/resources/db/migration/V060__project_finance_completion_permission.sql new file mode 100644 index 0000000..282bc39 --- /dev/null +++ b/backend/src/main/resources/db/migration/V060__project_finance_completion_permission.sql @@ -0,0 +1,27 @@ +INSERT INTO iam_permission (code, name) +VALUES ('project:project:finance-complete', '确认项目财务资料完整') +ON DUPLICATE KEY UPDATE name = VALUES(name); + +INSERT INTO iam_role_permission (role_id, permission_id) +SELECT role.id, permission.id +FROM iam_role role +JOIN iam_permission permission ON permission.code = 'project:project:finance-complete' +WHERE role.code = 'FINANCE_MANAGER' + AND NOT EXISTS ( + SELECT 1 FROM iam_role_permission existing + WHERE existing.role_id = role.id AND existing.permission_id = permission.id + ); + +/* Preserve existing finance data ranges when the permission is introduced. */ +INSERT IGNORE INTO iam_scope ( + user_id, role_id, permission_id, scope_type, company_public_id, project_public_id, + amount_limit, valid_from, valid_to, status +) +SELECT source_scope.user_id, source_scope.role_id, target_permission.id, + source_scope.scope_type, source_scope.company_public_id, source_scope.project_public_id, + source_scope.amount_limit, source_scope.valid_from, source_scope.valid_to, source_scope.status +FROM iam_scope source_scope +JOIN iam_role role ON role.id = source_scope.role_id AND role.code = 'FINANCE_MANAGER' +JOIN iam_permission source_permission ON source_permission.id = source_scope.permission_id +JOIN iam_permission target_permission ON target_permission.code = 'project:project:finance-complete' +WHERE source_permission.code = 'project:project:view'; diff --git a/backend/src/main/resources/db/migration/V061__accounting_result_notes.sql b/backend/src/main/resources/db/migration/V061__accounting_result_notes.sql new file mode 100644 index 0000000..001d839 --- /dev/null +++ b/backend/src/main/resources/db/migration/V061__accounting_result_notes.sql @@ -0,0 +1,14 @@ +ALTER TABLE acc_voucher + ADD COLUMN result_record_remark VARCHAR(1000) NULL AFTER result_recorded_at, + ADD COLUMN result_verify_opinion VARCHAR(1000) NULL AFTER result_verified_at; + +UPDATE acc_voucher +SET result_record_remark = CASE + WHEN status = 'RESULT_RECORDED' THEN result_remark + ELSE NULL + END, + result_verify_opinion = CASE + WHEN status = 'COMPLETED' THEN result_remark + ELSE NULL + END +WHERE result_remark IS NOT NULL; diff --git a/backend/src/main/resources/db/migration/V062__department_masterdata.sql b/backend/src/main/resources/db/migration/V062__department_masterdata.sql new file mode 100644 index 0000000..1e4b7b5 --- /dev/null +++ b/backend/src/main/resources/db/migration/V062__department_masterdata.sql @@ -0,0 +1,28 @@ +CREATE TABLE md_department ( + id BIGINT NOT NULL AUTO_INCREMENT, + public_id CHAR(26) NOT NULL, + code VARCHAR(64) NOT NULL, + name VARCHAR(128) NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'DRAFT', + version BIGINT NOT NULL DEFAULT 0, + created_by BIGINT NOT NULL, + updated_by BIGINT NOT NULL, + submitted_by BIGINT NULL, + submitted_at TIMESTAMP(3) NULL, + reviewed_by BIGINT NULL, + reviewed_at TIMESTAMP(3) NULL, + review_opinion VARCHAR(1000) NULL, + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_md_department_public_id (public_id), + UNIQUE KEY uk_md_department_code (code), + KEY idx_md_department_status (status, code), + CONSTRAINT fk_md_department_created_by FOREIGN KEY (created_by) REFERENCES iam_user (id), + CONSTRAINT fk_md_department_updated_by FOREIGN KEY (updated_by) REFERENCES iam_user (id), + CONSTRAINT fk_md_department_submitted_by FOREIGN KEY (submitted_by) REFERENCES iam_user (id), + CONSTRAINT fk_md_department_reviewed_by FOREIGN KEY (reviewed_by) REFERENCES iam_user (id), + CONSTRAINT ck_md_department_status CHECK ( + status IN ('DRAFT', 'REVIEWING', 'RETURNED', 'ACTIVE', 'DISABLED') + ) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; diff --git a/backend/src/main/resources/db/migration/V063__dictionary_scope_backfill.sql b/backend/src/main/resources/db/migration/V063__dictionary_scope_backfill.sql new file mode 100644 index 0000000..14907d3 --- /dev/null +++ b/backend/src/main/resources/db/migration/V063__dictionary_scope_backfill.sql @@ -0,0 +1,36 @@ +/* + * V010 introduced the shared financial-dictionary permissions after the + * original scope migration. Preserve each finance user's existing global + * master-data boundary for department and other dictionary operations. + */ +INSERT IGNORE INTO iam_scope ( + user_id, role_id, permission_id, scope_type, company_public_id, project_public_id, + amount_limit, valid_from, valid_to, status +) +SELECT source_scope.user_id, + source_scope.role_id, + target_permission.id, + 'GLOBAL', + NULL, + NULL, + source_scope.amount_limit, + source_scope.valid_from, + source_scope.valid_to, + source_scope.status +FROM iam_scope source_scope +JOIN iam_permission source_permission + ON source_permission.id = source_scope.permission_id +JOIN iam_role_permission target_assignment + ON target_assignment.role_id = source_scope.role_id +JOIN iam_permission target_permission + ON target_permission.id = target_assignment.permission_id +WHERE source_permission.code = 'masterdata:company:view' + AND source_scope.scope_type = 'GLOBAL' + AND target_permission.code IN ( + 'masterdata:dictionary:view', + 'masterdata:dictionary:create', + 'masterdata:dictionary:edit', + 'masterdata:dictionary:submit', + 'masterdata:dictionary:review', + 'masterdata:dictionary:disable' + ); diff --git a/backend/src/main/resources/db/migration/V064__masterdata_legacy_identifier_mapping.sql b/backend/src/main/resources/db/migration/V064__masterdata_legacy_identifier_mapping.sql new file mode 100644 index 0000000..bb7d5b7 --- /dev/null +++ b/backend/src/main/resources/db/migration/V064__masterdata_legacy_identifier_mapping.sql @@ -0,0 +1,35 @@ +CREATE TABLE md_legacy_identifier_mapping ( + id BIGINT NOT NULL AUTO_INCREMENT, + public_id CHAR(26) NOT NULL, + resource_type VARCHAR(32) NOT NULL, + source_system VARCHAR(64) NOT NULL, + legacy_code VARCHAR(128) NOT NULL, + normalized_legacy_code VARCHAR(128) NOT NULL, + target_public_id CHAR(26) NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE', + version BIGINT NOT NULL DEFAULT 0, + created_by BIGINT NOT NULL, + updated_by BIGINT NOT NULL, + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + active_identity VARCHAR(256) GENERATED ALWAYS AS ( + CASE + WHEN status = 'ACTIVE' + THEN CONCAT(resource_type, '|', source_system, '|', normalized_legacy_code) + ELSE NULL + END + ) STORED, + PRIMARY KEY (id), + UNIQUE KEY uk_md_legacy_mapping_public_id (public_id), + UNIQUE KEY uk_md_legacy_mapping_active (active_identity), + KEY idx_md_legacy_mapping_target (resource_type, target_public_id, status, updated_at), + KEY idx_md_legacy_mapping_lookup (resource_type, source_system, normalized_legacy_code, status), + CONSTRAINT fk_md_legacy_mapping_created_by FOREIGN KEY (created_by) REFERENCES iam_user (id), + CONSTRAINT fk_md_legacy_mapping_updated_by FOREIGN KEY (updated_by) REFERENCES iam_user (id), + CONSTRAINT ck_md_legacy_mapping_resource CHECK (resource_type IN ( + 'COMPANY', 'DEPARTMENT', 'PROJECT', 'COUNTERPARTY', 'BANK_ACCOUNT', + 'CONTRACT', 'COST_CATEGORY', 'ACCOUNT', 'TAX_RATE' + )), + CONSTRAINT ck_md_legacy_mapping_status CHECK (status IN ('ACTIVE', 'DISABLED')), + CONSTRAINT ck_md_legacy_mapping_version CHECK (version >= 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; diff --git a/backend/src/main/resources/db/migration/V065__accounting_result_reversal_and_reopen.sql b/backend/src/main/resources/db/migration/V065__accounting_result_reversal_and_reopen.sql new file mode 100644 index 0000000..9f705e7 --- /dev/null +++ b/backend/src/main/resources/db/migration/V065__accounting_result_reversal_and_reopen.sql @@ -0,0 +1,92 @@ +ALTER TABLE acc_voucher + DROP CHECK ck_acc_voucher_status, + ADD COLUMN result_cycle_no INT NOT NULL DEFAULT 0 AFTER result_verify_opinion, + ADD CONSTRAINT ck_acc_voucher_status CHECK ( + status IN ('DRAFT', 'REVIEWING', 'RETURNED', 'APPROVED', 'EXPORTED', + 'RESULT_RECORDED', 'COMPLETED', 'RESULT_REVERSED', 'VOID') + ), + ADD CONSTRAINT ck_acc_voucher_result_cycle CHECK (result_cycle_no >= 0); + +ALTER TABLE acc_voucher_result_file + DROP INDEX uk_acc_voucher_result_file, + ADD COLUMN result_cycle_no INT NOT NULL DEFAULT 0 AFTER voucher_id, + ADD UNIQUE KEY uk_acc_voucher_result_file (voucher_id, result_cycle_no, file_id), + ADD CONSTRAINT ck_acc_voucher_result_file_cycle CHECK (result_cycle_no >= 0); + +CREATE TABLE acc_voucher_result_action ( + id BIGINT NOT NULL AUTO_INCREMENT, + public_id CHAR(26) NOT NULL, + voucher_id BIGINT NOT NULL, + company_id BIGINT NOT NULL, + period CHAR(7) NOT NULL, + sequence_no INT NOT NULL, + result_cycle_no INT NOT NULL, + action_type VARCHAR(16) NOT NULL, + reason VARCHAR(1000) NOT NULL, + historical_external_voucher_no VARCHAR(100) NULL, + result_snapshot_json JSON NOT NULL, + operated_by BIGINT NOT NULL, + operated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + version_before BIGINT NOT NULL, + version_after BIGINT NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_acc_voucher_result_action_public_id (public_id), + UNIQUE KEY uk_acc_voucher_result_action_sequence (voucher_id, sequence_no), + UNIQUE KEY uk_acc_voucher_result_action_external_no ( + company_id, period, historical_external_voucher_no + ), + KEY idx_acc_voucher_result_action_voucher_time (voucher_id, operated_at, id), + CONSTRAINT fk_acc_voucher_result_action_voucher + FOREIGN KEY (voucher_id) REFERENCES acc_voucher (id), + CONSTRAINT fk_acc_voucher_result_action_company + FOREIGN KEY (company_id) REFERENCES md_company (id), + CONSTRAINT fk_acc_voucher_result_action_user + FOREIGN KEY (operated_by) REFERENCES iam_user (id), + CONSTRAINT ck_acc_voucher_result_action_sequence CHECK (sequence_no > 0), + CONSTRAINT ck_acc_voucher_result_action_cycle CHECK (result_cycle_no >= 0), + CONSTRAINT ck_acc_voucher_result_action_type CHECK (action_type IN ('REVERSE', 'REOPEN')), + CONSTRAINT ck_acc_voucher_result_action_external_no CHECK ( + (action_type = 'REVERSE' AND historical_external_voucher_no IS NOT NULL) + OR (action_type = 'REOPEN' AND historical_external_voucher_no IS NULL) + ), + CONSTRAINT ck_acc_voucher_result_action_versions CHECK (version_after = version_before + 1) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +INSERT INTO iam_permission (code, name) +VALUES + ('accounting:voucher:reverse-result', '冲销已复核记账结果'), + ('accounting:voucher:reopen-result', '重开已冲销记账结果') +ON DUPLICATE KEY UPDATE name = VALUES(name); + +INSERT INTO iam_role_permission (role_id, permission_id) +SELECT role.id, permission.id +FROM iam_role role +JOIN iam_permission permission +WHERE role.code = 'FINANCE_MANAGER' + AND permission.code IN ( + 'accounting:voucher:reverse-result', + 'accounting:voucher:reopen-result' + ) + AND NOT EXISTS ( + SELECT 1 FROM iam_role_permission existing + WHERE existing.role_id = role.id AND existing.permission_id = permission.id + ); + +/* Preserve each user's existing accounting scope and amount limit on upgrade. */ +INSERT IGNORE INTO iam_scope ( + user_id, role_id, permission_id, scope_type, company_public_id, project_public_id, + amount_limit, valid_from, valid_to, status +) +SELECT source_scope.user_id, source_scope.role_id, target_permission.id, + source_scope.scope_type, source_scope.company_public_id, source_scope.project_public_id, + source_scope.amount_limit, source_scope.valid_from, source_scope.valid_to, source_scope.status +FROM iam_scope source_scope +JOIN iam_role role ON role.id = source_scope.role_id AND role.code = 'FINANCE_MANAGER' +JOIN iam_permission source_permission ON source_permission.id = source_scope.permission_id +JOIN iam_permission target_permission ON ( + (source_permission.code = 'accounting:voucher:verify-result' + AND target_permission.code = 'accounting:voucher:reverse-result') + OR + (source_permission.code = 'accounting:voucher:record-result' + AND target_permission.code = 'accounting:voucher:reopen-result') +); diff --git a/backend/src/main/resources/db/migration/V066__audit_global_timeline_index.sql b/backend/src/main/resources/db/migration/V066__audit_global_timeline_index.sql new file mode 100644 index 0000000..1dcafab --- /dev/null +++ b/backend/src/main/resources/db/migration/V066__audit_global_timeline_index.sql @@ -0,0 +1,2 @@ +ALTER TABLE audit_log + ADD KEY idx_audit_log_created_id (created_at, id); diff --git a/backend/src/main/resources/db/migration/V067__system_update_permissions.sql b/backend/src/main/resources/db/migration/V067__system_update_permissions.sql new file mode 100644 index 0000000..e50b65a --- /dev/null +++ b/backend/src/main/resources/db/migration/V067__system_update_permissions.sql @@ -0,0 +1,10 @@ +INSERT IGNORE INTO iam_permission (code, name) VALUES + ('admin:update:view', '查看系统版本与更新状态'), + ('admin:update:execute', '执行系统在线更新'); + +INSERT IGNORE INTO iam_role_permission (role_id, permission_id) +SELECT role.id, permission.id +FROM iam_role role +CROSS JOIN iam_permission permission +WHERE role.code = 'SYSTEM_ADMIN' + AND permission.code IN ('admin:update:view', 'admin:update:execute'); diff --git a/backend/src/main/resources/db/migration/V068__source_table_column_validation_metadata.sql b/backend/src/main/resources/db/migration/V068__source_table_column_validation_metadata.sql new file mode 100644 index 0000000..75096b4 --- /dev/null +++ b/backend/src/main/resources/db/migration/V068__source_table_column_validation_metadata.sql @@ -0,0 +1,252 @@ +DROP TEMPORARY TABLE IF EXISTS tmp_v068_source_table_columns; + +CREATE TEMPORARY TABLE tmp_v068_source_table_columns ( + form_type VARCHAR(16) NOT NULL, + field_code VARCHAR(64) NOT NULL, + options_json JSON NOT NULL, + PRIMARY KEY (form_type, field_code) +); + +INSERT INTO tmp_v068_source_table_columns (form_type, field_code, options_json) VALUES +('OA-01', 'winningPeople', CAST('[ + {"code":"position","label":"岗位","dataType":"TEXT","required":true,"maxLength":100}, + {"code":"name","label":"姓名","dataType":"TEXT","required":true,"maxLength":100}, + {"code":"category","label":"类别","dataType":"TEXT","required":false,"maxLength":100}, + {"code":"phone","label":"电话","dataType":"TEXT","required":false,"maxLength":32}, + {"code":"certificateType","label":"证件类型","dataType":"TEXT","required":false,"maxLength":100}, + {"code":"onDuty","label":"是否上岗","dataType":"BOOLEAN","required":false} +]' AS JSON)), +('OA-01', 'constructionDetails', CAST('[ + {"code":"contractName","label":"合同名称","dataType":"TEXT","required":true,"maxLength":200}, + {"code":"counterparty","label":"对方单位","dataType":"TEXT","required":true,"maxLength":200}, + {"code":"amount","label":"金额","dataType":"DECIMAL","required":true}, + {"code":"fundSource","label":"资金来源","dataType":"TEXT","required":false,"maxLength":200}, + {"code":"workContent","label":"工程内容","dataType":"TEXT","required":false,"maxLength":1000}, + {"code":"startDate","label":"开工日","dataType":"DATE","required":true}, + {"code":"endDate","label":"竣工日","dataType":"DATE","required":false} +]' AS JSON)), +('OA-01', 'costBudget', CAST('[ + {"code":"originalAmount","label":"原额","dataType":"DECIMAL","required":true}, + {"code":"incrementAmount","label":"增量","dataType":"DECIMAL","required":false}, + {"code":"totalBudget","label":"预算总成本","dataType":"DECIMAL","required":true}, + {"code":"labor","label":"人工","dataType":"DECIMAL","required":false}, + {"code":"material","label":"材料","dataType":"DECIMAL","required":false}, + {"code":"machinery","label":"机械","dataType":"DECIMAL","required":false}, + {"code":"tax","label":"税金","dataType":"DECIMAL","required":false}, + {"code":"other","label":"其他成本","dataType":"DECIMAL","required":false} +]' AS JSON)), +('OA-01', 'paymentTerms', CAST('[ + {"code":"description","label":"付款描述","dataType":"TEXT","required":true,"maxLength":1000}, + {"code":"ratio","label":"比率","dataType":"DECIMAL","required":false}, + {"code":"amount","label":"金额","dataType":"DECIMAL","required":false}, + {"code":"settlementMethod","label":"结算方式","dataType":"TEXT","required":false,"maxLength":100} +]' AS JSON)), +('OA-02', 'contact', CAST('[ + {"code":"name","label":"联系人","dataType":"TEXT","required":true,"maxLength":100}, + {"code":"phone","label":"电话","dataType":"TEXT","required":true,"maxLength":32}, + {"code":"product","label":"产品","dataType":"TEXT","required":false,"maxLength":200} +]' AS JSON)), +('OA-02', 'bankAccounts', CAST('[ + {"code":"accountName","label":"户名","dataType":"TEXT","required":true,"maxLength":200}, + {"code":"bankName","label":"开户行","dataType":"TEXT","required":true,"maxLength":200}, + {"code":"accountNo","label":"账号","dataType":"TEXT","required":true,"maxLength":64}, + {"code":"accountCategory","label":"账户类别","dataType":"ENUM","required":true,"options":[{"code":"BASIC","label":"基本户"},{"code":"GENERAL","label":"一般户"},{"code":"SPECIAL","label":"专用户"},{"code":"PROJECT","label":"项目户"},{"code":"OTHER","label":"其他"}]}, + {"code":"validFrom","label":"生效日期","dataType":"DATE","required":true}, + {"code":"validTo","label":"失效日期","dataType":"DATE","required":false}, + {"code":"reason","label":"准入原因","dataType":"TEXT","required":false,"maxLength":500} +]' AS JSON)), +('OA-02', 'qualifications', CAST('[ + {"code":"name","label":"资质","dataType":"TEXT","required":true,"maxLength":200}, + {"code":"certificateNo","label":"证件号码","dataType":"TEXT","required":true,"maxLength":100}, + {"code":"validUntil","label":"有效期","dataType":"DATE","required":true}, + {"code":"fileId","label":"附件","dataType":"FILE","required":true} +]' AS JSON)), +('OA-03', 'supplierSnapshot', CAST('[ + {"code":"supplier","label":"供应商","dataType":"TEXT","required":true,"maxLength":200}, + {"code":"accountName","label":"户名","dataType":"TEXT","required":true,"maxLength":200}, + {"code":"bankName","label":"开户行","dataType":"TEXT","required":true,"maxLength":200}, + {"code":"accountNo","label":"账号","dataType":"TEXT","required":true,"maxLength":64} +]' AS JSON)), +('OA-03', 'realtimeCost', CAST('[ + {"code":"targetAmount","label":"目标合同金额","dataType":"DECIMAL","required":true}, + {"code":"signedAmount","label":"累计签订","dataType":"DECIMAL","required":true}, + {"code":"paidAmount","label":"累计支付","dataType":"DECIMAL","required":true}, + {"code":"receivedAmount","label":"累计收款","dataType":"DECIMAL","required":true}, + {"code":"deductionAmount","label":"累计扣费","dataType":"DECIMAL","required":false}, + {"code":"balance","label":"项目余额","dataType":"DECIMAL","required":true} +]' AS JSON)), +('OA-03', 'quantityPriceReview', CAST('[ + {"code":"quantity","label":"数量","dataType":"DECIMAL","required":true}, + {"code":"unitPrice","label":"单价","dataType":"DECIMAL","required":true}, + {"code":"costPlan","label":"成本计划","dataType":"DECIMAL","required":false}, + {"code":"overrunAmount","label":"预计超清单","dataType":"DECIMAL","required":false}, + {"code":"opinion","label":"审核意见","dataType":"TEXT","required":false,"maxLength":1000} +]' AS JSON)), +('OA-04', 'invoiceDetails', CAST('[ + {"code":"invoiceNo","label":"发票号码","dataType":"TEXT","required":true,"maxLength":100}, + {"code":"invoiceType","label":"发票类型","dataType":"TEXT","required":true,"maxLength":100}, + {"code":"invoiceDate","label":"发票日期","dataType":"DATE","required":true}, + {"code":"taxInclusiveAmount","label":"含税金额","dataType":"DECIMAL","required":true}, + {"code":"taxExclusiveAmount","label":"不含税金额","dataType":"DECIMAL","required":true}, + {"code":"taxRate","label":"税率","dataType":"DECIMAL","required":true}, + {"code":"taxAmount","label":"税额","dataType":"DECIMAL","required":true} +]' AS JSON)), +('OA-04', 'attachmentMatrix', CAST('[ + {"code":"attachmentType","label":"附件类型","dataType":"TEXT","required":true,"maxLength":100}, + {"code":"required","label":"是否必需","dataType":"BOOLEAN","required":true}, + {"code":"fileId","label":"文件","dataType":"FILE","required":false}, + {"code":"notApplicableReason","label":"不涉及原因","dataType":"TEXT","required":false,"maxLength":500} +]' AS JSON)), +('OA-05', 'receiptAccount', CAST('[ + {"code":"accountName","label":"户名","dataType":"TEXT","required":true,"maxLength":200}, + {"code":"bankName","label":"开户行","dataType":"TEXT","required":true,"maxLength":200}, + {"code":"accountNo","label":"账号","dataType":"TEXT","required":true,"maxLength":64} +]' AS JSON)), +('OA-06', 'payeeAccount', CAST('[ + {"code":"payee","label":"收款对象","dataType":"TEXT","required":true,"maxLength":200}, + {"code":"accountName","label":"户名","dataType":"TEXT","required":true,"maxLength":200}, + {"code":"bankName","label":"开户行","dataType":"TEXT","required":true,"maxLength":200}, + {"code":"accountNo","label":"账号","dataType":"TEXT","required":true,"maxLength":64} +]' AS JSON)), +('OA-06', 'paymentDetails', CAST('[ + {"code":"costContractNo","label":"成本合同编号","dataType":"TEXT","required":true,"maxLength":64}, + {"code":"subject","label":"标的物","dataType":"TEXT","required":true,"maxLength":200}, + {"code":"supplier","label":"供应商","dataType":"TEXT","required":true,"maxLength":200}, + {"code":"payableAmount","label":"累计应付","dataType":"DECIMAL","required":true}, + {"code":"paidAmount","label":"累计已付","dataType":"DECIMAL","required":true}, + {"code":"requestAmount","label":"本次申请","dataType":"DECIMAL","required":true} +]' AS JSON)), +('OA-06', 'attachmentMatrix', CAST('[ + {"code":"attachmentType","label":"附件类型","dataType":"TEXT","required":true,"maxLength":100}, + {"code":"required","label":"是否必需","dataType":"BOOLEAN","required":true}, + {"code":"fileId","label":"文件","dataType":"FILE","required":false}, + {"code":"notApplicableReason","label":"不涉及原因","dataType":"TEXT","required":false,"maxLength":500} +]' AS JSON)), +('OA-07', 'openingMaterials', CAST('[ + {"code":"materialType","label":"资料类型","dataType":"TEXT","required":true,"maxLength":100}, + {"code":"complete","label":"是否齐全","dataType":"BOOLEAN","required":true}, + {"code":"fileId","label":"打码附件","dataType":"FILE","required":false}, + {"code":"remark","label":"备注","dataType":"TEXT","required":false,"maxLength":500} +]' AS JSON)), +('OA-08', 'invoiceHeader', CAST('[ + {"code":"title","label":"开票抬头","dataType":"TEXT","required":true,"maxLength":200}, + {"code":"taxNo","label":"纳税识别号","dataType":"TEXT","required":true,"maxLength":64}, + {"code":"addressPhone","label":"注册地址/电话","dataType":"TEXT","required":false,"maxLength":300}, + {"code":"bankAccount","label":"开户行/账号","dataType":"TEXT","required":false,"maxLength":300} +]' AS JSON)), +('OA-08', 'invoiceRequirements', CAST('[ + {"code":"invoiceType","label":"发票类型","dataType":"TEXT","required":true,"maxLength":100}, + {"code":"taxRate","label":"税率","dataType":"DECIMAL","required":true}, + {"code":"content","label":"开票内容","dataType":"TEXT","required":true,"maxLength":1000}, + {"code":"settlementMethod","label":"结算方式","dataType":"TEXT","required":false,"maxLength":100}, + {"code":"expectedReceiptDate","label":"预计到账时间","dataType":"DATE","required":false} +]' AS JSON)), +('OA-09', 'teamLeader', CAST('[ + {"code":"name","label":"负责人","dataType":"TEXT","required":true,"maxLength":100}, + {"code":"phone","label":"联系方式","dataType":"TEXT","required":true,"maxLength":32} +]' AS JSON)), +('OA-09', 'workers', CAST('[ + {"code":"name","label":"姓名","dataType":"TEXT","required":true,"maxLength":100}, + {"code":"idCardNo","label":"身份证号","dataType":"TEXT","required":true,"maxLength":32}, + {"code":"phone","label":"联系方式","dataType":"TEXT","required":true,"maxLength":32}, + {"code":"monthlySalary","label":"月度薪酬标准","dataType":"DECIMAL","required":true}, + {"code":"workType","label":"工种","dataType":"TEXT","required":true,"maxLength":100}, + {"code":"entryDate","label":"进场时间","dataType":"DATE","required":true}, + {"code":"emergencyContact","label":"紧急联系方式","dataType":"TEXT","required":false,"maxLength":32}, + {"code":"bankName","label":"工资卡开户行","dataType":"TEXT","required":false,"maxLength":200}, + {"code":"bankAccountNo","label":"工资卡账号","dataType":"TEXT","required":false,"maxLength":64} +]' AS JSON)), +('OA-09', 'workerFiles', CAST('[ + {"code":"workerName","label":"人员","dataType":"TEXT","required":true,"maxLength":100}, + {"code":"laborContractFileId","label":"劳动合同","dataType":"FILE","required":true}, + {"code":"idCardFileId","label":"身份证","dataType":"FILE","required":true} +]' AS JSON)), +('OA-10', 'progressMetrics', CAST('[ + {"code":"currentOutput","label":"本期完成产值","dataType":"DECIMAL","required":true}, + {"code":"cumulativeOutput","label":"累计完成产值","dataType":"DECIMAL","required":true}, + {"code":"currentCost","label":"本期投入成本","dataType":"DECIMAL","required":true}, + {"code":"cumulativeCost","label":"累计投入成本","dataType":"DECIMAL","required":true}, + {"code":"vatRevenue","label":"本期应确认增值税收入","dataType":"DECIMAL","required":true}, + {"code":"completionRate","label":"完成进度","dataType":"DECIMAL","required":true}, + {"code":"remainingDays","label":"剩余工期","dataType":"INTEGER","required":true} +]' AS JSON)), +('OA-10', 'taskDetails', CAST('[ + {"code":"name","label":"任务名称","dataType":"TEXT","required":true,"maxLength":200}, + {"code":"startDate","label":"开始日","dataType":"DATE","required":true}, + {"code":"endDate","label":"完成日","dataType":"DATE","required":true}, + {"code":"progress","label":"进度","dataType":"DECIMAL","required":true}, + {"code":"owner","label":"负责人","dataType":"TEXT","required":true,"maxLength":100}, + {"code":"fileId","label":"文件","dataType":"FILE","required":false} +]' AS JSON)), +('OA-11', 'amounts', CAST('[ + {"code":"contractAmount","label":"合同金额","dataType":"DECIMAL","required":true}, + {"code":"settlementAmount","label":"结算金额","dataType":"DECIMAL","required":true}, + {"code":"invoicedAmount","label":"累计已开票","dataType":"DECIMAL","required":true}, + {"code":"paidAmount","label":"累计已付款","dataType":"DECIMAL","required":true} +]' AS JSON)), +('OA-11', 'settlementFiles', CAST('[ + {"code":"fileType","label":"附件类型","dataType":"TEXT","required":true,"maxLength":100}, + {"code":"fileId","label":"文件","dataType":"FILE","required":true}, + {"code":"remark","label":"备注","dataType":"TEXT","required":false,"maxLength":500} +]' AS JSON)), +('OA-12', 'acceptanceDetails', CAST('[ + {"code":"stage","label":"验收阶段","dataType":"TEXT","required":true,"maxLength":100}, + {"code":"acceptedAt","label":"验收时间","dataType":"DATE","required":true}, + {"code":"conclusion","label":"验收结论","dataType":"TEXT","required":true,"maxLength":1000}, + {"code":"fileId","label":"附件","dataType":"FILE","required":true}, + {"code":"remark","label":"备注","dataType":"TEXT","required":false,"maxLength":500} +]' AS JSON)), +('OA-13', 'feeDetails', CAST('[ + {"code":"position","label":"岗位","dataType":"TEXT","required":true,"maxLength":100}, + {"code":"name","label":"姓名","dataType":"TEXT","required":true,"maxLength":100}, + {"code":"personOwner","label":"人员归属","dataType":"TEXT","required":false,"maxLength":200}, + {"code":"certificateStartDate","label":"证件开始时间","dataType":"DATE","required":true}, + {"code":"feeStandard","label":"收费标准","dataType":"DECIMAL","required":true}, + {"code":"monthlyAmount","label":"本月收费金额","dataType":"DECIMAL","required":true} +]' AS JSON)), +('OA-14', 'projectLeaders', CAST('[ + {"code":"businessLeaderName","label":"经营负责人","dataType":"TEXT","required":true,"maxLength":100}, + {"code":"businessLeaderPhone","label":"经营负责人电话","dataType":"TEXT","required":true,"maxLength":32}, + {"code":"projectLeaderName","label":"项目负责人","dataType":"TEXT","required":true,"maxLength":100}, + {"code":"projectLeaderPhone","label":"项目负责人电话","dataType":"TEXT","required":true,"maxLength":32} +]' AS JSON)), +('OA-14', 'projectDates', CAST('[ + {"code":"awardAmount","label":"中标金额","dataType":"DECIMAL","required":true}, + {"code":"contractDuration","label":"合同工期","dataType":"INTEGER","required":true}, + {"code":"awardPublicDate","label":"中标公示日","dataType":"DATE","required":true}, + {"code":"contractCompletionDate","label":"合同竣工日","dataType":"DATE","required":true}, + {"code":"actualAcceptanceDate","label":"实际验收日","dataType":"DATE","required":false} +]' AS JSON)), +('OA-14', 'certificatePeople', CAST('[ + {"code":"position","label":"岗位","dataType":"TEXT","required":true,"maxLength":100}, + {"code":"name","label":"姓名","dataType":"TEXT","required":true,"maxLength":100}, + {"code":"category","label":"类别","dataType":"TEXT","required":false,"maxLength":100}, + {"code":"phone","label":"电话","dataType":"TEXT","required":false,"maxLength":32}, + {"code":"address","label":"地址","dataType":"TEXT","required":false,"maxLength":300}, + {"code":"certificateType","label":"证件类型","dataType":"TEXT","required":false,"maxLength":100}, + {"code":"online","label":"是否上网/上岗","dataType":"BOOLEAN","required":false} +]' AS JSON)), +('OA-14', 'personChanges', CAST('[ + {"code":"position","label":"岗位","dataType":"TEXT","required":true,"maxLength":100}, + {"code":"name","label":"姓名","dataType":"TEXT","required":true,"maxLength":100}, + {"code":"phone","label":"电话","dataType":"TEXT","required":false,"maxLength":32}, + {"code":"reason","label":"变更原因","dataType":"TEXT","required":true,"maxLength":500}, + {"code":"changeDate","label":"变更日期","dataType":"DATE","required":true}, + {"code":"online","label":"是否上网/上岗","dataType":"BOOLEAN","required":false} +]' AS JSON)), +('OA-14', 'residentPeople', CAST('[ + {"code":"name","label":"姓名","dataType":"TEXT","required":true,"maxLength":100}, + {"code":"phone","label":"电话","dataType":"TEXT","required":false,"maxLength":32}, + {"code":"appointmentDate","label":"任命时间","dataType":"DATE","required":true}, + {"code":"transferDate","label":"调离时间","dataType":"DATE","required":false}, + {"code":"salary","label":"工资","dataType":"DECIMAL","required":false}, + {"code":"remark","label":"备注","dataType":"TEXT","required":false,"maxLength":500} +]' AS JSON)); + +UPDATE src_template_field field +JOIN src_template template ON template.id = field.template_id +JOIN tmp_v068_source_table_columns metadata + ON metadata.form_type = template.form_type AND metadata.field_code = field.field_code +SET field.options_json = metadata.options_json; + +DROP TEMPORARY TABLE tmp_v068_source_table_columns; diff --git a/backend/src/main/resources/db/migration/V069__preserve_oa02_v1_table_contract.sql b/backend/src/main/resources/db/migration/V069__preserve_oa02_v1_table_contract.sql new file mode 100644 index 0000000..86daf7f --- /dev/null +++ b/backend/src/main/resources/db/migration/V069__preserve_oa02_v1_table_contract.sql @@ -0,0 +1,11 @@ +UPDATE src_template_field field +JOIN src_template template ON template.id = field.template_id +SET field.options_json = CAST('[ + {"code":"accountName","label":"户名"}, + {"code":"bankName","label":"开户行"}, + {"code":"accountNo","label":"账号"}, + {"code":"reason","label":"准入原因"} +]' AS JSON) +WHERE template.form_type = 'OA-02' + AND template.template_version = 1 + AND field.field_code = 'bankAccounts'; diff --git a/backend/src/main/resources/db/migration/V070__approved_oa06_payment_projection.sql b/backend/src/main/resources/db/migration/V070__approved_oa06_payment_projection.sql new file mode 100644 index 0000000..6c4be38 --- /dev/null +++ b/backend/src/main/resources/db/migration/V070__approved_oa06_payment_projection.sql @@ -0,0 +1,150 @@ +CREATE TEMPORARY TABLE v070_payment_preflight ( + issue_code VARCHAR(64) NOT NULL, + issue_count BIGINT NOT NULL, + CONSTRAINT ck_v070_payment_preflight_clear CHECK (issue_count = 0) +); + +INSERT INTO v070_payment_preflight (issue_code, issue_count) +SELECT 'DUPLICATE_SOURCE_DOCUMENT', COUNT(*) +FROM ( + SELECT source_document_id + FROM fin_payment_request + WHERE source_document_id IS NOT NULL + AND original_payment_id IS NULL + GROUP BY source_document_id + HAVING COUNT(*) > 1 +) duplicate_source; + +INSERT INTO v070_payment_preflight (issue_code, issue_count) +SELECT 'SOURCE_CURRENT_VERSION_MISSING', COUNT(*) +FROM fin_payment_request payment +LEFT JOIN src_source_document document ON document.id = payment.source_document_id +LEFT JOIN src_document_version source_version + ON source_version.id = document.current_version_id + AND source_version.source_document_id = document.id +WHERE payment.source_document_id IS NOT NULL + AND payment.original_payment_id IS NULL + AND (document.id IS NULL OR document.current_version_id IS NULL OR source_version.id IS NULL); + +INSERT INTO v070_payment_preflight (issue_code, issue_count) +SELECT 'INVALID_SOURCE_FORM_TYPE', COUNT(*) +FROM fin_payment_request payment +JOIN src_source_document document ON document.id = payment.source_document_id +WHERE payment.original_payment_id IS NULL + AND document.form_type <> 'OA-06'; + +INSERT INTO v070_payment_preflight (issue_code, issue_count) +SELECT 'SUBMIT_APPROVE_SOD_VIOLATION', COUNT(*) +FROM fin_payment_request +WHERE submitted_by IS NOT NULL + AND approved_by IS NOT NULL + AND submitted_by = approved_by; + +DROP TEMPORARY TABLE v070_payment_preflight; + +ALTER TABLE fin_payment_request + ADD COLUMN source_version_id BIGINT NULL AFTER source_document_id; + +-- A repayment is traced through original_payment_id and owns a distinct event key. +-- Keeping the original OA source on both rows would make one approved source produce +-- two primary payment requests and would prevent a database-level source uniqueness rule. +UPDATE fin_payment_request +SET source_document_id = NULL, + source_version_id = NULL +WHERE original_payment_id IS NOT NULL; + +UPDATE fin_payment_request payment +JOIN src_source_document document ON document.id = payment.source_document_id +SET payment.source_version_id = document.current_version_id +WHERE payment.source_document_id IS NOT NULL + AND payment.source_version_id IS NULL; + +ALTER TABLE fin_payment_request + ADD UNIQUE KEY uk_fin_payment_source_document (source_document_id), + ADD KEY idx_fin_payment_source_version (source_version_id), + ADD CONSTRAINT fk_fin_payment_source_version_document + FOREIGN KEY (source_version_id, source_document_id) + REFERENCES src_document_version (id, source_document_id), + ADD CONSTRAINT ck_fin_payment_source_pair CHECK ( + (source_document_id IS NULL AND source_version_id IS NULL) + OR (source_document_id IS NOT NULL AND source_version_id IS NOT NULL) + ), + ADD CONSTRAINT ck_fin_payment_submit_approve_sod CHECK ( + submitted_by IS NULL OR approved_by IS NULL OR submitted_by <> approved_by + ); + +INSERT INTO src_template ( + public_id, form_type, template_version, name, group_code, purpose, initiator_roles, + schema_version, status, published_at, created_by, updated_by +) +SELECT '00000000000000000000007006', source.form_type, 2, source.name, source.group_code, + source.purpose, source.initiator_roles, source.schema_version + 1, 'ACTIVE', + UTC_TIMESTAMP(3), source.created_by, source.updated_by +FROM src_template source +WHERE source.form_type = 'OA-06' + AND source.template_version = 1 + AND source.status = 'ACTIVE'; + +INSERT INTO src_template_field ( + template_id, field_code, label, section_code, section_name, data_type, + required_flag, repeatable, max_length, numeric_precision, numeric_scale, + required_when_json, enum_code, options_json, unit, sensitivity, masking_rule, + mapping_code, import_column, sort_order, help_text +) +SELECT target.id, field.field_code, field.label, field.section_code, field.section_name, + field.data_type, field.required_flag, field.repeatable, field.max_length, + field.numeric_precision, field.numeric_scale, field.required_when_json, field.enum_code, + field.options_json, field.unit, field.sensitivity, field.masking_rule, + field.mapping_code, field.import_column, field.sort_order, field.help_text +FROM src_template source +JOIN src_template target + ON target.form_type = source.form_type AND target.template_version = 2 +JOIN src_template_field field ON field.template_id = source.id +WHERE source.form_type = 'OA-06' + AND source.template_version = 1; + +INSERT INTO src_template_field ( + template_id, field_code, label, section_code, section_name, data_type, + required_flag, repeatable, max_length, required_when_json, options_json, + sensitivity, mapping_code, import_column, sort_order +) +SELECT template.id, 'contractId', '生效成本合同', 'PAYMENT', '付款申请', 'REFERENCE', + TRUE, FALSE, 26, JSON_OBJECT('required', TRUE), JSON_ARRAY(), + 'INTERNAL', 'contractId', '生效成本合同', 31 +FROM src_template template +WHERE template.form_type = 'OA-06' AND template.template_version = 2; + +INSERT INTO src_template_field ( + template_id, field_code, label, section_code, section_name, data_type, + required_flag, repeatable, max_length, required_when_json, options_json, + sensitivity, mapping_code, import_column, sort_order +) +SELECT template.id, 'supplierId', '生效供应商', 'PAYMENT', '付款申请', 'REFERENCE', + TRUE, FALSE, 26, JSON_OBJECT('required', TRUE), JSON_ARRAY(), + 'INTERNAL', 'supplierId', '生效供应商', 32 +FROM src_template template +WHERE template.form_type = 'OA-06' AND template.template_version = 2; + +INSERT INTO src_template_field ( + template_id, field_code, label, section_code, section_name, data_type, + required_flag, repeatable, max_length, required_when_json, options_json, + sensitivity, mapping_code, import_column, sort_order +) +SELECT template.id, 'payableId', '已确认应付', 'PAYMENT', '付款申请', 'REFERENCE', + TRUE, FALSE, 26, JSON_OBJECT('required', TRUE), JSON_ARRAY(), + 'INTERNAL', 'payableId', '已确认应付', 33 +FROM src_template template +WHERE template.form_type = 'OA-06' AND template.template_version = 2; + +UPDATE src_template_field field +JOIN src_template template ON template.id = field.template_id +SET field.repeatable = FALSE +WHERE template.form_type = 'OA-06' + AND template.template_version = 2 + AND field.field_code = 'paymentDetails'; + +UPDATE src_template +SET status = 'RETIRED', updated_at = UTC_TIMESTAMP(3), version = version + 1 +WHERE form_type = 'OA-06' + AND template_version = 1 + AND status = 'ACTIVE'; diff --git a/backend/src/main/resources/db/migration/V071__system_administrator_super_permissions.sql b/backend/src/main/resources/db/migration/V071__system_administrator_super_permissions.sql new file mode 100644 index 0000000..37aa386 --- /dev/null +++ b/backend/src/main/resources/db/migration/V071__system_administrator_super_permissions.sql @@ -0,0 +1,43 @@ +UPDATE iam_role +SET name = '超级管理员', + description = '拥有全部功能权限和全局数据范围的隔离管理员身份' +WHERE code = 'SYSTEM_ADMIN'; + +INSERT IGNORE INTO iam_role_permission (role_id, permission_id) +SELECT role.id, permission.id +FROM iam_role role +CROSS JOIN iam_permission permission +WHERE role.code = 'SYSTEM_ADMIN' + AND role.enabled = TRUE; + +UPDATE iam_scope scope_grant +JOIN iam_user_role user_role + ON user_role.user_id = scope_grant.user_id + AND user_role.role_id = scope_grant.role_id +JOIN iam_role role ON role.id = user_role.role_id +SET scope_grant.status = 'ACTIVE', + scope_grant.amount_limit = NULL, + scope_grant.valid_from = LEAST(scope_grant.valid_from, UTC_TIMESTAMP(3)), + scope_grant.valid_to = NULL, + scope_grant.version = scope_grant.version + 1 +WHERE role.code = 'SYSTEM_ADMIN' + AND role.enabled = TRUE + AND scope_grant.scope_type = 'GLOBAL' + AND ( + scope_grant.status <> 'ACTIVE' + OR scope_grant.amount_limit IS NOT NULL + OR scope_grant.valid_from > UTC_TIMESTAMP(3) + OR scope_grant.valid_to IS NOT NULL + ); + +INSERT IGNORE INTO iam_scope ( + user_id, role_id, permission_id, scope_type, company_public_id, project_public_id, + amount_limit, status +) +SELECT user_role.user_id, user_role.role_id, role_permission.permission_id, + 'GLOBAL', NULL, NULL, NULL, 'ACTIVE' +FROM iam_user_role user_role +JOIN iam_role role ON role.id = user_role.role_id +JOIN iam_role_permission role_permission ON role_permission.role_id = role.id +WHERE role.code = 'SYSTEM_ADMIN' + AND role.enabled = TRUE; diff --git a/backend/src/main/resources/db/migration/V072__setup_installation_state.sql b/backend/src/main/resources/db/migration/V072__setup_installation_state.sql new file mode 100644 index 0000000..1e44be0 --- /dev/null +++ b/backend/src/main/resources/db/migration/V072__setup_installation_state.sql @@ -0,0 +1,12 @@ +CREATE TABLE kaidi_setup_installation ( + id TINYINT UNSIGNED NOT NULL, + setup_token_sha256 CHAR(64) NOT NULL, + administrator_user_id BIGINT NOT NULL, + administrator_username VARCHAR(64) NOT NULL, + completed_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_kaidi_setup_installation_admin (administrator_user_id), + CONSTRAINT fk_kaidi_setup_installation_admin + FOREIGN KEY (administrator_user_id) REFERENCES iam_user (id), + CONSTRAINT ck_kaidi_setup_installation_singleton CHECK (id = 1) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; diff --git a/backend/src/test/java/com/kaidi/finance/accounting/AccountingIntegrationTest.java b/backend/src/test/java/com/kaidi/finance/accounting/AccountingIntegrationTest.java new file mode 100644 index 0000000..ef62002 --- /dev/null +++ b/backend/src/test/java/com/kaidi/finance/accounting/AccountingIntegrationTest.java @@ -0,0 +1,1183 @@ +package com.kaidi.finance.accounting; + +import static org.hamcrest.Matchers.containsString; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; + +import com.jayway.jsonpath.JsonPath; +import com.kaidi.finance.archive.infrastructure.ArchiveMapper; +import com.kaidi.finance.shared.file.FileStorageProperties; +import jakarta.servlet.http.Cookie; +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.util.LinkedHashMap; +import java.util.HexFormat; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.jdbc.datasource.init.ScriptUtils; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +@Testcontainers(disabledWithoutDocker = true) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class AccountingIntegrationTest { + + private static String idempotencyKey() { + return "test-accounting-" + java.util.UUID.randomUUID(); + } + + private static final String COMPANY_ID = "00000000000000000000001601"; + private static final String COUNTERPARTY_ID = "00000000000000000000001602"; + private static final String PROJECT_ID = "00000000000000000000001603"; + private static final String EVENT_RECEIPT = "00000000000000000000001611"; + private static final String EVENT_PAYABLE = "00000000000000000000001612"; + private static final String EVENT_INVOICE = "00000000000000000000001613"; + private static final String EVENT_PAYMENT = "00000000000000000000001614"; + private static final String EVENT_PAYMENT_REFUND = "00000000000000000000001615"; + private static final String ACCOUNT_1002 = "00000000000000000000000311"; + private static final String ACCOUNT_1122 = "00000000000000000000000312"; + private static final String ACCOUNT_5401 = "00000000000000000000000314"; + + @Container + static final MySQLContainer MYSQL = new MySQLContainer<>("mysql:8.4") + .withDatabaseName("kaidi_finance_accounting_test") + .withUsername("kaidi") + .withPassword("kaidi_test_password"); + + @DynamicPropertySource + static void database(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", MYSQL::getJdbcUrl); + registry.add("spring.datasource.username", MYSQL::getUsername); + registry.add("spring.datasource.password", MYSQL::getPassword); + registry.add("finance.bootstrap.enabled", () -> true); + registry.add("finance.bootstrap.password", () -> "LocalOnly@123"); + } + + @Autowired MockMvc mockMvc; + @Autowired JdbcTemplate jdbcTemplate; + @Autowired ArchiveMapper archiveMapper; + @Autowired FileStorageProperties storageProperties; + + @BeforeEach + void seedAndReset() { + long financeId = userId("finance"); + long projectUserId = userId("project"); + if (count("SELECT COUNT(*) FROM md_company WHERE public_id=?", COMPANY_ID) == 0) { + jdbcTemplate.update("INSERT INTO md_company (public_id,business_no,name,status,created_by,updated_by) VALUES (?,'GS-P16','记账测试公司','ACTIVE',?,?)", COMPANY_ID, financeId, financeId); + jdbcTemplate.update("INSERT INTO md_counterparty (public_id,business_no,counterparty_type,name,status,created_by,updated_by) VALUES (?,'KH-P16','CUSTOMER','记账测试往来单位','ACTIVE',?,?)", COUNTERPARTY_ID, financeId, financeId); + jdbcTemplate.update("INSERT INTO md_project (public_id,company_id,business_no,name,owner_user_id,currency,status,created_by,updated_by) SELECT ?,id,'XM-P16','记账测试项目',?,'CNY','ACTIVE',?,? FROM md_company WHERE public_id=?", PROJECT_ID, projectUserId, financeId, financeId, COMPANY_ID); + } + cleanup(); + jdbcTemplate.update("UPDATE md_project SET status='ACTIVE', stage='EXECUTION', " + + "archive_status='NOT_READY', finance_status='COMPLETED' WHERE public_id=?", PROJECT_ID); + insertEvent(EVENT_RECEIPT, "EV-P16-R", "RECEIPT", "2026-08-01", "1000.00", financeId); + insertEvent(EVENT_PAYABLE, "EV-P16-P", "PAYABLE", "2026-08-02", "500.00", financeId); + } + + @Test + void fullVoucherWorkflowIsIdempotentSeparatedAndTraceable() throws Exception { + ClientSession maker = loginAndSelect("finance", "FINANCE_MANAGER"); + ClientSession reviewer = loginAndSelect("demo", "FINANCE_MANAGER"); + + mockMvc.perform(get("/api/v1/accounting/events").cookie(maker.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.meta.totalElements").value(2)); + + MvcResult generated = postJson("/api/v1/accounting/events/" + EVENT_RECEIPT + "/generate-draft", maker, + "{\"version\":0,\"ruleVersion\":\"ACCOUNTING-RULE-V1\"}") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.voucher.status").value("DRAFT")) + .andExpect(jsonPath("$.data.entries.length()").value(2)) + .andReturn(); + String voucherId = JsonPath.read(generated.getResponse().getContentAsString(), "$.data.voucher.publicId"); + + postJson("/api/v1/accounting/events/" + EVENT_RECEIPT + "/generate-draft", maker, + "{\"version\":1,\"ruleVersion\":\"ACCOUNTING-RULE-V1\"}") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.voucher.publicId").value(voucherId)); + assertEquals(1, count("SELECT COUNT(*) FROM acc_voucher WHERE event_id=(SELECT id FROM acc_event WHERE public_id=?)", EVENT_RECEIPT)); + + postJson("/api/v1/accounting/vouchers/" + voucherId + "/submit", maker, "{\"version\":0}") + .andExpect(status().isOk()).andExpect(jsonPath("$.data.voucher.status").value("REVIEWING")); + postJson("/api/v1/accounting/vouchers/" + voucherId + "/approve", maker, "{\"version\":1}") + .andExpect(status().isUnprocessableEntity()).andExpect(jsonPath("$.code").value("SOD_VIOLATION")); + postJson("/api/v1/accounting/vouchers/" + voucherId + "/approve", reviewer, "{\"version\":1}") + .andExpect(status().isOk()).andExpect(jsonPath("$.data.voucher.status").value("APPROVED")); + + MvcResult exported = postJson("/api/v1/accounting/vouchers/" + voucherId + "/export", maker, + "{\"version\":2}").andExpect(status().isOk()) + .andExpect(jsonPath("$.data.voucher.status").value("EXPORTED")) + .andExpect(jsonPath("$.data.sha256").isNotEmpty()) + .andExpect(jsonPath("$.data.fileId").isNotEmpty()) + .andExpect(jsonPath("$.data.rowCount").value(2)) + .andReturn(); + String hash = JsonPath.read(exported.getResponse().getContentAsString(), "$.data.sha256"); + String exportFileId = JsonPath.read(exported.getResponse().getContentAsString(), "$.data.fileId"); + assertEquals(64, hash.length()); + assertEquals(1, count("SELECT COUNT(*) FROM acc_export_batch WHERE voucher_id=(SELECT id FROM acc_voucher WHERE public_id=?)", voucherId)); + mockMvc.perform(get("/api/v1/accounting/vouchers/" + voucherId + "/export-file").cookie(maker.cookies())) + .andExpect(status().isOk()) + .andExpect(header().string("X-File-SHA256", hash)) + .andExpect(header().string("X-Content-Type-Options", "nosniff")) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content() + .string(containsString("凭证号,期间,业务日期"))); + + String evidenceId = insertAvailableEvidence("finance", "accounting-result.pdf"); + + postJson("/api/v1/accounting/vouchers/" + voucherId + "/record-result", maker, + "{\"version\":3,\"externalVoucherNo\":\"TPLUS-P16-001\",\"resultAt\":\"2026-08-03\"," + + "\"evidenceFileIds\":[\"" + evidenceId + "\"],\"remark\":\"人工导入成功\"}") + .andExpect(status().isOk()).andExpect(jsonPath("$.data.voucher.status").value("RESULT_RECORDED")) + .andExpect(jsonPath("$.data.voucher.resultAt").value("2026-08-03")) + .andExpect(jsonPath("$.data.evidenceFiles[0].publicId").value(evidenceId)); + assertEquals(1, count("SELECT COUNT(*) FROM acc_voucher_result_file WHERE voucher_id=(SELECT id FROM acc_voucher WHERE public_id=?)", voucherId)); + mockMvc.perform(get("/api/v1/accounting/vouchers/" + voucherId + "/result-files/" + evidenceId + + "/content").cookie(reviewer.cookies())) + .andExpect(status().isOk()) + .andExpect(header().string("X-File-SHA256", sha256("accounting-result.pdf"))) + .andExpect(header().string("X-Content-Type-Options", "nosniff")) + .andExpect(header().string("Cache-Control", "private, no-store")) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content() + .bytes("accounting-result.pdf".getBytes(StandardCharsets.UTF_8))); + assertTrue(count("SELECT COUNT(*) FROM audit_log WHERE object_public_id=? AND action_code='ACCOUNTING_RESULT_EVIDENCE_DOWNLOAD'", voucherId) > 0); + postJson("/api/v1/accounting/vouchers/" + voucherId + "/verify-result", maker, + "{\"version\":4,\"opinion\":\"本人不能复核\"}") + .andExpect(status().isUnprocessableEntity()).andExpect(jsonPath("$.code").value("SOD_VIOLATION")); + postJson("/api/v1/accounting/vouchers/" + voucherId + "/verify-result", reviewer, + "{\"version\":4,\"opinion\":\"凭证号及导入结果一致\"}") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.voucher.status").value("COMPLETED")) + .andExpect(jsonPath("$.data.voucher.resultRecordRemark").value("人工导入成功")) + .andExpect(jsonPath("$.data.voucher.resultVerifyOpinion").value("凭证号及导入结果一致")); + assertEquals("COMPLETED", jdbcTemplate.queryForObject( + "SELECT status FROM acc_event WHERE public_id=?", String.class, EVENT_RECEIPT)); + assertTrue(count("SELECT COUNT(*) FROM audit_log WHERE object_public_id=? AND action_code='ACCOUNTING_RESULT_VERIFY'", voucherId) > 0); + + mockMvc.perform(get("/api/v1/projects/{id}/accounting", PROJECT_ID) + .param("view", "events").cookie(maker.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[?(@.publicId == '%s')].status".formatted(EVENT_RECEIPT)) + .value(org.hamcrest.Matchers.contains("COMPLETED"))) + .andExpect(jsonPath("$.data[?(@.publicId == '%s')].voucherPublicId".formatted(EVENT_RECEIPT)) + .value(org.hamcrest.Matchers.contains(voucherId))) + .andExpect(jsonPath("$.data[?(@.publicId == '%s')].sourcePublicId".formatted(EVENT_RECEIPT)) + .value(org.hamcrest.Matchers.contains(EVENT_RECEIPT))); + mockMvc.perform(get("/api/v1/projects/{id}/accounting", PROJECT_ID) + .param("view", "vouchers").cookie(maker.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[?(@.publicId == '%s')].status".formatted(voucherId)) + .value(org.hamcrest.Matchers.contains("COMPLETED"))) + .andExpect(jsonPath("$.data[?(@.publicId == '%s')].eventPublicId".formatted(voucherId)) + .value(org.hamcrest.Matchers.contains(EVENT_RECEIPT))) + .andExpect(jsonPath("$.data[?(@.publicId == '%s')].exportFileId".formatted(voucherId)) + .value(org.hamcrest.Matchers.contains(exportFileId))) + .andExpect(jsonPath("$.data[?(@.publicId == '%s')].evidenceFileIds[0]".formatted(voucherId)) + .value(org.hamcrest.Matchers.contains(evidenceId))); + + // PAGE-16 keeps its operational defaults: only pending events and draft/returned vouchers. + mockMvc.perform(get("/api/v1/accounting/events").cookie(maker.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[?(@.publicId == '%s')]".formatted(EVENT_RECEIPT)).doesNotExist()) + .andExpect(jsonPath("$.data[?(@.publicId == '%s')]".formatted(EVENT_PAYABLE)).exists()); + mockMvc.perform(get("/api/v1/accounting/vouchers").cookie(maker.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[?(@.publicId == '%s')]".formatted(voucherId)).doesNotExist()); + + long projectDatabaseId = jdbcTemplate.queryForObject( + "SELECT id FROM md_project WHERE public_id=?", Long.class, PROJECT_ID); + Map archiveSnapshot = archiveMapper.voucherSnapshots(projectDatabaseId).stream() + .filter(row -> voucherId.equals(row.get("publicId"))) + .findFirst().orElseThrow(); + assertEquals(exportFileId, archiveSnapshot.get("exportFilePublicId")); + assertEquals(hash, archiveSnapshot.get("exportFileSha256")); + String makerPublicId = jdbcTemplate.queryForObject( + "SELECT public_id FROM iam_user WHERE username='finance'", String.class); + String reviewerPublicId = jdbcTemplate.queryForObject( + "SELECT public_id FROM iam_user WHERE username='demo'", String.class); + assertEquals(makerPublicId, archiveSnapshot.get("createdByPublicId")); + assertNotNull(archiveSnapshot.get("createdAt")); + assertEquals(makerPublicId, archiveSnapshot.get("submittedByPublicId")); + assertNotNull(archiveSnapshot.get("submittedAt")); + assertEquals(reviewerPublicId, archiveSnapshot.get("reviewedByPublicId")); + assertNotNull(archiveSnapshot.get("reviewedAt")); + assertNotNull(archiveSnapshot.get("exportedByPublicId")); + assertNotNull(archiveSnapshot.get("exportedAt")); + assertNotNull(archiveSnapshot.get("resultRecordedByPublicId")); + assertNotNull(archiveSnapshot.get("resultRecordedAt")); + assertNotNull(archiveSnapshot.get("resultVerifiedByPublicId")); + assertNotNull(archiveSnapshot.get("resultVerifiedAt")); + assertEquals("人工导入成功", archiveSnapshot.get("resultRecordRemark")); + assertEquals("凭证号及导入结果一致", archiveSnapshot.get("resultVerifyOpinion")); + Object resultFilesValue = archiveSnapshot.get("resultFilesJson"); + String resultFilesJson = resultFilesValue instanceof byte[] bytes + ? new String(bytes, StandardCharsets.UTF_8) : String.valueOf(resultFilesValue); + assertEquals(evidenceId, JsonPath.read(resultFilesJson, "$[0].filePublicId")); + assertEquals("application/pdf", JsonPath.read(resultFilesJson, "$[0].fileMediaType")); + assertEquals("pdf", JsonPath.read(resultFilesJson, "$[0].fileExtension")); + assertEquals("AVAILABLE", JsonPath.read(resultFilesJson, "$[0].fileScanStatus")); + assertEquals(makerPublicId, JsonPath.read(resultFilesJson, "$[0].createdByPublicId")); + assertNotNull(JsonPath.read(resultFilesJson, "$[0].createdAt")); + } + + @Test + void completedVoucherFlowsIntoPersistedArchiveSnapshotAndFinalArchive() throws Exception { + ClientSession maker = loginAndSelect("finance", "FINANCE_MANAGER"); + ClientSession reviewer = loginAndSelect("demo", "FINANCE_MANAGER"); + ClientSession archive = loginAndSelect("archive", "ARCHIVE_MANAGER"); + + String voucherId = prepareExportedVoucher(EVENT_RECEIPT, maker, reviewer); + String evidenceId = insertAvailableEvidence("finance", "accounting-archive-evidence.pdf"); + postJson("/api/v1/accounting/vouchers/" + voucherId + "/record-result", maker, + resultBody("TPLUS-P16-ARCHIVE", evidenceId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.voucher.status").value("RESULT_RECORDED")); + postJson("/api/v1/accounting/vouchers/" + voucherId + "/verify-result", reviewer, + "{\"version\":4,\"opinion\":\"记账结果与凭证一致,可归档\"}") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.voucher.status").value("COMPLETED")); + + MvcResult generatedPackage = postJson("/api/v1/archive/packages", maker, + "{\"projectId\":\"%s\",\"retentionYears\":10,\"physicalLocation\":\"档案室 A-16\"}" + .formatted(PROJECT_ID)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("PREPARING")) + .andExpect(jsonPath("$.data.items[?(@.itemType == 'ACCOUNTING')].status") + .value(org.hamcrest.Matchers.contains("PRESENT"))) + .andReturn(); + String packageId = JsonPath.read(generatedPackage.getResponse().getContentAsString(), "$.data.publicId"); + long packageVersion = approveMissingArchiveItemsThroughApi( + packageId, generatedPackage, maker, reviewer); + + assertEquals(1, count("SELECT COUNT(*) FROM arc_package_object_snapshot snapshot " + + "JOIN arc_package package_record ON package_record.id=snapshot.package_id " + + "WHERE package_record.public_id=? AND snapshot.object_type='ACCOUNTING_VOUCHER' " + + "AND snapshot.object_public_id=?", packageId, voucherId)); + mockMvc.perform(get("/api/v1/projects/{id}/archives", PROJECT_ID) + .param("view", "packages").cookie(maker.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[?(@.publicId == '%s')].objectSnapshots" + .formatted(packageId)).isArray()) + .andExpect(jsonPath("$.data[?(@.publicId == '%s')].objectSnapshots" + .formatted(packageId) + + "[?(@.objectType == 'ACCOUNTING_VOUCHER' && @.objectPublicId == '%s')]" + .formatted(voucherId)) + .exists()); + String initialSnapshot = accountingSnapshot(packageId, voucherId); + assertEquals(EVENT_RECEIPT, JsonPath.read(initialSnapshot, "$.eventPublicId")); + assertEquals("RECEIPT", JsonPath.read(initialSnapshot, "$.eventType")); + assertEquals("1002", JsonPath.read(initialSnapshot, + "$.ruleSnapshotJson.eventTypes.RECEIPT.debitAccount")); + assertEquals("1122", JsonPath.read(initialSnapshot, + "$.ruleSnapshotJson.eventTypes.RECEIPT.creditAccount")); + assertEquals(evidenceId, JsonPath.read(initialSnapshot, "$.resultFilesJson[0].filePublicId")); + assertEquals("AVAILABLE", JsonPath.read(initialSnapshot, "$.resultFilesJson[0].fileScanStatus")); + + postJson("/api/v1/archive/packages/" + packageId + "/check", maker, + "{\"version\":" + packageVersion + ",\"opinion\":\"核对会计结果及档案清单\"}") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("CHECKED")) + .andExpect(jsonPath("$.data.missingCount").value(0)) + .andExpect(jsonPath("$.data.version").value(packageVersion + 1)); + postJson("/api/v1/archive/packages/" + packageId + "/submit", maker, + "{\"version\":" + (packageVersion + 1) + ",\"opinion\":\"提交档案管理员审核\"}") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("REVIEWING")) + .andExpect(jsonPath("$.data.version").value(packageVersion + 2)); + postJson("/api/v1/archive/packages/" + packageId + "/archive", archive, + "{\"version\":" + (packageVersion + 2) + ",\"opinion\":\"资料与记账结果一致,同意归档\"}") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("ARCHIVED")); + + assertEquals("ARCHIVED", jdbcTemplate.queryForObject( + "SELECT status FROM arc_package WHERE public_id=?", String.class, packageId)); + assertEquals("ARCHIVED", jdbcTemplate.queryForObject( + "SELECT archive_status FROM md_project WHERE public_id=?", String.class, PROJECT_ID)); + assertEquals("ARCHIVED", jdbcTemplate.queryForObject( + "SELECT status FROM md_project WHERE public_id=?", String.class, PROJECT_ID)); + String archivedSnapshot = accountingSnapshot(packageId, voucherId); + assertEquals(voucherId, JsonPath.read(archivedSnapshot, "$.publicId")); + assertEquals(evidenceId, JsonPath.read(archivedSnapshot, "$.resultFilesJson[0].filePublicId")); + assertNotNull(JsonPath.read(archivedSnapshot, "$.exportFileSha256")); + assertNotNull(JsonPath.read(archivedSnapshot, "$.resultVerifiedAt")); + } + + @Test + void ruleTemplateCreatesExpectedEntriesForEveryEventType() throws Exception { + ClientSession maker = loginAndSelect("finance", "FINANCE_MANAGER"); + long financeId = userId("finance"); + insertEvent(EVENT_INVOICE, "EV-P16-I", "INVOICE", "2026-08-03", "300.00", financeId); + insertEvent(EVENT_PAYMENT, "EV-P16-PAY", "PAYMENT", "2026-08-04", "200.00", financeId); + insertEvent(EVENT_PAYMENT_REFUND, "EV-P16-REF", "PAYMENT_REFUND", "2026-08-05", "100.00", financeId); + + Map expectedRules = new LinkedHashMap<>(); + expectedRules.put(EVENT_RECEIPT, new ExpectedRule("RECEIPT", "1002", "1122")); + expectedRules.put(EVENT_INVOICE, new ExpectedRule("INVOICE", "1122", "6001")); + expectedRules.put(EVENT_PAYABLE, new ExpectedRule("PAYABLE", "5401", "2202")); + expectedRules.put(EVENT_PAYMENT, new ExpectedRule("PAYMENT", "2202", "1002")); + expectedRules.put(EVENT_PAYMENT_REFUND, new ExpectedRule("PAYMENT_REFUND", "1002", "2202")); + + for (Map.Entry expected : expectedRules.entrySet()) { + String voucherId = generate(expected.getKey(), maker); + ExpectedRule rule = expected.getValue(); + assertEquals(List.of("DEBIT:" + rule.debitAccount(), "CREDIT:" + rule.creditAccount()), + jdbcTemplate.queryForList(""" + SELECT CONCAT(entry.direction, ':', account.code) + FROM acc_voucher_entry entry + JOIN md_account account ON account.id = entry.account_id + JOIN acc_voucher voucher ON voucher.id = entry.voucher_id + WHERE voucher.public_id=? + ORDER BY entry.line_no + """, String.class, voucherId)); + Map snapshot = jdbcTemplate.queryForMap( + "SELECT rule_version, CAST(rule_snapshot_json AS CHAR) AS rule_snapshot_json " + + "FROM acc_voucher WHERE public_id=?", voucherId); + assertEquals("ACCOUNTING-RULE-V1", snapshot.get("rule_version")); + String ruleSnapshot = String.valueOf(snapshot.get("rule_snapshot_json")); + assertEquals(rule.debitAccount(), JsonPath.read(ruleSnapshot, + "$.eventTypes." + rule.eventType() + ".debitAccount")); + assertEquals(rule.creditAccount(), JsonPath.read(ruleSnapshot, + "$.eventTypes." + rule.eventType() + ".creditAccount")); + } + } + + @Test + void existingDraftAndExportRequireCurrentVersionsAndRules() throws Exception { + ClientSession maker = loginAndSelect("finance", "FINANCE_MANAGER"); + ClientSession reviewer = loginAndSelect("demo", "FINANCE_MANAGER"); + String voucherId = generate(EVENT_RECEIPT, maker); + + postJson("/api/v1/accounting/events/" + EVENT_RECEIPT + "/generate-draft", maker, + "{\"version\":0,\"ruleVersion\":\"ACCOUNTING-RULE-V1\"}") + .andExpect(status().isConflict()).andExpect(jsonPath("$.code").value("VERSION_CONFLICT")); + postJson("/api/v1/accounting/events/" + EVENT_RECEIPT + "/generate-draft", maker, + "{\"version\":1,\"ruleVersion\":\"ACCOUNTING-RULE-V2\"}") + .andExpect(status().isUnprocessableEntity()).andExpect(jsonPath("$.code").value("VALIDATION_FAILED")); + + postJson("/api/v1/accounting/vouchers/" + voucherId + "/submit", maker, "{\"version\":0}") + .andExpect(status().isOk()); + postJson("/api/v1/accounting/vouchers/" + voucherId + "/approve", reviewer, "{\"version\":1}") + .andExpect(status().isOk()); + postJson("/api/v1/accounting/vouchers/" + voucherId + "/export", maker, "{\"version\":2}") + .andExpect(status().isOk()); + + postJson("/api/v1/accounting/vouchers/" + voucherId + "/export", maker, "{\"version\":2}") + .andExpect(status().isConflict()).andExpect(jsonPath("$.code").value("VERSION_CONFLICT")); + mockMvc.perform(get("/api/v1/accounting/vouchers/" + voucherId).cookie(maker.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.voucher.allowedActions", + org.hamcrest.Matchers.not(org.hamcrest.Matchers.hasItem("EXPORT")))); + } + + @Test + void verifyResultRollsBackWhenItsEventCannotBeCompleted() throws Exception { + ClientSession maker = loginAndSelect("finance", "FINANCE_MANAGER"); + ClientSession reviewer = loginAndSelect("demo", "FINANCE_MANAGER"); + String voucherId = prepareExportedVoucher(EVENT_RECEIPT, maker, reviewer); + String evidenceId = insertAvailableEvidence("finance", "completion-failure.pdf"); + postJson("/api/v1/accounting/vouchers/" + voucherId + "/record-result", maker, + resultBody("TPLUS-P16-ROLLBACK", evidenceId)).andExpect(status().isOk()); + jdbcTemplate.update("UPDATE acc_event SET status='PENDING' WHERE public_id=?", EVENT_RECEIPT); + + postJson("/api/v1/accounting/vouchers/" + voucherId + "/verify-result", reviewer, + "{\"version\":4,\"opinion\":\"核验完成\"}") + .andExpect(status().isConflict()).andExpect(jsonPath("$.code").value("VERSION_CONFLICT")); + assertEquals("RESULT_RECORDED", jdbcTemplate.queryForObject( + "SELECT status FROM acc_voucher WHERE public_id=?", String.class, voucherId)); + assertEquals("PENDING", jdbcTemplate.queryForObject( + "SELECT status FROM acc_event WHERE public_id=?", String.class, EVENT_RECEIPT)); + } + + @Test + void draftMayBeSavedUnbalancedButCannotBeApproved() throws Exception { + ClientSession maker = loginAndSelect("finance", "FINANCE_MANAGER"); + ClientSession reviewer = loginAndSelect("demo", "FINANCE_MANAGER"); + String voucherId = generate(EVENT_RECEIPT, maker); + String update = """ + {"version":0,"period":"2026-08","businessDate":"2026-08-01","summary":"测试不平衡分录", + "changeReason":"验证借贷平衡控制","entries":[ + {"lineNo":1,"direction":"DEBIT","accountId":"%s","amount":"1000.00","summary":"借方"}, + {"lineNo":2,"direction":"CREDIT","accountId":"%s","amount":"900.00","summary":"贷方","auxiliaryJson":"{\\"projectId\\":\\"%s\\"}"} + ]} + """.formatted(ACCOUNT_1002, ACCOUNT_1122, PROJECT_ID); + patchJson("/api/v1/accounting/vouchers/" + voucherId, maker, update) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.voucher.balanceStatus").value("UNBALANCED")); + postJson("/api/v1/accounting/vouchers/" + voucherId + "/submit", maker, "{\"version\":1}") + .andExpect(status().isOk()); + postJson("/api/v1/accounting/vouchers/" + voucherId + "/approve", reviewer, "{\"version\":2}") + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("VALIDATION_FAILED")); + } + + @Test + void auxiliaryPeriodVersionAndVoidControlsAreEnforced() throws Exception { + ClientSession maker = loginAndSelect("finance", "FINANCE_MANAGER"); + ClientSession reviewer = loginAndSelect("demo", "FINANCE_MANAGER"); + String voucherId = generate(EVENT_RECEIPT, maker); + String missingAuxiliary = """ + {"version":0,"period":"2026-08","businessDate":"2026-08-01","summary":"缺少辅助核算", + "changeReason":"验证辅助核算控制","entries":[ + {"lineNo":1,"direction":"DEBIT","accountId":"%s","amount":"1000.00","summary":"借方"}, + {"lineNo":2,"direction":"CREDIT","accountId":"%s","amount":"1000.00","summary":"贷方"} + ]} + """.formatted(ACCOUNT_1002, ACCOUNT_1122); + patchJson("/api/v1/accounting/vouchers/" + voucherId, maker, missingAuxiliary) + .andExpect(status().isOk()); + postJson("/api/v1/accounting/vouchers/" + voucherId + "/submit", maker, "{\"version\":1}") + .andExpect(status().isOk()); + postJson("/api/v1/accounting/vouchers/" + voucherId + "/approve", reviewer, "{\"version\":2}") + .andExpect(status().isUnprocessableEntity()).andExpect(jsonPath("$.code").value("VALIDATION_FAILED")); + postJson("/api/v1/accounting/vouchers/" + voucherId + "/return", reviewer, + "{\"version\":2,\"opinion\":\"请补充辅助核算\"}") + .andExpect(status().isOk()).andExpect(jsonPath("$.data.voucher.status").value("RETURNED")); + postJson("/api/v1/accounting/vouchers/" + voucherId + "/void", maker, + "{\"version\":2,\"reason\":\"使用过期版本\"}") + .andExpect(status().isConflict()).andExpect(jsonPath("$.code").value("VERSION_CONFLICT")); + postJson("/api/v1/accounting/vouchers/" + voucherId + "/void", maker, + "{\"version\":3,\"reason\":\"来源业务确认有误\"}") + .andExpect(status().isOk()).andExpect(jsonPath("$.data.voucher.status").value("VOID")); + assertEquals("PENDING", jdbcTemplate.queryForObject("SELECT status FROM acc_event WHERE public_id=?", + String.class, EVENT_RECEIPT)); + long eventVersion = jdbcTemplate.queryForObject("SELECT version FROM acc_event WHERE public_id=?", Long.class, + EVENT_RECEIPT); + String replacement = generate(EVENT_RECEIPT, eventVersion, maker); + org.junit.jupiter.api.Assertions.assertNotEquals(voucherId, replacement); + assertEquals(2, count("SELECT COUNT(*) FROM acc_voucher WHERE event_id=(SELECT id FROM acc_event WHERE public_id=?)", EVENT_RECEIPT)); + assertEquals(1, count("SELECT COUNT(*) FROM acc_voucher WHERE event_id=(SELECT id FROM acc_event WHERE public_id=?) AND status<>'VOID'", EVENT_RECEIPT)); + } + + @Test + void voucherCannotDriftFromSourcePeriodOrAuxiliaryScope() throws Exception { + ClientSession maker = loginAndSelect("finance", "FINANCE_MANAGER"); + String voucherId = generate(EVENT_RECEIPT, maker); + String crossPeriod = """ + {"version":0,"period":"2026-09","businessDate":"2026-09-01","summary":"跨期凭证", + "changeReason":"验证来源期间一致性","entries":[ + {"lineNo":1,"direction":"DEBIT","accountId":"%s","amount":"1000.00","summary":"借方","auxiliaryJson":"{\\"projectId\\":\\"%s\\"}"}, + {"lineNo":2,"direction":"CREDIT","accountId":"%s","amount":"1000.00","summary":"贷方","auxiliaryJson":"{\\"projectId\\":\\"%s\\"}"} + ]} + """.formatted(ACCOUNT_1002, PROJECT_ID, ACCOUNT_1122, PROJECT_ID); + patchJson("/api/v1/accounting/vouchers/" + voucherId, maker, crossPeriod) + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("VALIDATION_FAILED")); + + String wrongDimension = """ + {"version":0,"period":"2026-08","businessDate":"2026-08-01","summary":"跨项目辅助核算", + "changeReason":"验证辅助核算范围","entries":[ + {"lineNo":1,"direction":"DEBIT","accountId":"%s","amount":"1000.00","summary":"借方","auxiliaryJson":"{\\"projectId\\":\\"%s\\"}"}, + {"lineNo":2,"direction":"CREDIT","accountId":"%s","amount":"1000.00","summary":"贷方","auxiliaryJson":"{\\"projectId\\":\\"%s\\"}"} + ]} + """.formatted(ACCOUNT_1002, "00000000000000000000001699", ACCOUNT_1122, "00000000000000000000001699"); + patchJson("/api/v1/accounting/vouchers/" + voucherId, maker, wrongDimension) + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("VALIDATION_FAILED")); + assertEquals(0L, jdbcTemplate.queryForObject( + "SELECT version FROM acc_voucher WHERE public_id=?", Long.class, voucherId)); + } + + @Test + void disabledAccountBlocksApprovalAndExportOfExistingVoucher() throws Exception { + ClientSession maker = loginAndSelect("finance", "FINANCE_MANAGER"); + ClientSession reviewer = loginAndSelect("demo", "FINANCE_MANAGER"); + try { + String draft = generate(EVENT_RECEIPT, maker); + jdbcTemplate.update("UPDATE md_account SET status='DISABLED' WHERE public_id=?", ACCOUNT_1002); + postJson("/api/v1/accounting/vouchers/" + draft + "/submit", maker, "{\"version\":0}") + .andExpect(status().isOk()); + postJson("/api/v1/accounting/vouchers/" + draft + "/approve", reviewer, "{\"version\":1}") + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("VALIDATION_FAILED")); + + jdbcTemplate.update("UPDATE md_account SET status='ACTIVE' WHERE public_id=?", ACCOUNT_1002); + String approved = generate(EVENT_PAYABLE, maker); + postJson("/api/v1/accounting/vouchers/" + approved + "/submit", maker, "{\"version\":0}") + .andExpect(status().isOk()); + postJson("/api/v1/accounting/vouchers/" + approved + "/approve", reviewer, "{\"version\":1}") + .andExpect(status().isOk()); + jdbcTemplate.update("UPDATE md_account SET status='DISABLED' WHERE public_id=?", ACCOUNT_5401); + postJson("/api/v1/accounting/vouchers/" + approved + "/export", maker, "{\"version\":2}") + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("VALIDATION_FAILED")); + } finally { + jdbcTemplate.update("UPDATE md_account SET status='ACTIVE' WHERE public_id=?", ACCOUNT_1002); + jdbcTemplate.update("UPDATE md_account SET status='ACTIVE' WHERE public_id=?", ACCOUNT_5401); + } + } + + @Test + void projectIdentityCannotReadAccountingAndExternalNumberIsUniquePerPeriod() throws Exception { + ClientSession project = loginAndSelect("project", "PROJECT_MANAGER"); + mockMvc.perform(get("/api/v1/accounting/events").cookie(project.cookies())) + .andExpect(status().isForbidden()).andExpect(jsonPath("$.code").value("PERMISSION_DENIED")); + } + + @Test + void voucherMakerCannotReviewAfterAnotherUserSubmitsAndAmountScopeLimitsReads() throws Exception { + ClientSession maker = loginAndSelect("finance", "FINANCE_MANAGER"); + ClientSession submitter = loginAndSelect("demo", "FINANCE_MANAGER"); + String voucherId = generate(EVENT_RECEIPT, maker); + + postJson("/api/v1/accounting/vouchers/" + voucherId + "/submit", submitter, "{\"version\":0}") + .andExpect(status().isOk()); + mockMvc.perform(get("/api/v1/accounting/vouchers/" + voucherId).cookie(maker.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.voucher.allowedActions").isEmpty()); + postJson("/api/v1/accounting/vouchers/" + voucherId + "/approve", maker, "{\"version\":1}") + .andExpect(status().isUnprocessableEntity()).andExpect(jsonPath("$.code").value("SOD_VIOLATION")); + + restrictAmountScope("finance", "accounting:voucher:view", "500.00"); + try { + mockMvc.perform(get("/api/v1/accounting/vouchers?tab=review").cookie(maker.cookies())) + .andExpect(status().isOk()).andExpect(jsonPath("$.meta.totalElements").value(0)); + mockMvc.perform(get("/api/v1/accounting/vouchers/" + voucherId).cookie(maker.cookies())) + .andExpect(status().isForbidden()).andExpect(jsonPath("$.code").value("AMOUNT_LIMIT_EXCEEDED")); + } finally { + restrictAmountScope("finance", "accounting:voucher:view", null); + } + } + + @Test + void approvedVoucherRemainsReachableFromExportQueueBeforeItIsExported() throws Exception { + ClientSession maker = loginAndSelect("finance", "FINANCE_MANAGER"); + ClientSession reviewer = loginAndSelect("demo", "FINANCE_MANAGER"); + String voucherId = generate(EVENT_RECEIPT, maker); + postJson("/api/v1/accounting/vouchers/" + voucherId + "/submit", maker, "{\"version\":0}") + .andExpect(status().isOk()); + postJson("/api/v1/accounting/vouchers/" + voucherId + "/approve", reviewer, "{\"version\":1}") + .andExpect(status().isOk()); + + mockMvc.perform(get("/api/v1/accounting/vouchers?tab=exported").cookie(maker.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.meta.totalElements").value(1)) + .andExpect(jsonPath("$.data[0].publicId").value(voucherId)) + .andExpect(jsonPath("$.data[0].status").value("APPROVED")) + .andExpect(jsonPath("$.data[0].allowedActions[0]").value("EXPORT")); + } + + @Test + void exportedVoucherWithoutBatchRebuildsEvidenceIdempotently() throws Exception { + ClientSession maker = loginAndSelect("finance", "FINANCE_MANAGER"); + ClientSession reviewer = loginAndSelect("demo", "FINANCE_MANAGER"); + String voucherId = prepareExportedVoucher(EVENT_RECEIPT, maker, reviewer); + String previousHash = jdbcTemplate.queryForObject( + "SELECT export_sha256 FROM acc_voucher WHERE public_id=?", String.class, voucherId); + String previousBatchNo = jdbcTemplate.queryForObject( + "SELECT export_batch_no FROM acc_voucher WHERE public_id=?", String.class, voucherId); + jdbcTemplate.update("DELETE FROM acc_export_batch WHERE voucher_id=(SELECT id FROM acc_voucher WHERE public_id=?)", + voucherId); + + mockMvc.perform(get("/api/v1/accounting/vouchers/" + voucherId).cookie(maker.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.voucher.allowedActions").value(org.hamcrest.Matchers.hasItem("EXPORT"))); + + MvcResult rebuilt = postJson("/api/v1/accounting/vouchers/" + voucherId + "/export", maker, + "{\"version\":3}").andExpect(status().isOk()) + .andExpect(jsonPath("$.data.batchNo").value(previousBatchNo)) + .andExpect(jsonPath("$.data.sha256").value(previousHash)) + .andExpect(jsonPath("$.data.rowCount").value(2)) + .andReturn(); + String rebuiltFileId = JsonPath.read(rebuilt.getResponse().getContentAsString(), "$.data.fileId"); + assertEquals(1, count("SELECT COUNT(*) FROM acc_export_batch WHERE voucher_id=(SELECT id FROM acc_voucher WHERE public_id=?)", voucherId)); + + postJson("/api/v1/accounting/vouchers/" + voucherId + "/export", maker, "{\"version\":3}") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.fileId").value(rebuiltFileId)); + assertEquals(1, count("SELECT COUNT(*) FROM acc_export_batch WHERE voucher_id=(SELECT id FROM acc_voucher WHERE public_id=?)", voucherId)); + } + + @Test + void resultPendingMigrationUsesPersistedExportEvidence() throws Exception { + ClientSession maker = loginAndSelect("finance", "FINANCE_MANAGER"); + ClientSession reviewer = loginAndSelect("demo", "FINANCE_MANAGER"); + String exportedVoucherId = prepareExportedVoucher(EVENT_RECEIPT, maker, reviewer); + String approvedVoucherId = generate(EVENT_PAYABLE, maker); + postJson("/api/v1/accounting/vouchers/" + approvedVoucherId + "/submit", maker, "{\"version\":0}") + .andExpect(status().isOk()); + postJson("/api/v1/accounting/vouchers/" + approvedVoucherId + "/approve", reviewer, "{\"version\":1}") + .andExpect(status().isOk()); + + jdbcTemplate.execute("ALTER TABLE acc_voucher DROP CHECK ck_acc_voucher_status, " + + "ADD CONSTRAINT ck_acc_voucher_status CHECK (status IN " + + "('DRAFT','REVIEWING','RETURNED','APPROVED','EXPORTED','RESULT_PENDING'," + + "'RESULT_RECORDED','COMPLETED','VOID'))"); + jdbcTemplate.update("UPDATE acc_voucher SET status='RESULT_PENDING' WHERE public_id IN (?, ?)", + exportedVoucherId, approvedVoucherId); + + try (var connection = jdbcTemplate.getDataSource().getConnection()) { + ScriptUtils.executeSqlScript(connection, + new ClassPathResource("db/migration/V041__accounting_result_pending_state_backfill.sql")); + } + + assertEquals("EXPORTED", jdbcTemplate.queryForObject( + "SELECT status FROM acc_voucher WHERE public_id=?", String.class, exportedVoucherId)); + assertEquals("APPROVED", jdbcTemplate.queryForObject( + "SELECT status FROM acc_voucher WHERE public_id=?", String.class, approvedVoucherId)); + org.springframework.dao.DataAccessException rejected = Assertions.assertThrows( + org.springframework.dao.DataAccessException.class, + () -> jdbcTemplate.update("UPDATE acc_voucher SET status='RESULT_PENDING' WHERE public_id=?", approvedVoucherId)); + assertTrue(rejected.getMostSpecificCause().getMessage().contains("ck_acc_voucher_status")); + jdbcTemplate.execute("ALTER TABLE acc_voucher DROP CHECK ck_acc_voucher_status, " + + "ADD CONSTRAINT ck_acc_voucher_status CHECK (status IN " + + "('DRAFT','REVIEWING','RETURNED','APPROVED','EXPORTED','RESULT_RECORDED'," + + "'COMPLETED','RESULT_REVERSED','VOID'))"); + } + + @Test + void resultEvidenceMustBeAvailableInScopeAndAssociatedWithVoucher() throws Exception { + ClientSession maker = loginAndSelect("finance", "FINANCE_MANAGER"); + ClientSession reviewer = loginAndSelect("demo", "FINANCE_MANAGER"); + String voucherId = prepareExportedVoucher(EVENT_RECEIPT, maker, reviewer); + + String quarantined = insertEvidence("finance", "quarantined.pdf", "QUARANTINED"); + postJson("/api/v1/accounting/vouchers/" + voucherId + "/record-result", maker, + resultBody("TPLUS-P16-Q", quarantined, quarantined)) + .andExpect(status().isUnprocessableEntity()).andExpect(jsonPath("$.code").value("FILE_REJECTED")); + + String rejected = insertEvidence("finance", "rejected.pdf", "REJECTED"); + postJson("/api/v1/accounting/vouchers/" + voucherId + "/record-result", maker, + resultBody("TPLUS-P16-R", rejected)) + .andExpect(status().isUnprocessableEntity()).andExpect(jsonPath("$.code").value("FILE_REJECTED")); + + String foreign = insertEvidence("demo", "foreign.pdf", "AVAILABLE"); + postJson("/api/v1/accounting/vouchers/" + voucherId + "/record-result", maker, + resultBody("TPLUS-P16-F", foreign)) + .andExpect(status().isForbidden()).andExpect(jsonPath("$.code").value("DATA_SCOPE_DENIED")); + + String available = insertAvailableEvidence("finance", "deduplicated.pdf"); + postJson("/api/v1/accounting/vouchers/" + voucherId + "/record-result", maker, + resultBody("TPLUS-P16-D", available, available)) + .andExpect(status().isOk()).andExpect(jsonPath("$.data.evidenceFiles.length()").value(1)); + assertEquals(1, count("SELECT COUNT(*) FROM acc_voucher_result_file WHERE voucher_id=(SELECT id FROM acc_voucher WHERE public_id=?)", voucherId)); + + String other = insertAvailableEvidence("finance", "other.pdf"); + mockMvc.perform(get("/api/v1/accounting/vouchers/" + voucherId + "/result-files/" + other + "/content") + .cookie(reviewer.cookies())) + .andExpect(status().isNotFound()).andExpect(jsonPath("$.code").value("RESOURCE_NOT_FOUND")); + } + + @Test + void resultCommandsEnforceVersionScopeIdempotencyAndExternalVoucherUniqueness() throws Exception { + ClientSession maker = loginAndSelect("finance", "FINANCE_MANAGER"); + ClientSession reviewer = loginAndSelect("demo", "FINANCE_MANAGER"); + String firstVoucherId = prepareExportedVoucher(EVENT_RECEIPT, maker, reviewer); + String secondVoucherId = prepareExportedVoucher(EVENT_PAYABLE, maker, reviewer); + String firstEvidence = insertAvailableEvidence("finance", "result-command-first.pdf"); + String secondEvidence = insertAvailableEvidence("finance", "result-command-second.pdf"); + String externalNo = "TPLUS-P16-UNIQUE"; + String firstBody = resultBody(externalNo, firstEvidence); + + postJson("/api/v1/accounting/vouchers/" + firstVoucherId + "/record-result", maker, + firstBody.replace("\"version\":3", "\"version\":2")) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("VERSION_CONFLICT")); + + restrictAmountScope("finance", "accounting:voucher:record-result", "500.00"); + try { + postJson("/api/v1/accounting/vouchers/" + firstVoucherId + "/record-result", maker, firstBody) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("AMOUNT_LIMIT_EXCEEDED")); + } finally { + restrictAmountScope("finance", "accounting:voucher:record-result", null); + } + + setScopeStatus("finance", "accounting:voucher:record-result", "DISABLED"); + try { + postJson("/api/v1/accounting/vouchers/" + firstVoucherId + "/record-result", maker, firstBody) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("DATA_SCOPE_DENIED")); + } finally { + setScopeStatus("finance", "accounting:voucher:record-result", "ACTIVE"); + } + + String recordPath = "/api/v1/accounting/vouchers/" + firstVoucherId + "/record-result"; + String recordKey = idempotencyKey(); + postJson(recordPath, maker, firstBody, recordKey) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.voucher.status").value("RESULT_RECORDED")) + .andExpect(jsonPath("$.data.voucher.version").value(4)); + postJson(recordPath, maker, firstBody, recordKey) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.voucher.status").value("RESULT_RECORDED")); + assertEquals(1, count("SELECT COUNT(*) FROM acc_voucher_result_file result_file " + + "JOIN acc_voucher voucher ON voucher.id=result_file.voucher_id WHERE voucher.public_id=?", + firstVoucherId)); + postJson(recordPath, maker, firstBody.replace(externalNo, externalNo + "-CHANGED"), recordKey) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("IDEMPOTENCY_KEY_REUSED")); + + postJson("/api/v1/accounting/vouchers/" + secondVoucherId + "/record-result", maker, + resultBody(externalNo, secondEvidence)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("DUPLICATE_RESOURCE")); + assertEquals("EXPORTED", jdbcTemplate.queryForObject( + "SELECT status FROM acc_voucher WHERE public_id=?", String.class, secondVoucherId)); + assertEquals(0, count("SELECT COUNT(*) FROM acc_voucher_result_file result_file " + + "JOIN acc_voucher voucher ON voucher.id=result_file.voucher_id WHERE voucher.public_id=?", + secondVoucherId)); + + String verifyPath = "/api/v1/accounting/vouchers/" + firstVoucherId + "/verify-result"; + String verifyBody = "{\"version\":4,\"opinion\":\"外部凭证号、日期和附件一致\"}"; + postJson(verifyPath, reviewer, "{\"version\":3,\"opinion\":\"使用过期版本\"}") + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("VERSION_CONFLICT")); + + restrictAmountScope("demo", "accounting:voucher:verify-result", "500.00"); + try { + postJson(verifyPath, reviewer, verifyBody) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("AMOUNT_LIMIT_EXCEEDED")); + } finally { + restrictAmountScope("demo", "accounting:voucher:verify-result", null); + } + + setScopeStatus("demo", "accounting:voucher:verify-result", "DISABLED"); + try { + postJson(verifyPath, reviewer, verifyBody) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("DATA_SCOPE_DENIED")); + } finally { + setScopeStatus("demo", "accounting:voucher:verify-result", "ACTIVE"); + } + + String verifyKey = idempotencyKey(); + postJson(verifyPath, reviewer, verifyBody, verifyKey) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.voucher.status").value("COMPLETED")) + .andExpect(jsonPath("$.data.voucher.version").value(5)); + postJson(verifyPath, reviewer, verifyBody, verifyKey) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.voucher.status").value("COMPLETED")); + postJson(verifyPath, reviewer, verifyBody.replace("一致", "已复核"), verifyKey) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("IDEMPOTENCY_KEY_REUSED")); + } + + @Test + void completedResultCanBeReversedAndReopenedWithoutLosingHistory() throws Exception { + ClientSession recorder = loginAndSelect("finance", "FINANCE_MANAGER"); + ClientSession verifier = loginAndSelect("demo", "FINANCE_MANAGER"); + String voucherId = prepareExportedVoucher(EVENT_RECEIPT, recorder, verifier); + String originalEvidence = insertAvailableEvidence("finance", "result-before-reversal.pdf"); + String originalExternalNo = "TPLUS-P16-REVERSED"; + + postJson("/api/v1/accounting/vouchers/" + voucherId + "/record-result", recorder, + resultBody(originalExternalNo, originalEvidence)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.voucher.version").value(4)); + postJson("/api/v1/accounting/vouchers/" + voucherId + "/verify-result", verifier, + "{\"version\":4,\"opinion\":\"原记账结果复核通过\"}") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.voucher.status").value("COMPLETED")) + .andExpect(jsonPath("$.data.voucher.allowedActions").value( + org.hamcrest.Matchers.hasItem("REVERSE_RESULT"))); + + String reversePath = "/api/v1/accounting/vouchers/" + voucherId + "/reverse-result"; + postJson(reversePath, recorder, "{\"version\":5,\"reason\":\"登记人尝试冲销\"}") + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("SOD_VIOLATION")); + postJson(reversePath, verifier, "{\"version\":4,\"reason\":\"使用过期版本\"}") + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("VERSION_CONFLICT")); + postJson(reversePath, verifier, "{\"version\":5,\"reason\":\" a \"}") + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("VALIDATION_FAILED")); + + restrictAmountScope("demo", "accounting:voucher:reverse-result", "500.00"); + try { + postJson(reversePath, verifier, "{\"version\":5,\"reason\":\"外部账套凭证有误\"}") + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("AMOUNT_LIMIT_EXCEEDED")); + } finally { + restrictAmountScope("demo", "accounting:voucher:reverse-result", null); + } + + String reverseBody = "{\"version\":5,\"reason\":\"外部账套凭证有误\"}"; + String reverseKey = idempotencyKey(); + postJson(reversePath, verifier, reverseBody, reverseKey) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.voucher.status").value("RESULT_REVERSED")) + .andExpect(jsonPath("$.data.voucher.version").value(6)) + .andExpect(jsonPath("$.data.voucher.resultCycleNo").value(0)) + .andExpect(jsonPath("$.data.resultActions[0].actionType").value("REVERSE")) + .andExpect(jsonPath("$.data.resultActions[0].externalVoucherNo").value(originalExternalNo)) + .andExpect(jsonPath("$.data.resultActions[0].evidenceFiles[0].publicId").value(originalEvidence)); + postJson(reversePath, verifier, reverseBody, reverseKey) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.voucher.status").value("RESULT_REVERSED")); + postJson(reversePath, verifier, reverseBody.replace("外部账套凭证有误", "另一冲销原因"), reverseKey) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("IDEMPOTENCY_KEY_REUSED")); + assertEquals(1, count("SELECT COUNT(*) FROM acc_voucher_result_action action " + + "JOIN acc_voucher voucher ON voucher.id=action.voucher_id " + + "WHERE voucher.public_id=? AND action.action_type='REVERSE'", voucherId)); + assertEquals("DRAFTED", jdbcTemplate.queryForObject( + "SELECT status FROM acc_event WHERE public_id=?", String.class, EVENT_RECEIPT)); + + String reopenPath = "/api/v1/accounting/vouchers/" + voucherId + "/reopen-result"; + postJson(reopenPath, verifier, "{\"version\":6,\"reason\":\"冲销人尝试重开\"}") + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("SOD_VIOLATION")); + postJson(reopenPath, recorder, "{\"version\":6,\"reason\":\" a \"}") + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("VALIDATION_FAILED")); + restrictAmountScope("finance", "accounting:voucher:reopen-result", "500.00"); + try { + postJson(reopenPath, recorder, "{\"version\":6,\"reason\":\"准备重新登记\"}") + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("AMOUNT_LIMIT_EXCEEDED")); + } finally { + restrictAmountScope("finance", "accounting:voucher:reopen-result", null); + } + setScopeStatus("finance", "accounting:voucher:reopen-result", "DISABLED"); + try { + postJson(reopenPath, recorder, "{\"version\":6,\"reason\":\"准备重新登记\"}") + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("DATA_SCOPE_DENIED")); + } finally { + setScopeStatus("finance", "accounting:voucher:reopen-result", "ACTIVE"); + } + + String reopenBody = "{\"version\":6,\"reason\":\"准备重新登记\"}"; + String reopenKey = idempotencyKey(); + postJson(reopenPath, recorder, reopenBody, reopenKey) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.voucher.status").value("EXPORTED")) + .andExpect(jsonPath("$.data.voucher.version").value(7)) + .andExpect(jsonPath("$.data.voucher.resultCycleNo").value(1)) + .andExpect(jsonPath("$.data.voucher.externalVoucherNo").doesNotExist()) + .andExpect(jsonPath("$.data.evidenceFiles.length()").value(0)) + .andExpect(jsonPath("$.data.resultActions.length()").value(2)) + .andExpect(jsonPath("$.data.resultActions[0].actionType").value("REOPEN")); + postJson(reopenPath, recorder, reopenBody, reopenKey) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.voucher.status").value("EXPORTED")); + postJson(reopenPath, recorder, reopenBody.replace("准备重新登记", "另一重开原因"), reopenKey) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("IDEMPOTENCY_KEY_REUSED")); + + mockMvc.perform(get("/api/v1/accounting/vouchers/" + voucherId + "/result-files/" + originalEvidence + + "/content").cookie(verifier.cookies())) + .andExpect(status().isOk()) + .andExpect(header().string("X-File-SHA256", sha256("result-before-reversal.pdf"))); + + String secondVoucherId = prepareExportedVoucher(EVENT_PAYABLE, recorder, verifier); + String duplicateEvidence = insertAvailableEvidence("finance", "historical-number-duplicate.pdf"); + postJson("/api/v1/accounting/vouchers/" + secondVoucherId + "/record-result", recorder, + resultBody(originalExternalNo, duplicateEvidence)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("DUPLICATE_RESOURCE")); + + String replacementEvidence = insertAvailableEvidence("finance", "result-after-reopen.pdf"); + postJson("/api/v1/accounting/vouchers/" + voucherId + "/record-result", recorder, + resultBody("TPLUS-P16-REOPENED", replacementEvidence) + .replace("\"version\":3", "\"version\":7")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.voucher.status").value("RESULT_RECORDED")) + .andExpect(jsonPath("$.data.evidenceFiles.length()").value(1)) + .andExpect(jsonPath("$.data.evidenceFiles[0].publicId").value(replacementEvidence)) + .andExpect(jsonPath("$.data.resultActions.length()").value(2)); + postJson("/api/v1/accounting/vouchers/" + voucherId + "/verify-result", verifier, + "{\"version\":8,\"opinion\":\"重新登记结果复核通过\"}") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.voucher.status").value("COMPLETED")) + .andExpect(jsonPath("$.data.voucher.version").value(9)); + + assertEquals(2, count("SELECT COUNT(*) FROM acc_voucher_result_file result_file " + + "JOIN acc_voucher voucher ON voucher.id=result_file.voucher_id WHERE voucher.public_id=?", voucherId)); + assertEquals(2, count("SELECT COUNT(DISTINCT result_file.result_cycle_no) " + + "FROM acc_voucher_result_file result_file " + + "JOIN acc_voucher voucher ON voucher.id=result_file.voucher_id WHERE voucher.public_id=?", voucherId)); + assertEquals(2, count("SELECT COUNT(*) FROM acc_voucher_result_action action " + + "JOIN acc_voucher voucher ON voucher.id=action.voucher_id WHERE voucher.public_id=?", voucherId)); + assertTrue(count("SELECT COUNT(*) FROM audit_log WHERE object_public_id=? " + + "AND action_code IN ('ACCOUNTING_RESULT_REVERSE','ACCOUNTING_RESULT_REOPEN')", voucherId) == 2); + } + + @Test + void voucherCommandsRequireAndEnforceIdempotencyKeys() throws Exception { + ClientSession maker = loginAndSelect("finance", "FINANCE_MANAGER"); + String path = "/api/v1/accounting/events/" + EVENT_RECEIPT + "/generate-draft"; + String body = "{\"version\":0,\"ruleVersion\":\"ACCOUNTING-RULE-V1\"}"; + + mockMvc.perform(post(path).cookie(maker.cookies()).header("X-XSRF-TOKEN", maker.token()) + .contentType("application/json").content(body)) + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("VALIDATION_FAILED")); + + String key = idempotencyKey(); + MvcResult first = postJson(path, maker, body, key).andExpect(status().isOk()).andReturn(); + String voucherId = JsonPath.read(first.getResponse().getContentAsString(), "$.data.voucher.publicId"); + postJson(path, maker, body, key).andExpect(status().isOk()) + .andExpect(jsonPath("$.data.voucher.publicId").value(voucherId)); + assertEquals(1, count("SELECT COUNT(*) FROM acc_voucher WHERE event_id=(SELECT id FROM acc_event WHERE public_id=?)", + EVENT_RECEIPT)); + + postJson(path, maker, "{\"version\":0,\"ruleVersion\":\"ACCOUNTING-RULE-V2\"}", key) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("IDEMPOTENCY_KEY_REUSED")); + } + + private String generate(String eventId, ClientSession session) throws Exception { + return generate(eventId, 0, session); + } + + private String prepareExportedVoucher(String eventId, ClientSession maker, ClientSession reviewer) + throws Exception { + String voucherId = generate(eventId, maker); + postJson("/api/v1/accounting/vouchers/" + voucherId + "/submit", maker, "{\"version\":0}") + .andExpect(status().isOk()); + postJson("/api/v1/accounting/vouchers/" + voucherId + "/approve", reviewer, "{\"version\":1}") + .andExpect(status().isOk()); + postJson("/api/v1/accounting/vouchers/" + voucherId + "/export", maker, "{\"version\":2}") + .andExpect(status().isOk()); + return voucherId; + } + + private String resultBody(String externalVoucherNo, String... evidenceIds) { + return "{\"version\":3,\"externalVoucherNo\":\"" + externalVoucherNo + + "\",\"resultAt\":\"2026-08-03\",\"evidenceFileIds\":[\"" + + String.join("\",\"", evidenceIds) + "\"],\"remark\":\"测试结果登记\"}"; + } + + private String generate(String eventId, long version, ClientSession session) throws Exception { + MvcResult result = postJson("/api/v1/accounting/events/" + eventId + "/generate-draft", session, + "{\"version\":" + version + ",\"ruleVersion\":\"ACCOUNTING-RULE-V1\"}") + .andExpect(status().isOk()).andReturn(); + return JsonPath.read(result.getResponse().getContentAsString(), "$.data.voucher.publicId"); + } + + private org.springframework.test.web.servlet.ResultActions postJson(String path, ClientSession session, + String body) throws Exception { + return postJson(path, session, body, idempotencyKey()); + } + + private org.springframework.test.web.servlet.ResultActions postJson(String path, ClientSession session, + String body, String key) throws Exception { + return mockMvc.perform(post(path).cookie(session.cookies()).header("Idempotency-Key", key).header("X-XSRF-TOKEN", session.token()) + .contentType("application/json").content(body)); + } + + private org.springframework.test.web.servlet.ResultActions patchJson(String path, ClientSession session, + String body) throws Exception { + return mockMvc.perform(patch(path).cookie(session.cookies()).header("Idempotency-Key", idempotencyKey()).header("X-XSRF-TOKEN", session.token()) + .contentType("application/json").content(body)); + } + + private void cleanup() { + jdbcTemplate.update("DELETE returned_item FROM arc_package_return_item returned_item " + + "JOIN arc_package package_record ON package_record.id=returned_item.package_id " + + "JOIN md_project project ON project.id=package_record.project_id WHERE project.public_id=?", + PROJECT_ID); + jdbcTemplate.update("DELETE action FROM arc_package_action action " + + "JOIN arc_package package_record ON package_record.id=action.package_id " + + "JOIN md_project project ON project.id=package_record.project_id WHERE project.public_id=?", + PROJECT_ID); + jdbcTemplate.update("DELETE snapshot FROM arc_package_object_snapshot snapshot " + + "JOIN arc_package package_record ON package_record.id=snapshot.package_id " + + "JOIN md_project project ON project.id=package_record.project_id WHERE project.public_id=?", + PROJECT_ID); + jdbcTemplate.update("DELETE item FROM arc_package_item item " + + "JOIN arc_package package_record ON package_record.id=item.package_id " + + "JOIN md_project project ON project.id=package_record.project_id WHERE project.public_id=?", + PROJECT_ID); + jdbcTemplate.update("DELETE package_record FROM arc_package package_record " + + "JOIN md_project project ON project.id=package_record.project_id WHERE project.public_id=?", + PROJECT_ID); + Object[] eventIds = {EVENT_RECEIPT, EVENT_PAYABLE, EVENT_INVOICE, EVENT_PAYMENT, EVENT_PAYMENT_REFUND}; + jdbcTemplate.update("UPDATE acc_event SET voucher_id=NULL WHERE public_id IN (?,?,?,?,?)", eventIds); + jdbcTemplate.update("DELETE result_action FROM acc_voucher_result_action result_action " + + "JOIN acc_voucher voucher ON voucher.id=result_action.voucher_id " + + "JOIN acc_event event ON event.id=voucher.event_id WHERE event.public_id IN (?,?,?,?,?)", eventIds); + jdbcTemplate.update("DELETE result_file FROM acc_voucher_result_file result_file JOIN acc_voucher voucher ON voucher.id=result_file.voucher_id JOIN acc_event event ON event.id=voucher.event_id WHERE event.public_id IN (?,?,?,?,?)", eventIds); + jdbcTemplate.update("DELETE export_batch FROM acc_export_batch export_batch JOIN acc_voucher voucher ON voucher.id=export_batch.voucher_id JOIN acc_event event ON event.id=voucher.event_id WHERE event.public_id IN (?,?,?,?,?)", eventIds); + jdbcTemplate.update("DELETE change_record FROM acc_voucher_change change_record JOIN acc_voucher voucher ON voucher.id=change_record.voucher_id JOIN acc_event event ON event.id=voucher.event_id WHERE event.public_id IN (?,?,?,?,?)", eventIds); + jdbcTemplate.update("DELETE entry FROM acc_voucher_entry entry JOIN acc_voucher voucher ON voucher.id=entry.voucher_id JOIN acc_event event ON event.id=voucher.event_id WHERE event.public_id IN (?,?,?,?,?)", eventIds); + jdbcTemplate.update("DELETE voucher FROM acc_voucher voucher JOIN acc_event event ON event.id=voucher.event_id WHERE event.public_id IN (?,?,?,?,?)", eventIds); + jdbcTemplate.update("DELETE FROM acc_event WHERE public_id IN (?,?,?,?,?)", eventIds); + } + + private String accountingSnapshot(String packageId, String voucherId) { + return jdbcTemplate.queryForObject("SELECT CAST(snapshot.snapshot_json AS CHAR) " + + "FROM arc_package_object_snapshot snapshot " + + "JOIN arc_package package_record ON package_record.id=snapshot.package_id " + + "WHERE package_record.public_id=? AND snapshot.object_type='ACCOUNTING_VOUCHER' " + + "AND snapshot.object_public_id=?", String.class, packageId, voucherId); + } + + private long approveMissingArchiveItemsThroughApi( + String packageId, MvcResult packageResult, ClientSession requester, ClientSession reviewer) throws Exception { + String packageBody = packageResult.getResponse().getContentAsString(); + List missingItemIds = JsonPath.read( + packageBody, "$.data.items[?(@.status == 'MISSING')].publicId"); + assertTrue(!missingItemIds.isEmpty(), "archive package should contain non-accounting requirements"); + long version = ((Number) JsonPath.read(packageBody, "$.data.version")).longValue(); + for (int index = 0; index < missingItemIds.size(); index++) { + String itemId = missingItemIds.get(index); + MvcResult requested = postJson( + "/api/v1/archive/packages/" + packageId + "/items/" + itemId + "/not-applicable", + requester, + "{\"version\":" + version + ",\"reason\":\"测试业务确认该资料不涉及\"}") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.version").value(version + 1)) + .andExpect(jsonPath("$.data.items[?(@.publicId == '" + itemId + "')].status") + .value(org.hamcrest.Matchers.contains("NA_REVIEWING"))) + .andReturn(); + version = ((Number) JsonPath.read( + requested.getResponse().getContentAsString(), "$.data.version")).longValue(); + + MvcResult reviewed = postJson( + "/api/v1/archive/packages/" + packageId + "/items/" + itemId + "/not-applicable/review", + reviewer, + "{\"version\":" + version + + ",\"approved\":true,\"opinion\":\"异人复核确认该资料不涉及\"}") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.version").value(version + 1)) + .andExpect(jsonPath("$.data.items[?(@.publicId == '" + itemId + "')].status") + .value(org.hamcrest.Matchers.contains("NOT_APPLICABLE"))) + .andExpect(jsonPath("$.data.missingCount").value(missingItemIds.size() - index - 1)) + .andReturn(); + version = ((Number) JsonPath.read( + reviewed.getResponse().getContentAsString(), "$.data.version")).longValue(); + } + return version; + } + + private String insertAvailableEvidence(String username, String name) { + return insertEvidence(username, name, "AVAILABLE"); + } + + private String insertEvidence(String username, String name, String scanStatus) { + String publicId = "000000000000000000" + + String.format("%08d", java.util.concurrent.ThreadLocalRandom.current().nextInt(100_000_000)); + String uploaderPublicId = jdbcTemplate.queryForObject("SELECT public_id FROM iam_user WHERE username=?", + String.class, username); + byte[] content = name.getBytes(StandardCharsets.UTF_8); + String storageKey = "test/" + publicId + ".pdf"; + try { + Path target = storageProperties.rootPath().resolve(storageKey).normalize(); + if (!target.startsWith(storageProperties.rootPath())) { + throw new IllegalStateException("test evidence path escapes the storage root"); + } + Files.createDirectories(target.getParent()); + Files.write(target, content); + } catch (java.io.IOException exception) { + throw new IllegalStateException(exception); + } + jdbcTemplate.update("INSERT INTO file_object (public_id,original_name,storage_key,media_type,extension,size_bytes,sha256,scan_status,scan_detail,scanned_at,uploaded_by) VALUES (?,?,?,'application/pdf','pdf',?,?,?,'TEST',UTC_TIMESTAMP(3),?)", + publicId, name, storageKey, content.length, sha256(name), scanStatus, uploaderPublicId); + return publicId; + } + + private String sha256(String value) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(value.getBytes(StandardCharsets.UTF_8))); + } catch (java.security.NoSuchAlgorithmException exception) { + throw new IllegalStateException(exception); + } + } + + private void insertEvent(String publicId, String businessNo, String type, String date, String amount, + long creatorId) { + jdbcTemplate.update(""" + INSERT INTO acc_event (public_id,business_no,event_type,source_type,source_public_id, + company_id,project_id,counterparty_id,business_date,period,amount,currency,status,created_by) + SELECT ?,?,?,?,?,c.id,p.id,x.id,?,DATE_FORMAT(?,'%Y-%m'),?,'CNY','PENDING',? + FROM md_company c JOIN md_project p ON p.company_id=c.id JOIN md_counterparty x ON x.public_id=? + WHERE c.public_id=? + """, publicId, businessNo, type, type, publicId, date, date, new BigDecimal(amount), creatorId, + COUNTERPARTY_ID, COMPANY_ID); + } + + private long userId(String username) { + return jdbcTemplate.queryForObject("SELECT id FROM iam_user WHERE username=?", Long.class, username); + } + + private int count(String sql, Object... args) { + return jdbcTemplate.queryForObject(sql, Integer.class, args); + } + + private void restrictAmountScope(String username, String permission, String amountLimit) { + int updated = jdbcTemplate.update(""" + UPDATE iam_scope scope + JOIN iam_user user_account ON user_account.id = scope.user_id + JOIN iam_role role ON role.id = scope.role_id + JOIN iam_permission granted_permission ON granted_permission.id = scope.permission_id + SET scope.amount_limit = ? + WHERE user_account.username = ? + AND role.code = 'FINANCE_MANAGER' + AND granted_permission.code = ? + """, amountLimit, username, permission); + assertTrue(updated > 0, "expected accounting scope grant"); + } + + private void setScopeStatus(String username, String permission, String scopeStatus) { + int updated = jdbcTemplate.update(""" + UPDATE iam_scope scope + JOIN iam_user user_account ON user_account.id = scope.user_id + JOIN iam_role role ON role.id = scope.role_id + JOIN iam_permission granted_permission ON granted_permission.id = scope.permission_id + SET scope.status = ? + WHERE user_account.username = ? + AND role.code = 'FINANCE_MANAGER' + AND granted_permission.code = ? + """, scopeStatus, username, permission); + assertTrue(updated > 0, "expected accounting scope grant"); + } + + private ClientSession loginAndSelect(String username, String role) throws Exception { + MvcResult csrf = mockMvc.perform(get("/api/v1/auth/csrf")).andExpect(status().isOk()).andReturn(); + Cookie csrfCookie = csrf.getResponse().getCookie("XSRF-TOKEN"); + assertNotNull(csrfCookie); + String token = JsonPath.read(csrf.getResponse().getContentAsString(), "$.data.token"); + MvcResult login = mockMvc.perform(post("/api/v1/auth/login").cookie(csrfCookie) + .header("X-XSRF-TOKEN", token).contentType("application/json") + .content("{\"username\":\"%s\",\"password\":\"LocalOnly@123\"}".formatted(username))) + .andExpect(status().isOk()).andReturn(); + Cookie session = login.getResponse().getCookie("KAIDI_SESSION"); + assertNotNull(session); + MvcResult selected = mockMvc.perform(post("/api/v1/auth/select-role").cookie(session, csrfCookie) + .header("X-XSRF-TOKEN", token).contentType("application/json") + .content("{\"roleCode\":\"%s\"}".formatted(role))) + .andExpect(status().isOk()).andReturn(); + Cookie rotated = selected.getResponse().getCookie("KAIDI_SESSION"); + return new ClientSession(rotated == null ? session : rotated, csrfCookie, token); + } + + private record ClientSession(Cookie session, Cookie csrf, String token) { + Cookie[] cookies() { + return new Cookie[]{session, csrf}; + } + } + + private record ExpectedRule(String eventType, String debitAccount, String creditAccount) { + } +} diff --git a/backend/src/test/java/com/kaidi/finance/architecture/ControllerAuthorizationTest.java b/backend/src/test/java/com/kaidi/finance/architecture/ControllerAuthorizationTest.java new file mode 100644 index 0000000..5dabf19 --- /dev/null +++ b/backend/src/test/java/com/kaidi/finance/architecture/ControllerAuthorizationTest.java @@ -0,0 +1,51 @@ +package com.kaidi.finance.architecture; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.tngtech.archunit.core.domain.JavaClass; +import com.tngtech.archunit.core.domain.JavaMethod; +import com.tngtech.archunit.core.importer.ClassFileImporter; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; + +class ControllerAuthorizationTest { + + private static final Set SERVICE_GUARDED_CONTROLLERS = Set.of( + "com.kaidi.finance.iam.api.AuthController", + "com.kaidi.finance.governance.api.GovernanceController", + "com.kaidi.finance.workbench.api.WorkbenchController", + "com.kaidi.finance.operations.api.FinanceOperationsController" + ); + + @Test + void businessEndpointsRequireMethodAuthorization() { + List missing = new ArrayList<>(); + for (JavaClass type : new ClassFileImporter().importPackages("com.kaidi.finance")) { + if (!type.getSimpleName().endsWith("Controller") || SERVICE_GUARDED_CONTROLLERS.contains(type.getName())) { + continue; + } + for (JavaMethod method : type.getMethods()) { + if (isEndpoint(method) && !method.isAnnotatedWith(PreAuthorize.class)) { + missing.add(type.getName() + "#" + method.getName()); + } + } + } + assertTrue(missing.isEmpty(), "Business endpoints missing @PreAuthorize: " + missing); + } + + private boolean isEndpoint(JavaMethod method) { + return method.isAnnotatedWith(GetMapping.class) + || method.isAnnotatedWith(PostMapping.class) + || method.isAnnotatedWith(PatchMapping.class) + || method.isAnnotatedWith(PutMapping.class) + || method.isAnnotatedWith(DeleteMapping.class); + } +} diff --git a/backend/src/test/java/com/kaidi/finance/archive/ArchiveIntegrationTest.java b/backend/src/test/java/com/kaidi/finance/archive/ArchiveIntegrationTest.java new file mode 100644 index 0000000..0f69573 --- /dev/null +++ b/backend/src/test/java/com/kaidi/finance/archive/ArchiveIntegrationTest.java @@ -0,0 +1,1358 @@ +package com.kaidi.finance.archive; + +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.greaterThanOrEqualTo; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.jayway.jsonpath.JsonPath; +import com.kaidi.finance.iam.application.BootstrapUserInitializer; +import com.kaidi.finance.shared.file.FileScanner; +import jakarta.servlet.http.Cookie; +import java.awt.Color; +import java.awt.Font; +import java.awt.Graphics2D; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import javax.imageio.ImageIO; +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PDPageContentStream; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.font.PDType1Font; +import org.apache.pdfbox.pdmodel.font.Standard14Fonts; +import org.apache.pdfbox.rendering.ImageType; +import org.apache.pdfbox.rendering.PDFRenderer; +import org.apache.pdfbox.text.PDFTextStripper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.context.annotation.Primary; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +@Testcontainers(disabledWithoutDocker = true) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@Import(ArchiveIntegrationTest.ScannerTestConfiguration.class) +class ArchiveIntegrationTest { + + private static String idempotencyKey() { + return "test-archive-" + java.util.UUID.randomUUID(); + } + + private static final String COMPANY_ID = "00000000000000000000001701"; + private static final String PROJECT_ID = "00000000000000000000001702"; + private static final String OTHER_COMPANY_ID = "00000000000000000000001703"; + private static final String OTHER_PROJECT_ID = "00000000000000000000001704"; + private static final String COUNTERPARTY_ID = "00000000000000000000001705"; + private static final String CONTRACT_ID = "00000000000000000000001706"; + private static final String ACCOUNTING_EVENT_ID = "00000000000000000000001711"; + private static final String ACCOUNTING_VOUCHER_ID = "00000000000000000000001712"; + + @Container + static final MySQLContainer MYSQL = new MySQLContainer<>("mysql:8.4") + .withDatabaseName("kaidi_finance_archive_test") + .withUsername("kaidi") + .withPassword("kaidi_test_password"); + + @DynamicPropertySource + static void database(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", MYSQL::getJdbcUrl); + registry.add("spring.datasource.username", MYSQL::getUsername); + registry.add("spring.datasource.password", MYSQL::getPassword); + registry.add("finance.bootstrap.enabled", () -> true); + registry.add("finance.bootstrap.password", () -> "LocalOnly@123"); + } + + @Autowired MockMvc mockMvc; + @Autowired JdbcTemplate jdbcTemplate; + @Autowired BootstrapUserInitializer bootstrapUserInitializer; + @Autowired TestFileScanner fileScanner; + + @BeforeEach + void seedAndReset() { + fileScanner.reset(); + long financeId = userId("finance"); + long projectUserId = userId("project"); + if (count("SELECT COUNT(*) FROM md_company WHERE public_id=?", COMPANY_ID) == 0) { + jdbcTemplate.update("INSERT INTO md_company (public_id,business_no,name,status,created_by,updated_by) VALUES (?,'GS-P17','档案测试公司','ACTIVE',?,?)", COMPANY_ID, financeId, financeId); + jdbcTemplate.update("INSERT INTO md_company (public_id,business_no,name,status,created_by,updated_by) VALUES (?,'GS-P17-B','档案范围外公司','ACTIVE',?,?)", OTHER_COMPANY_ID, financeId, financeId); + jdbcTemplate.update("INSERT INTO md_project (public_id,company_id,business_no,name,owner_user_id,currency,status,created_by,updated_by) SELECT ?,id,'XM-P17','档案测试项目',?,'CNY','ACTIVE',?,? FROM md_company WHERE public_id=?", PROJECT_ID, projectUserId, financeId, financeId, COMPANY_ID); + jdbcTemplate.update("INSERT INTO md_project (public_id,company_id,business_no,name,owner_user_id,currency,status,created_by,updated_by) SELECT ?,id,'XM-P17-B','档案范围外项目',?,'CNY','ACTIVE',?,? FROM md_company WHERE public_id=?", OTHER_PROJECT_ID, projectUserId, financeId, financeId, OTHER_COMPANY_ID); + jdbcTemplate.update("INSERT INTO md_counterparty (public_id,business_no,counterparty_type,name,status,created_by,updated_by) VALUES (?,'GY-P17','SUPPLIER','档案测试供应商','ACTIVE',?,?)", COUNTERPARTY_ID, financeId, financeId); + jdbcTemplate.update("INSERT INTO md_contract (public_id,company_id,project_id,counterparty_id,business_no,name,original_amount,approved_change_amount,currency,status,created_by,updated_by) SELECT ?,company.id,project.id,counterparty.id,'HT-P17','档案测试合同',10000,0,'CNY','ACTIVE',?,? FROM md_company company JOIN md_project project ON project.company_id=company.id JOIN md_counterparty counterparty ON counterparty.public_id=? WHERE company.public_id=? AND project.public_id=?", CONTRACT_ID, financeId, financeId, COUNTERPARTY_ID, COMPANY_ID, PROJECT_ID); + } + bootstrapUserInitializer.ensureGlobalScopes(financeId, "FINANCE_MANAGER"); + cleanupArchiveData(); + // Archive workflow fixtures represent projects whose finance owner has + // already confirmed the close-out package. Dedicated gate tests reset + // this field to PENDING before exercising the negative path. + jdbcTemplate.update("UPDATE md_project SET status='ACTIVE',stage='EXECUTION',archive_status='NOT_READY',finance_status='COMPLETED' WHERE public_id IN (?,?)", PROJECT_ID, OTHER_PROJECT_ID); + } + + @Test + void packageWorkflowBlocksMissingItemsEnforcesSodAndPreservesActions() throws Exception { + ClientSession finance = loginAndSelect("finance", "FINANCE_MANAGER"); + ClientSession archive = loginAndSelect("archive", "ARCHIVE_MANAGER"); + + String packageId = createPackage(finance, PROJECT_ID); + String projectBaseItemId = jdbcTemplate.queryForObject( + "SELECT item.public_id FROM arc_package_item item JOIN arc_package package_record ON package_record.id=item.package_id WHERE package_record.public_id=? AND item.item_type='PROJECT_BASE'", + String.class, packageId); + uploadFile(finance, PROJECT_ID, packageId, projectBaseItemId, "PROJECT_BASE", "PAPER", + "project-base.pdf", "project-base-content"); + MvcResult checked = command(finance, "/api/v1/archive/packages/{id}/check", packageId, + "{\"version\":0,\"opinion\":\"执行完整性检查\"}", status().isOk()) + .andExpect(jsonPath("$.data.status").value("CHECKED")) + .andExpect(jsonPath("$.data.missingCount").value(greaterThanOrEqualTo(1))) + .andReturn(); + int missingCount = JsonPath.read(checked.getResponse().getContentAsString(), "$.data.missingCount"); + assertNotEquals(0, missingCount); + mockMvc.perform(get("/api/v1/archive/packages").param("view", "prepare") + .param("completeness", "INCOMPLETE").param("sort", "completeness,asc") + .cookie(finance.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.length()").value(1)) + .andExpect(jsonPath("$.data[0].publicId").value(packageId)); + mockMvc.perform(get("/api/v1/archives/packages").param("view", "prepare") + .param("completeness", "INCOMPLETE").param("sort", "completeness,asc") + .cookie(finance.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.length()").value(1)) + .andExpect(jsonPath("$.data[0].publicId").value(packageId)); + + command(finance, "/api/v1/archive/packages/{id}/submit", packageId, + "{\"version\":1,\"opinion\":\"提交资料审核\"}", status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("ATTACHMENT_REQUIRED")); + + List missingItemIds = JsonPath.read(checked.getResponse().getContentAsString(), + "$.data.items[?(@.status == 'MISSING')].publicId"); + String missingItemId = missingItemIds.get(0); + mockMvc.perform(post("/api/v1/archive/packages/{packageId}/items/{itemId}/not-applicable", + packageId, missingItemId).cookie(finance.cookies()).header("Idempotency-Key", idempotencyKey()).header("X-XSRF-TOKEN", finance.token()) + .contentType("application/json").content("{\"version\":1,\"reason\":\"本项目经确认不涉及该类资料\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.version").value(2)) + .andExpect(jsonPath("$.data.missingCount").value(missingCount)) + .andExpect(jsonPath("$.data.items[?(@.publicId == '%s')].status".formatted(missingItemId)) + .value(org.hamcrest.Matchers.contains("NA_REVIEWING"))) + .andReturn(); + command(finance, "/api/v1/archive/packages/{id}/items/%s/not-applicable/review".formatted(missingItemId), + packageId, "{\"version\":2,\"approved\":true,\"opinion\":\"本人不能复核\"}", + status().isUnprocessableEntity()).andExpect(jsonPath("$.code").value("SOD_VIOLATION")); + command(archive, "/api/v1/archive/packages/{id}/items/%s/not-applicable/review".formatted(missingItemId), + packageId, "{\"version\":2,\"approved\":true,\"opinion\":\"资料类型确认不适用\"}", status().isForbidden()); + ClientSession demoFinance = loginAndSelect("demo", "FINANCE_MANAGER"); + command(demoFinance, "/api/v1/archive/packages/{id}/items/%s/not-applicable/review".formatted(missingItemId), + packageId, "{\"version\":2,\"approved\":true,\"opinion\":\"资料类型确认不适用\"}", status().isOk()) + .andExpect(jsonPath("$.data.version").value(3)) + .andExpect(jsonPath("$.data.missingCount").value(missingCount - 1)); + + markRemainingItemsNotApplicable(packageId); + command(finance, "/api/v1/archive/packages/{id}/check", packageId, + "{\"version\":3,\"opinion\":\"缺件处理完成后复查\"}", status().isOk()) + .andExpect(jsonPath("$.data.completeness").value("100.00")) + .andExpect(jsonPath("$.data.missingCount").value(0)) + .andExpect(jsonPath("$.data.version").value(4)); + command(finance, "/api/v1/archive/packages/{id}/submit", packageId, + "{\"version\":4,\"opinion\":\"提交资料管理员审核\"}", status().isOk()) + .andExpect(jsonPath("$.data.status").value("REVIEWING")) + .andExpect(jsonPath("$.data.version").value(5)); + + command(finance, "/api/v1/archive/packages/{id}/archive", packageId, + "{\"version\":5,\"opinion\":\"本人不能确认归档\"}", status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("SOD_VIOLATION")); + command(archive, "/api/v1/archive/packages/{id}/return", packageId, + "{\"version\":5,\"opinion\":\"请复核纸质原件位置\"}", status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("VALIDATION_FAILED")); + command(archive, "/api/v1/archive/packages/{id}/return", packageId, + "{\"version\":5,\"opinion\":\"请复核纸质原件位置\",\"returnItemIds\":[\"%s\"]}".formatted(missingItemId), status().isOk()) + .andExpect(jsonPath("$.data.status").value("RETURNED")) + .andExpect(jsonPath("$.data.missingCount").value(1)) + .andExpect(jsonPath("$.data.items[?(@.publicId == '%s')].returnReason".formatted(missingItemId)) + .value(org.hamcrest.Matchers.contains("请复核纸质原件位置"))) + .andExpect(jsonPath("$.data.actions[*].actionCode").value(org.hamcrest.Matchers.hasItems( + "GENERATE", "CHECK", "SUBMIT", "RETURN"))); + + String returnedManifest = jdbcTemplate.queryForObject( + "SELECT manifest_sha256 FROM arc_package WHERE public_id=?", String.class, packageId); + String returnedItemState = jdbcTemplate.queryForObject( + "SELECT GROUP_CONCAT(CONCAT(item.public_id, ':', item.status, ':', COALESCE(item.file_sha256, '-')) ORDER BY item.public_id SEPARATOR '|') FROM arc_package_item item JOIN arc_package package_record ON package_record.id=item.package_id WHERE package_record.public_id=?", + String.class, packageId); + String returnedSnapshotState = jdbcTemplate.queryForObject( + "SELECT GROUP_CONCAT(CONCAT(snapshot.object_type, ':', snapshot.object_public_id, ':', snapshot.snapshot_sha256) ORDER BY snapshot.object_type, snapshot.object_public_id, snapshot.id SEPARATOR '|') FROM arc_package_object_snapshot snapshot JOIN arc_package package_record ON package_record.id=snapshot.package_id WHERE package_record.public_id=?", + String.class, packageId); + mockMvc.perform(post("/api/v1/archive/packages").cookie(finance.cookies()) + .header("Idempotency-Key", idempotencyKey()).header("X-XSRF-TOKEN", finance.token()) + .contentType("application/json") + .content("{\"projectId\":\"%s\",\"retentionYears\":10}".formatted(PROJECT_ID))) + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("INVALID_STATE_TRANSITION")); + MvcResult revised = command(finance, "/api/v1/archive/packages/{id}/revise", packageId, + "{\"version\":6,\"reason\":\"根据退回意见生成补件版本\"}", status().isOk()) + .andExpect(jsonPath("$.data.packageVersion").value(2)) + .andExpect(jsonPath("$.data.supersedesPackagePublicId").value(packageId)) + .andExpect(jsonPath("$.data.status").value("PREPARING")) + .andReturn(); + String revisedPackageId = JsonPath.read(revised.getResponse().getContentAsString(), "$.data.publicId"); + List revisedItemIds = JsonPath.read(revised.getResponse().getContentAsString(), + "$.data.items[?(@.itemType == 'ACCEPTANCE')].publicId"); + String revisedItemId = revisedItemIds.get(0); + assertNotEquals(packageId, revisedPackageId); + mockMvc.perform(get("/api/v1/archive/files").param("fileName", "project-base.pdf") + .cookie(finance.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.length()").value(1)) + .andExpect(jsonPath("$.meta.totalElements").value(1)) + .andExpect(jsonPath("$.data[0].packagePublicId").value(revisedPackageId)); + String projectBaseFileId = jdbcTemplate.queryForObject( + "SELECT file.public_id FROM file_object file WHERE file.original_name='project-base.pdf' ORDER BY file.id DESC LIMIT 1", + String.class); + mockMvc.perform(get("/api/v1/archive/files/{id}", projectBaseFileId).cookie(finance.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.versions.length()").value(1)); + assertEquals(returnedManifest, jdbcTemplate.queryForObject( + "SELECT manifest_sha256 FROM arc_package WHERE public_id=?", String.class, packageId)); + assertEquals(returnedItemState, jdbcTemplate.queryForObject( + "SELECT GROUP_CONCAT(CONCAT(item.public_id, ':', item.status, ':', COALESCE(item.file_sha256, '-')) ORDER BY item.public_id SEPARATOR '|') FROM arc_package_item item JOIN arc_package package_record ON package_record.id=item.package_id WHERE package_record.public_id=?", + String.class, packageId)); + assertEquals(returnedSnapshotState, jdbcTemplate.queryForObject( + "SELECT GROUP_CONCAT(CONCAT(snapshot.object_type, ':', snapshot.object_public_id, ':', snapshot.snapshot_sha256) ORDER BY snapshot.object_type, snapshot.object_public_id, snapshot.id SEPARATOR '|') FROM arc_package_object_snapshot snapshot JOIN arc_package package_record ON package_record.id=snapshot.package_id WHERE package_record.public_id=?", + String.class, packageId)); + command(finance, "/api/v1/archive/packages/{id}/revise", packageId, + "{\"version\":6,\"reason\":\"重复生成补件版本\"}", status().isConflict()) + .andExpect(jsonPath("$.code").value("DUPLICATE_RESOURCE")); + + mockMvc.perform(post("/api/v1/archive/packages/{packageId}/items/{itemId}/not-applicable", + revisedPackageId, revisedItemId).cookie(finance.cookies()).header("Idempotency-Key", idempotencyKey()).header("X-XSRF-TOKEN", finance.token()) + .contentType("application/json").content("{\"version\":0,\"reason\":\"原件位置已复核,本项目不涉及\"}")) + .andExpect(status().isOk()).andExpect(jsonPath("$.data.version").value(1)); + command(demoFinance, "/api/v1/archive/packages/{id}/items/%s/not-applicable/review".formatted(revisedItemId), + revisedPackageId, "{\"version\":1,\"approved\":true,\"opinion\":\"补件版本不涉及依据有效\"}", status().isOk()); + command(finance, "/api/v1/archive/packages/{id}/check", revisedPackageId, + "{\"version\":2,\"opinion\":\"补件处理完成后复查\"}", status().isOk()) + .andExpect(jsonPath("$.data.status").value("CHECKED")) + .andExpect(jsonPath("$.data.version").value(3)); + command(finance, "/api/v1/archive/packages/{id}/submit", revisedPackageId, + "{\"version\":3,\"opinion\":\"重新提交审核\"}", status().isOk()); + command(archive, "/api/v1/archive/packages/{id}/archive", revisedPackageId, + "{\"version\":4,\"opinion\":\"资料完整,同意归档\"}", status().isOk()) + .andExpect(jsonPath("$.data.status").value("ARCHIVED")) + .andExpect(jsonPath("$.data.retentionExpiresOn").isNotEmpty()) + .andExpect(jsonPath("$.data.retentionStatus").value("ACTIVE")) + .andExpect(jsonPath("$.data.reviewedByName").value("资料管理员")) + .andExpect(jsonPath("$.data.actions[*].actionCode").value(org.hamcrest.Matchers.hasItems( + "REVISE", "CHECK", "SUBMIT", "ARCHIVE"))); + assertEquals("ARCHIVED", jdbcTemplate.queryForObject( + "SELECT archive_status FROM md_project WHERE public_id=?", String.class, PROJECT_ID)); + assertEquals("ARCHIVED", jdbcTemplate.queryForObject( + "SELECT status FROM md_project WHERE public_id=?", String.class, PROJECT_ID)); + assertEquals("CLOSED", jdbcTemplate.queryForObject( + "SELECT stage FROM md_project WHERE public_id=?", String.class, PROJECT_ID)); + + command(archive, "/api/v1/archive/packages/{id}/freeze", revisedPackageId, + "{\"version\":5,\"reason\":\"项目进入审计检查\"}", status().isOk()) + .andExpect(jsonPath("$.data.status").value("FROZEN")) + .andExpect(jsonPath("$.data.frozen").value(true)); + assertEquals("FROZEN", jdbcTemplate.queryForObject( + "SELECT link.archive_status FROM file_link link JOIN file_object file ON file.id=link.file_id WHERE file.original_name='project-base.pdf' ORDER BY link.id DESC LIMIT 1", + String.class)); + command(archive, "/api/v1/archive/packages/{id}/unfreeze", revisedPackageId, + "{\"version\":5,\"opinion\":\"使用旧版本号\"}", status().isConflict()) + .andExpect(jsonPath("$.code").value("VERSION_CONFLICT")); + command(archive, "/api/v1/archive/packages/{id}/unfreeze", revisedPackageId, + "{\"version\":6,\"opinion\":\"审计检查结束\"}", status().isOk()) + .andExpect(jsonPath("$.data.status").value("ARCHIVED")) + .andExpect(jsonPath("$.data.frozen").value(false)); + assertEquals("ARCHIVED", jdbcTemplate.queryForObject( + "SELECT link.archive_status FROM file_link link JOIN file_object file ON file.id=link.file_id WHERE file.original_name='project-base.pdf' ORDER BY link.id DESC LIMIT 1", + String.class)); + } + + @Test + void packageItemRejectsMismatchedFileTypeBeforeFilePersistence() throws Exception { + ClientSession finance = loginAndSelect("finance", "FINANCE_MANAGER"); + String packageId = createPackage(finance, PROJECT_ID); + String acceptanceItemId = jdbcTemplate.queryForObject( + "SELECT item.public_id FROM arc_package_item item " + + "JOIN arc_package package_record ON package_record.id=item.package_id " + + "WHERE package_record.public_id=? AND item.item_type='ACCEPTANCE'", + String.class, packageId); + int filesBefore = count("SELECT COUNT(*) FROM file_object"); + int linksBefore = count("SELECT COUNT(*) FROM file_link"); + + String metadataJson = """ + {"projectId":"%s","packageId":"%s","packageItemId":"%s", + "fileType":"CONTRACT","originalType":"PAPER"} + """.formatted(PROJECT_ID, packageId, acceptanceItemId); + MockMultipartFile metadata = new MockMultipartFile("metadata", "metadata.json", "application/json", + metadataJson.getBytes(StandardCharsets.UTF_8)); + MockMultipartFile file = new MockMultipartFile("file", "wrong-contract.pdf", "application/pdf", + "wrong-type-content".getBytes(StandardCharsets.UTF_8)); + + mockMvc.perform(multipart("/api/v1/archive/files").file(metadata).file(file) + .cookie(finance.cookies()).header("Idempotency-Key", idempotencyKey()) + .header("X-XSRF-TOKEN", finance.token())) + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("VALIDATION_FAILED")) + .andExpect(jsonPath("$.detail").value("上传文件类型必须与档案项类型一致")); + + assertEquals(filesBefore, count("SELECT COUNT(*) FROM file_object")); + assertEquals(linksBefore, count("SELECT COUNT(*) FROM file_link")); + assertEquals("MISSING", jdbcTemplate.queryForObject( + "SELECT status FROM arc_package_item WHERE public_id=?", String.class, acceptanceItemId)); + + uploadFile(finance, PROJECT_ID, packageId, acceptanceItemId, "ACCEPTANCE", "PAPER", + "acceptance-correct.pdf", "correct-type-content"); + assertEquals("PRESENT", jdbcTemplate.queryForObject( + "SELECT status FROM arc_package_item WHERE public_id=?", String.class, acceptanceItemId)); + } + + @Test + void notApplicableRejectionKeepsItemMissingAndAllowsResubmission() throws Exception { + ClientSession finance = loginAndSelect("finance", "FINANCE_MANAGER"); + ClientSession demoFinance = loginAndSelect("demo", "FINANCE_MANAGER"); + String packageId = createPackage(finance, PROJECT_ID); + String itemId = jdbcTemplate.queryForObject( + "SELECT item.public_id FROM arc_package_item item JOIN arc_package package_record ON package_record.id=item.package_id WHERE package_record.public_id=? AND item.status='MISSING' ORDER BY item.id LIMIT 1", + String.class, packageId); + + command(finance, "/api/v1/archive/packages/{id}/items/%s/not-applicable".formatted(itemId), + packageId, "{\"version\":0,\"reason\":\"申请确认本项目不涉及\"}", status().isOk()); + command(demoFinance, "/api/v1/archive/packages/{id}/items/%s/not-applicable/review".formatted(itemId), + packageId, "{\"version\":1,\"approved\":false,\"opinion\":\"依据不足,请补充资料\"}", status().isOk()) + .andExpect(jsonPath("$.data.version").value(2)) + .andExpect(jsonPath("$.data.items[?(@.publicId == '%s')].status".formatted(itemId)) + .value(org.hamcrest.Matchers.contains("NA_REJECTED"))) + .andExpect(jsonPath("$.data.items[?(@.publicId == '%s')].notApplicableReviewOpinion".formatted(itemId)) + .value(org.hamcrest.Matchers.contains("依据不足,请补充资料"))) + .andExpect(jsonPath("$.data.items[?(@.publicId == '%s')].allowedActions[*]".formatted(itemId)) + .value(org.hamcrest.Matchers.hasItem("REQUEST_NOT_APPLICABLE"))); + command(finance, "/api/v1/archive/packages/{id}/items/%s/not-applicable".formatted(itemId), + packageId, "{\"version\":2,\"reason\":\"补充项目范围说明后重新申请\"}", status().isOk()) + .andExpect(jsonPath("$.data.items[?(@.publicId == '%s')].status".formatted(itemId)) + .value(org.hamcrest.Matchers.contains("NA_REVIEWING"))); + } + + @Test + void submitAndArchiveRecalculateManifestInsteadOfTrustingStaleCheck() throws Exception { + ClientSession finance = loginAndSelect("finance", "FINANCE_MANAGER"); + ClientSession archive = loginAndSelect("archive", "ARCHIVE_MANAGER"); + String packageId = createPackage(finance, PROJECT_ID); + markRemainingItemsNotApplicable(packageId); + + MvcResult checked = command(finance, "/api/v1/archive/packages/{id}/check", packageId, + "{\"version\":0,\"opinion\":\"首次检查\"}", status().isOk()).andReturn(); + String checkedHash = JsonPath.read(checked.getResponse().getContentAsString(), "$.data.manifestSha256"); + String itemId = jdbcTemplate.queryForObject( + "SELECT item.public_id FROM arc_package_item item JOIN arc_package package_record ON package_record.id=item.package_id WHERE package_record.public_id=? AND item.item_type='PROJECT_BASE'", + String.class, packageId); + uploadFile(finance, PROJECT_ID, packageId, itemId, "PROJECT_BASE", "PAPER", "base-v1.pdf", "base-v1"); + + MvcResult submitted = command(finance, "/api/v1/archive/packages/{id}/submit", packageId, + "{\"version\":1,\"opinion\":\"上传文件后提交\"}", status().isOk()) + .andExpect(jsonPath("$.data.status").value("REVIEWING")) + .andReturn(); + String submittedHash = JsonPath.read(submitted.getResponse().getContentAsString(), "$.data.manifestSha256"); + assertNotEquals(checkedHash, submittedHash, "提交前必须重算包含文件哈希的档案清单"); + + jdbcTemplate.update("UPDATE arc_package_item item JOIN arc_package package_record ON package_record.id=item.package_id SET item.status='MISSING',item.missing_reason='归档确认前文件失效' WHERE package_record.public_id=? AND item.public_id=?", packageId, itemId); + command(archive, "/api/v1/archive/packages/{id}/archive", packageId, + "{\"version\":2,\"opinion\":\"尝试使用过期检查结果归档\"}", status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("ATTACHMENT_REQUIRED")); + } + + @Test + void uploadAfterCheckRefreshesMissingCountAndAllowsSubmission() throws Exception { + ClientSession finance = loginAndSelect("finance", "FINANCE_MANAGER"); + String packageId = createPackage(finance, PROJECT_ID); + String missingItemId = jdbcTemplate.queryForObject( + "SELECT item.public_id FROM arc_package_item item JOIN arc_package package_record " + + "ON package_record.id=item.package_id WHERE package_record.public_id=? " + + "AND item.item_type='ACCEPTANCE' AND item.status='MISSING'", + String.class, packageId); + jdbcTemplate.update("UPDATE arc_package_item item JOIN arc_package package_record " + + "ON package_record.id=item.package_id SET item.status='NOT_APPLICABLE', " + + "item.not_applicable_reason='测试前置:经确认不涉及', item.missing_reason=NULL " + + "WHERE package_record.public_id=? AND item.status='MISSING' AND item.public_id<>?", + packageId, missingItemId); + + MvcResult checked = command(finance, "/api/v1/archive/packages/{id}/check", packageId, + "{\"version\":0,\"opinion\":\"检查后补交唯一缺件\"}", status().isOk()) + .andExpect(jsonPath("$.data.status").value("CHECKED")) + .andExpect(jsonPath("$.data.missingCount").value(1)) + .andExpect(jsonPath("$.data.version").value(1)) + .andReturn(); + String checkedHash = JsonPath.read(checked.getResponse().getContentAsString(), "$.data.manifestSha256"); + + uploadFile(finance, PROJECT_ID, packageId, missingItemId, "ACCEPTANCE", "PAPER", + "late-acceptance.pdf", "late-acceptance-content"); + mockMvc.perform(get("/api/v1/archive/packages/{id}", packageId).cookie(finance.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.missingCount").value(0)) + .andExpect(jsonPath("$.data.completeness").value("100.00")) + .andExpect(jsonPath("$.data.version").value(1)) + .andExpect(jsonPath("$.data.manifestSha256").value(org.hamcrest.Matchers.not(checkedHash))); + command(finance, "/api/v1/archive/packages/{id}/submit", packageId, + "{\"version\":1,\"opinion\":\"缺件补齐后提交审核\"}", status().isOk()) + .andExpect(jsonPath("$.data.status").value("REVIEWING")); + } + + @Test + void lateCompletedAccountingVoucherRefreshesArchiveChecklistAndSnapshot() throws Exception { + ClientSession finance = loginAndSelect("finance", "FINANCE_MANAGER"); + String packageId = createPackage(finance, PROJECT_ID); + + // Leave only the object-backed accounting requirement pending. Unlike + // file items, it must be satisfied by a later accounting completion. + jdbcTemplate.update(""" + UPDATE arc_package_item item + JOIN arc_package package_record ON package_record.id = item.package_id + SET item.status = 'NOT_APPLICABLE', item.missing_reason = NULL, + item.not_applicable_reason = '测试前置:本项由后续记账结果补齐' + WHERE package_record.public_id = ? + AND item.status = 'MISSING' AND item.item_type <> 'ACCOUNTING' + """, packageId); + assertEquals("MISSING", jdbcTemplate.queryForObject( + "SELECT item.status FROM arc_package_item item JOIN arc_package package_record " + + "ON package_record.id=item.package_id WHERE package_record.public_id=? AND item.item_type='ACCOUNTING'", + String.class, packageId)); + + insertCompletedAccountingFixture(); + + command(finance, "/api/v1/archive/packages/{id}/check", packageId, + "{\"version\":0,\"opinion\":\"记账结果到达后重新检查\"}", status().isOk()) + .andExpect(jsonPath("$.data.status").value("CHECKED")) + .andExpect(jsonPath("$.data.missingCount").value(0)) + .andExpect(jsonPath("$.data.items[?(@.itemType == 'ACCOUNTING')].status") + .value(org.hamcrest.Matchers.contains("PRESENT"))); + assertEquals("PRESENT", jdbcTemplate.queryForObject( + "SELECT item.status FROM arc_package_item item JOIN arc_package package_record " + + "ON package_record.id=item.package_id WHERE package_record.public_id=? AND item.item_type='ACCOUNTING'", + String.class, packageId)); + assertEquals(1, count("SELECT COUNT(*) FROM arc_package_object_snapshot snapshot " + + "JOIN arc_package package_record ON package_record.id=snapshot.package_id " + + "WHERE package_record.public_id=? AND snapshot.object_type='ACCOUNTING_VOUCHER' " + + "AND snapshot.object_public_id=?", packageId, ACCOUNTING_VOUCHER_ID)); + + command(finance, "/api/v1/archive/packages/{id}/submit", packageId, + "{\"version\":1,\"opinion\":\"记账准备结果已归集,提交审核\"}", status().isOk()) + .andExpect(jsonPath("$.data.status").value("REVIEWING")); + } + + @Test + void eventProjectFallbackInitializesAccountingItemAndCompletesArchiveWorkflow() throws Exception { + ClientSession finance = loginAndSelect("finance", "FINANCE_MANAGER"); + ClientSession archive = loginAndSelect("archive", "ARCHIVE_MANAGER"); + insertCompletedAccountingFixture(); + String packageId = createPackage(finance, PROJECT_ID); + + // Legacy vouchers may have no project_id while their source event still + // carries the project. Generation must use the same fallback as snapshots. + assertEquals("PRESENT", jdbcTemplate.queryForObject( + "SELECT item.status FROM arc_package_item item JOIN arc_package package_record " + + "ON package_record.id=item.package_id WHERE package_record.public_id=? AND item.item_type='ACCOUNTING'", + String.class, packageId)); + assertEquals(1, count("SELECT COUNT(*) FROM arc_package_object_snapshot snapshot " + + "JOIN arc_package package_record ON package_record.id=snapshot.package_id " + + "WHERE package_record.public_id=? AND snapshot.object_type='ACCOUNTING_VOUCHER' " + + "AND snapshot.object_public_id=?", packageId, ACCOUNTING_VOUCHER_ID)); + jdbcTemplate.update("UPDATE arc_package_item item JOIN arc_package package_record " + + "ON package_record.id=item.package_id SET item.status='NOT_APPLICABLE', " + + "item.not_applicable_reason='测试前置:经确认不涉及', item.missing_reason=NULL " + + "WHERE package_record.public_id=? AND item.status='MISSING'", packageId); + + command(finance, "/api/v1/archive/packages/{id}/check", packageId, + "{\"version\":0,\"opinion\":\"检查已完成记账准备结果\"}", status().isOk()) + .andExpect(jsonPath("$.data.missingCount").value(0)) + .andExpect(jsonPath("$.data.version").value(1)); + command(finance, "/api/v1/archive/packages/{id}/submit", packageId, + "{\"version\":1,\"opinion\":\"提交归档审核\"}", status().isOk()) + .andExpect(jsonPath("$.data.status").value("REVIEWING")) + .andExpect(jsonPath("$.data.version").value(2)); + command(archive, "/api/v1/archive/packages/{id}/archive", packageId, + "{\"version\":2,\"opinion\":\"资料完整,同意归档\"}", status().isOk()) + .andExpect(jsonPath("$.data.status").value("ARCHIVED")); + assertEquals("ARCHIVED", jdbcTemplate.queryForObject( + "SELECT archive_status FROM md_project WHERE public_id=?", String.class, PROJECT_ID)); + } + + @Test + void fileSearchVersionsControlledContentAndAuditAreComplete() throws Exception { + ClientSession archive = loginAndSelect("archive", "ARCHIVE_MANAGER"); + + mockMvc.perform(get("/api/v1/archive/files").cookie(archive.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.length()").value(0)) + .andExpect(jsonPath("$.meta.totalElements").value(0)); + + String firstId = uploadFile(archive, PROJECT_ID, null, null, "CONTRACT", "PAPER", + "contract-v1.pdf", "contract-version-one"); + String secondId = uploadFileVersion(archive, PROJECT_ID, null, null, "CONTRACT", "PAPER", + "contract-v2.pdf", "contract-version-two", firstId); + + mockMvc.perform(get("/api/v1/archive/files").param("projectId", PROJECT_ID).cookie(archive.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.length()").value(1)) + .andExpect(jsonPath("$.data[0].publicId").value(secondId)) + .andExpect(jsonPath("$.data[0].versionNo").value(2)); + mockMvc.perform(get("/api/v1/archive/files").param("scanStatus", "AVAILABLE") + .cookie(archive.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[*].publicId", org.hamcrest.Matchers.hasItem(secondId))); + jdbcTemplate.update("UPDATE file_object SET scan_status='QUARANTINED' WHERE public_id=?", secondId); + mockMvc.perform(get("/api/v1/archive/files").param("scanStatus", "QUARANTINED") + .cookie(archive.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.length()").value(1)) + .andExpect(jsonPath("$.data[0].publicId").value(secondId)) + .andExpect(jsonPath("$.data[0].scanStatus").value("QUARANTINED")); + jdbcTemplate.update("UPDATE file_object SET scan_status='AVAILABLE' WHERE public_id=?", secondId); + mockMvc.perform(get("/api/v1/archive/files").param("scanStatus", "UNKNOWN") + .cookie(archive.cookies())) + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("VALIDATION_FAILED")); + mockMvc.perform(get("/api/v1/archive/files").param("projectId", PROJECT_ID) + .param("sort", "displayName,asc").cookie(archive.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.length()").value(greaterThanOrEqualTo(1))); + mockMvc.perform(get("/api/v1/archive/files").param("projectId", PROJECT_ID) + .param("sort", "storageKey,desc").cookie(archive.cookies())) + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("VALIDATION_FAILED")); + mockMvc.perform(get("/api/v1/archive/files/{id}", secondId).cookie(archive.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.file.archiveStatus").value("ACTIVE")) + .andExpect(jsonPath("$.data.versions.length()").value(2)) + .andExpect(jsonPath("$.data.versions[0].publicId").value(secondId)) + .andExpect(jsonPath("$.data.versions[1].publicId").value(firstId)) + .andExpect(jsonPath("$.data.versions[1].archiveStatus").value("SUPERSEDED")); + + String dimensionedId = uploadDimensionedFile(archive); + mockMvc.perform(get("/api/v1/archive/files").param("contractId", CONTRACT_ID) + .param("counterpartyId", COUNTERPARTY_ID).param("formType", "OA-12").cookie(archive.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.length()").value(1)) + .andExpect(jsonPath("$.data[0].publicId").value(dimensionedId)) + .andExpect(jsonPath("$.data[0].contractPublicId").value(CONTRACT_ID)) + .andExpect(jsonPath("$.data[0].counterpartyPublicId").value(COUNTERPARTY_ID)) + .andExpect(jsonPath("$.data[0].formType").value("OA-12")); + + MvcResult preview = mockMvc.perform(get("/api/v1/archive/files/{id}/content", secondId).param("preview", "true") + .cookie(archive.cookies())) + .andExpect(status().isOk()) + .andExpect(header().string("Content-Disposition", containsString("inline"))) + .andExpect(header().string("X-Archive-Watermark", containsString("%E8%B5%84%E6%96%99%E7%AE%A1%E7%90%86%E5%91%98"))) + .andExpect(header().string("X-Archive-Watermark-Encoding", "uri-component")) + .andExpect(header().exists("X-File-SHA256")) + .andExpect(header().exists("X-Derived-File-SHA256")) + .andExpect(content().contentType("application/pdf")) + .andReturn(); + assertNotEquals("contract-version-two", + new String(preview.getResponse().getContentAsByteArray(), StandardCharsets.UTF_8)); + mockMvc.perform(get("/api/v1/archive/files/{id}/content", secondId).cookie(archive.cookies())) + .andExpect(status().isOk()) + .andExpect(header().string("Content-Disposition", containsString("attachment"))) + .andExpect(header().string("X-Archive-Watermark", containsString("ARCHIVE_MANAGER"))); + assertEquals(1, count("SELECT COUNT(*) FROM audit_log WHERE action_code='ARCHIVE_FILE_PREVIEW' AND object_public_id=?", secondId)); + assertEquals(1, count("SELECT COUNT(*) FROM audit_log WHERE action_code='ARCHIVE_FILE_DOWNLOAD' AND object_public_id=?", secondId)); + } + + @Test + void archiveUploadRequiresKeyReplaysIdenticalFileAndRejectsDifferentContent() throws Exception { + ClientSession archive = loginAndSelect("archive", "ARCHIVE_MANAGER"); + String metadataJson = "{\"projectId\":\"%s\",\"fileType\":\"CONTRACT\",\"originalType\":\"PAPER\"}" + .formatted(PROJECT_ID); + String key = idempotencyKey(); + + mockMvc.perform(archiveUploadRequest(archive, metadataJson, "idem-upload.pdf", "same-content", null)) + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("VALIDATION_FAILED")); + + MvcResult first = mockMvc.perform(archiveUploadRequest( + archive, metadataJson, "idem-upload.pdf", "same-content", key)) + .andExpect(status().isOk()).andReturn(); + String fileId = JsonPath.read(first.getResponse().getContentAsString(), "$.data.publicId"); + mockMvc.perform(archiveUploadRequest(archive, metadataJson, "idem-upload.pdf", "same-content", key)) + .andExpect(status().isOk()).andExpect(jsonPath("$.data.publicId").value(fileId)); + assertEquals(1, count("SELECT COUNT(*) FROM file_link WHERE project_public_id=?", PROJECT_ID)); + + mockMvc.perform(archiveUploadRequest(archive, metadataJson, "idem-upload.pdf", "different-content", key)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("IDEMPOTENCY_KEY_REUSED")); + } + + @Test + void unavailableScannerCreatesOneVisibleScopedQuarantineAndReplaysTheSameFailure() throws Exception { + ClientSession archive = loginAndSelect("archive", "ARCHIVE_MANAGER"); + String metadataJson = "{\"projectId\":\"%s\",\"fileType\":\"CONTRACT\",\"originalType\":\"PAPER\"}" + .formatted(PROJECT_ID); + String key = idempotencyKey(); + fileScanner.unavailable(); + + mockMvc.perform(archiveUploadRequest(archive, metadataJson, "quarantine.pdf", "same-content", key)) + .andExpect(status().isServiceUnavailable()) + .andExpect(jsonPath("$.code").value("FILE_SCANNER_UNAVAILABLE")); + String fileId = jdbcTemplate.queryForObject( + "SELECT public_id FROM file_object WHERE scan_status='QUARANTINED' ORDER BY id DESC LIMIT 1", + String.class); + + assertEquals(1, fileScanner.calls()); + assertEquals(1, count("SELECT COUNT(*) FROM file_object WHERE public_id=? AND scan_status='QUARANTINED'", + fileId)); + assertEquals(1, count("SELECT COUNT(*) FROM file_link link JOIN file_object file ON file.id=link.file_id " + + "WHERE file.public_id=? AND link.company_public_id=? AND link.project_public_id=?", + fileId, COMPANY_ID, PROJECT_ID)); + assertEquals(1, count("SELECT COUNT(*) FROM sys_idempotency_record WHERE idempotency_key=? " + + "AND state='COMPLETED' AND response_status=503", key)); + + mockMvc.perform(get("/api/v1/archive/files").param("scanStatus", "QUARANTINED") + .cookie(archive.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.length()").value(1)) + .andExpect(jsonPath("$.data[0].publicId").value(fileId)) + .andExpect(jsonPath("$.data[0].allowedActions") + .value(org.hamcrest.Matchers.contains("RESCAN"))); + mockMvc.perform(get("/api/v1/workbenches/archive").cookie(archive.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.metrics[?(@.code == 'QUARANTINED_FILES')].value") + .value(org.hamcrest.Matchers.contains(1))); + + mockMvc.perform(archiveUploadRequest(archive, metadataJson, "quarantine.pdf", "same-content", key)) + .andExpect(status().isServiceUnavailable()) + .andExpect(jsonPath("$.code").value("FILE_SCANNER_UNAVAILABLE")); + assertEquals(1, fileScanner.calls()); + assertEquals(1, count("SELECT COUNT(*) FROM file_link link JOIN file_object file ON file.id=link.file_id " + + "WHERE file.public_id=?", fileId)); + + mockMvc.perform(archiveUploadRequest(archive, metadataJson, "quarantine.pdf", "different-content", key)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("IDEMPOTENCY_KEY_REUSED")); + + String unavailableRescanKey = idempotencyKey(); + mockMvc.perform(post("/api/v1/archive/files/{id}/rescan", fileId).cookie(archive.cookies()) + .header("X-XSRF-TOKEN", archive.token()) + .header("Idempotency-Key", unavailableRescanKey)) + .andExpect(status().isServiceUnavailable()) + .andExpect(jsonPath("$.code").value("FILE_SCANNER_UNAVAILABLE")); + assertEquals(2, fileScanner.calls()); + mockMvc.perform(post("/api/v1/archive/files/{id}/rescan", fileId).cookie(archive.cookies()) + .header("X-XSRF-TOKEN", archive.token()) + .header("Idempotency-Key", unavailableRescanKey)) + .andExpect(status().isServiceUnavailable()) + .andExpect(jsonPath("$.code").value("FILE_SCANNER_UNAVAILABLE")); + assertEquals(2, fileScanner.calls()); + assertEquals(1, count("SELECT COUNT(*) FROM sys_idempotency_record WHERE idempotency_key=? " + + "AND state='COMPLETED' AND response_status=503", unavailableRescanKey)); + assertEquals(1, count("SELECT COUNT(*) FROM audit_log WHERE object_public_id=? " + + "AND action_code='FILE_RESCAN' AND result_code='UNAVAILABLE'", fileId)); + assertEquals(1, count("SELECT COUNT(*) FROM audit_log WHERE object_public_id=? " + + "AND action_code='ARCHIVE_FILE_RESCAN' AND result_code='UNAVAILABLE'", fileId)); + + fileScanner.reset(); + String successfulRescanKey = idempotencyKey(); + mockMvc.perform(post("/api/v1/archives/files/{id}/rescan", fileId).cookie(archive.cookies()) + .header("X-XSRF-TOKEN", archive.token()) + .header("Idempotency-Key", successfulRescanKey)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.publicId").value(fileId)) + .andExpect(jsonPath("$.data.scanStatus").value("AVAILABLE")) + .andExpect(jsonPath("$.data.allowedActions", + org.hamcrest.Matchers.not(org.hamcrest.Matchers.hasItem("RESCAN")))); + assertEquals(1, fileScanner.calls()); + mockMvc.perform(post("/api/v1/archive/files/{id}/rescan", fileId).cookie(archive.cookies()) + .header("X-XSRF-TOKEN", archive.token()) + .header("Idempotency-Key", successfulRescanKey)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.scanStatus").value("AVAILABLE")); + assertEquals(1, fileScanner.calls()); + assertEquals(1, count("SELECT COUNT(*) FROM audit_log WHERE object_public_id=? " + + "AND action_code='ARCHIVE_FILE_RESCAN' AND result_code='SUCCESS'", fileId)); + assertEquals(1, count("SELECT COUNT(*) FROM sys_idempotency_record WHERE idempotency_key=? " + + "AND state='COMPLETED' AND response_status=200", successfulRescanKey)); + mockMvc.perform(post("/api/v1/archives/files/{id}/rescan", fileId).cookie(archive.cookies()) + .header("X-XSRF-TOKEN", archive.token()) + .header("Idempotency-Key", unavailableRescanKey)) + .andExpect(status().isServiceUnavailable()) + .andExpect(jsonPath("$.code").value("FILE_SCANNER_UNAVAILABLE")); + assertEquals(1, fileScanner.calls()); + assertEquals(1, count("SELECT COUNT(*) FROM audit_log WHERE object_public_id=? " + + "AND action_code='ARCHIVE_FILE_RESCAN' AND result_code='SUCCESS'", fileId)); + mockMvc.perform(get("/api/v1/workbenches/archive").cookie(archive.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.metrics[?(@.code == 'QUARANTINED_FILES')].value") + .value(org.hamcrest.Matchers.contains(0))); + } + + @Test + void previewPermissionDoesNotImplicitlyRequireDownloadPermission() throws Exception { + ClientSession archive = loginAndSelect("archive", "ARCHIVE_MANAGER"); + String fileId = uploadFile(archive, PROJECT_ID, null, null, "ACCEPTANCE", "PAPER", + "acceptance.pdf", "acceptance-content"); + jdbcTemplate.update("DELETE assignment FROM iam_role_permission assignment JOIN iam_role role ON role.id=assignment.role_id JOIN iam_permission permission ON permission.id=assignment.permission_id WHERE role.code='PROJECT_MANAGER' AND permission.code='archive:file:download'"); + try { + ClientSession project = loginAndSelect("project", "PROJECT_MANAGER"); + mockMvc.perform(get("/api/v1/archive/files/{id}/content", fileId).param("preview", "true") + .cookie(project.cookies())) + .andExpect(status().isOk()) + .andExpect(header().string("Content-Disposition", containsString("inline"))); + mockMvc.perform(get("/api/v1/archive/files/{id}/content", fileId).cookie(project.cookies())) + .andExpect(status().isForbidden()); + } finally { + restoreRolePermission("PROJECT_MANAGER", "archive:file:download"); + } + } + + @Test + void borrowWorkflowBlocksPendingDuplicatesAndEnforcesSeparationAndReturnCondition() throws Exception { + ClientSession finance = loginAndSelect("finance", "FINANCE_MANAGER"); + ClientSession archive = loginAndSelect("archive", "ARCHIVE_MANAGER"); + ClientSession demoFinance = loginAndSelect("demo", "FINANCE_MANAGER"); + String fileId = uploadFile(finance, PROJECT_ID, null, null, "CONTRACT", "PAPER", + "borrow-contract.pdf", "borrow-contract-content"); + + String borrowId = createBorrow(finance, fileId, "项目审计核对原件"); + mockMvc.perform(get("/api/v1/archive/files").param("projectId", PROJECT_ID).cookie(finance.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[0].borrowStatus").value("APPLIED")) + .andExpect(jsonPath("$.data[0].allowedActions").value(org.hamcrest.Matchers.not( + org.hamcrest.Matchers.hasItem("BORROW")))); + command(finance, "/api/v1/archive/borrows/{id}/approve", borrowId, + "{\"version\":0,\"opinion\":\"本人不能批准\"}", status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("SOD_VIOLATION")); + + mockMvc.perform(post("/api/v1/archive/borrows").cookie(demoFinance.cookies()) + .header("Idempotency-Key", idempotencyKey()).header("X-XSRF-TOKEN", demoFinance.token()).contentType("application/json") + .content("{\"fileId\":\"%s\",\"purpose\":\"重复申请应阻断\",\"dueAt\":\"2099-12-31T12:00:00\"}".formatted(fileId))) + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("INVALID_STATE_TRANSITION")); + + command(archive, "/api/v1/archive/borrows/{id}/approve", borrowId, + "{\"version\":0,\"opinion\":\"用途和期限符合要求\"}", status().isOk()) + .andExpect(jsonPath("$.data.status").value("BORROWED")) + .andExpect(jsonPath("$.data.approvedByName").value("资料管理员")); + command(archive, "/api/v1/archive/borrows/{id}/return", borrowId, + "{\"version\":1}", status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("VALIDATION_FAILED")); + command(archive, "/api/v1/archive/borrows/{id}/return", borrowId, + "{\"version\":0,\"returnCondition\":\"文件完好\"}", status().isConflict()) + .andExpect(jsonPath("$.code").value("VERSION_CONFLICT")); + command(archive, "/api/v1/archive/borrows/{id}/return", borrowId, + "{\"version\":1,\"returnCondition\":\"纸质原件完好,页数一致\"}", status().isOk()) + .andExpect(jsonPath("$.data.status").value("RETURNED")) + .andExpect(jsonPath("$.data.returnCondition").value("纸质原件完好,页数一致")); + + String rejectedId = createBorrow(finance, fileId, "归还后再次申请借阅"); + command(archive, "/api/v1/archive/borrows/{id}/reject", rejectedId, + "{\"version\":0,\"opinion\":\"当前项目处于结算复核期\"}", status().isOk()) + .andExpect(jsonPath("$.data.status").value("REJECTED")); + assertEquals(1, count("SELECT COUNT(*) FROM audit_log WHERE action_code='ARCHIVE_BORROW_RETURN' AND object_public_id=?", borrowId)); + } + + @Test + void independentFilesDoNotShareVersionsAndExplicitReplacementLocksTheSeries() throws Exception { + ClientSession archive = loginAndSelect("archive", "ARCHIVE_MANAGER"); + ClientSession finance = loginAndSelect("finance", "FINANCE_MANAGER"); + String firstId = uploadFile(archive, PROJECT_ID, null, null, "CONTRACT", "PAPER", + "first-contract.pdf", "first-contract"); + String independentId = uploadFile(archive, PROJECT_ID, null, null, "CONTRACT", "PAPER", + "independent-contract.pdf", "independent-contract"); + + mockMvc.perform(get("/api/v1/archive/files/{id}", firstId).cookie(archive.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.versions.length()").value(1)) + .andExpect(jsonPath("$.data.file.versionNo").value(1)); + mockMvc.perform(get("/api/v1/archive/files/{id}", independentId).cookie(archive.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.versions.length()").value(1)) + .andExpect(jsonPath("$.data.file.seriesPublicId").value(org.hamcrest.Matchers.not( + jdbcTemplate.queryForObject("SELECT series_public_id FROM file_link link JOIN file_object file ON file.id=link.file_id WHERE file.public_id=?", String.class, firstId)))); + + String replacementId = uploadFileVersion(archive, PROJECT_ID, null, null, "CONTRACT", "PAPER", + "first-contract-v2.pdf", "first-contract-v2", firstId); + mockMvc.perform(get("/api/v1/archive/files/{id}", replacementId).cookie(archive.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.file.versionNo").value(2)) + .andExpect(jsonPath("$.data.versions.length()").value(2)); + + String borrowId = createBorrow(finance, replacementId, "锁定系列进行审计"); + mockMvc.perform(archiveUploadRequest(archive, + "{\"projectId\":\"%s\",\"fileType\":\"CONTRACT\",\"originalType\":\"PAPER\",\"replacesFileId\":\"%s\",\"replacementReason\":\"借阅期间替换\"}" + .formatted(PROJECT_ID, replacementId), + "blocked-v3.pdf", "blocked-v3", idempotencyKey())) + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("INVALID_STATE_TRANSITION")); + command(archive, "/api/v1/archive/borrows/{id}/reject", borrowId, + "{\"version\":0,\"opinion\":\"结束系列锁定测试\"}", status().isOk()); + } + + @Test + void overdueBorrowIsProjectedAtomicallyAndCanBeReturned() throws Exception { + ClientSession finance = loginAndSelect("finance", "FINANCE_MANAGER"); + ClientSession archive = loginAndSelect("archive", "ARCHIVE_MANAGER"); + String fileId = uploadFile(finance, PROJECT_ID, null, null, "PROJECT_BASE", "PAPER", + "overdue.pdf", "overdue-content"); + String borrowId = createBorrow(finance, fileId, "验证借阅到期提醒"); + command(archive, "/api/v1/archive/borrows/{id}/approve", borrowId, + "{\"version\":0,\"opinion\":\"批准用于到期验证\"}", status().isOk()) + .andExpect(jsonPath("$.data.status").value("BORROWED")); + jdbcTemplate.update("UPDATE arc_borrow SET due_at=UTC_TIMESTAMP(3)-INTERVAL 1 SECOND WHERE public_id=?", + borrowId); + + mockMvc.perform(get("/api/v1/archive/borrows").param("status", "OVERDUE").cookie(archive.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.length()").value(1)) + .andExpect(jsonPath("$.data[0].publicId").value(borrowId)) + .andExpect(jsonPath("$.data[0].version").value(2)); + command(archive, "/api/v1/archive/borrows/{id}/return", borrowId, + "{\"version\":2,\"returnCondition\":\"逾期归还,文件完好\"}", status().isOk()) + .andExpect(jsonPath("$.data.status").value("RETURNED")); + } + + @Test + void frozenPackagePausesBorrowOverdueProjectionUntilUnfreeze() throws Exception { + ClientSession finance = loginAndSelect("finance", "FINANCE_MANAGER"); + ClientSession archive = loginAndSelect("archive", "ARCHIVE_MANAGER"); + String packageId = createPackage(finance, PROJECT_ID); + String projectBaseItemId = jdbcTemplate.queryForObject( + "SELECT item.public_id FROM arc_package_item item " + + "JOIN arc_package package_record ON package_record.id=item.package_id " + + "WHERE package_record.public_id=? AND item.item_type='PROJECT_BASE'", + String.class, packageId); + String fileId = uploadFile(finance, PROJECT_ID, packageId, projectBaseItemId, "PROJECT_BASE", "PAPER", + "frozen-borrow.pdf", "frozen-borrow-content"); + markRemainingItemsNotApplicable(packageId); + command(finance, "/api/v1/archive/packages/{id}/check", packageId, + "{\"version\":0,\"opinion\":\"冻结借阅测试检查\"}", status().isOk()); + command(finance, "/api/v1/archive/packages/{id}/submit", packageId, + "{\"version\":1,\"opinion\":\"冻结借阅测试提交\"}", status().isOk()); + command(archive, "/api/v1/archive/packages/{id}/archive", packageId, + "{\"version\":2,\"opinion\":\"冻结借阅测试归档\"}", status().isOk()); + + String borrowId = createBorrow(finance, fileId, "冻结期间暂停到期处置"); + command(archive, "/api/v1/archive/borrows/{id}/approve", borrowId, + "{\"version\":0,\"opinion\":\"批准冻结到期测试\"}", status().isOk()) + .andExpect(jsonPath("$.data.status").value("BORROWED")) + .andExpect(jsonPath("$.data.version").value(1)); + command(archive, "/api/v1/archive/packages/{id}/freeze", packageId, + "{\"version\":3,\"reason\":\"审计冻结期间暂停到期处置\"}", status().isOk()) + .andExpect(jsonPath("$.data.status").value("FROZEN")) + .andExpect(jsonPath("$.data.version").value(4)); + assertEquals(1, jdbcTemplate.update( + "UPDATE arc_borrow SET due_at=UTC_TIMESTAMP(3)-INTERVAL 1 SECOND WHERE public_id=?", borrowId)); + + mockMvc.perform(get("/api/v1/archive/borrows").param("status", "OVERDUE") + .cookie(archive.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.length()").value(0)); + assertEquals("BORROWED", jdbcTemplate.queryForObject( + "SELECT status FROM arc_borrow WHERE public_id=?", String.class, borrowId)); + assertEquals(1, jdbcTemplate.queryForObject( + "SELECT version FROM arc_borrow WHERE public_id=?", Integer.class, borrowId)); + command(archive, "/api/v1/archive/borrows/{id}/return", borrowId, + "{\"version\":1,\"returnCondition\":\"冻结期间不应允许登记归还\"}", status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("INVALID_STATE_TRANSITION")); + assertEquals("BORROWED", jdbcTemplate.queryForObject( + "SELECT status FROM arc_borrow WHERE public_id=?", String.class, borrowId)); + assertEquals(1, jdbcTemplate.queryForObject( + "SELECT version FROM arc_borrow WHERE public_id=?", Integer.class, borrowId)); + + command(archive, "/api/v1/archive/packages/{id}/unfreeze", packageId, + "{\"version\":4,\"opinion\":\"审计冻结解除\"}", status().isOk()) + .andExpect(jsonPath("$.data.status").value("ARCHIVED")) + .andExpect(jsonPath("$.data.version").value(5)); + mockMvc.perform(get("/api/v1/archive/borrows").param("status", "OVERDUE") + .cookie(archive.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.length()").value(1)) + .andExpect(jsonPath("$.data[0].publicId").value(borrowId)) + .andExpect(jsonPath("$.data[0].version").value(2)); + } + + @Test + void frozenPackageReportsFrozenRetentionBeforeArchive() throws Exception { + ClientSession finance = loginAndSelect("finance", "FINANCE_MANAGER"); + ClientSession archive = loginAndSelect("archive", "ARCHIVE_MANAGER"); + String packageId = createPackage(finance, PROJECT_ID); + + command(finance, "/api/v1/archive/packages/{id}/check", packageId, + "{\"version\":0,\"opinion\":\"冻结状态展示检查\"}", status().isOk()) + .andExpect(jsonPath("$.data.status").value("CHECKED")) + .andExpect(jsonPath("$.data.retentionExpiresOn").doesNotExist()) + .andExpect(jsonPath("$.data.retentionStatus").value("PENDING")); + command(archive, "/api/v1/archive/packages/{id}/freeze", packageId, + "{\"version\":1,\"reason\":\"归档前审计冻结\"}", status().isOk()) + .andExpect(jsonPath("$.data.status").value("FROZEN")) + .andExpect(jsonPath("$.data.frozen").value(true)) + .andExpect(jsonPath("$.data.retentionExpiresOn").doesNotExist()) + .andExpect(jsonPath("$.data.retentionStatus").value("FROZEN")); + command(archive, "/api/v1/archive/packages/{id}/unfreeze", packageId, + "{\"version\":2,\"opinion\":\"归档前审计冻结解除\"}", status().isOk()) + .andExpect(jsonPath("$.data.status").value("CHECKED")) + .andExpect(jsonPath("$.data.frozen").value(false)) + .andExpect(jsonPath("$.data.retentionStatus").value("PENDING")); + } + + @Test + void fileAndPackageQueriesRespectProjectScope() throws Exception { + ClientSession archive = loginAndSelect("archive", "ARCHIVE_MANAGER"); + String inScopeFile = uploadFile(archive, PROJECT_ID, null, null, "PROJECT_BASE", "PAPER", + "in-scope.pdf", "in-scope-content"); + String outOfScopeFile = uploadFile(archive, OTHER_PROJECT_ID, null, null, "PROJECT_BASE", "PAPER", + "out-of-scope.pdf", "out-of-scope-content"); + createPackage(archive, PROJECT_ID); + createPackage(archive, OTHER_PROJECT_ID); + + long projectUserId = userId("project"); + restrictScope(projectUserId, "PROJECT_MANAGER", "archive:file:view", PROJECT_ID); + restrictScope(projectUserId, "PROJECT_MANAGER", "archive:file:preview", PROJECT_ID); + restrictScope(projectUserId, "PROJECT_MANAGER", "archive:package:view", PROJECT_ID); + try { + ClientSession project = loginAndSelect("project", "PROJECT_MANAGER"); + mockMvc.perform(get("/api/v1/archive/files").param("fileName", "scope") + .cookie(project.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.length()").value(1)) + .andExpect(jsonPath("$.data[0].publicId").value(inScopeFile)); + mockMvc.perform(get("/api/v1/archive/files/{id}", outOfScopeFile).cookie(project.cookies())) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("DATA_SCOPE_DENIED")); + mockMvc.perform(get("/api/v1/archive/files/{id}/content", outOfScopeFile) + .param("preview", "true").cookie(project.cookies())) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("DATA_SCOPE_DENIED")); + mockMvc.perform(get("/api/v1/archive/files/{id}/content", outOfScopeFile) + .cookie(project.cookies())) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("DATA_SCOPE_DENIED")); + mockMvc.perform(get("/api/v1/archive/packages").param("view", "prepare") + .cookie(project.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.length()").value(1)) + .andExpect(jsonPath("$.data[0].project.publicId").value(PROJECT_ID)); + } finally { + restoreGlobalScope(projectUserId, "PROJECT_MANAGER", "archive:file:view"); + restoreGlobalScope(projectUserId, "PROJECT_MANAGER", "archive:file:preview"); + restoreGlobalScope(projectUserId, "PROJECT_MANAGER", "archive:package:view"); + } + } + + @Test + void fileDetailFiltersOutOfScopeVersionsAndBorrowHistory() throws Exception { + ClientSession archive = loginAndSelect("archive", "ARCHIVE_MANAGER"); + String inScopeFile = uploadFile(archive, PROJECT_ID, null, null, "PROJECT_BASE", "PAPER", + "series-in-scope.pdf", "series-in-scope-content"); + String otherProjectFile = uploadFile(archive, OTHER_PROJECT_ID, null, null, "PROJECT_BASE", "PAPER", + "series-out-of-scope.pdf", "series-out-of-scope-content"); + String seriesId = jdbcTemplate.queryForObject( + "SELECT series_public_id FROM file_link link JOIN file_object file ON file.id=link.file_id WHERE file.public_id=?", + String.class, inScopeFile); + String borrowId = createBorrow(archive, otherProjectFile, "跨项目借阅历史"); + long otherLinkDbId = jdbcTemplate.queryForObject( + "SELECT link.id FROM file_link link JOIN file_object file ON file.id=link.file_id WHERE file.public_id=?", + Long.class, otherProjectFile); + long otherFileDbId = jdbcTemplate.queryForObject( + "SELECT id FROM file_object WHERE public_id=?", Long.class, otherProjectFile); + String borrowBusinessNo = jdbcTemplate.queryForObject( + "SELECT business_no FROM arc_borrow WHERE public_id=?", String.class, borrowId); + jdbcTemplate.update("DELETE FROM arc_borrow WHERE public_id=?", borrowId); + // Simulate a corrupt/imported shared series. The link remains owned by + // the other project, but shares the in-scope series as version 2. + jdbcTemplate.update(""" + UPDATE file_link link + JOIN file_object file ON file.id = link.file_id + SET link.active = FALSE, link.archive_status = 'SUPERSEDED' + WHERE file.public_id = ? + """, otherProjectFile); + jdbcTemplate.update(""" + UPDATE file_link link + JOIN file_object file ON file.id = link.file_id + SET link.series_public_id = ?, link.version_no = 2 + WHERE file.public_id = ? + """, seriesId, otherProjectFile); + jdbcTemplate.update(""" + INSERT INTO arc_borrow ( + public_id, business_no, file_id, file_link_id, series_public_id, + applicant_id, purpose, due_at, status, version + ) VALUES (?, ?, ?, ?, ?, ?, '跨项目借阅历史', '2099-12-31 12:00:00', 'BORROWED', 1) + """, borrowId, borrowBusinessNo, otherFileDbId, otherLinkDbId, seriesId, + userId("archive")); + + long projectUserId = userId("project"); + restrictScope(projectUserId, "PROJECT_MANAGER", "archive:file:view", PROJECT_ID); + try { + ClientSession project = loginAndSelect("project", "PROJECT_MANAGER"); + mockMvc.perform(get("/api/v1/archive/files/{id}", inScopeFile).cookie(project.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.versions.length()").value(1)) + .andExpect(jsonPath("$.data.versions[0].publicId").value(inScopeFile)) + .andExpect(jsonPath("$.data.file.borrowStatus").doesNotExist()) + .andExpect(jsonPath("$.data.borrows[?(@.publicId == '%s')]".formatted(borrowId)).doesNotExist()); + } finally { + restoreGlobalScope(projectUserId, "PROJECT_MANAGER", "archive:file:view"); + } + } + + @Test + void previewOfRealPdfIsParseableContainsWatermarkAndChangesRenderedPixels() throws Exception { + ClientSession archive = loginAndSelect("archive", "ARCHIVE_MANAGER"); + byte[] original = realPdf("Original PDF content"); + String fileId = uploadBinaryFile(archive, "watermark-source.pdf", "application/pdf", original); + + MvcResult preview = mockMvc.perform(get("/api/v1/archive/files/{id}/content", fileId) + .param("preview", "true").cookie(archive.cookies())) + .andExpect(status().isOk()) + .andExpect(content().contentType("application/pdf")) + .andExpect(header().exists("X-Derived-File-SHA256")) + .andReturn(); + + byte[] watermarked = preview.getResponse().getContentAsByteArray(); + assertNotEquals(java.util.HexFormat.of().formatHex(original), java.util.HexFormat.of().formatHex(watermarked)); + try (PDDocument source = Loader.loadPDF(original); + PDDocument result = Loader.loadPDF(watermarked)) { + assertEquals(source.getNumberOfPages(), result.getNumberOfPages()); + assertTrue(new PDFTextStripper().getText(result).contains("KAIDI CONTROLLED COPY")); + assertRenderedPixelsDiffer(renderFirstPage(source), renderFirstPage(result)); + } + } + + @Test + void previewOfPngIsDecodableAndChangesPixels() throws Exception { + ClientSession archive = loginAndSelect("archive", "ARCHIVE_MANAGER"); + byte[] original = realPng(); + String fileId = uploadBinaryFile(archive, "watermark-source.png", "image/png", original); + + MvcResult preview = mockMvc.perform(get("/api/v1/archive/files/{id}/content", fileId) + .param("preview", "true").cookie(archive.cookies())) + .andExpect(status().isOk()) + .andExpect(content().contentType("image/png")) + .andReturn(); + + BufferedImage source = ImageIO.read(new ByteArrayInputStream(original)); + BufferedImage result = ImageIO.read(new ByteArrayInputStream(preview.getResponse().getContentAsByteArray())); + assertNotNull(result); + assertRenderedPixelsDiffer(source, result); + } + + private String createPackage(ClientSession session, String projectId) throws Exception { + MvcResult result = mockMvc.perform(post("/api/v1/archive/packages").cookie(session.cookies()) + .header("Idempotency-Key", idempotencyKey()).header("X-XSRF-TOKEN", session.token()).contentType("application/json") + .content("{\"projectId\":\"%s\",\"retentionYears\":10,\"physicalLocation\":\"档案室 A-17\"}".formatted(projectId))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("PREPARING")) + .andExpect(jsonPath("$.data.ruleVersion").value("ARCHIVE-RULE-V1")) + .andReturn(); + return JsonPath.read(result.getResponse().getContentAsString(), "$.data.publicId"); + } + + private String uploadFile(ClientSession session, String projectId, String packageId, String packageItemId, + String fileType, String originalType, String name, String body) throws Exception { + return uploadFileVersion(session, projectId, packageId, packageItemId, fileType, originalType, name, body, + null); + } + + private String uploadFileVersion(ClientSession session, String projectId, String packageId, + String packageItemId, String fileType, String originalType, + String name, String body, String replacesFileId) throws Exception { + String metadataJson = "{\"projectId\":\"%s\",\"packageId\":%s,\"packageItemId\":%s,\"fileType\":\"%s\",\"originalType\":\"%s\",\"replacesFileId\":%s,\"replacementReason\":\"测试版本替换\"}" + .formatted(projectId, jsonString(packageId), jsonString(packageItemId), fileType, originalType, + jsonString(replacesFileId)); + MockMultipartFile metadata = new MockMultipartFile("metadata", "metadata.json", "application/json", + metadataJson.getBytes(StandardCharsets.UTF_8)); + MockMultipartFile file = new MockMultipartFile("file", name, "application/pdf", + body.getBytes(StandardCharsets.UTF_8)); + MvcResult result = mockMvc.perform(multipart("/api/v1/archive/files").file(metadata).file(file) + .cookie(session.cookies()).header("Idempotency-Key", idempotencyKey()).header("X-XSRF-TOKEN", session.token())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.sha256").isNotEmpty()) + .andReturn(); + return JsonPath.read(result.getResponse().getContentAsString(), "$.data.publicId"); + } + + private String uploadBinaryFile(ClientSession session, String name, String mediaType, byte[] body) + throws Exception { + String metadataJson = "{\"projectId\":\"%s\",\"fileType\":\"CONTRACT\",\"originalType\":\"PAPER\"}" + .formatted(PROJECT_ID); + MockMultipartFile metadata = new MockMultipartFile("metadata", "metadata.json", "application/json", + metadataJson.getBytes(StandardCharsets.UTF_8)); + MockMultipartFile file = new MockMultipartFile("file", name, mediaType, body); + MvcResult result = mockMvc.perform(multipart("/api/v1/archive/files").file(metadata).file(file) + .cookie(session.cookies()).header("Idempotency-Key", idempotencyKey()) + .header("X-XSRF-TOKEN", session.token())) + .andExpect(status().isOk()).andReturn(); + return JsonPath.read(result.getResponse().getContentAsString(), "$.data.publicId"); + } + + private static byte[] realPdf(String text) throws IOException { + try (PDDocument document = new PDDocument(); + ByteArrayOutputStream output = new ByteArrayOutputStream()) { + PDPage page = new PDPage(PDRectangle.LETTER); + document.addPage(page); + try (PDPageContentStream stream = new PDPageContentStream(document, page)) { + stream.beginText(); + stream.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 14); + stream.newLineAtOffset(72, 720); + stream.showText(text); + stream.endText(); + } + document.save(output); + return output.toByteArray(); + } + } + + private static byte[] realPng() throws IOException { + BufferedImage image = new BufferedImage(800, 500, BufferedImage.TYPE_INT_ARGB); + Graphics2D graphics = image.createGraphics(); + try { + graphics.setColor(Color.WHITE); + graphics.fillRect(0, 0, image.getWidth(), image.getHeight()); + graphics.setColor(Color.BLACK); + graphics.setFont(new Font(Font.SANS_SERIF, Font.PLAIN, 28)); + graphics.drawString("Original PNG content", 80, 250); + } finally { + graphics.dispose(); + } + ByteArrayOutputStream output = new ByteArrayOutputStream(); + assertTrue(ImageIO.write(image, "png", output)); + return output.toByteArray(); + } + + private static BufferedImage renderFirstPage(PDDocument document) throws IOException { + return new PDFRenderer(document).renderImageWithDPI(0, 96, ImageType.RGB); + } + + private static void assertRenderedPixelsDiffer(BufferedImage expected, BufferedImage actual) { + assertNotNull(expected); + assertNotNull(actual); + assertEquals(expected.getWidth(), actual.getWidth()); + assertEquals(expected.getHeight(), actual.getHeight()); + int changed = 0; + for (int y = 0; y < expected.getHeight(); y++) { + for (int x = 0; x < expected.getWidth(); x++) { + if (expected.getRGB(x, y) != actual.getRGB(x, y)) changed++; + } + } + assertTrue(changed > 0, "watermark must change rendered pixels"); + } + + private String createBorrow(ClientSession session, String fileId, String purpose) throws Exception { + MvcResult result = mockMvc.perform(post("/api/v1/archive/borrows").cookie(session.cookies()) + .header("Idempotency-Key", idempotencyKey()).header("X-XSRF-TOKEN", session.token()).contentType("application/json") + .content("{\"fileId\":\"%s\",\"purpose\":\"%s\",\"dueAt\":\"2099-12-31T12:00:00\"}".formatted(fileId, purpose))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("APPLIED")) + .andReturn(); + return JsonPath.read(result.getResponse().getContentAsString(), "$.data.publicId"); + } + + private String uploadDimensionedFile(ClientSession session) throws Exception { + String metadataJson = "{\"projectId\":\"%s\",\"contractId\":\"%s\",\"counterpartyId\":\"%s\",\"formType\":\"OA-12\",\"fileType\":\"ACCEPTANCE\",\"originalType\":\"PAPER\"}" + .formatted(PROJECT_ID, CONTRACT_ID, COUNTERPARTY_ID); + MockMultipartFile metadata = new MockMultipartFile("metadata", "metadata.json", "application/json", + metadataJson.getBytes(StandardCharsets.UTF_8)); + MockMultipartFile file = new MockMultipartFile("file", "acceptance-dimensioned.pdf", "application/pdf", + "dimensioned-archive-content".getBytes(StandardCharsets.UTF_8)); + MvcResult result = mockMvc.perform(multipart("/api/v1/archive/files").file(metadata).file(file) + .cookie(session.cookies()).header("Idempotency-Key", idempotencyKey()).header("X-XSRF-TOKEN", session.token())) + .andExpect(status().isOk()).andReturn(); + return JsonPath.read(result.getResponse().getContentAsString(), "$.data.publicId"); + } + + private org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder archiveUploadRequest( + ClientSession session, String metadataJson, String fileName, String body, String key) { + MockMultipartFile metadata = new MockMultipartFile("metadata", "metadata.json", "application/json", + metadataJson.getBytes(StandardCharsets.UTF_8)); + MockMultipartFile file = new MockMultipartFile("file", fileName, "application/pdf", + body.getBytes(StandardCharsets.UTF_8)); + var builder = multipart("/api/v1/archive/files").file(metadata).file(file) + .cookie(session.cookies()).header("X-XSRF-TOKEN", session.token()); + if (key != null) builder.header("Idempotency-Key", key); + return builder; + } + + private org.springframework.test.web.servlet.ResultActions command( + ClientSession session, String url, String id, String body, + org.springframework.test.web.servlet.ResultMatcher expectedStatus) throws Exception { + return mockMvc.perform(post(url, id).cookie(session.cookies()).header("Idempotency-Key", idempotencyKey()).header("X-XSRF-TOKEN", session.token()) + .contentType("application/json").content(body)) + .andExpect(expectedStatus); + } + + private void markRemainingItemsNotApplicable(String packageId) { + jdbcTemplate.update("UPDATE arc_package_item item JOIN arc_package package_record ON package_record.id=item.package_id SET item.status='NOT_APPLICABLE',item.not_applicable_reason='测试前置:经确认不涉及',item.missing_reason=NULL WHERE package_record.public_id=? AND item.status='MISSING'", packageId); + } + + private void cleanupArchiveData() { + jdbcTemplate.update("UPDATE acc_event SET voucher_id=NULL WHERE public_id=?", ACCOUNTING_EVENT_ID); + jdbcTemplate.update("DELETE FROM acc_voucher_entry WHERE voucher_id=(SELECT id FROM acc_voucher WHERE public_id=?)", + ACCOUNTING_VOUCHER_ID); + jdbcTemplate.update("DELETE FROM acc_voucher WHERE public_id=?", ACCOUNTING_VOUCHER_ID); + jdbcTemplate.update("DELETE FROM acc_event WHERE public_id=?", ACCOUNTING_EVENT_ID); + jdbcTemplate.update("DELETE borrow FROM arc_borrow borrow JOIN file_object file ON file.id=borrow.file_id JOIN file_link link ON link.file_id=file.id WHERE link.project_public_id IN (?,?)", PROJECT_ID, OTHER_PROJECT_ID); + jdbcTemplate.update("DELETE returned_item FROM arc_package_return_item returned_item JOIN arc_package package_record ON package_record.id=returned_item.package_id JOIN md_project project ON project.id=package_record.project_id WHERE project.public_id IN (?,?)", PROJECT_ID, OTHER_PROJECT_ID); + jdbcTemplate.update("DELETE action FROM arc_package_action action JOIN arc_package package_record ON package_record.id=action.package_id JOIN md_project project ON project.id=package_record.project_id WHERE project.public_id IN (?,?)", PROJECT_ID, OTHER_PROJECT_ID); + jdbcTemplate.update("DELETE snapshot FROM arc_package_object_snapshot snapshot JOIN arc_package package_record ON package_record.id=snapshot.package_id JOIN md_project project ON project.id=package_record.project_id WHERE project.public_id IN (?,?)", PROJECT_ID, OTHER_PROJECT_ID); + jdbcTemplate.update("DELETE item FROM arc_package_item item JOIN arc_package package_record ON package_record.id=item.package_id JOIN md_project project ON project.id=package_record.project_id WHERE project.public_id IN (?,?)", PROJECT_ID, OTHER_PROJECT_ID); + jdbcTemplate.update("DELETE package_record FROM arc_package package_record JOIN md_project project ON project.id=package_record.project_id WHERE project.public_id IN (?,?)", PROJECT_ID, OTHER_PROJECT_ID); + jdbcTemplate.update("UPDATE file_link SET supersedes_link_id=NULL WHERE project_public_id IN (?,?)", PROJECT_ID, OTHER_PROJECT_ID); + jdbcTemplate.update("DELETE FROM file_link WHERE project_public_id IN (?,?)", PROJECT_ID, OTHER_PROJECT_ID); + jdbcTemplate.update("DELETE FROM file_object WHERE storage_key LIKE CONCAT('%/', public_id, '.%') AND uploaded_by IN (SELECT public_id FROM iam_user WHERE username IN ('finance','archive'))"); + } + + private void insertCompletedAccountingFixture() { + long financeId = userId("finance"); + long demoId = userId("demo"); + jdbcTemplate.update(""" + INSERT INTO acc_event (public_id,business_no,event_type,source_type,source_public_id, + company_id,project_id,counterparty_id,business_date,period,amount,currency,status,created_by) + SELECT ?, 'EV-ARCHIVE-LATE', 'RECEIPT', 'RECEIPT', ?, company.id, project.id, + counterparty.id, '2026-08-10', '2026-08', ?, 'CNY', 'PENDING', ? + FROM md_company company + JOIN md_project project ON project.company_id = company.id + JOIN md_counterparty counterparty ON counterparty.public_id = ? + WHERE company.public_id = ? AND project.public_id = ? + """, ACCOUNTING_EVENT_ID, ACCOUNTING_EVENT_ID, new BigDecimal("1000.00"), financeId, + COUNTERPARTY_ID, COMPANY_ID, PROJECT_ID); + long eventDatabaseId = jdbcTemplate.queryForObject( + "SELECT id FROM acc_event WHERE public_id=?", Long.class, ACCOUNTING_EVENT_ID); + long companyDatabaseId = jdbcTemplate.queryForObject( + "SELECT id FROM md_company WHERE public_id=?", Long.class, COMPANY_ID); + long projectDatabaseId = jdbcTemplate.queryForObject( + "SELECT id FROM md_project WHERE public_id=?", Long.class, PROJECT_ID); + jdbcTemplate.update(""" + INSERT INTO acc_voucher ( + public_id,business_no,event_id,company_id,project_id,rule_version,rule_snapshot_json, + period,business_date,summary,debit_total,credit_total,status,submitted_by,reviewed_by, + external_voucher_no,result_at,result_recorded_by,result_recorded_at,result_verified_by, + result_verified_at,result_record_remark,result_verify_opinion,created_by,updated_by) + VALUES (?, 'VCH-ARCHIVE-LATE', ?, ?, ?, 'ACCOUNTING-RULE-V1', + '{\"schemaVersion\":1,\"ruleVersion\":\"ACCOUNTING-RULE-V1\"}', + '2026-08', '2026-08-10', '晚到记账结果', 1000.00, 1000.00, 'COMPLETED', + ?, ?, 'ARCHIVE-LATE-001', '2026-08-11', ?, UTC_TIMESTAMP(3), ?, + UTC_TIMESTAMP(3), '测试登记备注', '测试复核意见', ?, ?) + """, ACCOUNTING_VOUCHER_ID, eventDatabaseId, companyDatabaseId, projectDatabaseId, + financeId, demoId, financeId, demoId, financeId, financeId); + jdbcTemplate.update("UPDATE acc_event SET status='COMPLETED', voucher_id=? WHERE id=?", + jdbcTemplate.queryForObject("SELECT id FROM acc_voucher WHERE public_id=?", Long.class, ACCOUNTING_VOUCHER_ID), + eventDatabaseId); + jdbcTemplate.update("UPDATE acc_voucher SET project_id=NULL WHERE public_id=?", ACCOUNTING_VOUCHER_ID); + } + + private void restrictScope(long userId, String roleCode, String permissionCode, String projectId) { + jdbcTemplate.update("DELETE scope FROM iam_scope scope JOIN iam_role role ON role.id=scope.role_id JOIN iam_permission permission ON permission.id=scope.permission_id WHERE scope.user_id=? AND role.code=? AND permission.code=?", userId, roleCode, permissionCode); + jdbcTemplate.update("INSERT INTO iam_scope (user_id,role_id,permission_id,scope_type,project_public_id,status) SELECT ?,role.id,permission.id,'PROJECT',?,'ACTIVE' FROM iam_role role JOIN iam_permission permission WHERE role.code=? AND permission.code=?", userId, projectId, roleCode, permissionCode); + } + + private void restoreGlobalScope(long userId, String roleCode, String permissionCode) { + jdbcTemplate.update("DELETE scope FROM iam_scope scope JOIN iam_role role ON role.id=scope.role_id JOIN iam_permission permission ON permission.id=scope.permission_id WHERE scope.user_id=? AND role.code=? AND permission.code=?", userId, roleCode, permissionCode); + jdbcTemplate.update("INSERT INTO iam_scope (user_id,role_id,permission_id,scope_type,status) SELECT ?,role.id,permission.id,'GLOBAL','ACTIVE' FROM iam_role role JOIN iam_permission permission WHERE role.code=? AND permission.code=?", userId, roleCode, permissionCode); + } + + private void restoreRolePermission(String roleCode, String permissionCode) { + jdbcTemplate.update("INSERT INTO iam_role_permission (role_id,permission_id) SELECT role.id,permission.id FROM iam_role role JOIN iam_permission permission WHERE role.code=? AND permission.code=? AND NOT EXISTS (SELECT 1 FROM iam_role_permission existing WHERE existing.role_id=role.id AND existing.permission_id=permission.id)", roleCode, permissionCode); + } + + private long userId(String username) { + return jdbcTemplate.queryForObject("SELECT id FROM iam_user WHERE username=?", Long.class, username); + } + + private int count(String sql, Object... args) { + return jdbcTemplate.queryForObject(sql, Integer.class, args); + } + + private static String jsonString(String value) { + return value == null ? "null" : "\"" + value + "\""; + } + + private ClientSession loginAndSelect(String username, String role) throws Exception { + MvcResult csrf = mockMvc.perform(get("/api/v1/auth/csrf")).andExpect(status().isOk()).andReturn(); + Cookie csrfCookie = csrf.getResponse().getCookie("XSRF-TOKEN"); + assertNotNull(csrfCookie); + String token = JsonPath.read(csrf.getResponse().getContentAsString(), "$.data.token"); + MvcResult login = mockMvc.perform(post("/api/v1/auth/login").cookie(csrfCookie) + .header("X-XSRF-TOKEN", token).contentType("application/json") + .content("{\"username\":\"%s\",\"password\":\"LocalOnly@123\"}".formatted(username))) + .andExpect(status().isOk()).andReturn(); + Cookie session = login.getResponse().getCookie("KAIDI_SESSION"); + assertNotNull(session); + MvcResult selected = mockMvc.perform(post("/api/v1/auth/select-role").cookie(session, csrfCookie) + .header("X-XSRF-TOKEN", token).contentType("application/json") + .content("{\"roleCode\":\"%s\"}".formatted(role))) + .andExpect(status().isOk()).andReturn(); + Cookie rotated = selected.getResponse().getCookie("KAIDI_SESSION"); + return new ClientSession(rotated == null ? session : rotated, csrfCookie, token); + } + + private record ClientSession(Cookie session, Cookie csrf, String token) { + Cookie[] cookies() { + return new Cookie[]{session, csrf}; + } + } + + @TestConfiguration(proxyBeanMethods = false) + static class ScannerTestConfiguration { + @Bean + @Primary + TestFileScanner archiveTestScanner() { + return new TestFileScanner(); + } + } + + static final class TestFileScanner implements FileScanner { + private final AtomicInteger calls = new AtomicInteger(); + private ScanResult result = ScanResult.clean("archive integration test scanner: clean"); + + @Override + public ScanResult scan(Path path) { + calls.incrementAndGet(); + return result; + } + + void unavailable() { + result = ScanResult.unavailable("archive integration test scanner: unavailable"); + } + + int calls() { + return calls.get(); + } + + void reset() { + calls.set(0); + result = ScanResult.clean("archive integration test scanner: clean"); + } + } +} diff --git a/backend/src/test/java/com/kaidi/finance/audit/AuditIntegrationTest.java b/backend/src/test/java/com/kaidi/finance/audit/AuditIntegrationTest.java new file mode 100644 index 0000000..3d83104 --- /dev/null +++ b/backend/src/test/java/com/kaidi/finance/audit/AuditIntegrationTest.java @@ -0,0 +1,545 @@ +package com.kaidi.finance.audit; + +import static org.hamcrest.Matchers.containsString; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.jayway.jsonpath.JsonPath; +import jakarta.servlet.http.Cookie; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.HexFormat; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +/** Regression coverage for the PAGE-20 system-administrator audit contract. */ +@Testcontainers(disabledWithoutDocker = true) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class AuditIntegrationTest { + + private static final String ALLOWED_COMPANY = "01J00000000000000000000001"; + private static final String ALLOWED_PROJECT = "01J00000000000000000000002"; + private static final String DENIED_COMPANY = "01J00000000000000000000003"; + private static final String DENIED_PROJECT = "01J00000000000000000000004"; + private static final String ALLOWED_AUDIT = "01J00000000000000000000005"; + private static final String DENIED_AUDIT = "01J00000000000000000000006"; + private static final String DATE_BEFORE_AUDIT = "01J00000000000000000000009"; + private static final String DATE_START_AUDIT = "01J0000000000000000000000A"; + private static final String DATE_END_AUDIT = "01J0000000000000000000000B"; + private static final String DATE_AFTER_AUDIT = "01J0000000000000000000000C"; + private static final String EXPORT_ACTION = "TEST_AUDIT_EXPORT_FULL"; + private static final String SORT_ACTION = "TEST_AUDIT_SORT"; + private static final String SORT_FIRST_AUDIT = "01J0000000000000000000000Q"; + private static final String SORT_SECOND_AUDIT = "01J0000000000000000000000R"; + private static final String FORMULA_ACTION = "=SUM(1+1)"; + private static final String PREAUTHORIZE_DENIED_REQUEST = "01J0000000000000000000000J"; + private static final String CSRF_DENIED_REQUEST = "01J0000000000000000000000K"; + private static final String AUTHENTICATION_DENIED_REQUEST = "01J0000000000000000000000M"; + + @Container + static final MySQLContainer MYSQL = new MySQLContainer<>("mysql:8.4") + .withDatabaseName("kaidi_finance_audit_test") + .withUsername("kaidi") + .withPassword("kaidi_test_password"); + + @DynamicPropertySource + static void configureDatabase(DynamicPropertyRegistry registry) { + // The audit API treats request bounds and stored timestamps as UTC instants. + // Keep the container connection aligned with production so the default + // current-timestamp seed and explicit boundary fixtures have the same meaning. + registry.add("spring.datasource.url", () -> MYSQL.getJdbcUrl() + + "?connectionTimeZone=UTC&serverTimezone=UTC"); + registry.add("spring.datasource.username", MYSQL::getUsername); + registry.add("spring.datasource.password", MYSQL::getPassword); + registry.add("finance.bootstrap.enabled", () -> true); + registry.add("finance.bootstrap.password", () -> "LocalOnly@123"); + } + + @Autowired + MockMvc mockMvc; + + @Autowired + JdbcTemplate jdbcTemplate; + + @BeforeEach + void seedRows() { + jdbcTemplate.update("DELETE FROM audit_log WHERE public_id IN (?, ?, ?, ?, ?, ?)", ALLOWED_AUDIT, + DENIED_AUDIT, DATE_BEFORE_AUDIT, DATE_START_AUDIT, DATE_END_AUDIT, DATE_AFTER_AUDIT); + insertAudit(ALLOWED_AUDIT, "01J00000000000000000000007", ALLOWED_COMPANY, ALLOWED_PROJECT, + "{\"password\":\"secret\",\"visible\":\"ok\"}"); + insertAudit(DENIED_AUDIT, "01J00000000000000000000008", DENIED_COMPANY, DENIED_PROJECT, + "{\"token\":\"private\"}"); + } + + @AfterEach + void removeSeedRows() { + jdbcTemplate.update("DELETE FROM audit_log WHERE action_code = 'HTTP_ACCESS_DENIED'"); + jdbcTemplate.update("DELETE FROM audit_log WHERE action_code = ?", EXPORT_ACTION); + jdbcTemplate.update("DELETE FROM audit_log WHERE action_code = ?", SORT_ACTION); + jdbcTemplate.update("DELETE FROM audit_log WHERE action_code = ?", FORMULA_ACTION); + jdbcTemplate.update("DELETE FROM audit_log WHERE action_code = 'AUDIT_LOG_EXPORT' AND object_type = 'AUDIT_LOG'"); + jdbcTemplate.update("DELETE FROM export_log WHERE resource_type = 'AUDIT_LOG'"); + jdbcTemplate.update("DELETE FROM audit_log WHERE public_id IN (?, ?, ?, ?, ?, ?)", ALLOWED_AUDIT, + DENIED_AUDIT, DATE_BEFORE_AUDIT, DATE_START_AUDIT, DATE_END_AUDIT, DATE_AFTER_AUDIT); + } + + @Test + void financeManagerCannotReadOrExportAuditLogs() throws Exception { + ClientSession finance = loginAndSelect("finance", "FINANCE_MANAGER"); + + mockMvc.perform(get("/api/v1/audit/logs") + .param("page", "1") + .param("size", "20") + .header("X-Request-Id", PREAUTHORIZE_DENIED_REQUEST) + .cookie(finance.cookies())) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("PERMISSION_DENIED")) + .andExpect(jsonPath("$.requestId").value(PREAUTHORIZE_DENIED_REQUEST)); + + assertDeniedAudit(PREAUTHORIZE_DENIED_REQUEST, "GET", "/api/v1/audit/logs", "finance", + "FINANCE_MANAGER"); + + mockMvc.perform(post("/api/v1/audit/exports") + .cookie(finance.cookies()) + .header("X-XSRF-TOKEN", finance.csrfToken()) + .contentType("application/json") + .content("{}")) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("PERMISSION_DENIED")); + } + + @Test + void csrfFilterRejectionIsAuditedWithoutEnteringTheController() throws Exception { + ClientSession finance = loginAndSelect("finance", "FINANCE_MANAGER"); + + mockMvc.perform(post("/api/v1/audit/exports") + .header("X-Request-Id", CSRF_DENIED_REQUEST) + .cookie(finance.cookies()) + .contentType("application/json") + .content("{}")) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("PERMISSION_DENIED")) + .andExpect(jsonPath("$.requestId").value(CSRF_DENIED_REQUEST)); + + assertDeniedAudit(CSRF_DENIED_REQUEST, "POST", "/api/v1/audit/exports", "finance", + "FINANCE_MANAGER"); + } + + @Test + void anonymousAuthenticationEntryPointRejectionIsAudited() throws Exception { + mockMvc.perform(get("/restricted-area") + .header("X-Request-Id", AUTHENTICATION_DENIED_REQUEST)) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value("AUTH_SESSION_REQUIRED")) + .andExpect(jsonPath("$.requestId").value(AUTHENTICATION_DENIED_REQUEST)); + + Integer count = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM audit_log + WHERE request_id = ? + AND user_public_id IS NULL + AND username IS NULL + AND active_role IS NULL + AND action_code = 'HTTP_ACCESS_DENIED' + AND object_type = 'HTTP_ENDPOINT' + AND result_code = 'DENIED' + AND reason = 'AUTH_SESSION_REQUIRED' + AND JSON_UNQUOTE(JSON_EXTRACT(after_json, '$.method')) = 'GET' + AND JSON_UNQUOTE(JSON_EXTRACT(after_json, '$.uri')) = '/restricted-area' + AND JSON_UNQUOTE(JSON_EXTRACT(after_json, '$.status')) = '401' + AND JSON_UNQUOTE(JSON_EXTRACT(after_json, '$.errorCode')) = 'AUTH_SESSION_REQUIRED' + """, Integer.class, AUTHENTICATION_DENIED_REQUEST); + assertEquals(1, count); + } + + @Test + void systemAdminQueriesAllScopesAndMasksSensitiveValues() throws Exception { + ClientSession admin = loginAndSelect("admin", "SYSTEM_ADMIN"); + jdbcTemplate.update( + "UPDATE audit_log SET reason = ? WHERE public_id = ?", + "审批备注账号 6222020202020202020", ALLOWED_AUDIT + ); + + mockMvc.perform(get("/api/v1/audit/logs") + .param("action", "TEST_AUDIT_SCOPE") + .param("page", "1") + .param("size", "20") + .cookie(admin.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.meta.totalElements").value(2)); + + mockMvc.perform(get("/api/v1/audit/logs/{publicId}", ALLOWED_AUDIT) + .cookie(admin.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.publicId").value(ALLOWED_AUDIT)) + .andExpect(jsonPath("$.data.reason").value("审批备注账号 ***2020")) + .andExpect(jsonPath("$.data.beforeJson").value(org.hamcrest.Matchers.containsString("***"))) + .andExpect(jsonPath("$.data.beforeJson").value(org.hamcrest.Matchers.not( + org.hamcrest.Matchers.containsString("secret")))); + + mockMvc.perform(get("/api/v1/audit/logs/{publicId}", DENIED_AUDIT) + .cookie(admin.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.publicId").value(DENIED_AUDIT)); + } + + @Test + void auditListRejectsUnknownAndMalformedParametersButAcceptsThePublishedWhitelist() throws Exception { + ClientSession admin = loginAndSelect("admin", "SYSTEM_ADMIN"); + + mockMvc.perform(get("/api/v1/audit/logs") + .param("unexpected", "value") + .cookie(admin.cookies())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("QUERY_PARAMETER_INVALID")); + + mockMvc.perform(get("/api/v1/audit/logs") + .param("page", "not-a-number") + .cookie(admin.cookies())) + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("VALIDATION_FAILED")); + + mockMvc.perform(get("/api/v1/audit/logs") + .param("actorId", "A".repeat(27)) + .cookie(admin.cookies())) + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("VALIDATION_FAILED")); + + mockMvc.perform(get("/api/v1/audit/logs") + .param("sort", "username,asc") + .cookie(admin.cookies())) + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("VALIDATION_FAILED")); + + mockMvc.perform(post("/api/v1/audit/exports") + .cookie(admin.cookies()) + .header("X-XSRF-TOKEN", admin.csrfToken()) + .contentType("application/json") + .content("{\"sort\":\"username,asc\"}")) + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("VALIDATION_FAILED")); + + String financeId = jdbcTemplate.queryForObject( + "SELECT public_id FROM iam_user WHERE username = 'finance'", String.class); + mockMvc.perform(get("/api/v1/audit/logs") + .param("actorId", financeId) + .param("action", "TEST_AUDIT_SCOPE") + .param("sort", "eventSequence,asc") + .param("page", "1") + .param("size", "20") + .cookie(admin.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.meta.totalElements").value(2)) + .andExpect(jsonPath("$.data[0].eventSequence").isNumber()); + } + + @Test + void listAndExportUseThePublishedStableAuditSortOrder() throws Exception { + insertAuditWithAction(SORT_FIRST_AUDIT, "01J0000000000000000000000S", ALLOWED_COMPANY, ALLOWED_PROJECT, + SORT_ACTION, "first sort row"); + insertAuditWithAction(SORT_SECOND_AUDIT, "01J0000000000000000000000T", ALLOWED_COMPANY, ALLOWED_PROJECT, + SORT_ACTION, "second sort row"); + jdbcTemplate.update("UPDATE audit_log SET created_at = ? WHERE public_id = ?", + LocalDateTime.parse("2026-08-15T08:00:00"), SORT_FIRST_AUDIT); + jdbcTemplate.update("UPDATE audit_log SET created_at = ? WHERE public_id = ?", + LocalDateTime.parse("2026-08-15T09:00:00"), SORT_SECOND_AUDIT); + Long firstSequence = jdbcTemplate.queryForObject( + "SELECT id FROM audit_log WHERE public_id = ?", Long.class, SORT_FIRST_AUDIT); + Long secondSequence = jdbcTemplate.queryForObject( + "SELECT id FROM audit_log WHERE public_id = ?", Long.class, SORT_SECOND_AUDIT); + org.junit.jupiter.api.Assertions.assertTrue(firstSequence < secondSequence); + ClientSession admin = loginAndSelect("admin", "SYSTEM_ADMIN"); + + mockMvc.perform(get("/api/v1/audit/logs") + .param("occurredFrom", "2026-08-15T00:00:00Z") + .param("occurredTo", "2026-08-16T00:00:00Z") + .param("action", SORT_ACTION) + .param("sort", "eventSequence,asc") + .param("page", "1") + .param("size", "20") + .cookie(admin.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[0].publicId").value(SORT_FIRST_AUDIT)) + .andExpect(jsonPath("$.data[0].eventSequence").value(firstSequence)) + .andExpect(jsonPath("$.data[1].publicId").value(SORT_SECOND_AUDIT)) + .andExpect(jsonPath("$.data[1].eventSequence").value(secondSequence)); + + mockMvc.perform(get("/api/v1/audit/logs") + .param("occurredFrom", "2026-08-15T00:00:00Z") + .param("occurredTo", "2026-08-16T00:00:00Z") + .param("action", SORT_ACTION) + .param("sort", "occurredAt,desc") + .param("page", "1") + .param("size", "20") + .cookie(admin.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[0].publicId").value(SORT_SECOND_AUDIT)) + .andExpect(jsonPath("$.data[1].publicId").value(SORT_FIRST_AUDIT)); + + MvcResult export = mockMvc.perform(post("/api/v1/audit/exports") + .cookie(admin.cookies()) + .header("X-XSRF-TOKEN", admin.csrfToken()) + .contentType("application/json") + .content(""" + {"occurredFrom":"2026-08-15T00:00:00Z","occurredTo":"2026-08-16T00:00:00Z", + "action":"TEST_AUDIT_SORT","sort":"eventSequence,asc"} + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.rowCount").value(2)) + .andReturn(); + String content = JsonPath.read(export.getResponse().getContentAsString(), "$.data.content"); + org.junit.jupiter.api.Assertions.assertTrue( + content.indexOf(SORT_FIRST_AUDIT) < content.indexOf(SORT_SECOND_AUDIT)); + + String storedSort = jdbcTemplate.queryForObject(""" + SELECT JSON_UNQUOTE(JSON_EXTRACT(filter_json, '$.sort')) + FROM export_log + WHERE resource_type = 'AUDIT_LOG' + ORDER BY id DESC LIMIT 1 + """, String.class); + assertEquals("eventSequence,asc", storedSort); + Integer sequenceColumn = jdbcTemplate.queryForObject(""" + SELECT JSON_CONTAINS(columns_json, JSON_QUOTE('eventSequence')) + FROM export_log + WHERE resource_type = 'AUDIT_LOG' + ORDER BY id DESC LIMIT 1 + """, Integer.class); + assertEquals(1, sequenceColumn); + } + + @Test + void requestIdLookupReconstructsHistoryBeyondTheDefaultTwentyFourHours() throws Exception { + ClientSession admin = loginAndSelect("admin", "SYSTEM_ADMIN"); + jdbcTemplate.update("UPDATE audit_log SET created_at = UTC_TIMESTAMP(3) - INTERVAL 2 DAY WHERE public_id = ?", + ALLOWED_AUDIT); + + mockMvc.perform(get("/api/v1/audit/logs") + .param("requestId", "01J00000000000000000000007") + .param("page", "1") + .param("size", "20") + .cookie(admin.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.meta.totalElements").value(1)) + .andExpect(jsonPath("$.data[0].publicId").value(ALLOWED_AUDIT)); + + mockMvc.perform(get("/api/v1/audit/logs") + .param("action", "TEST_AUDIT_SCOPE") + .param("page", "1") + .param("size", "20") + .cookie(admin.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.meta.totalElements").value(1)) + .andExpect(jsonPath("$.data[0].publicId").value(DENIED_AUDIT)); + } + + @Test + void dateRangeUsesUtcHalfOpenBoundariesAndReturnsExplicitUtcInstants() throws Exception { + insertAuditAt(DATE_BEFORE_AUDIT, "01J0000000000000000000000D", "2026-08-11 15:59:59.999"); + insertAuditAt(DATE_START_AUDIT, "01J0000000000000000000000E", "2026-08-11 16:00:00.000"); + insertAuditAt(DATE_END_AUDIT, "01J0000000000000000000000F", "2026-08-12 15:59:59.999"); + insertAuditAt(DATE_AFTER_AUDIT, "01J0000000000000000000000G", "2026-08-12 16:00:00.000"); + ClientSession admin = loginAndSelect("admin", "SYSTEM_ADMIN"); + + mockMvc.perform(get("/api/v1/audit/logs") + .param("occurredFrom", "2026-08-11T16:00:00Z") + .param("occurredTo", "2026-08-12T16:00:00Z") + .param("action", "TEST_AUDIT_DATE") + .param("page", "1") + .param("size", "20") + .cookie(admin.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.meta.totalElements").value(2)) + .andExpect(jsonPath("$.data[0].publicId").value(DATE_END_AUDIT)) + .andExpect(jsonPath("$.data[0].occurredAt").value("2026-08-12T15:59:59.999Z")) + .andExpect(jsonPath("$.data[1].publicId").value(DATE_START_AUDIT)) + .andExpect(jsonPath("$.data[1].occurredAt").value("2026-08-11T16:00:00Z")); + } + + @Test + void systemAdminExportReturnsEveryMatchingRowBeyondTheListPageLimit() throws Exception { + jdbcTemplate.update("DELETE FROM audit_log WHERE action_code = ?", EXPORT_ACTION); + for (int index = 0; index < 101; index++) { + String publicId = "01J00000000000000000000" + String.format("%03d", 200 + index); + insertAuditWithAction(publicId, "01J00000000000000000000" + String.format("%03d", 400 + index), + DENIED_COMPANY, DENIED_PROJECT, EXPORT_ACTION, "export row " + index); + } + ClientSession admin = loginAndSelect("admin", "SYSTEM_ADMIN"); + + mockMvc.perform(get("/api/v1/audit/logs") + .param("action", EXPORT_ACTION) + .param("page", "1") + .param("size", "20") + .cookie(admin.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.meta.totalElements").value(101)); + + MvcResult exportResult = mockMvc.perform(post("/api/v1/audit/exports") + .cookie(admin.cookies()) + .header("X-XSRF-TOKEN", admin.csrfToken()) + .contentType("application/json") + .content("{\"action\":\"%s\"}".formatted(EXPORT_ACTION))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.rowCount").value(101)) + .andExpect(jsonPath("$.data.content").value(containsString("01J00000000000000000000300"))) + .andReturn(); + + String content = JsonPath.read(exportResult.getResponse().getContentAsString(), "$.data.content"); + String responseSha256 = JsonPath.read(exportResult.getResponse().getContentAsString(), "$.data.sha256"); + assertEquals('\uFEFF', content.charAt(0)); + assertEquals(HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(content.getBytes(StandardCharsets.UTF_8))), responseSha256); + + Integer exportLogs = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) FROM export_log + WHERE resource_type = 'AUDIT_LOG' AND row_count = 101 + """, Integer.class); + org.junit.jupiter.api.Assertions.assertEquals(1, exportLogs); + String storedSha256 = jdbcTemplate.queryForObject(""" + SELECT sha256 FROM export_log + WHERE resource_type = 'AUDIT_LOG' AND row_count = 101 + ORDER BY id DESC LIMIT 1 + """, String.class); + assertEquals(responseSha256, storedSha256); + Integer auditEvents = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) FROM audit_log + WHERE action_code = 'AUDIT_LOG_EXPORT' + AND object_type = 'AUDIT_LOG' + AND JSON_EXTRACT(after_json, '$.rowCount') = 101 + """, Integer.class); + org.junit.jupiter.api.Assertions.assertEquals(1, auditEvents); + } + + @Test + void auditExportNeutralizesSpreadsheetFormulaCells() throws Exception { + String publicId = "01J0000000000000000000000N"; + insertAuditWithAction(publicId, "01J0000000000000000000000P", ALLOWED_COMPANY, ALLOWED_PROJECT, + FORMULA_ACTION, "formula regression"); + ClientSession admin = loginAndSelect("admin", "SYSTEM_ADMIN"); + + mockMvc.perform(post("/api/v1/audit/exports") + .cookie(admin.cookies()) + .header("X-XSRF-TOKEN", admin.csrfToken()) + .contentType("application/json") + .content("{\"action\":\"%s\"}".formatted(FORMULA_ACTION))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.rowCount").value(1)) + .andExpect(jsonPath("$.data.content").value(containsString("\"'=SUM(1+1)\""))); + } + + private void insertAudit(String publicId, String requestId, String companyId, String projectId, + String beforeJson) { + insertAuditWithAction(publicId, requestId, companyId, projectId, "TEST_AUDIT_SCOPE", "scope regression", + beforeJson); + } + + private void insertAuditWithAction(String publicId, String requestId, String companyId, String projectId, + String action, String reason) { + insertAuditWithAction(publicId, requestId, companyId, projectId, action, reason, + "{\"password\":\"secret\",\"visible\":\"ok\"}"); + } + + private void insertAuditWithAction(String publicId, String requestId, String companyId, String projectId, + String action, String reason, String beforeJson) { + String userPublicId = jdbcTemplate.queryForObject( + "SELECT public_id FROM iam_user WHERE username = 'finance'", String.class); + jdbcTemplate.update(""" + INSERT INTO audit_log ( + public_id, request_id, user_public_id, username, active_role, + company_public_id, project_public_id, action_code, object_type, + object_public_id, result_code, reason, before_json, after_json, created_at + ) VALUES (?, ?, ?, 'finance', 'FINANCE_MANAGER', ?, ?, ?, + 'TEST', ?, 'SUCCESS', ?, CAST(? AS JSON), CAST(? AS JSON), ?) + """, publicId, requestId, userPublicId, companyId, projectId, action, publicId, reason, + beforeJson, "{\"result\":\"ok\"}", LocalDateTime.now(ZoneOffset.UTC).minusMinutes(1)); + } + + private void insertAuditAt(String publicId, String requestId, String occurredAt) { + String userPublicId = jdbcTemplate.queryForObject( + "SELECT public_id FROM iam_user WHERE username = 'finance'", String.class); + jdbcTemplate.update(""" + INSERT INTO audit_log ( + public_id, request_id, user_public_id, username, active_role, + company_public_id, project_public_id, action_code, object_type, + object_public_id, result_code, reason, before_json, after_json, created_at + ) VALUES (?, ?, ?, 'finance', 'FINANCE_MANAGER', ?, ?, 'TEST_AUDIT_DATE', + 'TEST', ?, 'SUCCESS', 'date boundary', CAST(? AS JSON), CAST(? AS JSON), ?) + """, publicId, requestId, userPublicId, ALLOWED_COMPANY, ALLOWED_PROJECT, publicId, + "{\"before\":true}", "{\"after\":true}", LocalDateTime.parse(occurredAt.replace(' ', 'T'))); + } + + private void assertDeniedAudit(String requestId, String method, String uri, String username, String activeRole) { + String userPublicId = jdbcTemplate.queryForObject( + "SELECT public_id FROM iam_user WHERE username = ?", String.class, username); + Integer count = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM audit_log + WHERE request_id = ? + AND user_public_id = ? + AND username = ? + AND active_role = ? + AND action_code = 'HTTP_ACCESS_DENIED' + AND object_type = 'HTTP_ENDPOINT' + AND object_public_id IS NULL + AND result_code = 'DENIED' + AND reason = 'PERMISSION_DENIED' + AND JSON_UNQUOTE(JSON_EXTRACT(after_json, '$.method')) = ? + AND JSON_UNQUOTE(JSON_EXTRACT(after_json, '$.uri')) = ? + AND JSON_UNQUOTE(JSON_EXTRACT(after_json, '$.status')) = '403' + AND JSON_UNQUOTE(JSON_EXTRACT(after_json, '$.errorCode')) = 'PERMISSION_DENIED' + """, Integer.class, requestId, userPublicId, username, activeRole, method, uri); + assertEquals(1, count); + } + + private ClientSession loginAndSelect(String username, String role) throws Exception { + MvcResult csrf = mockMvc.perform(get("/api/v1/auth/csrf")) + .andExpect(status().isOk()).andReturn(); + Cookie csrfCookie = csrf.getResponse().getCookie("XSRF-TOKEN"); + assertNotNull(csrfCookie); + String csrfToken = JsonPath.read(csrf.getResponse().getContentAsString(), "$.data.token"); + MvcResult login = mockMvc.perform(post("/api/v1/auth/login") + .cookie(csrfCookie) + .header("X-XSRF-TOKEN", csrfToken) + .contentType("application/json") + .content("{\"username\":\"%s\",\"password\":\"LocalOnly@123\"}".formatted(username))) + .andExpect(status().isOk()).andReturn(); + Cookie sessionCookie = login.getResponse().getCookie("KAIDI_SESSION"); + assertNotNull(sessionCookie); + MvcResult selected = mockMvc.perform(post("/api/v1/auth/select-role") + .cookie(sessionCookie, csrfCookie) + .header("X-XSRF-TOKEN", csrfToken) + .contentType("application/json") + .content("{\"roleCode\":\"%s\"}".formatted(role))) + .andExpect(status().isOk()).andReturn(); + Cookie rotated = selected.getResponse().getCookie("KAIDI_SESSION"); + return new ClientSession(rotated == null ? sessionCookie : rotated, csrfCookie, csrfToken); + } + + private record ClientSession(Cookie sessionCookie, Cookie csrfCookie, String csrfToken) { + Cookie[] cookies() { + return new Cookie[]{sessionCookie, csrfCookie}; + } + } +} diff --git a/backend/src/test/java/com/kaidi/finance/governance/GovernanceIntegrationTest.java b/backend/src/test/java/com/kaidi/finance/governance/GovernanceIntegrationTest.java new file mode 100644 index 0000000..30d3861 --- /dev/null +++ b/backend/src/test/java/com/kaidi/finance/governance/GovernanceIntegrationTest.java @@ -0,0 +1,343 @@ +package com.kaidi.finance.governance; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.jayway.jsonpath.JsonPath; +import jakarta.servlet.http.Cookie; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +@Testcontainers(disabledWithoutDocker = true) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class GovernanceIntegrationTest { + + @Container + static final MySQLContainer MYSQL = new MySQLContainer<>("mysql:8.4") + .withDatabaseName("kaidi_finance_governance_test") + .withUsername("kaidi") + .withPassword("kaidi_test_password"); + + @DynamicPropertySource + static void configureDatabase(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", MYSQL::getJdbcUrl); + registry.add("spring.datasource.username", MYSQL::getUsername); + registry.add("spring.datasource.password", MYSQL::getPassword); + registry.add("finance.bootstrap.enabled", () -> true); + registry.add("finance.bootstrap.password", () -> "LocalOnly@123"); + } + + @Autowired + MockMvc mockMvc; + + @Autowired + JdbcTemplate jdbcTemplate; + + @Test + void systemUpdateStatusIsAdministratorOnlyAndDisabledConfigurationDoesNotQueueWork() throws Exception { + ClientSession admin = loginAndSelect("admin", "SYSTEM_ADMIN"); + mockMvc.perform(get("/api/v1/admin/system-update").cookie(admin.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.enabled").value(false)) + .andExpect(jsonPath("$.data.state").value("DISABLED")) + .andExpect(jsonPath("$.data.allowedActions.length()").value(0)); + + ClientSession finance = loginAndSelect("finance", "FINANCE_MANAGER"); + mockMvc.perform(get("/api/v1/admin/system-update").cookie(finance.cookies())) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("PERMISSION_DENIED")); + + mockMvc.perform(post("/api/v1/admin/system-update/check") + .cookie(admin.cookies()).header("X-XSRF-TOKEN", admin.csrfToken())) + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("VALIDATION_FAILED")); + } + + @Test + void retiredTemplateAndParameterRequireExplicitRestoreAndAreAudited() throws Exception { + ClientSession admin = loginAndSelect("admin", "SYSTEM_ADMIN"); + String templateId = create(admin, "templates", """ + {"formType":"OA-01","templateVersion":2,"name":"治理恢复测试模板","schemaVersion":2} + """); + assertVersionLifecycle(admin, "templates", templateId, "ADMIN_TEMPLATE_RESTORE"); + + String parameterId = create(admin, "parameters", """ + {"parameterGroup":"GOVERNANCE_RESTORE_TEST","valueJson":"{}"} + """); + assertVersionLifecycle(admin, "parameters", parameterId, "ADMIN_PARAMETER_RESTORE"); + } + + @Test + void userDisableInvalidatesExistingRuntimeSessionAndLeavesAuditTrail() throws Exception { + ClientSession finance = loginAndSelect("finance", "FINANCE_MANAGER"); + ClientSession admin = loginAndSelect("admin", "SYSTEM_ADMIN"); + String financeId = jdbcTemplate.queryForObject( + "SELECT public_id FROM iam_user WHERE username = 'finance'", String.class); + Long version = jdbcTemplate.queryForObject( + "SELECT version FROM iam_user WHERE username = 'finance'", Long.class); + command(admin, "users", financeId, "enable", version) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("INVALID_STATE_TRANSITION")); + command(admin, "users", financeId, "disable", version) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("DISABLED")); + mockMvc.perform(get("/api/v1/auth/session").cookie(finance.cookies())) + .andExpect(status().isUnauthorized()); + Integer auditCount = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM audit_log WHERE action_code = 'ADMIN_USER_DISABLE' " + + "AND object_public_id = ? AND result_code = 'SUCCESS'", + Integer.class, financeId); + org.junit.jupiter.api.Assertions.assertEquals(1, auditCount); + command(admin, "users", financeId, "enable", version + 1).andExpect(status().isOk()); + } + + @Test + void scopeCreationInvalidatesExistingSessionAndVersionGroupCreatesSerialize() throws Exception { + ClientSession admin = loginAndSelect("admin", "SYSTEM_ADMIN"); + String scopedUsername = "scope" + System.nanoTime(); + MvcResult createdUser = mockMvc.perform(post("/api/v1/admin/users").cookie(admin.cookies()) + .header("X-XSRF-TOKEN", admin.csrfToken()).contentType("application/json") + .content(""" + {"username":"%s","displayName":"范围会话测试","password":"LocalOnly@123","roleCode":"FINANCE_MANAGER"} + """.formatted(scopedUsername))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("ACTIVE")) + .andReturn(); + String scopedUserId = JsonPath.read(createdUser.getResponse().getContentAsString(), "$.data.publicId"); + ClientSession scopedUser = loginAndSelect(scopedUsername, "FINANCE_MANAGER"); + String permissionCode = "payment:request:view"; + + mockMvc.perform(post("/api/v1/admin/scopes").cookie(admin.cookies()) + .header("X-XSRF-TOKEN", admin.csrfToken()).contentType("application/json") + .content(""" + {"userId":"%s","roleCode":"FINANCE_MANAGER","permissionCode":"%s","scopeType":"GLOBAL"} + """.formatted(scopedUserId, permissionCode))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("ACTIVE")); + mockMvc.perform(get("/api/v1/auth/session").cookie(scopedUser.cookies())) + .andExpect(status().isUnauthorized()); + + String parameterGroup = "GOVERNANCE_PARALLEL_CREATE"; + CountDownLatch ready = new CountDownLatch(2); + CountDownLatch start = new CountDownLatch(1); + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + Future first = executor.submit(() -> concurrentParameterCreate(admin, parameterGroup, ready, start)); + Future second = executor.submit(() -> concurrentParameterCreate(admin, parameterGroup, ready, start)); + assertTrue(ready.await(5, TimeUnit.SECONDS), "并发参数创建请求未按时就绪"); + start.countDown(); + List results = List.of(first.get(15, TimeUnit.SECONDS), second.get(15, TimeUnit.SECONDS)); + String responses = results.stream().map(result -> "status=" + result.getResponse().getStatus() + + ", body=" + new String(result.getResponse().getContentAsByteArray(), + java.nio.charset.StandardCharsets.UTF_8)).collect(java.util.stream.Collectors.joining("\n")); + assertEquals(2, results.stream().filter(result -> result.getResponse().getStatus() == 200).count(), responses); + } finally { + start.countDown(); + executor.shutdownNow(); + assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS), "并发参数创建线程未按时结束"); + } + List versions = jdbcTemplate.queryForList( + "SELECT version_no FROM sys_parameter_version WHERE parameter_group = ? ORDER BY version_no", + Integer.class, parameterGroup); + assertEquals(List.of(1, 2), versions); + } + + @Test + void governanceListsExposeStableDtoAndRejectUnknownQueryParameters() throws Exception { + ClientSession admin = loginAndSelect("admin", "SYSTEM_ADMIN"); + String scopedUsername = "dto" + System.nanoTime(); + MvcResult createdUser = mockMvc.perform(post("/api/v1/admin/users") + .cookie(admin.cookies()).header("X-XSRF-TOKEN", admin.csrfToken()) + .contentType("application/json") + .content(""" + {"username":"%s","displayName":"DTO 映射测试","password":"LocalOnly@123", + "roleCode":"FINANCE_MANAGER"} + """.formatted(scopedUsername))) + .andExpect(status().isOk()) + .andReturn(); + String scopedUserId = JsonPath.read(createdUser.getResponse().getContentAsString(), "$.data.publicId"); + mockMvc.perform(post("/api/v1/admin/scopes") + .cookie(admin.cookies()).header("X-XSRF-TOKEN", admin.csrfToken()) + .contentType("application/json") + .content(""" + {"userId":"%s","roleCode":"FINANCE_MANAGER","permissionCode":"masterdata:company:view", + "scopeType":"GLOBAL","amountLimit":12345.67} + """.formatted(scopedUserId))) + .andExpect(status().isOk()); + + mockMvc.perform(get("/api/v1/admin/users") + .param("keyword", "finance").param("page", "1").param("size", "20") + .cookie(admin.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.length()").value(1)) + .andExpect(jsonPath("$.data[0].resource").value("users")) + .andExpect(jsonPath("$.data[0].username").value("finance")) + .andExpect(jsonPath("$.data[0].publicId").isString()) + .andExpect(jsonPath("$.data[0].roleCodes").isArray()) + .andExpect(jsonPath("$.data[0].status").value("ACTIVE")) + .andExpect(jsonPath("$.data[0].updatedAt").isString()) + .andExpect(jsonPath("$.data[0].public_id").doesNotExist()) + .andExpect(jsonPath("$.data[0].role_codes").doesNotExist()); + + mockMvc.perform(get("/api/v1/admin/roles").param("keyword", "FINANCE_MANAGER") + .cookie(admin.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.length()").value(1)) + .andExpect(jsonPath("$.data[0].resource").value("roles")) + .andExpect(jsonPath("$.data[0].code").value("FINANCE_MANAGER")) + .andExpect(jsonPath("$.data[0].memberCount").isNumber()) + .andExpect(jsonPath("$.data[0].updatedAt").isString()); + + mockMvc.perform(get("/api/v1/admin/scopes").param("keyword", scopedUsername) + .cookie(admin.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.length()").value(1)) + .andExpect(jsonPath("$.data[0].resource").value("scopes")) + .andExpect(jsonPath("$.data[0].userPublicId").value(scopedUserId)) + .andExpect(jsonPath("$.data[0].roleCode").value("FINANCE_MANAGER")) + .andExpect(jsonPath("$.data[0].permissionCode").value("masterdata:company:view")) + .andExpect(jsonPath("$.data[0].amountLimit").value("12345.67")) + .andExpect(jsonPath("$.data[0].updatedAt").isString()) + .andExpect(jsonPath("$.data[0].amount_limit").doesNotExist()); + + mockMvc.perform(get("/api/v1/admin/templates").param("page", "1").param("size", "20") + .cookie(admin.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data").isNotEmpty()) + .andExpect(jsonPath("$.data[0].resource").value("templates")) + .andExpect(jsonPath("$.data[0].formType").isString()) + .andExpect(jsonPath("$.data[0].templateVersion").isNumber()) + .andExpect(jsonPath("$.data[0].updatedAt").isString()); + + mockMvc.perform(get("/api/v1/admin/parameters").param("page", "1").param("size", "20") + .cookie(admin.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data").isNotEmpty()) + .andExpect(jsonPath("$.data[0].resource").value("parameters")) + .andExpect(jsonPath("$.data[0].parameterGroup").isString()) + .andExpect(jsonPath("$.data[0].versionNo").isNumber()) + .andExpect(jsonPath("$.data[0].createdAt").isString()) + .andExpect(jsonPath("$.data[0].updatedAt").isString()); + + mockMvc.perform(get("/api/v1/admin/users").param("unexpected", "ignored-before-fix") + .cookie(admin.cookies())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("QUERY_PARAMETER_INVALID")); + mockMvc.perform(get("/api/v1/admin/unknown-resource").cookie(admin.cookies())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("QUERY_PARAMETER_INVALID")); + } + + private String create(ClientSession session, String resource, String body) throws Exception { + MvcResult result = mockMvc.perform(post("/api/v1/admin/" + resource) + .cookie(session.cookies()).header("X-XSRF-TOKEN", session.csrfToken()) + .contentType("application/json").content(body)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("DRAFT")) + .andExpect(jsonPath("$.data.version").value(0)) + .andReturn(); + return JsonPath.read(result.getResponse().getContentAsString(), "$.data.publicId"); + } + + private void assertVersionLifecycle(ClientSession session, String resource, String publicId, String restoreAudit) + throws Exception { + command(session, resource, publicId, "publish", 0) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("ACTIVE")) + .andExpect(jsonPath("$.data.version").value(1)) + .andExpect(jsonPath("$.data.allowedActions[0]").value("DISABLE")); + command(session, resource, publicId, "disable", 0) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("VERSION_CONFLICT")); + command(session, resource, publicId, "disable", 1) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("RETIRED")) + .andExpect(jsonPath("$.data.version").value(2)) + .andExpect(jsonPath("$.data.allowedActions[0]").value("RESTORE")); + command(session, resource, publicId, "publish", 2) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("INVALID_STATE_TRANSITION")); + command(session, resource, publicId, "restore", 2) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("ACTIVE")) + .andExpect(jsonPath("$.data.version").value(3)) + .andExpect(jsonPath("$.data.allowedActions[0]").value("DISABLE")); + mockMvc.perform(get("/api/v1/admin/" + resource + "/" + publicId).cookie(session.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.allowedActions").isNotEmpty()); + Integer auditCount = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM audit_log WHERE action_code = ? AND object_public_id = ? AND result_code = 'SUCCESS'", + Integer.class, restoreAudit, publicId); + org.junit.jupiter.api.Assertions.assertEquals(1, auditCount); + } + + private org.springframework.test.web.servlet.ResultActions command(ClientSession session, String resource, + String publicId, String command, + long version) throws Exception { + return mockMvc.perform(post("/api/v1/admin/{resource}/{publicId}/{command}", resource, publicId, command) + .cookie(session.cookies()).header("X-XSRF-TOKEN", session.csrfToken()) + .contentType("application/json").content("{\"version\":%d,\"reason\":\"治理版本测试\"}".formatted(version))); + } + + private MvcResult concurrentParameterCreate(ClientSession session, String group, CountDownLatch ready, + CountDownLatch start) throws Exception { + ready.countDown(); + if (!start.await(5, TimeUnit.SECONDS)) throw new IllegalStateException("并发参数创建启动信号超时"); + return mockMvc.perform(post("/api/v1/admin/parameters").cookie(session.cookies()) + .header("X-XSRF-TOKEN", session.csrfToken()).contentType("application/json") + .content("{\"parameterGroup\":\"%s\",\"valueJson\":\"{}\"}".formatted(group))) + .andReturn(); + } + + private ClientSession loginAndSelect(String username, String role) throws Exception { + MvcResult csrf = mockMvc.perform(get("/api/v1/auth/csrf")).andExpect(status().isOk()).andReturn(); + Cookie csrfCookie = csrf.getResponse().getCookie("XSRF-TOKEN"); + assertNotNull(csrfCookie); + String csrfToken = JsonPath.read(csrf.getResponse().getContentAsString(), "$.data.token"); + MvcResult login = mockMvc.perform(post("/api/v1/auth/login") + .cookie(csrfCookie).header("X-XSRF-TOKEN", csrfToken) + .contentType("application/json") + .content("{\"username\":\"%s\",\"password\":\"LocalOnly@123\"}".formatted(username))) + .andExpect(status().isOk()).andReturn(); + Cookie sessionCookie = login.getResponse().getCookie("KAIDI_SESSION"); + assertNotNull(sessionCookie); + MvcResult selected = mockMvc.perform(post("/api/v1/auth/select-role") + .cookie(sessionCookie, csrfCookie).header("X-XSRF-TOKEN", csrfToken) + .contentType("application/json").content("{\"roleCode\":\"%s\"}".formatted(role))) + .andExpect(status().isOk()).andReturn(); + Cookie rotated = selected.getResponse().getCookie("KAIDI_SESSION"); + return new ClientSession(rotated == null ? sessionCookie : rotated, csrfCookie, csrfToken); + } + + private record ClientSession(Cookie sessionCookie, Cookie csrfCookie, String csrfToken) { + Cookie[] cookies() { + return new Cookie[]{sessionCookie, csrfCookie}; + } + } +} diff --git a/backend/src/test/java/com/kaidi/finance/iam/AuthIntegrationTest.java b/backend/src/test/java/com/kaidi/finance/iam/AuthIntegrationTest.java new file mode 100644 index 0000000..258b1c8 --- /dev/null +++ b/backend/src/test/java/com/kaidi/finance/iam/AuthIntegrationTest.java @@ -0,0 +1,603 @@ +package com.kaidi.finance.iam; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.hamcrest.Matchers.hasSize; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.jayway.jsonpath.JsonPath; +import com.kaidi.finance.iam.application.BootstrapUserInitializer; +import jakarta.servlet.http.Cookie; +import com.kaidi.finance.shared.security.SessionLifecycle; +import java.time.Instant; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.session.FindByIndexNameSessionRepository; +import org.springframework.session.Session; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +@Testcontainers(disabledWithoutDocker = true) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class AuthIntegrationTest { + + @Container + static final MySQLContainer MYSQL = new MySQLContainer<>("mysql:8.4") + .withDatabaseName("kaidi_finance_test") + .withUsername("kaidi") + .withPassword("kaidi_test_password"); + + @DynamicPropertySource + static void configureDatabase(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", MYSQL::getJdbcUrl); + registry.add("spring.datasource.username", MYSQL::getUsername); + registry.add("spring.datasource.password", MYSQL::getPassword); + registry.add("finance.bootstrap.enabled", () -> true); + registry.add("finance.bootstrap.password", () -> "LocalOnly@123"); + } + + @Autowired + MockMvc mockMvc; + + @Autowired + JdbcTemplate jdbcTemplate; + + @Autowired + PasswordEncoder passwordEncoder; + + @Autowired + FindByIndexNameSessionRepository sessionRepository; + + @Autowired + BootstrapUserInitializer bootstrapUserInitializer; + + @BeforeEach + void resetAccounts() { + for (String username : List.of("admin", "project", "finance", "archive", "demo")) { + sessionRepository.findByPrincipalName(username).keySet().forEach(sessionRepository::deleteById); + } + jdbcTemplate.update(""" + DELETE assignment + FROM iam_user_role assignment + JOIN iam_user user_account ON user_account.id = assignment.user_id + JOIN iam_role role ON role.id = assignment.role_id + WHERE user_account.username = 'project' AND role.code = 'SYSTEM_ADMIN' + """); + jdbcTemplate.update(""" + UPDATE iam_user SET enabled = TRUE, must_change_password = FALSE, + password_hash = ?, failed_login_count = 0, locked_until = NULL + """, passwordEncoder.encode("LocalOnly@123")); + jdbcTemplate.update("DELETE FROM audit_log WHERE action_code='AUTH_LOGIN'"); + } + + @Test + void csrfEndpointIsAvailableWithoutAuthentication() throws Exception { + mockMvc.perform(get("/api/v1/auth/csrf")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.headerName").value("X-XSRF-TOKEN")) + .andExpect(jsonPath("$.data.token").isNotEmpty()) + .andExpect(jsonPath("$.requestId").isNotEmpty()) + .andExpect(result -> assertNotNull(result.getResponse().getCookie("XSRF-TOKEN"), + "CSRF endpoint must issue the readable CSRF cookie")); + } + + @Test + void unknownApiPathReturnsUniformNotFoundProblemWithoutInternalDetails() throws Exception { + ClientSession session = clientSession(login("finance", "LocalOnly@123").andReturn()); + + mockMvc.perform(get("/api/v1/unknown-resource").cookie(session.cookies())) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("RESOURCE_NOT_FOUND")) + .andExpect(jsonPath("$.detail").value("请求的资源不存在")); + + mockMvc.perform(get("/api/v1/unknown-resource")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("RESOURCE_NOT_FOUND")) + .andExpect(jsonPath("$.detail").value("请求的资源不存在")); + } + + @Test + void everyLoginRequiresExplicitRoleSelection() throws Exception { + MvcResult login = login("project", "LocalOnly@123") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.authenticated").value(true)) + .andExpect(jsonPath("$.data.activeRole").doesNotExist()) + .andExpect(jsonPath("$.data.roles[0].code").value("PROJECT_MANAGER")) + .andExpect(jsonPath("$.data.permissions").isEmpty()) + .andReturn(); + + ClientSession session = clientSession(login); + mockMvc.perform(post("/api/v1/auth/select-role") + .cookie(session.cookies()) + .header("X-XSRF-TOKEN", session.csrfToken()) + .contentType("application/json") + .content("{\"roleCode\":\"PROJECT_MANAGER\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.activeRole").value("PROJECT_MANAGER")) + .andExpect(jsonPath("$.data.permissions").isNotEmpty()); + } + + @Test + void sessionProfileAndRoleContractsExposeOnlyCurrentUsersNavigationData() throws Exception { + MvcResult login = loginWithClient("project", "LocalOnly@123", "198.51.100.42", + "Mozilla/5.0 (Macintosh) Chrome/126.0") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.roles[0].workbenchRoute").value("/workbench/project")) + .andExpect(jsonPath("$.data.permissionVersion").isNotEmpty()) + .andExpect(jsonPath("$.data.sessionExpiresAt").isNotEmpty()) + .andExpect(jsonPath("$.data.absoluteSessionExpiresAt").isNotEmpty()) + .andReturn(); + ClientSession session = clientSession(login); + + mockMvc.perform(get("/api/v1/auth/profile").cookie(session.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.username").value("project")) + .andExpect(jsonPath("$.data.authorizationScopes[0].roleCode").value("PROJECT_MANAGER")) + .andExpect(jsonPath("$.data.authorizationScopes[0].scopeType").value("GLOBAL")) + .andExpect(jsonPath("$.data.recentLogins[0].maskedIp").value("198.51.*.*")) + .andExpect(jsonPath("$.data.recentLogins[0].deviceSummary").value("Chrome · macOS")) + .andExpect(jsonPath("$.data.recentLogins[0].ipAddress").doesNotExist()) + .andExpect(jsonPath("$.data.recentLogins[0].userAgent").doesNotExist()) + .andExpect(jsonPath("$.data.passwordHash").doesNotExist()); + mockMvc.perform(get("/api/v1/auth/roles").cookie(session.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[0].code").value("PROJECT_MANAGER")); + mockMvc.perform(get("/api/v1/auth/sessions").cookie(session.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[0].deviceSummary").value("Chrome · macOS")) + .andExpect(jsonPath("$.data[0].maskedIp").value("198.51.*.*")); + } + + @Test + void systemAdministratorUsesAnIsolatedCanonicalRole() throws Exception { + MvcResult login = login("admin", "LocalOnly@123") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.roles.length()").value(1)) + .andExpect(jsonPath("$.data.roles[0].code").value("SYSTEM_ADMIN")) + .andExpect(jsonPath("$.data.roles[0].name").value("超级管理员")) + .andExpect(jsonPath("$.data.roles[0].workbenchRoute").value("/governance/settings")) + .andReturn(); + + ClientSession session = clientSession(login); + MvcResult selected = mockMvc.perform(post("/api/v1/auth/select-role") + .cookie(session.cookies()) + .header("X-XSRF-TOKEN", session.csrfToken()) + .contentType("application/json") + .content("{\"roleCode\":\"SYSTEM_ADMIN\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.activeRole").value("SYSTEM_ADMIN")) + .andExpect(jsonPath("$.data.permissions").isNotEmpty()) + .andReturn(); + + List permissions = JsonPath.read(selected.getResponse().getContentAsString(), "$.data.permissions"); + Integer permissionCount = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM iam_permission", Integer.class); + Integer globalScopeCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM iam_scope scope_grant + JOIN iam_user user_account ON user_account.id = scope_grant.user_id + JOIN iam_role role ON role.id = scope_grant.role_id + WHERE user_account.username = 'admin' + AND role.code = 'SYSTEM_ADMIN' + AND scope_grant.scope_type = 'GLOBAL' + AND scope_grant.status = 'ACTIVE' + AND scope_grant.amount_limit IS NULL + AND scope_grant.valid_from <= UTC_TIMESTAMP(3) + AND scope_grant.valid_to IS NULL + """, Integer.class); + assertEquals(permissionCount, permissions.size()); + assertEquals(permissionCount, globalScopeCount); + } + + @Test + void mixedSystemAdministratorAndBusinessRolesAreBlockedAndAuditedAtLogin() throws Exception { + jdbcTemplate.update(""" + INSERT INTO iam_user_role (user_id, role_id) + SELECT user_account.id, role.id + FROM iam_user user_account + JOIN iam_role role ON role.code = 'SYSTEM_ADMIN' + WHERE user_account.username = 'project' + """); + + login("project", "LocalOnly@123") + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("ACCESS_DENIED")); + assertTrue(jdbcTemplate.queryForObject(""" + SELECT COUNT(*) FROM audit_log + WHERE username = 'project' AND action_code = 'AUTH_LOGIN' + AND result_code = 'FAILED' AND reason = 'ROLE_CONFIGURATION_CONFLICT' + """, Integer.class) == 1); + } + + @Test + void passwordConfirmationMismatchIsRejectedWithoutChangingPassword() throws Exception { + ClientSession session = clientSession(login("project", "LocalOnly@123").andReturn()); + mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch( + "/api/v1/auth/password") + .cookie(session.cookies()) + .header("X-XSRF-TOKEN", session.csrfToken()) + .contentType("application/json") + .content(""" + { + "currentPassword":"LocalOnly@123", + "newPassword":"ChangedOnly@123", + "confirmPassword":"DifferentOnly@123" + } + """)) + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("VALIDATION_FAILED")) + .andExpect(jsonPath("$.fieldErrors.confirmPassword").isNotEmpty()); + + login("project", "LocalOnly@123").andExpect(status().isOk()); + } + + @Test + void requiredPasswordChangeBlocksBusinessAccessAndInvalidatesOtherSessions() throws Exception { + jdbcTemplate.update("UPDATE iam_user SET must_change_password = TRUE WHERE username = 'project'"); + ClientSession first = clientSession(login("project", "LocalOnly@123") + .andExpect(jsonPath("$.data.user.mustChangePassword").value(true)).andReturn()); + ClientSession second = clientSession(login("project", "LocalOnly@123").andReturn()); + + MvcResult selected = mockMvc.perform(post("/api/v1/auth/select-role") + .cookie(first.cookies()).header("X-XSRF-TOKEN", first.csrfToken()) + .contentType("application/json").content("{\"roleCode\":\"PROJECT_MANAGER\"}")) + .andExpect(status().isOk()).andReturn(); + Cookie rotated = selected.getResponse().getCookie("KAIDI_SESSION"); + ClientSession active = new ClientSession(rotated == null ? first.sessionCookie() : rotated, + first.csrfCookie(), first.csrfToken()); + mockMvc.perform(get("/api/v1/workbenches/project").cookie(active.cookies())) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("AUTH_PASSWORD_CHANGE_REQUIRED")); + mockMvc.perform(get("/api/v1/auth/sessions").cookie(active.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.length()").value(org.hamcrest.Matchers.greaterThanOrEqualTo(2))); + + mockMvc.perform(patch("/api/v1/auth/password") + .cookie(active.cookies()).header("X-XSRF-TOKEN", active.csrfToken()) + .contentType("application/json").content(""" + {"currentPassword":"LocalOnly@123","newPassword":"ChangedOnly@123", + "confirmPassword":"ChangedOnly@123"} + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.user.mustChangePassword").value(false)); + + mockMvc.perform(get("/api/v1/workbenches/project").cookie(active.cookies())) + .andExpect(status().isOk()); + mockMvc.perform(get("/api/v1/auth/session").cookie(second.cookies())) + .andExpect(status().isUnauthorized()); + } + + @Test + void ungrantedRoleAndAnonymousSessionAreRejected() throws Exception { + mockMvc.perform(get("/api/v1/auth/session")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value("AUTH_SESSION_REQUIRED")); + + ClientSession session = clientSession(login("project", "LocalOnly@123").andReturn()); + mockMvc.perform(post("/api/v1/auth/select-role") + .cookie(session.cookies()) + .header("X-XSRF-TOKEN", session.csrfToken()) + .contentType("application/json") + .content("{\"roleCode\":\"FINANCE_MANAGER\"}")) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("AUTH_ROLE_NOT_GRANTED")); + } + + @Test + void roleSelectionRechecksCurrentDatabaseAssignmentsAndSessionDropsRevokedActiveRole() throws Exception { + ClientSession session = clientSession(login("project", "LocalOnly@123").andReturn()); + MvcResult selected = mockMvc.perform(post("/api/v1/auth/select-role") + .cookie(session.cookies()) + .header("X-XSRF-TOKEN", session.csrfToken()) + .contentType("application/json") + .content("{\"roleCode\":\"PROJECT_MANAGER\"}")) + .andExpect(status().isOk()) + .andReturn(); + Cookie rotated = selected.getResponse().getCookie("KAIDI_SESSION"); + ClientSession active = new ClientSession(rotated == null ? session.sessionCookie() : rotated, + session.csrfCookie(), session.csrfToken()); + + jdbcTemplate.update(""" + DELETE assignment + FROM iam_user_role assignment + JOIN iam_user user_account ON user_account.id = assignment.user_id + JOIN iam_role role ON role.id = assignment.role_id + WHERE user_account.username = 'project' AND role.code = 'PROJECT_MANAGER' + """); + try { + mockMvc.perform(get("/api/v1/auth/session").cookie(active.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.activeRole").doesNotExist()) + .andExpect(jsonPath("$.data.roles").isEmpty()) + .andExpect(jsonPath("$.data.permissions").isEmpty()); + mockMvc.perform(post("/api/v1/auth/select-role") + .cookie(active.cookies()).header("X-XSRF-TOKEN", active.csrfToken()) + .contentType("application/json").content("{\"roleCode\":\"PROJECT_MANAGER\"}")) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("AUTH_ROLE_NOT_GRANTED")); + } finally { + jdbcTemplate.update(""" + INSERT INTO iam_user_role (user_id, role_id) + SELECT user_account.id, role.id + FROM iam_user user_account + JOIN iam_role role ON role.code = 'PROJECT_MANAGER' + WHERE user_account.username = 'project' + """); + } + } + + @Test + void roleSelectionRejectsAdministratorBusinessRoleConflictIntroducedAfterLogin() throws Exception { + ClientSession session = clientSession(login("project", "LocalOnly@123").andReturn()); + jdbcTemplate.update(""" + INSERT INTO iam_user_role (user_id, role_id) + SELECT user_account.id, role.id + FROM iam_user user_account + JOIN iam_role role ON role.code = 'SYSTEM_ADMIN' + WHERE user_account.username = 'project' + """); + + try { + mockMvc.perform(post("/api/v1/auth/select-role") + .cookie(session.cookies()).header("X-XSRF-TOKEN", session.csrfToken()) + .contentType("application/json").content("{\"roleCode\":\"PROJECT_MANAGER\"}")) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("ACCESS_DENIED")); + assertTrue(jdbcTemplate.queryForObject(""" + SELECT COUNT(*) FROM audit_log + WHERE username = 'project' AND action_code = 'AUTH_ROLE_CONFIGURATION_CONFLICT' + AND result_code = 'FAILED' AND reason = 'ROLE_CONFIGURATION_CONFLICT' + """, Integer.class) == 1); + } finally { + jdbcTemplate.update(""" + DELETE assignment + FROM iam_user_role assignment + JOIN iam_user user_account ON user_account.id = assignment.user_id + JOIN iam_role role ON role.id = assignment.role_id + WHERE user_account.username = 'project' AND role.code = 'SYSTEM_ADMIN' + """); + } + } + + @Test + void fiveBadPasswordsLockTheAccountAndLogoutInvalidatesSession() throws Exception { + for (int attempt = 0; attempt < 5; attempt++) { + login("finance", "wrong-password") + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value("AUTH_BAD_CREDENTIALS")); + } + + login("finance", "LocalOnly@123") + .andExpect(status().isLocked()) + .andExpect(jsonPath("$.code").value("AUTH_ACCOUNT_LOCKED")); + + jdbcTemplate.update(""" + UPDATE iam_user SET failed_login_count = 0, locked_until = NULL WHERE username = 'finance' + """); + ClientSession session = clientSession(login("finance", "LocalOnly@123").andReturn()); + mockMvc.perform(post("/api/v1/auth/logout") + .cookie(session.cookies()) + .header("X-XSRF-TOKEN", session.csrfToken())) + .andExpect(status().isOk()); + mockMvc.perform(get("/api/v1/auth/session").cookie(session.cookies())) + .andExpect(status().isUnauthorized()); + } + + @Test + void fourthLoginEvictsOldestSessionAndKeepsOnlyThree() throws Exception { + ClientSession first = clientSession(login("project", "LocalOnly@123").andReturn()); + Thread.sleep(5); + ClientSession second = clientSession(login("project", "LocalOnly@123").andReturn()); + Thread.sleep(5); + ClientSession third = clientSession(login("project", "LocalOnly@123").andReturn()); + Thread.sleep(5); + ClientSession fourth = clientSession(login("project", "LocalOnly@123").andReturn()); + + mockMvc.perform(get("/api/v1/auth/session").cookie(first.cookies())) + .andExpect(status().isUnauthorized()); + mockMvc.perform(get("/api/v1/auth/session").cookie(second.cookies())).andExpect(status().isOk()); + mockMvc.perform(get("/api/v1/auth/session").cookie(third.cookies())).andExpect(status().isOk()); + mockMvc.perform(get("/api/v1/auth/sessions").cookie(fourth.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.length()").value(3)) + .andExpect(jsonPath("$.data[?(@.current == true)]", hasSize(1))); + assertTrue(jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM audit_log WHERE action_code='AUTH_SESSION_EVICTED'", Integer.class) > 0); + } + + @Test + void sessionDeletionIsOwnerScopedCsrfProtectedAndCanDeleteAll() throws Exception { + ClientSession first = clientSession(login("project", "LocalOnly@123").andReturn()); + ClientSession second = clientSession(login("project", "LocalOnly@123").andReturn()); + String firstId = sessionIds("project").stream() + .filter(id -> !id.equals(rawSessionId(second.sessionCookie()))) + .findFirst().orElseThrow(); + + mockMvc.perform(delete("/api/v1/auth/sessions/{sessionId}", firstId).cookie(second.cookies())) + .andExpect(status().isForbidden()); + mockMvc.perform(delete("/api/v1/auth/sessions/{sessionId}", firstId).cookie(second.cookies()) + .header("X-XSRF-TOKEN", second.csrfToken())) + .andExpect(status().isOk()); + mockMvc.perform(get("/api/v1/auth/session").cookie(first.cookies())) + .andExpect(status().isUnauthorized()); + + ClientSession finance = clientSession(login("finance", "LocalOnly@123").andReturn()); + String financeId = sessionIds("finance").get(0); + mockMvc.perform(delete("/api/v1/auth/sessions/{sessionId}", financeId).cookie(second.cookies()) + .header("X-XSRF-TOKEN", second.csrfToken())) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("RESOURCE_NOT_FOUND")); + + mockMvc.perform(delete("/api/v1/auth/sessions").cookie(second.cookies()) + .header("X-XSRF-TOKEN", second.csrfToken())) + .andExpect(status().isOk()); + mockMvc.perform(get("/api/v1/auth/session").cookie(second.cookies())) + .andExpect(status().isUnauthorized()); + mockMvc.perform(get("/api/v1/auth/session").cookie(finance.cookies())).andExpect(status().isOk()); + } + + @Test + void absoluteDeadlineIsEnforcedWithoutBeingExtendedByActivity() throws Exception { + ClientSession client = clientSession(login("project", "LocalOnly@123").andReturn()); + String sessionId = rawSessionId(client.sessionCookie()); + Session stored = sessionRepository.findById(sessionId); + assertNotNull(stored); + Instant deadline = (Instant) stored.getAttribute(SessionLifecycle.ABSOLUTE_EXPIRES_AT); + assertNotNull(deadline); + assertTrue(deadline.isAfter(stored.getCreationTime().plusSeconds(8 * 60 * 60 - 5))); + + stored.setAttribute(SessionLifecycle.ABSOLUTE_EXPIRES_AT, Instant.now().minusSeconds(1)); + saveSession(stored); + mockMvc.perform(get("/api/v1/auth/session").cookie(client.cookies())) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value("AUTH_SESSION_REQUIRED")); + org.junit.jupiter.api.Assertions.assertNull(sessionRepository.findById(sessionId)); + } + + @Test + void bootstrapIsIdempotentAndCreatesPermissionScopesForExistingUsers() { + Integer duplicateAssignments = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM ( + SELECT user_id, role_id, COUNT(*) AS assignment_count + FROM iam_user_role + GROUP BY user_id, role_id + HAVING COUNT(*) > 1 + ) duplicate_rows + """, Integer.class); + Integer missingScopes = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM iam_user_role user_role + JOIN iam_role_permission role_permission ON role_permission.role_id = user_role.role_id + LEFT JOIN iam_scope scope + ON scope.user_id = user_role.user_id + AND scope.role_id = user_role.role_id + AND scope.permission_id = role_permission.permission_id + AND scope.scope_type = 'GLOBAL' + AND scope.status = 'ACTIVE' + JOIN iam_user user_account ON user_account.id = user_role.user_id + WHERE user_account.username IN ('admin', 'project', 'finance', 'archive', 'demo') + AND scope.id IS NULL + """, Integer.class); + + org.junit.jupiter.api.Assertions.assertEquals(0, duplicateAssignments); + org.junit.jupiter.api.Assertions.assertEquals(0, missingScopes); + + Long scopeId = jdbcTemplate.queryForObject(""" + SELECT scope_grant.id + FROM iam_scope scope_grant + JOIN iam_user user_account ON user_account.id = scope_grant.user_id + JOIN iam_role role ON role.id = scope_grant.role_id + WHERE user_account.username = 'admin' + AND role.code = 'SYSTEM_ADMIN' + AND scope_grant.scope_type = 'GLOBAL' + ORDER BY scope_grant.id + LIMIT 1 + """, Long.class); + jdbcTemplate.update(""" + UPDATE iam_scope + SET status = 'DISABLED', amount_limit = 1, + valid_from = DATE_ADD(UTC_TIMESTAMP(3), INTERVAL 1 DAY), + valid_to = DATE_ADD(UTC_TIMESTAMP(3), INTERVAL 2 DAY) + WHERE id = ? + """, scopeId); + Integer versionBeforeSync = jdbcTemplate.queryForObject( + "SELECT version FROM iam_scope WHERE id = ?", Integer.class, scopeId); + + bootstrapUserInitializer.ensureSystemAdministratorGrants(); + Map normalized = jdbcTemplate.queryForMap(""" + SELECT status, amount_limit, valid_from, valid_to, version + FROM iam_scope WHERE id = ? + """, scopeId); + assertEquals("ACTIVE", normalized.get("status")); + org.junit.jupiter.api.Assertions.assertNull(normalized.get("amount_limit")); + org.junit.jupiter.api.Assertions.assertNull(normalized.get("valid_to")); + assertEquals(versionBeforeSync + 1, ((Number) normalized.get("version")).intValue()); + + bootstrapUserInitializer.ensureSystemAdministratorGrants(); + Integer versionAfterSecondSync = jdbcTemplate.queryForObject( + "SELECT version FROM iam_scope WHERE id = ?", Integer.class, scopeId); + assertEquals(((Number) normalized.get("version")).intValue(), versionAfterSecondSync); + } + + private org.springframework.test.web.servlet.ResultActions login(String username, String password) + throws Exception { + return loginWithClient(username, password, null, null); + } + + private org.springframework.test.web.servlet.ResultActions loginWithClient(String username, String password, + String forwardedFor, + String userAgent) throws Exception { + MvcResult csrf = mockMvc.perform(get("/api/v1/auth/csrf")) + .andExpect(status().isOk()) + .andReturn(); + Cookie csrfCookie = csrf.getResponse().getCookie("XSRF-TOKEN"); + assertNotNull(csrfCookie, "CSRF endpoint must issue the readable CSRF cookie"); + String csrfToken = com.jayway.jsonpath.JsonPath.read(csrf.getResponse().getContentAsString(), + "$.data.token"); + var request = post("/api/v1/auth/login") + .cookie(csrfCookie) + .header("X-XSRF-TOKEN", csrfToken) + .contentType("application/json") + .content(""" + {"username":"%s","password":"%s"} + """.formatted(username, password)); + if (forwardedFor != null) request.header("X-Forwarded-For", forwardedFor); + if (userAgent != null) request.header("User-Agent", userAgent); + return mockMvc.perform(request); + } + + private ClientSession clientSession(MvcResult result) { + Cookie sessionCookie = result.getResponse().getCookie("KAIDI_SESSION"); + assertNotNull(sessionCookie, "login must issue the Spring Session cookie"); + Cookie csrfCookie = Arrays.stream(result.getRequest().getCookies()) + .filter(cookie -> cookie.getName().equals("XSRF-TOKEN")) + .findFirst() + .orElseThrow(); + return new ClientSession(sessionCookie, csrfCookie, + result.getRequest().getHeader("X-XSRF-TOKEN")); + } + + private List sessionIds(String username) { + return sessionRepository.findByPrincipalName(username).values().stream() + .map(Session::getId).sorted().toList(); + } + + @SuppressWarnings({"rawtypes", "unchecked"}) + private void saveSession(Session session) { + ((org.springframework.session.SessionRepository) sessionRepository).save(session); + } + + private String rawSessionId(Cookie cookie) { + return new String(java.util.Base64.getDecoder().decode(cookie.getValue()), java.nio.charset.StandardCharsets.UTF_8); + } + + private record ClientSession(Cookie sessionCookie, Cookie csrfCookie, String csrfToken) { + Cookie[] cookies() { + return new Cookie[]{sessionCookie, csrfCookie}; + } + } +} diff --git a/backend/src/test/java/com/kaidi/finance/masterdata/MasterDataIntegrationTest.java b/backend/src/test/java/com/kaidi/finance/masterdata/MasterDataIntegrationTest.java new file mode 100644 index 0000000..fc5c2a0 --- /dev/null +++ b/backend/src/test/java/com/kaidi/finance/masterdata/MasterDataIntegrationTest.java @@ -0,0 +1,868 @@ +package com.kaidi.finance.masterdata; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.jayway.jsonpath.JsonPath; +import jakarta.servlet.http.Cookie; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +@Testcontainers(disabledWithoutDocker = true) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class MasterDataIntegrationTest { + + private static final AtomicInteger SEQUENCE = new AtomicInteger(); + + @Container + static final MySQLContainer MYSQL = new MySQLContainer<>("mysql:8.4") + .withDatabaseName("kaidi_finance_masterdata_test") + .withUsername("kaidi") + .withPassword("kaidi_test_password"); + + @DynamicPropertySource + static void configureDatabase(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", MYSQL::getJdbcUrl); + registry.add("spring.datasource.username", MYSQL::getUsername); + registry.add("spring.datasource.password", MYSQL::getPassword); + registry.add("finance.bootstrap.enabled", () -> true); + registry.add("finance.bootstrap.password", () -> "LocalOnly@123"); + } + + @Autowired + MockMvc mockMvc; + + @Autowired + JdbcTemplate jdbcTemplate; + + @AfterEach + void restoreProjectUserGlobalCompanyViewScope() { + jdbcTemplate.update(""" + UPDATE iam_scope scope + JOIN iam_user user_account ON user_account.id = scope.user_id + SET scope.amount_limit = NULL + WHERE user_account.username = 'finance' + """); + jdbcTemplate.update(""" + INSERT INTO iam_scope ( + user_id, role_id, permission_id, scope_type, company_public_id, project_public_id, + amount_limit, status + ) + SELECT user_account.id, role.id, permission.id, 'GLOBAL', NULL, NULL, NULL, 'ACTIVE' + FROM iam_user user_account + JOIN iam_role role ON role.code = 'PROJECT_MANAGER' + JOIN iam_permission permission ON permission.code = 'masterdata:company:view' + WHERE user_account.username = 'project' + AND NOT EXISTS ( + SELECT 1 FROM iam_scope existing + WHERE existing.user_id = user_account.id + AND existing.role_id = role.id + AND existing.permission_id = permission.id + AND existing.scope_type = 'GLOBAL' + ) + """); + } + + @Test + void companyLifecycleRequiresAnotherReviewerAndMaintainsVersion() throws Exception { + ClientSession finance = loginAndSelect("finance", "FINANCE_MANAGER"); + ClientSession reviewer = loginAndSelect("demo", "FINANCE_MANAGER"); + String suffix = nextSuffix(); + + MvcResult created = postJson("/api/v1/masterdata/companies", finance, """ + {"businessNo":"C-%s","name":"测试公司 %s","taxNo":"TAX-%s"} + """.formatted(suffix, suffix, suffix)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("DRAFT")) + .andExpect(jsonPath("$.data.version").value(0)) + .andExpect(jsonPath("$.data.allowedActions").isArray()) + .andReturn(); + String id = JsonPath.read(created.getResponse().getContentAsString(), "$.data.publicId"); + + patchJson("/api/v1/masterdata/companies/" + id, finance, """ + {"name":"测试公司更新 %s","taxNo":"TAX-%s","version":0} + """.formatted(suffix, suffix)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.version").value(1)); + + postJson("/api/v1/masterdata/companies/" + id + "/submit", finance, "{\"version\":1}") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("REVIEWING")) + .andExpect(jsonPath("$.data.version").value(2)); + + postJson("/api/v1/masterdata/companies/" + id + "/review", finance, + "{\"version\":2,\"decision\":\"APPROVE\",\"opinion\":\"同意生效\"}") + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("SOD_VIOLATION")); + + postJson("/api/v1/masterdata/companies/" + id + "/review", reviewer, + "{\"version\":2,\"decision\":\"APPROVE\",\"opinion\":\"复核通过\"}") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("ACTIVE")) + .andExpect(jsonPath("$.data.version").value(3)); + + mockMvc.perform(get("/api/v1/masterdata/companies") + .param("keyword", suffix) + .cookie(reviewer.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.meta.totalElements").value(1)) + .andExpect(jsonPath("$.data[0].publicId").value(id)); + + postJson("/api/v1/masterdata/companies/" + id + "/disable", reviewer, "{\"version\":3}") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("DISABLED")) + .andExpect(jsonPath("$.data.version").value(4)); + } + + @Test + void duplicateAndStaleCompanyWritesAreRejected() throws Exception { + ClientSession finance = loginAndSelect("finance", "FINANCE_MANAGER"); + String suffix = nextSuffix(); + String payload = """ + {"businessNo":"DUP-%s","name":"重复测试公司","taxNo":"DUP-TAX-%s"} + """.formatted(suffix, suffix); + + MvcResult created = postJson("/api/v1/masterdata/companies", finance, payload) + .andExpect(status().isOk()).andReturn(); + String id = JsonPath.read(created.getResponse().getContentAsString(), "$.data.publicId"); + + postJson("/api/v1/masterdata/companies", finance, payload) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("DUPLICATE_RESOURCE")); + + patchJson("/api/v1/masterdata/companies/" + id, finance, + "{\"name\":\"第一次更新\",\"taxNo\":\"DUP-TAX-%s\",\"version\":0}".formatted(suffix)) + .andExpect(status().isOk()); + patchJson("/api/v1/masterdata/companies/" + id, finance, + "{\"name\":\"过期更新\",\"taxNo\":\"DUP-TAX-%s\",\"version\":0}".formatted(suffix)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("VERSION_CONFLICT")); + } + + @Test + void companyListAndDetailEnforceObjectScope() throws Exception { + ClientSession finance = loginAndSelect("finance", "FINANCE_MANAGER"); + ClientSession project = loginAndSelect("project", "PROJECT_MANAGER"); + String firstSuffix = nextSuffix(); + String secondSuffix = nextSuffix(); + String firstId = createCompany(finance, firstSuffix); + String secondId = createCompany(finance, secondSuffix); + + jdbcTemplate.update(""" + DELETE scope + FROM iam_scope scope + JOIN iam_user user_account ON user_account.id = scope.user_id + JOIN iam_role role ON role.id = scope.role_id + JOIN iam_permission permission ON permission.id = scope.permission_id + WHERE user_account.username = 'project' + AND role.code = 'PROJECT_MANAGER' + AND permission.code = 'masterdata:company:view' + """); + jdbcTemplate.update(""" + INSERT INTO iam_scope ( + user_id, role_id, permission_id, scope_type, company_public_id, status + ) + SELECT user_account.id, role.id, permission.id, 'COMPANY', ?, 'ACTIVE' + FROM iam_user user_account + JOIN iam_role role ON role.code = 'PROJECT_MANAGER' + JOIN iam_permission permission ON permission.code = 'masterdata:company:view' + WHERE user_account.username = 'project' + """, firstId); + + mockMvc.perform(get("/api/v1/masterdata/companies").cookie(project.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.meta.totalElements").value(1)) + .andExpect(jsonPath("$.data[0].publicId").value(firstId)); + + mockMvc.perform(get("/api/v1/masterdata/companies/{id}", secondId).cookie(project.cookies())) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("DATA_SCOPE_DENIED")); + + postJson("/api/v1/masterdata/companies/" + firstId + "/legacy-identifiers", finance, + "{\"sourceSystem\":\"LEGACY_SCOPE\",\"legacyCode\":\"FIRST-%s\"}".formatted(firstSuffix)) + .andExpect(status().isOk()); + postJson("/api/v1/masterdata/companies/" + secondId + "/legacy-identifiers", finance, + "{\"sourceSystem\":\"LEGACY_SCOPE\",\"legacyCode\":\"SECOND-%s\"}".formatted(secondSuffix)) + .andExpect(status().isOk()); + + mockMvc.perform(get("/api/v1/masterdata/companies/{id}/legacy-identifiers", firstId) + .cookie(project.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.meta.allowedActions").isEmpty()) + .andExpect(jsonPath("$.meta.totalElements").value(1)); + mockMvc.perform(get("/api/v1/masterdata/companies/legacy-identifiers/resolve") + .param("sourceSystem", "legacy_scope") + .param("legacyCode", "first-" + firstSuffix) + .cookie(project.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.targetPublicId").value(firstId)); + mockMvc.perform(get("/api/v1/masterdata/companies/legacy-identifiers/resolve") + .param("sourceSystem", "LEGACY_SCOPE") + .param("legacyCode", "SECOND-" + secondSuffix) + .cookie(project.cookies())) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("DATA_SCOPE_DENIED")); + postJson("/api/v1/masterdata/companies/" + firstId + "/legacy-identifiers", project, + "{\"sourceSystem\":\"LEGACY_SCOPE\",\"legacyCode\":\"FORBIDDEN-%s\"}".formatted(firstSuffix)) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("PERMISSION_DENIED")); + } + + @Test + void counterpartyLifecycleAndDuplicateTaxNumberAreEnforced() throws Exception { + ClientSession finance = loginAndSelect("finance", "FINANCE_MANAGER"); + ClientSession reviewer = loginAndSelect("demo", "FINANCE_MANAGER"); + String suffix = nextSuffix(); + String payload = """ + {"businessNo":"S-%s","type":"SUPPLIER","name":"测试供应商 %s", + "taxNo":"SUP-TAX-%s","contactName":"联系人","contactPhone":"13800000000"} + """.formatted(suffix, suffix, suffix); + MvcResult created = postJson("/api/v1/masterdata/counterparties", finance, payload) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("DRAFT")) + .andReturn(); + String id = JsonPath.read(created.getResponse().getContentAsString(), "$.data.publicId"); + + postJson("/api/v1/masterdata/counterparties", finance, payload) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("DUPLICATE_RESOURCE")); + postJson("/api/v1/masterdata/counterparties/" + id + "/submit", finance, "{\"version\":0}") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("REVIEWING")); + postJson("/api/v1/masterdata/counterparties/" + id + "/review", reviewer, + "{\"version\":1,\"decision\":\"APPROVE\",\"opinion\":\"资料完整\"}") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("ACTIVE")); + } + + @Test + void catalogResourcesUseStronglyTypedLifecycleAndMaskBankAccounts() throws Exception { + ClientSession finance = loginAndSelect("finance", "FINANCE_MANAGER"); + ClientSession reviewer = loginAndSelect("demo", "FINANCE_MANAGER"); + String suffix = nextSuffix(); + String companyId = createActiveCompany(finance, reviewer, "CAT-C-" + suffix, "CAT-TAX-" + suffix); + String counterpartyId = createActiveCounterparty(finance, reviewer, "CAT-S-" + suffix, + "CAT-SUP-TAX-" + suffix); + String projectId = createProject(finance, companyId, "CAT-P-" + suffix); + + mockMvc.perform(get("/api/v1/masterdata/references").cookie(finance.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.companies[?(@.publicId == '%s')]".formatted(companyId)).exists()) + .andExpect(jsonPath("$.data.projects[?(@.publicId == '%s')]".formatted(projectId)).exists()) + .andExpect(jsonPath("$.data.counterparties[?(@.publicId == '%s')]".formatted(counterpartyId)).exists()); + + MvcResult bank = postJson("/api/v1/masterdata/bank-accounts", finance, """ + {"ownerType":"COMPANY","ownerId":"%s","accountCategory":"BASIC", + "accountName":"测试公司","bankName":"测试银行","accountNo":"6222 0000-0000-7890", + "validFrom":"2026-01-01"} + """.formatted(companyId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("DRAFT")) + .andExpect(jsonPath("$.data.maskedAccountNo").value("**** **** 7890")) + .andExpect(jsonPath("$.data.accountNo").doesNotExist()) + .andReturn(); + String bankId = JsonPath.read(bank.getResponse().getContentAsString(), "$.data.publicId"); + + postJson("/api/v1/masterdata/bank-accounts", finance, """ + {"ownerType":"COMPANY","ownerId":"%s","accountCategory":"BASIC", + "accountName":"重复账号","bankName":"测试银行","accountNo":"6222000000007890", + "validFrom":"2026-02-01"} + """.formatted(companyId)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("DUPLICATE_RESOURCE")); + + postJson("/api/v1/masterdata/bank-accounts/" + bankId + "/submit", finance, "{\"version\":0}") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("REVIEWING")); + postJson("/api/v1/masterdata/bank-accounts/" + bankId + "/review", finance, + "{\"version\":1,\"decision\":\"APPROVE\",\"opinion\":\"同人复核\"}") + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("SOD_VIOLATION")); + postJson("/api/v1/masterdata/bank-accounts/" + bankId + "/review", reviewer, + "{\"version\":1,\"decision\":\"APPROVE\",\"opinion\":\"账户资料已复核\"}") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("ACTIVE")) + .andExpect(jsonPath("$.data.version").value(2)); + postJson("/api/v1/masterdata/bank-accounts/" + bankId + "/disable", reviewer, "{\"version\":1}") + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("VERSION_CONFLICT")); + postJson("/api/v1/masterdata/bank-accounts/" + bankId + "/disable", reviewer, "{\"version\":2}") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("DISABLED")); + + jdbcTemplate.update(""" + UPDATE audit_log + SET after_json = JSON_SET( + after_json, + '$.accountNo', '6222000000007890', + '$.contact', JSON_OBJECT('phone', '13800000000'), + '$.identity', JSON_OBJECT('idCardNo', '110101199001011234'), + '$.contacts', JSON_ARRAY(JSON_OBJECT( + 'mobile', '13800000000', + 'allowedActions', JSON_ARRAY('EDIT') + )) + ) + WHERE object_type = 'BANK_ACCOUNT' + AND object_public_id = ? + AND action_code = 'MASTERDATA_BANK_ACCOUNT_CREATE' + """, bankId); + mockMvc.perform(get("/api/v1/masterdata/bank-accounts/{id}/versions", bankId) + .param("page", "1") + .param("size", "20") + .cookie(finance.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.meta.totalElements").value(4)) + .andExpect(jsonPath("$.data[0].before.maskedAccountNo").value("***")) + .andExpect(jsonPath("$.data[0].after.maskedAccountNo").value("***")) + .andExpect(jsonPath("$.data[3].after.accountNo").value("***")) + .andExpect(jsonPath("$.data[3].after.contact.phone").value("***")) + .andExpect(jsonPath("$.data[3].after.identity.idCardNo").value("***")) + .andExpect(jsonPath("$.data[3].after.contacts[0].mobile").value("***")) + .andExpect(jsonPath("$.data[3].after.contacts[0].allowedActions").doesNotExist()); + + String validAuditSnapshot = jdbcTemplate.queryForObject(""" + SELECT after_json + FROM audit_log + WHERE object_type = 'BANK_ACCOUNT' + AND object_public_id = ? + AND action_code = 'MASTERDATA_BANK_ACCOUNT_CREATE' + """, String.class, bankId); + try { + jdbcTemplate.update(""" + UPDATE audit_log + SET after_json = JSON_ARRAY(JSON_OBJECT('accountNo', '6222000000007890')) + WHERE object_type = 'BANK_ACCOUNT' + AND object_public_id = ? + AND action_code = 'MASTERDATA_BANK_ACCOUNT_CREATE' + """, bankId); + mockMvc.perform(get("/api/v1/masterdata/bank-accounts/{id}/versions", bankId) + .param("page", "1") + .param("size", "20") + .cookie(finance.cookies())) + .andExpect(status().isInternalServerError()) + .andExpect(jsonPath("$.code").value("INTERNAL_ERROR")); + } finally { + jdbcTemplate.update(""" + UPDATE audit_log + SET after_json = ? + WHERE object_type = 'BANK_ACCOUNT' + AND object_public_id = ? + AND action_code = 'MASTERDATA_BANK_ACCOUNT_CREATE' + """, validAuditSnapshot, bankId); + } + + MvcResult contract = postJson("/api/v1/masterdata/contracts", finance, """ + {"companyId":"%s","projectId":"%s","counterpartyId":"%s", + "businessNo":"CAT-CON-%s","name":"测试成本合同","originalAmount":"1200.00","currency":"CNY"} + """.formatted(companyId, projectId, counterpartyId, suffix)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.originalAmount").value("1200.00")) + .andReturn(); + String contractId = JsonPath.read(contract.getResponse().getContentAsString(), "$.data.publicId"); + postJson("/api/v1/masterdata/contracts/" + contractId + "/submit", finance, "{\"version\":0}") + .andExpect(status().isOk()).andExpect(jsonPath("$.data.status").value("REVIEWING")); + postJson("/api/v1/masterdata/contracts/" + contractId + "/review", reviewer, + "{\"version\":1,\"decision\":\"APPROVE\",\"opinion\":\"合同资料已复核\"}") + .andExpect(status().isOk()).andExpect(jsonPath("$.data.status").value("ACTIVE")); + + MvcResult category = postJson("/api/v1/masterdata/cost-categories", finance, """ + {"code":"CAT-%s","name":"测试成本类别"} + """.formatted(suffix)) + .andExpect(status().isOk()).andExpect(jsonPath("$.data.status").value("DRAFT")).andReturn(); + String categoryId = JsonPath.read(category.getResponse().getContentAsString(), "$.data.publicId"); + postJson("/api/v1/masterdata/cost-categories/" + categoryId + "/submit", finance, "{\"version\":0}") + .andExpect(status().isOk()); + postJson("/api/v1/masterdata/cost-categories/" + categoryId + "/review", reviewer, + "{\"version\":1,\"decision\":\"APPROVE\",\"opinion\":\"类别已复核\"}") + .andExpect(status().isOk()).andExpect(jsonPath("$.data.status").value("ACTIVE")); + + postJson("/api/v1/masterdata/accounts", finance, """ + {"code":"9%s","name":"测试会计科目","accountType":"COST","auxiliaryRequired":true} + """.formatted(suffix)) + .andExpect(status().isOk()).andExpect(jsonPath("$.data.status").value("DRAFT")); + postJson("/api/v1/masterdata/tax-rates", finance, """ + {"code":"CAT-VAT-%s","name":"测试税率","rate":"0.130000"} + """.formatted(suffix)) + .andExpect(status().isOk()).andExpect(jsonPath("$.data.status").value("DRAFT")); + } + + @Test + void departmentLifecycleRejectsDuplicatesAndSamePersonReviewAndKeepsAuditTrail() throws Exception { + ClientSession finance = loginAndSelect("finance", "FINANCE_MANAGER"); + ClientSession reviewer = loginAndSelect("demo", "FINANCE_MANAGER"); + ClientSession project = loginAndSelect("project", "PROJECT_MANAGER"); + String suffix = nextSuffix(); + String code = "DEPT-" + suffix; + + MvcResult created = postJson("/api/v1/masterdata/departments", finance, """ + {"code":"%s","name":"财务共享中心"} + """.formatted(code)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.resource").value("departments")) + .andExpect(jsonPath("$.data.code").value(code)) + .andExpect(jsonPath("$.data.status").value("DRAFT")) + .andExpect(jsonPath("$.data.version").value(0)) + .andExpect(jsonPath("$.data.allowedActions[?(@ == 'EDIT')]").exists()) + .andExpect(jsonPath("$.data.allowedActions[?(@ == 'SUBMIT')]").exists()) + .andReturn(); + String id = JsonPath.read(created.getResponse().getContentAsString(), "$.data.publicId"); + + postJson("/api/v1/masterdata/departments", finance, + "{\"code\":\"%s\",\"name\":\"重复部门\"}".formatted(code)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("DUPLICATE_RESOURCE")); + + mockMvc.perform(get("/api/v1/masterdata/departments") + .param("keyword", suffix) + .cookie(finance.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.meta.totalElements").value(1)) + .andExpect(jsonPath("$.data[0].publicId").value(id)); + mockMvc.perform(get("/api/v1/masterdata/departments/{id}", id).cookie(finance.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.name").value("财务共享中心")); + + patchJson("/api/v1/masterdata/departments/" + id, finance, + "{\"name\":\"财务管理部\",\"version\":0}") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.name").value("财务管理部")) + .andExpect(jsonPath("$.data.version").value(1)); + postJson("/api/v1/masterdata/departments/" + id + "/submit", finance, "{\"version\":1}") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("REVIEWING")) + .andExpect(jsonPath("$.data.version").value(2)); + + postJson("/api/v1/masterdata/departments/" + id + "/review", finance, + "{\"version\":2,\"decision\":\"APPROVE\",\"opinion\":\"同人复核\"}") + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("SOD_VIOLATION")); + postJson("/api/v1/masterdata/departments/" + id + "/review", reviewer, + "{\"version\":2,\"decision\":\"APPROVE\",\"opinion\":\"部门资料已复核\"}") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("ACTIVE")) + .andExpect(jsonPath("$.data.version").value(3)) + .andExpect(jsonPath("$.data.allowedActions[?(@ == 'DISABLE')]").exists()); + + postJson("/api/v1/masterdata/departments/" + id + "/disable", reviewer, "{\"version\":2}") + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("VERSION_CONFLICT")); + postJson("/api/v1/masterdata/departments/" + id + "/disable", reviewer, "{\"version\":3}") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("DISABLED")) + .andExpect(jsonPath("$.data.version").value(4)); + + mockMvc.perform(get("/api/v1/masterdata/departments/{id}/versions", id) + .param("page", "1") + .param("size", "20") + .cookie(finance.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.meta.totalElements").value(5)) + .andExpect(jsonPath("$.meta.page").value(1)) + .andExpect(jsonPath("$.data.length()").value(5)) + .andExpect(jsonPath("$.data[?(@.actionCode == 'MASTERDATA_DICTIONARY_DISABLE')].version").value(4)) + .andExpect(jsonPath("$.data[?(@.actionCode == 'MASTERDATA_DICTIONARY_DISABLE')].status") + .value("DISABLED")) + .andExpect(jsonPath("$.data[?(@.actionCode == 'MASTERDATA_DICTIONARY_DISABLE')].actorName") + .value("demo")) + .andExpect(jsonPath("$.data[?(@.actionCode == 'MASTERDATA_DICTIONARY_DISABLE')].before.version") + .value(3)) + .andExpect(jsonPath("$.data[?(@.actionCode == 'MASTERDATA_DICTIONARY_DISABLE')].after.version") + .value(4)) + .andExpect(jsonPath("$.data[?(@.actionCode == 'MASTERDATA_DICTIONARY_DISABLE')]" + + ".before.allowedActions").doesNotExist()) + .andExpect(jsonPath("$.data[?(@.actionCode == 'MASTERDATA_DICTIONARY_DISABLE')]" + + ".after.allowedActions").doesNotExist()) + .andExpect(jsonPath("$.data[?(@.actionCode == 'MASTERDATA_DICTIONARY_UPDATE')].before.name") + .value("财务共享中心")) + .andExpect(jsonPath("$.data[?(@.actionCode == 'MASTERDATA_DICTIONARY_UPDATE')].after.name") + .value("财务管理部")) + .andExpect(jsonPath("$.data[?(@.actionCode == 'MASTERDATA_DICTIONARY_CREATE')].version").value(0)) + .andExpect(jsonPath("$.data[?(@.actionCode == 'MASTERDATA_DICTIONARY_CREATE')].status") + .value("DRAFT")); + + mockMvc.perform(get("/api/v1/masterdata/departments/{id}/versions", id) + .param("page", "2") + .param("size", "20") + .cookie(finance.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.meta.totalElements").value(5)) + .andExpect(jsonPath("$.data.length()").value(0)); + mockMvc.perform(get("/api/v1/masterdata/departments/{id}/versions", id) + .param("page", "0") + .cookie(finance.cookies())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("QUERY_PARAMETER_INVALID")); + mockMvc.perform(get("/api/v1/masterdata/departments/{id}/versions", id).cookie(project.cookies())) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("PERMISSION_DENIED")); + + MvcResult mappingCreated = postJson( + "/api/v1/masterdata/departments/" + id + "/legacy-identifiers", finance, + "{\"sourceSystem\":\"Old_Erp\",\"legacyCode\":\" dept-old-%s \"}".formatted(suffix)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.resource").value("departments")) + .andExpect(jsonPath("$.data.sourceSystem").value("OLD_ERP")) + .andExpect(jsonPath("$.data.legacyCode").value("dept-old-" + suffix)) + .andExpect(jsonPath("$.data.targetPublicId").value(id)) + .andExpect(jsonPath("$.data.status").value("ACTIVE")) + .andExpect(jsonPath("$.data.version").value(0)) + .andExpect(jsonPath("$.data.allowedActions[?(@ == 'DISABLE')]").exists()) + .andReturn(); + String mappingId = JsonPath.read(mappingCreated.getResponse().getContentAsString(), "$.data.publicId"); + + postJson("/api/v1/masterdata/departments/" + id + "/legacy-identifiers", finance, + "{\"sourceSystem\":\"OLD_ERP\",\"legacyCode\":\"DEPT-OLD-%s\"}".formatted(suffix)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("DUPLICATE_RESOURCE")); + postJson("/api/v1/masterdata/departments/" + id + "/legacy-identifiers", finance, + "{\"sourceSystem\":\"不合法系统\",\"legacyCode\":\"DEPT-INVALID-%s\"}".formatted(suffix)) + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("VALIDATION_FAILED")); + + mockMvc.perform(get("/api/v1/masterdata/departments/{id}/legacy-identifiers", id) + .param("page", "1") + .param("size", "20") + .cookie(finance.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.meta.totalElements").value(1)) + .andExpect(jsonPath("$.meta.allowedActions[?(@ == 'CREATE')]").exists()) + .andExpect(jsonPath("$.data[0].publicId").value(mappingId)); + mockMvc.perform(get("/api/v1/masterdata/departments/legacy-identifiers/resolve") + .param("sourceSystem", "old_erp") + .param("legacyCode", "DEPT-OLD-" + suffix) + .cookie(finance.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.mappingPublicId").value(mappingId)) + .andExpect(jsonPath("$.data.targetPublicId").value(id)); + mockMvc.perform(get("/api/v1/masterdata/departments/{id}/legacy-identifiers", id) + .cookie(project.cookies())) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("PERMISSION_DENIED")); + + postJson("/api/v1/masterdata/departments/" + id + "/legacy-identifiers/" + mappingId + "/disable", + finance, "{\"version\":1}") + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("VERSION_CONFLICT")); + postJson("/api/v1/masterdata/departments/" + id + "/legacy-identifiers/" + mappingId + "/disable", + finance, "{\"version\":0}") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("DISABLED")) + .andExpect(jsonPath("$.data.version").value(1)) + .andExpect(jsonPath("$.data.allowedActions").isEmpty()); + mockMvc.perform(get("/api/v1/masterdata/departments/legacy-identifiers/resolve") + .param("sourceSystem", "OLD_ERP") + .param("legacyCode", "DEPT-OLD-" + suffix) + .cookie(finance.cookies())) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("RESOURCE_NOT_FOUND")); + postJson("/api/v1/masterdata/departments/" + id + "/legacy-identifiers", finance, + "{\"sourceSystem\":\"OLD_ERP\",\"legacyCode\":\"DEPT-OLD-%s\"}".formatted(suffix)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("ACTIVE")); + + Long auditCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) FROM audit_log + WHERE object_type = 'DEPARTMENT' AND object_public_id = ? + AND action_code IN ( + 'MASTERDATA_DICTIONARY_CREATE', 'MASTERDATA_DICTIONARY_UPDATE', + 'MASTERDATA_DICTIONARY_SUBMIT', 'MASTERDATA_DICTIONARY_REVIEW', + 'MASTERDATA_DICTIONARY_DISABLE' + ) + """, Long.class, id); + org.junit.jupiter.api.Assertions.assertEquals(5L, auditCount); + Long mappingAuditCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) FROM audit_log + WHERE object_type = 'MASTERDATA_LEGACY_IDENTIFIER' + AND action_code IN ( + 'MASTERDATA_LEGACY_IDENTIFIER_CREATE', 'MASTERDATA_LEGACY_IDENTIFIER_DISABLE' + ) + AND JSON_UNQUOTE(JSON_EXTRACT(after_json, '$.targetPublicId')) = ? + """, Long.class, id); + org.junit.jupiter.api.Assertions.assertEquals(3L, mappingAuditCount); + } + + @Test + void catalogContractRejectsMismatchedProjectAndAmountLimit() throws Exception { + ClientSession finance = loginAndSelect("finance", "FINANCE_MANAGER"); + ClientSession reviewer = loginAndSelect("demo", "FINANCE_MANAGER"); + String suffix = nextSuffix(); + String firstCompany = createActiveCompany(finance, reviewer, "MISMATCH-A-" + suffix, "MISMATCH-TAX-A-" + suffix); + String secondCompany = createActiveCompany(finance, reviewer, "MISMATCH-B-" + suffix, "MISMATCH-TAX-B-" + suffix); + String counterparty = createActiveCounterparty(finance, reviewer, "MISMATCH-S-" + suffix, + "MISMATCH-SUP-TAX-" + suffix); + String project = createProject(finance, firstCompany, "MISMATCH-P-" + suffix); + + postJson("/api/v1/masterdata/contracts", finance, """ + {"companyId":"%s","projectId":"%s","counterpartyId":"%s", + "businessNo":"MISMATCH-CON-%s","name":"公司项目不一致","originalAmount":"10.00","currency":"CNY"} + """.formatted(secondCompany, project, counterparty, suffix)) + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("VALIDATION_FAILED")); + + setAmountLimit("finance", "masterdata:contract:create", "100.00"); + postJson("/api/v1/masterdata/contracts", finance, """ + {"companyId":"%s","projectId":"%s","counterpartyId":"%s", + "businessNo":"LIMIT-CON-%s","name":"超过额度合同","originalAmount":"100.01","currency":"CNY"} + """.formatted(firstCompany, project, counterparty, suffix)) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("AMOUNT_LIMIT_EXCEEDED")); + } + + @Test + void disablingReferencedMasterDataPreservesHistoryAndBlocksNewReferences() throws Exception { + ClientSession finance = loginAndSelect("finance", "FINANCE_MANAGER"); + ClientSession reviewer = loginAndSelect("demo", "FINANCE_MANAGER"); + String suffix = nextSuffix(); + String companyId = createActiveCompany(finance, reviewer, "REF-C-" + suffix, "REF-TAX-" + suffix); + String counterpartyId = createActiveCounterparty(finance, reviewer, "REF-S-" + suffix, + "REF-S-TAX-" + suffix); + String projectId = createProject(finance, companyId, "REF-P-" + suffix); + String contractId = createActiveContract(finance, reviewer, companyId, projectId, counterpartyId, suffix); + String categoryId = createActiveCostCategory(finance, reviewer, suffix); + String bankId = createActiveBankAccount(finance, reviewer, counterpartyId, suffix); + + Long companyDbId = dbId("md_company", companyId); + Long projectDbId = dbId("md_project", projectId); + Long counterpartyDbId = dbId("md_counterparty", counterpartyId); + Long contractDbId = dbId("md_contract", contractId); + Long categoryDbId = dbId("md_cost_category", categoryId); + Long bankDbId = dbId("md_bank_account_version", bankId); + Long financeUserId = jdbcTemplate.queryForObject("SELECT id FROM iam_user WHERE username = 'finance'", Long.class); + jdbcTemplate.update(""" + INSERT INTO fin_payable (public_id, business_no, company_id, project_id, contract_id, counterparty_id, + cost_category_id, business_date, amount, status, created_by, updated_by) + VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_DATE, 1.00, 'CONFIRMED', ?, ?) + """, uniquePublicId(suffix, "PAY"), "REF-PAY-" + suffix, companyDbId, projectDbId, contractDbId, + counterpartyDbId, categoryDbId, financeUserId, financeUserId); + jdbcTemplate.update(""" + INSERT INTO fin_payment_request (public_id, business_no, company_id, project_id, contract_id, supplier_id, + bank_account_version_id, requested_amount, currency, requested_date, purpose, status, created_by, updated_by) + VALUES (?, ?, ?, ?, ?, ?, ?, 1.00, 'CNY', CURRENT_DATE, '历史账户版本引用', 'DRAFT', ?, ?) + """, uniquePublicId(suffix, "PMT"), "REF-PMT-" + suffix, companyDbId, projectDbId, contractDbId, + counterpartyDbId, bankDbId, financeUserId, financeUserId); + + disable("/api/v1/masterdata/bank-accounts/" + bankId + "/disable", finance, 2); + disable("/api/v1/masterdata/cost-categories/" + categoryId + "/disable", finance, 2); + disable("/api/v1/masterdata/contracts/" + contractId + "/disable", finance, 2); + disable("/api/v1/masterdata/counterparties/" + counterpartyId + "/disable", finance, 2); + disable("/api/v1/masterdata/companies/" + companyId + "/disable", finance, 2); + + org.junit.jupiter.api.Assertions.assertEquals(bankDbId, jdbcTemplate.queryForObject( + "SELECT bank_account_version_id FROM fin_payment_request WHERE business_no = ?", Long.class, + "REF-PMT-" + suffix)); + org.junit.jupiter.api.Assertions.assertEquals(contractDbId, jdbcTemplate.queryForObject( + "SELECT contract_id FROM fin_payable WHERE business_no = ?", Long.class, "REF-PAY-" + suffix)); + org.junit.jupiter.api.Assertions.assertEquals(categoryDbId, jdbcTemplate.queryForObject( + "SELECT cost_category_id FROM fin_payable WHERE business_no = ?", Long.class, "REF-PAY-" + suffix)); + + mockMvc.perform(get("/api/v1/masterdata/references").cookie(finance.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.companies[?(@.publicId == '%s')]".formatted(companyId)).doesNotExist()) + .andExpect(jsonPath("$.data.projects[?(@.publicId == '%s')]".formatted(projectId)).doesNotExist()) + .andExpect(jsonPath("$.data.counterparties[?(@.publicId == '%s')]".formatted(counterpartyId)).doesNotExist()) + .andExpect(jsonPath("$.data.costCategories[?(@.publicId == '%s')]".formatted(categoryId)).doesNotExist()); + + postJson("/api/v1/masterdata/bank-accounts", finance, """ + {"ownerType":"COUNTERPARTY","ownerId":"%s","accountCategory":"GENERAL", + "accountName":"已停用主体账户","bankName":"测试银行","accountNo":"62229999%s","validFrom":"2026-01-01"} + """.formatted(counterpartyId, suffix)) + .andExpect(status().isNotFound()); + postJson("/api/v1/masterdata/cost-categories", finance, + "{\"code\":\"CHILD-%s\",\"name\":\"停用上级\",\"parentId\":\"%s\"}".formatted(suffix, categoryId)) + .andExpect(status().isNotFound()); + postJson("/api/v1/masterdata/contracts", finance, """ + {"companyId":"%s","projectId":"%s","counterpartyId":"%s","businessNo":"DISABLED-%s", + "name":"已停用引用合同","originalAmount":"10.00","currency":"CNY"} + """.formatted(companyId, projectId, counterpartyId, suffix)) + .andExpect(status().isNotFound()); + } + + private String createCompany(ClientSession session, String suffix) throws Exception { + MvcResult created = postJson("/api/v1/masterdata/companies", session, """ + {"businessNo":"SCOPE-%s","name":"范围公司 %s","taxNo":"SCOPE-TAX-%s"} + """.formatted(suffix, suffix, suffix)).andExpect(status().isOk()).andReturn(); + return JsonPath.read(created.getResponse().getContentAsString(), "$.data.publicId"); + } + + private String createActiveCompany(ClientSession creator, ClientSession reviewer, String businessNo, + String taxNo) throws Exception { + MvcResult created = postJson("/api/v1/masterdata/companies", creator, """ + {"businessNo":"%s","name":"%s","taxNo":"%s"} + """.formatted(businessNo, businessNo, taxNo)).andExpect(status().isOk()).andReturn(); + String id = JsonPath.read(created.getResponse().getContentAsString(), "$.data.publicId"); + postJson("/api/v1/masterdata/companies/" + id + "/submit", creator, "{\"version\":0}") + .andExpect(status().isOk()); + postJson("/api/v1/masterdata/companies/" + id + "/review", reviewer, + "{\"version\":1,\"decision\":\"APPROVE\",\"opinion\":\"测试复核\"}") + .andExpect(status().isOk()).andExpect(jsonPath("$.data.status").value("ACTIVE")); + return id; + } + + private String createActiveCounterparty(ClientSession creator, ClientSession reviewer, String businessNo, + String taxNo) throws Exception { + MvcResult created = postJson("/api/v1/masterdata/counterparties", creator, """ + {"businessNo":"%s","type":"SUPPLIER","name":"%s","taxNo":"%s", + "contactName":"测试联系人","contactPhone":"13800000000"} + """.formatted(businessNo, businessNo, taxNo)).andExpect(status().isOk()).andReturn(); + String id = JsonPath.read(created.getResponse().getContentAsString(), "$.data.publicId"); + postJson("/api/v1/masterdata/counterparties/" + id + "/submit", creator, "{\"version\":0}") + .andExpect(status().isOk()); + postJson("/api/v1/masterdata/counterparties/" + id + "/review", reviewer, + "{\"version\":1,\"decision\":\"APPROVE\",\"opinion\":\"测试复核\"}") + .andExpect(status().isOk()).andExpect(jsonPath("$.data.status").value("ACTIVE")); + return id; + } + + private String createProject(ClientSession session, String companyId, String businessNo) throws Exception { + String ownerId = jdbcTemplate.queryForObject( + "SELECT public_id FROM iam_user WHERE username = 'project'", String.class); + MvcResult created = postJson("/api/v1/projects", session, """ + {"businessNo":"%s","name":"%s","companyId":"%s","ownerId":"%s","currency":"CNY"} + """.formatted(businessNo, businessNo, companyId, ownerId)).andExpect(status().isOk()).andReturn(); + String projectId = JsonPath.read(created.getResponse().getContentAsString(), "$.data.publicId"); + jdbcTemplate.update("UPDATE md_project SET status = 'ACTIVE' WHERE public_id = ?", projectId); + return projectId; + } + + private String createActiveContract(ClientSession creator, ClientSession reviewer, String companyId, String projectId, + String counterpartyId, String suffix) throws Exception { + MvcResult created = postJson("/api/v1/masterdata/contracts", creator, """ + {"companyId":"%s","projectId":"%s","counterpartyId":"%s","businessNo":"REF-CON-%s", + "name":"引用保护合同","originalAmount":"10.00","currency":"CNY"} + """.formatted(companyId, projectId, counterpartyId, suffix)).andExpect(status().isOk()).andReturn(); + String id = JsonPath.read(created.getResponse().getContentAsString(), "$.data.publicId"); + postJson("/api/v1/masterdata/contracts/" + id + "/submit", creator, "{\"version\":0}") + .andExpect(status().isOk()); + postJson("/api/v1/masterdata/contracts/" + id + "/review", reviewer, + "{\"version\":1,\"decision\":\"APPROVE\",\"opinion\":\"测试复核\"}") + .andExpect(status().isOk()); + return id; + } + + private String createActiveCostCategory(ClientSession creator, ClientSession reviewer, String suffix) throws Exception { + MvcResult created = postJson("/api/v1/masterdata/cost-categories", creator, + "{\"code\":\"REF-CAT-%s\",\"name\":\"引用保护类别\"}".formatted(suffix)) + .andExpect(status().isOk()).andReturn(); + String id = JsonPath.read(created.getResponse().getContentAsString(), "$.data.publicId"); + postJson("/api/v1/masterdata/cost-categories/" + id + "/submit", creator, "{\"version\":0}") + .andExpect(status().isOk()); + postJson("/api/v1/masterdata/cost-categories/" + id + "/review", reviewer, + "{\"version\":1,\"decision\":\"APPROVE\",\"opinion\":\"测试复核\"}") + .andExpect(status().isOk()); + return id; + } + + private String createActiveBankAccount(ClientSession creator, ClientSession reviewer, String counterpartyId, + String suffix) throws Exception { + MvcResult created = postJson("/api/v1/masterdata/bank-accounts", creator, """ + {"ownerType":"COUNTERPARTY","ownerId":"%s","accountCategory":"GENERAL", + "accountName":"引用保护账户","bankName":"测试银行","accountNo":"62220000%s","validFrom":"2026-01-01"} + """.formatted(counterpartyId, suffix)).andExpect(status().isOk()).andReturn(); + String id = JsonPath.read(created.getResponse().getContentAsString(), "$.data.publicId"); + postJson("/api/v1/masterdata/bank-accounts/" + id + "/submit", creator, "{\"version\":0}") + .andExpect(status().isOk()); + postJson("/api/v1/masterdata/bank-accounts/" + id + "/review", reviewer, + "{\"version\":1,\"decision\":\"APPROVE\",\"opinion\":\"测试复核\"}") + .andExpect(status().isOk()); + return id; + } + + private Long dbId(String table, String publicId) { + return jdbcTemplate.queryForObject("SELECT id FROM " + table + " WHERE public_id = ?", Long.class, publicId); + } + + private String uniquePublicId(String suffix, String prefix) { + return prefix.substring(0, 1) + "%025d".formatted(Integer.parseInt(suffix)); + } + + private void disable(String path, ClientSession session, long version) throws Exception { + postJson(path, session, "{\"version\":%d}".formatted(version)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("DISABLED")) + .andExpect(jsonPath("$.data.version").value(version + 1)); + } + + private void setAmountLimit(String username, String permissionCode, String amountLimit) { + jdbcTemplate.update(""" + UPDATE iam_scope scope + JOIN iam_user user_account ON user_account.id = scope.user_id + JOIN iam_role role ON role.id = scope.role_id + JOIN iam_permission permission ON permission.id = scope.permission_id + SET scope.amount_limit = ? + WHERE user_account.username = ? AND role.code = 'FINANCE_MANAGER' + AND permission.code = ? AND scope.scope_type = 'GLOBAL' + """, amountLimit, username, permissionCode); + } + + private org.springframework.test.web.servlet.ResultActions postJson(String path, ClientSession session, + String json) throws Exception { + return mockMvc.perform(post(path) + .cookie(session.cookies()) + .header("X-XSRF-TOKEN", session.csrfToken()) + .contentType("application/json") + .content(json)); + } + + private org.springframework.test.web.servlet.ResultActions patchJson(String path, ClientSession session, + String json) throws Exception { + return mockMvc.perform(patch(path) + .cookie(session.cookies()) + .header("X-XSRF-TOKEN", session.csrfToken()) + .contentType("application/json") + .content(json)); + } + + private ClientSession loginAndSelect(String username, String role) throws Exception { + MvcResult csrf = mockMvc.perform(get("/api/v1/auth/csrf")).andExpect(status().isOk()).andReturn(); + Cookie csrfCookie = csrf.getResponse().getCookie("XSRF-TOKEN"); + assertNotNull(csrfCookie); + String csrfToken = JsonPath.read(csrf.getResponse().getContentAsString(), "$.data.token"); + MvcResult login = mockMvc.perform(post("/api/v1/auth/login") + .cookie(csrfCookie) + .header("X-XSRF-TOKEN", csrfToken) + .contentType("application/json") + .content("{\"username\":\"%s\",\"password\":\"LocalOnly@123\"}".formatted(username))) + .andExpect(status().isOk()).andReturn(); + Cookie sessionCookie = login.getResponse().getCookie("KAIDI_SESSION"); + assertNotNull(sessionCookie); + MvcResult selected = mockMvc.perform(post("/api/v1/auth/select-role") + .cookie(sessionCookie, csrfCookie) + .header("X-XSRF-TOKEN", csrfToken) + .contentType("application/json") + .content("{\"roleCode\":\"%s\"}".formatted(role))) + .andExpect(status().isOk()).andReturn(); + Cookie rotated = selected.getResponse().getCookie("KAIDI_SESSION"); + return new ClientSession(rotated == null ? sessionCookie : rotated, csrfCookie, csrfToken); + } + + private String nextSuffix() { + return "%06d".formatted(SEQUENCE.incrementAndGet()); + } + + private record ClientSession(Cookie sessionCookie, Cookie csrfCookie, String csrfToken) { + Cookie[] cookies() { + return new Cookie[]{sessionCookie, csrfCookie}; + } + } +} diff --git a/backend/src/test/java/com/kaidi/finance/migration/V0491ReceiptPreflightMigrationTest.java b/backend/src/test/java/com/kaidi/finance/migration/V0491ReceiptPreflightMigrationTest.java new file mode 100644 index 0000000..9c5dacc --- /dev/null +++ b/backend/src/test/java/com/kaidi/finance/migration/V0491ReceiptPreflightMigrationTest.java @@ -0,0 +1,149 @@ +package com.kaidi.finance.migration; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import javax.sql.DataSource; +import org.flywaydb.core.Flyway; +import org.flywaydb.core.api.FlywayException; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +@Testcontainers(disabledWithoutDocker = true) +class V0491ReceiptPreflightMigrationTest { + + @Container + static final MySQLContainer MYSQL = new MySQLContainer<>("mysql:8.4") + .withDatabaseName("kaidi_finance_v0491_test") + .withUsername("kaidi") + .withPassword("kaidi_test_password"); + + @Test + void blocksDuplicateReceiptSourceBeforeV050ChangesSchema() { + DataSource dataSource = prepareSchema("v0491_duplicate"); + JdbcTemplate jdbc = new JdbcTemplate(dataSource); + seedReceiptSource(jdbc, 1, true, 2); + + assertThrows(FlywayException.class, () -> migrateTo50(dataSource)); + assertEquals(0, columnCount(jdbc, "source_version_id")); + assertEquals(0, indexCount(jdbc, "uk_fin_receipt_source_document")); + } + + @Test + void blocksAmbiguousReceiptSourceVersionBeforeV050ChangesSchema() { + DataSource dataSource = prepareSchema("v0491_ambiguous"); + JdbcTemplate jdbc = new JdbcTemplate(dataSource); + seedReceiptSource(jdbc, 2, true, 1); + + assertThrows(FlywayException.class, () -> migrateTo50(dataSource)); + assertEquals(0, columnCount(jdbc, "source_version_id")); + assertEquals(0, indexCount(jdbc, "uk_fin_receipt_source_document")); + } + + private DataSource prepareSchema(String schema) { + DataSource root = new DriverManagerDataSource( + MYSQL.getJdbcUrl(), "root", MYSQL.getPassword() + ); + JdbcTemplate rootJdbc = new JdbcTemplate(root); + rootJdbc.execute("DROP DATABASE IF EXISTS " + schema); + rootJdbc.execute("CREATE DATABASE " + schema); + rootJdbc.execute("GRANT ALL PRIVILEGES ON `" + schema + "`.* TO 'kaidi'@'%'"); + String schemaUrl = MYSQL.getJdbcUrl().replace("/kaidi_finance_v0491_test", "/" + schema); + DataSource dataSource = new DriverManagerDataSource( + schemaUrl, MYSQL.getUsername(), MYSQL.getPassword() + ); + Flyway.configure() + .dataSource(dataSource) + .defaultSchema(schema) + .schemas(schema) + .target("49") + .load() + .migrate(); + return dataSource; + } + + private void migrateTo50(DataSource dataSource) { + Flyway.configure().dataSource(dataSource).target("50").load().migrate(); + } + + private void seedReceiptSource(JdbcTemplate jdbc, int versionCount, + boolean currentVersion, int receiptCount) { + jdbc.update(""" + INSERT INTO iam_user (public_id, username, display_name, password_hash) + VALUES ('00000000000000000000491001', 'v0491-user', 'V049.1 user', 'unused') + """); + long userId = jdbc.queryForObject( + "SELECT id FROM iam_user WHERE username = 'v0491-user'", Long.class + ); + jdbc.update(""" + INSERT INTO md_company (public_id, business_no, name, status, created_by, updated_by) + VALUES ('00000000000000000000491002', 'V0491-COMPANY', 'V049.1 company', 'ACTIVE', ?, ?) + """, userId, userId); + long companyId = jdbc.queryForObject( + "SELECT id FROM md_company WHERE public_id = '00000000000000000000491002'", Long.class + ); + long templateId = jdbc.queryForObject( + "SELECT id FROM src_template WHERE form_type = 'OA-05' AND template_version = 1", Long.class + ); + jdbc.update(""" + INSERT INTO src_source_document ( + public_id, business_no, form_type, source_system, source_no, + normalized_source_no, source_version, normalized_source_version, + template_id, company_id, status, created_by, updated_by + ) VALUES ( + '00000000000000000000491003', 'V0491-SOURCE', 'OA-05', 'MANUAL', 'V0491-SOURCE', + 'V0491-SOURCE', '1', '1', ?, ?, 'APPROVED', ?, ? + ) + """, templateId, companyId, userId, userId); + long sourceId = jdbc.queryForObject( + "SELECT id FROM src_source_document WHERE public_id = '00000000000000000000491003'", Long.class + ); + long latestVersionId = 0; + long previousVersionId = 0; + for (int version = 1; version <= versionCount; version++) { + String publicId = "00000000000000000000491" + String.format("%02d", version + 3); + jdbc.update(""" + INSERT INTO src_document_version ( + public_id, source_document_id, version_no, previous_version_id, + template_id, snapshot_sha256, created_by + ) VALUES (?, ?, ?, NULLIF(?, 0), ?, REPEAT('a', 64), ?) + """, publicId, sourceId, version, previousVersionId, templateId, userId); + latestVersionId = jdbc.queryForObject( + "SELECT id FROM src_document_version WHERE public_id = ?", Long.class, publicId + ); + previousVersionId = latestVersionId; + } + if (currentVersion) { + jdbc.update("UPDATE src_source_document SET current_version_id = ? WHERE id = ?", + latestVersionId, sourceId); + } + for (int receipt = 1; receipt <= receiptCount; receipt++) { + String suffix = String.format("%02d", receipt); + jdbc.update(""" + INSERT INTO fin_receipt ( + public_id, business_no, company_id, source_document_id, + receipt_date, amount, payer_name, created_by, updated_by + ) VALUES (?, ?, ?, ?, '2026-08-14', 100.00, 'V049.1 payer', ?, ?) + """, "00000000000000000000492" + suffix, "V0491-RECEIPT-" + suffix, + companyId, sourceId, userId, userId); + } + } + + private int columnCount(JdbcTemplate jdbc, String column) { + return jdbc.queryForObject(""" + SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'fin_receipt' AND column_name = ? + """, Integer.class, column); + } + + private int indexCount(JdbcTemplate jdbc, String index) { + return jdbc.queryForObject(""" + SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = 'fin_receipt' AND index_name = ? + """, Integer.class, index); + } +} diff --git a/backend/src/test/java/com/kaidi/finance/migration/V051MigrationIntegrationTest.java b/backend/src/test/java/com/kaidi/finance/migration/V051MigrationIntegrationTest.java new file mode 100644 index 0000000..fdc04d5 --- /dev/null +++ b/backend/src/test/java/com/kaidi/finance/migration/V051MigrationIntegrationTest.java @@ -0,0 +1,88 @@ +package com.kaidi.finance.migration; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import javax.sql.DataSource; +import org.flywaydb.core.Flyway; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +@Testcontainers(disabledWithoutDocker = true) +class V051MigrationIntegrationTest { + + @Container + static final MySQLContainer MYSQL = new MySQLContainer<>("mysql:8.4") + .withDatabaseName("kaidi_finance_v051_test") + .withUsername("kaidi") + .withPassword("kaidi_test_password"); + + @Test + void upgradePreservesCurrencyFromExistingInvoiceProject() { + DataSource dataSource = new DriverManagerDataSource( + MYSQL.getJdbcUrl(), MYSQL.getUsername(), MYSQL.getPassword() + ); + Flyway.configure().dataSource(dataSource).target("50").load().migrate(); + JdbcTemplate jdbc = new JdbcTemplate(dataSource); + + jdbc.update(""" + INSERT INTO iam_user (public_id, username, display_name, password_hash) + VALUES ('00000000000000000000510101', 'v051-user', 'V051 migration user', 'unused') + """); + long userId = jdbc.queryForObject( + "SELECT id FROM iam_user WHERE username = 'v051-user'", Long.class + ); + jdbc.update(""" + INSERT INTO md_company (public_id, business_no, name, status, created_by, updated_by) + VALUES ('00000000000000000000510102', 'V051-COMPANY', 'V051 company', 'ACTIVE', ?, ?) + """, userId, userId); + jdbc.update(""" + INSERT INTO md_counterparty ( + public_id, business_no, counterparty_type, name, status, created_by, updated_by + ) VALUES ( + '00000000000000000000510103', 'V051-CUSTOMER', 'CUSTOMER', + 'V051 customer', 'ACTIVE', ?, ? + ) + """, userId, userId); + jdbc.update(""" + INSERT INTO md_project ( + public_id, company_id, business_no, name, customer_id, owner_user_id, + currency, status, created_by, updated_by + ) SELECT + '00000000000000000000510104', company.id, 'V051-PROJECT', 'V051 project', + customer.id, ?, 'USD', 'ACTIVE', ?, ? + FROM md_company company + JOIN md_counterparty customer ON customer.public_id = '00000000000000000000510103' + WHERE company.public_id = '00000000000000000000510102' + """, userId, userId, userId); + jdbc.update(""" + INSERT INTO fin_invoice ( + public_id, business_no, company_id, project_id, counterparty_id, + invoice_type, amount, tax_rate, requested_date, status, created_by, updated_by + ) SELECT + '00000000000000000000510105', 'V051-INVOICE', company.id, project.id, customer.id, + 'NORMAL', 125.00, 0.060000, '2026-08-14', 'DRAFT', ?, ? + FROM md_company company + JOIN md_project project ON project.company_id = company.id + JOIN md_counterparty customer ON customer.id = project.customer_id + WHERE company.public_id = '00000000000000000000510102' + AND project.public_id = '00000000000000000000510104' + """, userId, userId); + + // V052 is the current head; target V051 so this test asserts the migration + // under test instead of becoming coupled to later feature migrations. + Flyway.configure().dataSource(dataSource).target("51").load().migrate(); + + assertEquals("USD", jdbc.queryForObject( + "SELECT currency FROM fin_invoice WHERE public_id = '00000000000000000000510105'", + String.class + )); + assertEquals("051", jdbc.queryForObject( + "SELECT version FROM flyway_schema_history WHERE success = TRUE ORDER BY installed_rank DESC LIMIT 1", + String.class + )); + } +} diff --git a/backend/src/test/java/com/kaidi/finance/migration/V056SourceDocumentCurrentVersionIntegrityMigrationTest.java b/backend/src/test/java/com/kaidi/finance/migration/V056SourceDocumentCurrentVersionIntegrityMigrationTest.java new file mode 100644 index 0000000..ef2404e --- /dev/null +++ b/backend/src/test/java/com/kaidi/finance/migration/V056SourceDocumentCurrentVersionIntegrityMigrationTest.java @@ -0,0 +1,145 @@ +package com.kaidi.finance.migration; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import javax.sql.DataSource; +import org.flywaydb.core.Flyway; +import org.flywaydb.core.api.FlywayException; +import org.junit.jupiter.api.Test; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +@Testcontainers(disabledWithoutDocker = true) +class V056SourceDocumentCurrentVersionIntegrityMigrationTest { + + @Container + static final MySQLContainer MYSQL = new MySQLContainer<>("mysql:8.4") + .withDatabaseName("kaidi_finance_v056_test") + .withUsername("kaidi") + .withPassword("kaidi_test_password"); + + @Test + void blocksExistingCrossDocumentPointerBeforeConstraintIsInstalled() { + DataSource dataSource = prepareSchema("v056_cross_document"); + JdbcTemplate jdbc = new JdbcTemplate(dataSource); + SourceIds ids = seedDocuments(jdbc, "CROSS"); + jdbc.update("UPDATE src_source_document SET current_version_id = ? WHERE id = ?", + ids.versionB(), ids.documentA()); + + assertThrows(FlywayException.class, () -> migrateTo56(dataSource)); + assertEquals(0, constraintCount(jdbc, "fk_src_document_current_version_document")); + assertEquals("055", latestVersion(jdbc)); + } + + @Test + void rejectsCrossDocumentPointerAfterConstraintIsInstalled() { + DataSource dataSource = prepareSchema("v056_valid_document"); + JdbcTemplate jdbc = new JdbcTemplate(dataSource); + SourceIds ids = seedDocuments(jdbc, "VALID"); + jdbc.update("UPDATE src_source_document SET current_version_id = ? WHERE id = ?", + ids.versionA(), ids.documentA()); + jdbc.update("UPDATE src_source_document SET current_version_id = ? WHERE id = ?", + ids.versionB(), ids.documentB()); + migrateTo56(dataSource); + + assertEquals(1, constraintCount(jdbc, "fk_src_document_current_version_document")); + assertThrows(DataIntegrityViolationException.class, + () -> jdbc.update("UPDATE src_source_document SET current_version_id = ? WHERE id = ?", + ids.versionB(), ids.documentA())); + assertEquals(1, jdbc.update("UPDATE src_source_document SET current_version_id = ? WHERE id = ?", + ids.versionA(), ids.documentA())); + } + + private DataSource prepareSchema(String schema) { + DataSource root = new DriverManagerDataSource(MYSQL.getJdbcUrl(), "root", MYSQL.getPassword()); + JdbcTemplate rootJdbc = new JdbcTemplate(root); + rootJdbc.execute("DROP DATABASE IF EXISTS `" + schema + "`"); + rootJdbc.execute("CREATE DATABASE `" + schema + "`"); + rootJdbc.execute("GRANT ALL PRIVILEGES ON `" + schema + "`.* TO 'kaidi'@'%'"); + String schemaUrl = MYSQL.getJdbcUrl().replace("/kaidi_finance_v056_test", "/" + schema); + DataSource dataSource = new DriverManagerDataSource(schemaUrl, MYSQL.getUsername(), MYSQL.getPassword()); + Flyway.configure().dataSource(dataSource).defaultSchema(schema).schemas(schema) + .target("55").load().migrate(); + return dataSource; + } + + private void migrateTo56(DataSource dataSource) { + Flyway.configure().dataSource(dataSource).target("56").load().migrate(); + } + + private SourceIds seedDocuments(JdbcTemplate jdbc, String marker) { + String suffix = marker.toLowerCase(); + jdbc.update(""" + INSERT INTO iam_user (public_id, username, display_name, password_hash) + VALUES (?, ?, ?, 'unused') + """, "00000000000000000000560001", "v056-" + suffix, "V056 " + marker); + long userId = jdbc.queryForObject("SELECT id FROM iam_user WHERE username = ?", + Long.class, "v056-" + suffix); + jdbc.update(""" + INSERT INTO md_company (public_id, business_no, name, status, created_by, updated_by) + VALUES (?, ?, ?, 'ACTIVE', ?, ?) + """, "00000000000000000000560002", "V056-" + marker + "-COMPANY", "V056 " + marker, + userId, userId); + long companyId = jdbc.queryForObject("SELECT id FROM md_company WHERE business_no = ?", + Long.class, "V056-" + marker + "-COMPANY"); + long templateId = jdbc.queryForObject( + "SELECT id FROM src_template WHERE form_type = 'OA-05' AND template_version = 1 LIMIT 1", + Long.class); + long documentA = insertDocument(jdbc, userId, companyId, templateId, marker, "A"); + long documentB = insertDocument(jdbc, userId, companyId, templateId, marker, "B"); + long versionA = insertVersion(jdbc, userId, templateId, documentA, marker, "A"); + long versionB = insertVersion(jdbc, userId, templateId, documentB, marker, "B"); + return new SourceIds(documentA, documentB, versionA, versionB); + } + + private long insertDocument(JdbcTemplate jdbc, long userId, long companyId, long templateId, + String marker, String suffix) { + String publicId = "0000000000000000000056030" + ("A".equals(suffix) ? "1" : "2"); + String businessNo = "V056-" + marker + "-DOC-" + suffix; + jdbc.update(""" + INSERT INTO src_source_document ( + public_id, business_no, form_type, source_system, source_no, + normalized_source_no, source_version, normalized_source_version, + template_id, company_id, status, created_by, updated_by + ) VALUES (?, ?, 'OA-05', 'MANUAL', ?, ?, '1', '1', ?, ?, 'APPROVED', ?, ?) + """, publicId, businessNo, businessNo, businessNo, templateId, companyId, userId, userId); + return jdbc.queryForObject("SELECT id FROM src_source_document WHERE business_no = ?", + Long.class, businessNo); + } + + private long insertVersion(JdbcTemplate jdbc, long userId, long templateId, long documentId, + String marker, String suffix) { + String publicId = "0000000000000000000056031" + ("A".equals(suffix) ? "1" : "2"); + String businessNo = "V056-" + marker + "-DOC-" + suffix; + jdbc.update(""" + INSERT INTO src_document_version ( + public_id, source_document_id, version_no, template_id, snapshot_sha256, created_by + ) SELECT ?, id, 1, ?, REPEAT('a', 64), ? + FROM src_source_document WHERE business_no = ? + """, publicId, templateId, userId, businessNo); + return jdbc.queryForObject("SELECT id FROM src_document_version WHERE public_id = ?", + Long.class, publicId); + } + + private int constraintCount(JdbcTemplate jdbc, String name) { + return jdbc.queryForObject(""" + SELECT COUNT(*) FROM information_schema.table_constraints + WHERE table_schema = DATABASE() AND table_name = 'src_source_document' + AND constraint_name = ? AND constraint_type = 'FOREIGN KEY' + """, Integer.class, name); + } + + private String latestVersion(JdbcTemplate jdbc) { + return jdbc.queryForObject( + "SELECT version FROM flyway_schema_history WHERE success = TRUE ORDER BY installed_rank DESC LIMIT 1", + String.class); + } + + private record SourceIds(long documentA, long documentB, long versionA, long versionB) { + } +} diff --git a/backend/src/test/java/com/kaidi/finance/migration/V057AccountingSourceEventKeyMigrationTest.java b/backend/src/test/java/com/kaidi/finance/migration/V057AccountingSourceEventKeyMigrationTest.java new file mode 100644 index 0000000..397143c --- /dev/null +++ b/backend/src/test/java/com/kaidi/finance/migration/V057AccountingSourceEventKeyMigrationTest.java @@ -0,0 +1,108 @@ +package com.kaidi.finance.migration; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import javax.sql.DataSource; +import org.flywaydb.core.Flyway; +import org.junit.jupiter.api.Test; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +@Testcontainers(disabledWithoutDocker = true) +class V057AccountingSourceEventKeyMigrationTest { + + @Container + static final MySQLContainer MYSQL = new MySQLContainer<>("mysql:8.4") + .withDatabaseName("kaidi_finance_v057_test") + .withUsername("kaidi") + .withPassword("kaidi_test_password"); + + @Test + void sourceRevisionAndSequenceArePartOfTheUniqueEventKeyAndRuleTemplateIsSeeded() { + DataSource dataSource = prepareSchema("v057_source_event_key"); + JdbcTemplate jdbc = new JdbcTemplate(dataSource); + migrateTo58(dataSource); + + assertEquals("058", latestVersion(jdbc)); + assertEquals(1, jdbc.queryForObject(""" + SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='acc_voucher' + AND column_name='rule_snapshot_json' + """, Integer.class)); + assertEquals(1, jdbc.queryForObject(""" + SELECT COUNT(*) FROM sys_parameter_version + WHERE parameter_group='ACCOUNTING_RULE_TEMPLATE' + AND status='ACTIVE' AND version_no=1 + """, Integer.class)); + + long userId = seedCompany(jdbc); + insertEvent(jdbc, userId, "EV-1", "REV-1", 1); + insertEvent(jdbc, userId, "EV-2", "REV-2", 1); + String firstKey = jdbc.queryForObject( + "SELECT source_event_key FROM acc_event WHERE public_id='00000000000000000000000571'", String.class); + String secondKey = jdbc.queryForObject( + "SELECT source_event_key FROM acc_event WHERE public_id='00000000000000000000000572'", String.class); + assertNotNull(firstKey); + assertNotEquals(firstKey, secondKey); + assertThrows(DuplicateKeyException.class, + () -> insertEvent(jdbc, userId, "EV-DUP", "REV-1", 1)); + } + + private DataSource prepareSchema(String schema) { + DataSource root = new DriverManagerDataSource(MYSQL.getJdbcUrl(), "root", MYSQL.getPassword()); + JdbcTemplate rootJdbc = new JdbcTemplate(root); + rootJdbc.execute("DROP DATABASE IF EXISTS `" + schema + "`"); + rootJdbc.execute("CREATE DATABASE `" + schema + "`"); + rootJdbc.execute("GRANT ALL PRIVILEGES ON `" + schema + "`.* TO 'kaidi'@'%'"); + String schemaUrl = MYSQL.getJdbcUrl().replace("/kaidi_finance_v057_test", "/" + schema); + DataSource dataSource = new DriverManagerDataSource(schemaUrl, MYSQL.getUsername(), MYSQL.getPassword()); + Flyway.configure().dataSource(dataSource).defaultSchema(schema).schemas(schema) + .target("56").load().migrate(); + return dataSource; + } + + private void migrateTo58(DataSource dataSource) { + Flyway.configure().dataSource(dataSource).target("58").load().migrate(); + } + + private long seedCompany(JdbcTemplate jdbc) { + jdbc.update(""" + INSERT INTO iam_user (public_id, username, display_name, password_hash) + VALUES ('00000000000000000000000570', 'v057-user', 'V057 user', 'unused') + """); + long userId = jdbc.queryForObject("SELECT id FROM iam_user WHERE username='v057-user'", Long.class); + jdbc.update(""" + INSERT INTO md_company (public_id, business_no, name, status, created_by, updated_by) + VALUES ('00000000000000000000000573', 'V057-COMPANY', 'V057 company', 'ACTIVE', ?, ?) + """, userId, userId); + return userId; + } + + private void insertEvent(JdbcTemplate jdbc, long userId, String businessNo, + String sourceVersion, int sequence) { + jdbc.update(""" + INSERT INTO acc_event ( + public_id, business_no, event_type, source_type, source_public_id, + source_version, event_sequence, company_id, business_date, period, + amount, currency, status, created_by + ) SELECT ?, ?, 'RECEIPT', 'RECEIPT', '00000000000000000000000574', ?, ?, + company.id, '2026-08-01', '2026-08', 1.00, 'CNY', 'PENDING', ? + FROM md_company company WHERE company.public_id='00000000000000000000000573' + """, "EV-1".equals(businessNo) ? "00000000000000000000000571" + : "EV-2".equals(businessNo) ? "00000000000000000000000572" + : "00000000000000000000000575", businessNo, sourceVersion, sequence, userId); + } + + private String latestVersion(JdbcTemplate jdbc) { + return jdbc.queryForObject( + "SELECT version FROM flyway_schema_history WHERE success=TRUE ORDER BY installed_rank DESC LIMIT 1", + String.class); + } +} diff --git a/backend/src/test/java/com/kaidi/finance/operations/FinanceOperationsIntegrationTest.java b/backend/src/test/java/com/kaidi/finance/operations/FinanceOperationsIntegrationTest.java new file mode 100644 index 0000000..b70d653 --- /dev/null +++ b/backend/src/test/java/com/kaidi/finance/operations/FinanceOperationsIntegrationTest.java @@ -0,0 +1,1307 @@ +package com.kaidi.finance.operations; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.hamcrest.Matchers.hasItem; +import static org.hamcrest.Matchers.not; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.jayway.jsonpath.JsonPath; +import jakarta.servlet.http.Cookie; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +@Testcontainers(disabledWithoutDocker = true) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class FinanceOperationsIntegrationTest { + + private static final java.util.concurrent.atomic.AtomicLong COMMAND_FIXTURE_SEQUENCE = + new java.util.concurrent.atomic.AtomicLong(); + + private static String idempotencyKey() { + return "test-contract-cost-" + java.util.UUID.randomUUID(); + } + + private static MockHttpServletRequestBuilder contractPost(String urlTemplate, Object... uriVariables) { + return post(urlTemplate, uriVariables).header("Idempotency-Key", idempotencyKey()); + } + + @Container + static final MySQLContainer MYSQL = new MySQLContainer<>("mysql:8.4") + .withDatabaseName("kaidi_finance_operations_test") + .withUsername("kaidi") + .withPassword("kaidi_test_password"); + + @DynamicPropertySource + static void configureDatabase(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", MYSQL::getJdbcUrl); + registry.add("spring.datasource.username", MYSQL::getUsername); + registry.add("spring.datasource.password", MYSQL::getPassword); + registry.add("finance.bootstrap.enabled", () -> true); + registry.add("finance.bootstrap.password", () -> "LocalOnly@123"); + } + + @Autowired + MockMvc mockMvc; + + @Autowired + JdbcTemplate jdbcTemplate; + + @BeforeEach + void restoreBuiltInScopes() { + restoreGlobalScopes("finance", "FINANCE_MANAGER"); + restoreGlobalScopes("demo", "PROJECT_MANAGER"); + restoreGlobalScopes("demo", "FINANCE_MANAGER"); + } + + @Test + void financePageResourcesReturnCommonPageEnvelope() throws Exception { + ClientSession finance = loginAndSelect("finance", "FINANCE_MANAGER"); + String[] paths = { + "/api/v1/masterdata/bank-accounts", "/api/v1/masterdata/contracts", + "/api/v1/masterdata/departments", "/api/v1/masterdata/cost-categories", "/api/v1/masterdata/accounts", + "/api/v1/masterdata/tax-rates", "/api/v1/contracts", "/api/v1/costs", + "/api/v1/payables", "/api/v1/receipts", "/api/v1/invoices", "/api/v1/payments", + "/api/v1/accounting/events", "/api/v1/accounting/vouchers", + "/api/v1/archive/packages", "/api/v1/archive/files", "/api/v1/archive/borrows", + "/api/v1/reports/project-ledger", "/api/v1/reports/contract-execution", + "/api/v1/reports/receipt-invoice", "/api/v1/reports/payment-progress", + "/api/v1/reports/workflow-duration", "/api/v1/reports/archive-completeness" + }; + for (String path : paths) { + mockMvc.perform(get(path).param("page", "1").param("size", "20").cookie(finance.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data").isArray()) + .andExpect(jsonPath("$.meta.page").value(1)) + .andExpect(jsonPath("$.meta.size").value(20)); + } + + mockMvc.perform(get("/api/v1/contract-costs/contracts") + .param("page", "1").param("size", "20").cookie(finance.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items").isArray()) + .andExpect(jsonPath("$.data.summary.contractCount").isNumber()) + .andExpect(jsonPath("$.meta.page").value(1)); + mockMvc.perform(get("/api/v1/contract-costs/references").cookie(finance.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.companies").isArray()) + .andExpect(jsonPath("$.data.projects").isArray()); + } + + @Test + void compatibilityFinanceQueriesEnforceFrozenParametersAndSortWhitelist() throws Exception { + seedContractFixture(); + ClientSession finance = loginAndSelect("finance", "FINANCE_MANAGER"); + + mockMvc.perform(get("/api/v1/contracts") + .param("status", "active") + .param("sort", "status,asc", "businessNo,desc") + .param("page", "1").param("size", "20") + .cookie(finance.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data").isArray()); + mockMvc.perform(get("/api/v1/costs") + .param("sort", "businessDate,asc", "amount,desc") + .param("page", "1").param("size", "20") + .cookie(finance.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data").isArray()); + + for (String path : new String[]{"/api/v1/contracts", "/api/v1/costs", "/api/v1/payables"}) { + mockMvc.perform(get(path).param("sort", "updatedAt,drop table").cookie(finance.cookies())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("SORT_FIELD_NOT_ALLOWED")); + mockMvc.perform(get(path).param("unexpected", "ignored-before-fix").cookie(finance.cookies())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("QUERY_PARAMETER_UNKNOWN")); + mockMvc.perform(get(path).param("status", "NOT_A_REAL_STATUS").cookie(finance.cookies())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("QUERY_PARAMETER_INVALID")); + } + } + + @Test + void contractCostLedgerAggregatesScopesDrillsDownAndExports() throws Exception { + ContractFixture fixture = seedContractFixture(); + ClientSession finance = loginAndSelect("finance", "FINANCE_MANAGER"); + + mockMvc.perform(get("/api/v1/contract-costs/contracts") + .param("companyId", fixture.companyId()) + .param("costCategoryId", fixture.costCategoryId()) + .param("businessDateFrom", "2026-07-01") + .param("businessDateTo", "2026-07-31") + .param("status", "ACTIVE") + .param("sort", "paidAmount,desc") + .param("page", "1").param("size", "20") + .cookie(finance.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items.length()").value(1)) + .andExpect(jsonPath("$.data.items[0].publicId").value(fixture.contractId())) + .andExpect(jsonPath("$.data.items[0].originalAmount").value("1000.00")) + .andExpect(jsonPath("$.data.items[0].approvedChangeAmount").value("100.00")) + .andExpect(jsonPath("$.data.items[0].settlementAmount").doesNotExist()) + .andExpect(jsonPath("$.data.items[0].invoicedAmount").value("200.00")) + .andExpect(jsonPath("$.data.items[0].pendingPayableAmount").value("0.00")) + .andExpect(jsonPath("$.data.items[0].payableAmount").value("600.00")) + .andExpect(jsonPath("$.data.items[0].paidAmount").value("150.00")) + .andExpect(jsonPath("$.data.items[0].availableAmount").value("950.00")) + .andExpect(jsonPath("$.data.summary.contractCount").value(1)) + .andExpect(jsonPath("$.data.summary.settlementAmount").value("1100.00")) + .andExpect(jsonPath("$.data.summary.pendingPayableAmount").value("0.00")) + .andExpect(jsonPath("$.data.summary.availableAmount").value("950.00")) + .andExpect(jsonPath("$.meta.totalElements").value(1)); + + mockMvc.perform(get("/api/v1/contract-costs/contracts/{id}", fixture.contractId()) + .cookie(finance.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.contract.businessNo").value("HT-PAGE13-001")) + .andExpect(jsonPath("$.data.payables.length()").value(1)) + .andExpect(jsonPath("$.data.payables[0].businessNo").value("YF-PAGE13-001")) + .andExpect(jsonPath("$.data.invoices.length()").value(3)) + .andExpect(jsonPath("$.data.invoices[0].invoiceNo").value("INV-PAGE13-001")) + .andExpect(jsonPath("$.data.invoices[*].amount", hasItem("-50.00"))) + .andExpect(jsonPath("$.data.payments.length()").value(1)) + .andExpect(jsonPath("$.data.payments[0].businessNo").value("FK-PAGE13-001")); + + mockMvc.perform(get("/api/v1/reports/contract-execution") + .param("keyword", "HT-PAGE13-001") + .param("page", "1").param("size", "20").cookie(finance.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.length()").value(1)) + .andExpect(jsonPath("$.data[0].invoicedAmount").value(200.0)); + + mockMvc.perform(contractPost("/api/v1/contract-costs/payables/{id}/void", fixture.payableId()) + .cookie(finance.cookies()) + .header("X-XSRF-TOKEN", finance.csrfToken()) + .contentType("application/json") + .content("{\"version\":0,\"reason\":\"测试已付款保护\"}")) + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("INVALID_STATE_TRANSITION")); + + mockMvc.perform(get("/api/v1/contract-costs/references").cookie(finance.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.companies[*].publicId", hasItem(fixture.companyId()))) + .andExpect(jsonPath("$.data.projects[*].publicId", hasItem(fixture.projectId()))) + .andExpect(jsonPath("$.data.counterparties[*].publicId", hasItem(fixture.counterpartyId()))) + .andExpect(jsonPath("$.data.costCategories[*].publicId", hasItem(fixture.costCategoryId()))); + + MvcResult export = mockMvc.perform(get("/api/v1/contract-costs/contracts/export") + .param("companyId", fixture.companyId()) + .param("sort", "updatedAt,desc") + .cookie(finance.cookies())) + .andExpect(status().isOk()) + .andReturn(); + String csv = export.getResponse().getContentAsString(); + assertTrue(csv.contains("HT-PAGE13-001")); + assertTrue(csv.contains("1000.00,100.00,,200.00,0.00,600.00,150.00,950.00")); + Integer exports = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM audit_log WHERE action_code = 'CONTRACT_LEDGER_EXPORT'", Integer.class); + assertTrue(exports != null && exports > 0); + + restrictDemoProjectRoleToCompany(fixture.companyId()); + ClientSession scoped = loginAndSelect("demo", "PROJECT_MANAGER"); + mockMvc.perform(get("/api/v1/contract-costs/contracts") + .param("page", "1").param("size", "20").cookie(scoped.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items.length()").value(1)) + .andExpect(jsonPath("$.data.items[0].publicId").value(fixture.contractId())) + .andExpect(jsonPath("$.meta.totalElements").value(1)); + mockMvc.perform(get("/api/v1/contract-costs/contracts/{id}", fixture.otherContractId()) + .cookie(scoped.cookies())) + .andExpect(status().isNotFound()); + for (String path : new String[]{"/api/v1/contracts", "/api/v1/costs", "/api/v1/payables"}) { + mockMvc.perform(get(path) + .param("page", "1").param("size", "20").cookie(scoped.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.meta.totalElements").value(1)); + } + mockMvc.perform(get("/api/v1/contracts") + .param("page", "1").param("size", "20").cookie(scoped.cookies())) + .andExpect(jsonPath("$.data[0].public_id").value(fixture.contractId())) + .andExpect(jsonPath("$.data[0].contract_amount").value("1000.00")) + .andExpect(jsonPath("$.data[0].change_amount").value("100.00")) + .andExpect(jsonPath("$.data[0].settlement_amount").value("0.00")) + .andExpect(jsonPath("$.data[*].public_id", + org.hamcrest.Matchers.not(hasItem(fixture.otherContractId())))); + mockMvc.perform(get("/api/v1/costs") + .param("page", "1").param("size", "20").cookie(scoped.cookies())) + .andExpect(jsonPath("$.data[0].public_id").value(fixture.payableId())) + .andExpect(jsonPath("$.data[0].amount").value("600.00")) + .andExpect(jsonPath("$.data[0].invoiced_amount").value("200.00")) + .andExpect(jsonPath("$.data[0].paid_amount").value("150.00")) + .andExpect(jsonPath("$.data[0].cost_category_name").value("材料费")) + .andExpect(jsonPath("$.data[0].business_date").value("2026-07-15")); + mockMvc.perform(get("/api/v1/payables") + .param("page", "1").param("size", "20").cookie(scoped.cookies())) + .andExpect(jsonPath("$.data[0].public_id").value(fixture.payableId())) + .andExpect(jsonPath("$.data[0].amount").value("600.00")) + .andExpect(jsonPath("$.data[0].invoiced_amount").value("200.00")) + .andExpect(jsonPath("$.data[0].paid_amount").value("150.00")); + mockMvc.perform(get("/api/v1/costs") + .param("keyword", "合同台账测试供应商") + .param("page", "1").param("size", "20").cookie(scoped.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.length()").value(1)) + .andExpect(jsonPath("$.meta.totalElements").value(1)); + mockMvc.perform(get("/api/v1/payables") + .param("keyword", "合同台账测试供应商") + .param("page", "1").param("size", "20").cookie(scoped.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.length()").value(1)) + .andExpect(jsonPath("$.meta.totalElements").value(1)); + mockMvc.perform(get("/api/v1/contract-costs/references").cookie(scoped.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.companies[*].publicId", hasItem(fixture.companyId()))) + .andExpect(jsonPath("$.data.companies.length()").value(1)); + mockMvc.perform(contractPost( + "/api/v1/contract-costs/contracts/{id}/changes", fixture.contractId()) + .cookie(scoped.cookies()) + .header("X-XSRF-TOKEN", scoped.csrfToken()) + .contentType("application/json") + .content(""" + {"changeNo":"BG-SCOPE-DENIED","changeAmount":10.00, + "effectiveDate":"2026-07-25","reason":"范围验证","contractVersion":0} + """)) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("DATA_SCOPE_DENIED")); + + mockMvc.perform(get("/api/v1/contract-costs/contracts") + .param("sort", "name,drop table") + .param("page", "1").param("size", "20") + .cookie(finance.cookies())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("QUERY_PARAMETER_INVALID")); + } + + @Test + void contractCostCommandsCloseChangeSettlementAndPayableWorkflow() throws Exception { + ContractCommandFixture fixture = seedContractCommandFixture(); + ClientSession finance = loginAndSelect("finance", "FINANCE_MANAGER"); + String firstSettlementSource = approvedSource(fixture, "OA-11", "01"); + String secondSettlementSource = approvedSource(fixture, "OA-11", "02"); + String payableSource = approvedSource(fixture, "OA-04", "03"); + + MvcResult changeCreated = mockMvc.perform(contractPost( + "/api/v1/contract-costs/contracts/{id}/changes", fixture.contractId()) + .cookie(finance.cookies()).header("X-XSRF-TOKEN", finance.csrfToken()) + .contentType("application/json") + .content(""" + {"changeNo":"BG-PAGE13-CMD-001","changeAmount":125.50, + "effectiveDate":"2026-08-01","reason":"经批准增加范围","contractVersion":0} + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.contract.approvedChangeAmount").value("125.50")) + .andExpect(jsonPath("$.data.contract.version").value(1)) + .andExpect(jsonPath("$.data.changes[0].changeNo").value("BG-PAGE13-CMD-001")) + .andExpect(jsonPath("$.data.changes[0].allowedActions", hasItem("VOID"))) + .andReturn(); + String changeId = JsonPath.read(changeCreated.getResponse().getContentAsString(), + "$.data.changes[0].publicId"); + + mockMvc.perform(contractPost("/api/v1/contract-costs/contracts/{id}/changes", fixture.contractId()) + .cookie(finance.cookies()).header("X-XSRF-TOKEN", finance.csrfToken()) + .contentType("application/json") + .content(""" + {"changeNo":"BG-PAGE13-CMD-002","changeAmount":1.00, + "effectiveDate":"2026-08-01","reason":"旧版本测试","contractVersion":0} + """)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("VERSION_CONFLICT")); + + mockMvc.perform(contractPost("/api/v1/contract-costs/contracts/{id}/changes", fixture.contractId()) + .cookie(finance.cookies()).header("X-XSRF-TOKEN", finance.csrfToken()) + .contentType("application/json") + .content(""" + {"changeNo":"BG-PAGE13-CMD-001","changeAmount":1.00, + "effectiveDate":"2026-08-02","reason":"重复编号测试","contractVersion":1} + """)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("DUPLICATE_RESOURCE")); + + mockMvc.perform(contractPost("/api/v1/contract-costs/contracts/{id}/settlements", fixture.contractId()) + .cookie(finance.cookies()).header("X-XSRF-TOKEN", finance.csrfToken()) + .contentType("application/json") + .content(""" + {"settlementNo":"JS-PAGE13-CMD-001","settlementAmount":1100.00, + "settlementDate":"2026-08-03","sourceDocumentId":"%s","contractVersion":1} + """.formatted(firstSettlementSource))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.contract.settlementAmount").value("1100.00")) + .andExpect(jsonPath("$.data.contract.version").value(2)); + + MvcResult secondSettlement = mockMvc.perform(contractPost( + "/api/v1/contract-costs/contracts/{id}/settlements", fixture.contractId()) + .cookie(finance.cookies()).header("X-XSRF-TOKEN", finance.csrfToken()) + .contentType("application/json") + .content(""" + {"settlementNo":"JS-PAGE13-CMD-002","settlementAmount":1150.00, + "settlementDate":"2026-08-04","sourceDocumentId":"%s","contractVersion":2} + """.formatted(secondSettlementSource))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.contract.settlementAmount").value("1150.00")) + .andReturn(); + String settlementId = JsonPath.read(secondSettlement.getResponse().getContentAsString(), + "$.data.settlements[0].publicId"); + + mockMvc.perform(contractPost("/api/v1/contract-costs/settlements/{id}/void", settlementId) + .cookie(finance.cookies()).header("X-XSRF-TOKEN", finance.csrfToken()) + .contentType("application/json") + .content("{\"version\":0,\"reason\":\"结算数据更正\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.contract.settlementAmount").value("1100.00")) + .andExpect(jsonPath("$.data.contract.version").value(4)); + + MvcResult payableCreated = mockMvc.perform(contractPost( + "/api/v1/contract-costs/contracts/{id}/payables", fixture.contractId()) + .cookie(finance.cookies()).header("X-XSRF-TOKEN", finance.csrfToken()) + .contentType("application/json") + .content(""" + {"businessNo":"YF-PAGE13-CMD-001","costCategoryId":"%s", + "sourceDocumentId":"%s","businessDate":"2026-08-05", + "amount":600.00,"contractVersion":4} + """.formatted(fixture.costCategoryId(), payableSource))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.contract.version").value(5)) + .andExpect(jsonPath("$.data.contract.pendingPayableAmount").value("600.00")) + .andExpect(jsonPath("$.data.contract.payableAmount").value("0.00")) + .andExpect(jsonPath("$.data.payables[0].status").value("PENDING")) + .andExpect(jsonPath("$.data.payables[0].allowedActions", not(hasItem("CONFIRM")))) + .andReturn(); + String payableId = JsonPath.read(payableCreated.getResponse().getContentAsString(), + "$.data.payables[0].publicId"); + + mockMvc.perform(contractPost( + "/api/v1/contract-costs/contracts/{id}/payables", fixture.contractId()) + .cookie(finance.cookies()).header("X-XSRF-TOKEN", finance.csrfToken()) + .contentType("application/json") + .content(""" + {"businessNo":"YF-PAGE13-CMD-RESERVED","costCategoryId":"%s", + "sourceDocumentId":"%s","businessDate":"2026-08-05", + "amount":600.00,"contractVersion":5} + """.formatted(fixture.costCategoryId(), approvedSource(fixture, "OA-04", "04")))) + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("CONTRACT_LIMIT_EXCEEDED")); + + mockMvc.perform(contractPost("/api/v1/contract-costs/payables/{id}/confirm", payableId) + .cookie(finance.cookies()).header("X-XSRF-TOKEN", finance.csrfToken()) + .contentType("application/json").content("{\"version\":0}")) + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("SOD_VIOLATION")); + + assertEquals(1, jdbcTemplate.queryForObject(""" + SELECT COUNT(*) FROM fin_payable + WHERE public_id = ? AND status = 'PENDING' AND version = 0 AND confirmed_by IS NULL + """, Integer.class, payableId)); + assertEquals(0, jdbcTemplate.queryForObject(""" + SELECT COUNT(*) FROM acc_event + WHERE event_type = 'PAYABLE' AND source_type = 'PAYABLE' AND source_public_id = ? + """, Integer.class, payableId)); + + ClientSession reviewer = loginAndSelect("demo", "FINANCE_MANAGER"); + mockMvc.perform(get("/api/v1/contract-costs/contracts/{id}", fixture.contractId()) + .cookie(reviewer.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.payables[0].allowedActions", hasItem("CONFIRM"))); + + mockMvc.perform(contractPost("/api/v1/contract-costs/payables/{id}/confirm", payableId) + .cookie(reviewer.cookies()).header("X-XSRF-TOKEN", reviewer.csrfToken()) + .contentType("application/json").content("{\"version\":0}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.contract.pendingPayableAmount").value("0.00")) + .andExpect(jsonPath("$.data.contract.payableAmount").value("600.00")) + .andExpect(jsonPath("$.data.payables[0].status").value("CONFIRMED")) + .andExpect(jsonPath("$.data.payables[0].version").value(1)); + + assertEquals(1, jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM fin_payable payable + JOIN iam_user creator ON creator.id = payable.created_by + JOIN iam_user confirmer ON confirmer.id = payable.confirmed_by + WHERE payable.public_id = ? + AND creator.username = 'finance' + AND confirmer.username = 'demo' + AND payable.created_by <> payable.confirmed_by + AND payable.confirmed_at IS NOT NULL + """, Integer.class, payableId)); + assertEquals(1, jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM acc_event event + JOIN iam_user creator ON creator.id = event.created_by + WHERE event.event_type = 'PAYABLE' + AND event.source_type = 'PAYABLE' + AND event.source_public_id = ? + AND event.status = 'PENDING' + AND creator.username = 'demo' + """, Integer.class, payableId)); + + mockMvc.perform(contractPost("/api/v1/contract-costs/payables/{id}/confirm", payableId) + .cookie(reviewer.cookies()).header("X-XSRF-TOKEN", reviewer.csrfToken()) + .contentType("application/json").content("{\"version\":0}")) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("VERSION_CONFLICT")); + + mockMvc.perform(contractPost("/api/v1/contract-costs/payables/{id}/void", payableId) + .cookie(finance.cookies()).header("X-XSRF-TOKEN", finance.csrfToken()) + .contentType("application/json") + .content("{\"version\":1,\"reason\":\"重复登记\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.contract.pendingPayableAmount").value("0.00")) + .andExpect(jsonPath("$.data.contract.payableAmount").value("0.00")) + .andExpect(jsonPath("$.data.payables[0].status").value("VOID")) + .andExpect(jsonPath("$.data.payables[0].version").value(2)); + + mockMvc.perform(contractPost("/api/v1/contract-costs/contracts/{id}/payables", fixture.contractId()) + .cookie(finance.cookies()).header("X-XSRF-TOKEN", finance.csrfToken()) + .contentType("application/json") + .content(""" + {"businessNo":"YF-PAGE13-CMD-OVER","costCategoryId":"%s", + "sourceDocumentId":"%s","businessDate":"2026-08-06", + "amount":1200.00,"contractVersion":5} + """.formatted(fixture.costCategoryId(), approvedSource(fixture, "OA-04", "05")))) + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("CONTRACT_LIMIT_EXCEEDED")); + + mockMvc.perform(contractPost("/api/v1/contract-costs/changes/{id}/void", changeId) + .cookie(finance.cookies()).header("X-XSRF-TOKEN", finance.csrfToken()) + .contentType("application/json") + .content("{\"version\":0,\"reason\":\"变更审批撤销\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.contract.approvedChangeAmount").value("0.00")) + .andExpect(jsonPath("$.data.contract.version").value(6)); + + mockMvc.perform(contractPost("/api/v1/contract-costs/contracts/{id}/changes", fixture.contractId()) + .cookie(finance.cookies()).header("X-XSRF-TOKEN", finance.csrfToken()) + .contentType("application/json") + .content(""" + {"changeNo":"BG-PAGE13-CMD-REDUCE","changeAmount":-50.00, + "effectiveDate":"2026-08-07","reason":"批准核减范围","contractVersion":6} + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.contract.approvedChangeAmount").value("-50.00")) + .andExpect(jsonPath("$.data.contract.version").value(7)); + + Integer auditCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) FROM audit_log + WHERE action_code IN ('CONTRACT_CHANGE_CREATE', 'CONTRACT_CHANGE_VOID', + 'CONTRACT_SETTLEMENT_CREATE', + 'CONTRACT_SETTLEMENT_VOID', 'PAYABLE_CREATE', + 'PAYABLE_CONFIRM', 'PAYABLE_VOID') + """, Integer.class); + assertTrue(auditCount != null && auditCount >= 8); + } + + @Test + void confirmedPayableFlowsIntoAccountingAndVoidsConsistently() throws Exception { + ContractCommandFixture fixture = seedContractCommandFixture("ACC"); + ClientSession finance = loginAndSelect("finance", "FINANCE_MANAGER"); + ClientSession reviewer = loginAndSelect("demo", "FINANCE_MANAGER"); + + String payableId = createAndConfirmPayable( + fixture, finance, reviewer, "YF-PAGE13-ACC-001", "2026-08-08", + approvedSource(fixture, "OA-04", "01")); + String eventId = jdbcTemplate.queryForObject(""" + SELECT public_id FROM acc_event + WHERE event_type = 'PAYABLE' AND source_type = 'PAYABLE' AND source_public_id = ? + """, String.class, payableId); + assertNotNull(eventId); + assertEquals(1, jdbcTemplate.queryForObject(""" + SELECT COUNT(*) FROM acc_event + WHERE event_type = 'PAYABLE' AND source_type = 'PAYABLE' AND source_public_id = ? + """, Integer.class, payableId)); + + mockMvc.perform(get("/api/v1/accounting/events") + .param("eventType", "PAYABLE").param("keyword", payableId) + .cookie(finance.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.meta.totalElements").value(1)) + .andExpect(jsonPath("$.data[0].publicId").value(eventId)) + .andExpect(jsonPath("$.data[0].sourcePublicId").value(payableId)) + .andExpect(jsonPath("$.data[0].amount").value("600.00")) + .andExpect(jsonPath("$.data[0].allowedActions", hasItem("GENERATE_DRAFT"))); + + MvcResult generated = mockMvc.perform(post( + "/api/v1/accounting/events/{id}/generate-draft", eventId) + .cookie(finance.cookies()) + .header("Idempotency-Key", "payable-accounting-draft-0001") + .header("X-XSRF-TOKEN", finance.csrfToken()) + .contentType("application/json") + .content("{\"version\":0,\"ruleVersion\":\"ACCOUNTING-RULE-V1\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.voucher.status").value("DRAFT")) + .andExpect(jsonPath("$.data.event.eventType").value("PAYABLE")) + .andExpect(jsonPath("$.data.entries.length()").value(2)) + .andReturn(); + String voucherId = JsonPath.read(generated.getResponse().getContentAsString(), + "$.data.voucher.publicId"); + + mockMvc.perform(contractPost("/api/v1/contract-costs/payables/{id}/void", payableId) + .cookie(finance.cookies()).header("X-XSRF-TOKEN", finance.csrfToken()) + .contentType("application/json") + .content("{\"version\":1,\"reason\":\"来源数据更正\"}")) + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("INVALID_STATE_TRANSITION")); + + mockMvc.perform(post("/api/v1/accounting/vouchers/{id}/void", voucherId) + .cookie(finance.cookies()) + .header("Idempotency-Key", "payable-accounting-void-00001") + .header("X-XSRF-TOKEN", finance.csrfToken()) + .contentType("application/json") + .content("{\"version\":0,\"reason\":\"来源应付需要更正\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.voucher.status").value("VOID")); + + mockMvc.perform(contractPost("/api/v1/contract-costs/payables/{id}/void", payableId) + .cookie(finance.cookies()).header("X-XSRF-TOKEN", finance.csrfToken()) + .contentType("application/json") + .content("{\"version\":1,\"reason\":\"来源数据更正\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.payables[0].status").value("VOID")); + + assertEquals("VOID", jdbcTemplate.queryForObject(""" + SELECT status FROM acc_event + WHERE event_type = 'PAYABLE' AND source_type = 'PAYABLE' AND source_public_id = ? + """, String.class, payableId)); + } + + @Test + void projectRoleCanRegisterPayableButOnlyFinanceCanConfirmIt() throws Exception { + ContractCommandFixture fixture = seedContractCommandFixture("ROLE"); + ClientSession project = loginAndSelect("project", "PROJECT_MANAGER"); + String payableSource = approvedSource(fixture, "OA-04", "01"); + + assertEquals(1, jdbcTemplate.queryForObject(""" + SELECT COUNT(*) FROM iam_role_permission assignment + JOIN iam_role role ON role.id = assignment.role_id + JOIN iam_permission permission ON permission.id = assignment.permission_id + WHERE role.code = 'PROJECT_MANAGER' + AND permission.code = 'contractcost:payable:create' + """, Integer.class)); + assertEquals(0, jdbcTemplate.queryForObject(""" + SELECT COUNT(*) FROM iam_role_permission assignment + JOIN iam_role role ON role.id = assignment.role_id + JOIN iam_permission permission ON permission.id = assignment.permission_id + WHERE role.code = 'PROJECT_MANAGER' + AND permission.code = 'contractcost:payable:confirm' + """, Integer.class)); + + MvcResult created = mockMvc.perform(contractPost( + "/api/v1/contract-costs/contracts/{id}/payables", fixture.contractId()) + .cookie(project.cookies()).header("X-XSRF-TOKEN", project.csrfToken()) + .contentType("application/json") + .content(""" + {"businessNo":"YF-PAGE13-ROLE-001","costCategoryId":"%s", + "sourceDocumentId":"%s","businessDate":"2026-08-09", + "amount":250.00,"contractVersion":0} + """.formatted(fixture.costCategoryId(), payableSource))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.contract.allowedActions", hasItem("CREATE_PAYABLE"))) + .andExpect(jsonPath("$.data.payables[0].status").value("PENDING")) + .andExpect(jsonPath("$.data.payables[0].allowedActions", not(hasItem("CONFIRM")))) + .andReturn(); + String payableId = JsonPath.read(created.getResponse().getContentAsString(), + "$.data.payables[0].publicId"); + + mockMvc.perform(contractPost("/api/v1/contract-costs/payables/{id}/confirm", payableId) + .cookie(project.cookies()).header("X-XSRF-TOKEN", project.csrfToken()) + .contentType("application/json").content("{\"version\":0}")) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("PERMISSION_DENIED")); + + assertEquals(1, jdbcTemplate.queryForObject(""" + SELECT COUNT(*) FROM fin_payable payable + JOIN iam_user creator ON creator.id = payable.created_by + WHERE payable.public_id = ? AND payable.status = 'PENDING' + AND payable.confirmed_by IS NULL AND creator.username = 'project' + """, Integer.class, payableId)); + assertEquals(0, jdbcTemplate.queryForObject(""" + SELECT COUNT(*) FROM acc_event + WHERE event_type = 'PAYABLE' AND source_type = 'PAYABLE' AND source_public_id = ? + """, Integer.class, payableId)); + + ClientSession finance = loginAndSelect("finance", "FINANCE_MANAGER"); + mockMvc.perform(contractPost("/api/v1/contract-costs/payables/{id}/confirm", payableId) + .cookie(finance.cookies()).header("X-XSRF-TOKEN", finance.csrfToken()) + .contentType("application/json").content("{\"version\":0}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.payables[0].status").value("CONFIRMED")); + + assertEquals(1, jdbcTemplate.queryForObject(""" + SELECT COUNT(*) FROM acc_event + WHERE event_type = 'PAYABLE' AND source_type = 'PAYABLE' AND source_public_id = ? + """, Integer.class, payableId)); + } + + @Test + void contractCostCommandsRequireApprovedTypedScopedUniqueSources() throws Exception { + ContractCommandFixture fixture = seedContractCommandFixture("SOURCE"); + ContractCommandFixture other = seedContractCommandFixture("OTHER"); + ClientSession finance = loginAndSelect("finance", "FINANCE_MANAGER"); + + mockMvc.perform(contractPost("/api/v1/contract-costs/contracts/{id}/payables", fixture.contractId()) + .cookie(finance.cookies()).header("X-XSRF-TOKEN", finance.csrfToken()) + .contentType("application/json") + .content(""" + {"businessNo":"YF-SOURCE-MISSING","costCategoryId":"%s", + "businessDate":"2026-08-10","amount":100.00,"contractVersion":0} + """.formatted(fixture.costCategoryId()))) + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("VALIDATION_FAILED")) + .andExpect(jsonPath("$.fieldErrors.sourceDocumentId").exists()); + + String wrongType = approvedSource(fixture, "OA-11", "01"); + assertPayableSourceRejected(finance, fixture, "YF-SOURCE-TYPE", wrongType, + "VALIDATION_FAILED"); + + String draft = sourceDocument(fixture, "OA-04", "02", "DRAFT", null); + assertPayableSourceRejected(finance, fixture, "YF-SOURCE-DRAFT", draft, + "VALIDATION_FAILED"); + + String staleApproval = approvedSource(fixture, "OA-04", "03"); + replaceCurrentVersionWithoutApproval(staleApproval, fixture.contractId().substring(0, 24) + "93"); + assertPayableSourceRejected(finance, fixture, "YF-SOURCE-STALE", staleApproval, + "VALIDATION_FAILED"); + + String otherCompany = approvedSource(other, "OA-04", "01"); + assertPayableSourceRejected(finance, fixture, "YF-SOURCE-COMPANY", otherCompany, + "VALIDATION_FAILED"); + + String otherProjectId = fixture.contractId().substring(0, 24) + "90"; + long userId = jdbcTemplate.queryForObject( + "SELECT id FROM iam_user WHERE username = 'finance'", Long.class); + jdbcTemplate.update(""" + INSERT INTO md_project + (public_id, company_id, business_no, name, owner_user_id, currency, status, + created_by, updated_by) + SELECT ?, company.id, 'XM-SOURCE-OTHER', '同公司其他项目', ?, 'CNY', 'ACTIVE', ?, ? + FROM md_company company WHERE company.public_id = ? + """, otherProjectId, userId, userId, userId, fixture.companyId()); + ContractCommandFixture otherProject = new ContractCommandFixture( + fixture.companyId(), otherProjectId, fixture.contractId(), fixture.costCategoryId(), + "SOURCE-OTHER-PROJECT"); + String crossProject = approvedSource(otherProject, "OA-04", "04"); + assertPayableSourceRejected(finance, fixture, "YF-SOURCE-PROJECT", crossProject, + "VALIDATION_FAILED"); + + assertPayableSourceRejected(finance, fixture, "YF-SOURCE-NOT-FOUND", + fixture.contractId().substring(0, 24) + "99", "RESOURCE_NOT_FOUND"); + + String payableSource = approvedSource(fixture, "OA-04", "05"); + mockMvc.perform(contractPost("/api/v1/contract-costs/contracts/{id}/payables", fixture.contractId()) + .cookie(finance.cookies()).header("X-XSRF-TOKEN", finance.csrfToken()) + .contentType("application/json") + .content(""" + {"businessNo":"YF-SOURCE-OK","costCategoryId":"%s", + "sourceDocumentId":"%s","businessDate":"2026-08-10", + "amount":100.00,"contractVersion":0} + """.formatted(fixture.costCategoryId(), payableSource))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.contract.version").value(1)); + + mockMvc.perform(contractPost("/api/v1/contract-costs/contracts/{id}/payables", fixture.contractId()) + .cookie(finance.cookies()).header("X-XSRF-TOKEN", finance.csrfToken()) + .contentType("application/json") + .content(""" + {"businessNo":"YF-SOURCE-DUPLICATE","costCategoryId":"%s", + "sourceDocumentId":"%s","businessDate":"2026-08-10", + "amount":100.00,"contractVersion":1} + """.formatted(fixture.costCategoryId(), payableSource))) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("DUPLICATE_SOURCE")); + + String settlementSource = approvedSource(fixture, "OA-11", "06"); + mockMvc.perform(contractPost("/api/v1/contract-costs/contracts/{id}/settlements", fixture.contractId()) + .cookie(finance.cookies()).header("X-XSRF-TOKEN", finance.csrfToken()) + .contentType("application/json") + .content(""" + {"settlementNo":"JS-SOURCE-OK","settlementAmount":900.00, + "settlementDate":"2026-08-10","sourceDocumentId":"%s","contractVersion":1} + """.formatted(settlementSource))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.contract.version").value(2)); + + mockMvc.perform(contractPost("/api/v1/contract-costs/contracts/{id}/settlements", fixture.contractId()) + .cookie(finance.cookies()).header("X-XSRF-TOKEN", finance.csrfToken()) + .contentType("application/json") + .content(""" + {"settlementNo":"JS-SOURCE-DUPLICATE","settlementAmount":850.00, + "settlementDate":"2026-08-11","sourceDocumentId":"%s","contractVersion":2} + """.formatted(settlementSource))) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("DUPLICATE_SOURCE")); + } + + @Test + void payableAndSettlementKeepApprovedSourceVersionSnapshots() throws Exception { + ContractCommandFixture fixture = seedContractCommandFixture("SNAPSHOT"); + ClientSession finance = loginAndSelect("finance", "FINANCE_MANAGER"); + String payableSource = approvedSource(fixture, "OA-04", "01"); + String settlementSource = approvedSource(fixture, "OA-11", "02"); + String payableVersion = currentSourceVersionPublicId(payableSource); + String settlementVersion = currentSourceVersionPublicId(settlementSource); + + MvcResult payable = mockMvc.perform(post("/api/v1/contract-costs/contracts/{id}/payables", fixture.contractId()) + .cookie(finance.cookies()).header("X-XSRF-TOKEN", finance.csrfToken()) + .header("Idempotency-Key", idempotencyKey()) + .contentType("application/json") + .content(""" + {"businessNo":"YF-SNAPSHOT-001","costCategoryId":"%s", + "sourceDocumentId":"%s","businessDate":"2026-08-10", + "amount":100.00,"contractVersion":0} + """.formatted(fixture.costCategoryId(), payableSource))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.payables[0].sourceDocumentId").value(payableSource)) + .andExpect(jsonPath("$.data.payables[0].sourceVersionId").value(payableVersion)) + .andReturn(); + String payableId = JsonPath.read(payable.getResponse().getContentAsString(), "$.data.payables[0].publicId"); + + MvcResult settlement = mockMvc.perform(post("/api/v1/contract-costs/contracts/{id}/settlements", fixture.contractId()) + .cookie(finance.cookies()).header("X-XSRF-TOKEN", finance.csrfToken()) + .header("Idempotency-Key", idempotencyKey()) + .contentType("application/json") + .content(""" + {"settlementNo":"JS-SNAPSHOT-001","settlementAmount":900.00, + "settlementDate":"2026-08-10","sourceDocumentId":"%s","contractVersion":1} + """.formatted(settlementSource))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.settlements[0].sourceDocumentId").value(settlementSource)) + .andExpect(jsonPath("$.data.settlements[0].sourceVersionId").value(settlementVersion)) + .andReturn(); + String settlementId = JsonPath.read(settlement.getResponse().getContentAsString(), "$.data.settlements[0].publicId"); + + replaceCurrentVersionWithoutApproval(payableSource, fixture.contractId().substring(0, 24) + "91"); + replaceCurrentVersionWithoutApproval(settlementSource, fixture.contractId().substring(0, 24) + "92"); + + mockMvc.perform(get("/api/v1/contract-costs/contracts/{id}", fixture.contractId()) + .cookie(finance.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.payables[?(@.publicId == '%s')].sourceVersionId".formatted(payableId)) + .value(payableVersion)) + .andExpect(jsonPath("$.data.settlements[?(@.publicId == '%s')].sourceVersionId".formatted(settlementId)) + .value(settlementVersion)); + assertEquals(payableVersion, jdbcTemplate.queryForObject(""" + SELECT version.public_id FROM fin_payable payable + JOIN src_document_version version ON version.id = payable.source_version_id + WHERE payable.public_id = ? + """, String.class, payableId)); + assertEquals(settlementVersion, jdbcTemplate.queryForObject(""" + SELECT version.public_id FROM fin_contract_settlement settlement + JOIN src_document_version version ON version.id = settlement.source_version_id + WHERE settlement.public_id = ? + """, String.class, settlementId)); + } + + @Test + void payableCommandsReplayIdempotentlyAndDoNotDuplicateAccountingOrAudit() throws Exception { + ContractCommandFixture fixture = seedContractCommandFixture("IDEMPOTENCY"); + ClientSession project = loginAndSelect("project", "PROJECT_MANAGER"); + ClientSession finance = loginAndSelect("finance", "FINANCE_MANAGER"); + String sourceId = approvedSource(fixture, "OA-04", "01"); + String createKey = idempotencyKey(); + String body = """ + {"businessNo":"YF-PAGE13-IDEM-001","costCategoryId":"%s", + "sourceDocumentId":"%s","businessDate":"2026-08-10", + "amount":250.00,"contractVersion":0} + """.formatted(fixture.costCategoryId(), sourceId); + + MvcResult created = mockMvc.perform(post("/api/v1/contract-costs/contracts/{id}/payables", fixture.contractId()) + .cookie(project.cookies()).header("X-XSRF-TOKEN", project.csrfToken()) + .header("Idempotency-Key", createKey).contentType("application/json").content(body)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.contract.allowedActions", hasItem("CREATE_PAYABLE"))) + .andExpect(jsonPath("$.data.payables[0].allowedActions", not(hasItem("CONFIRM")))) + .andReturn(); + String payableId = JsonPath.read(created.getResponse().getContentAsString(), + "$.data.payables[0].publicId"); + + mockMvc.perform(post("/api/v1/contract-costs/contracts/{id}/payables", fixture.contractId()) + .cookie(project.cookies()).header("X-XSRF-TOKEN", project.csrfToken()) + .header("Idempotency-Key", createKey).contentType("application/json").content(body)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.payables[0].publicId").value(payableId)); + assertEquals(1, jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM fin_payable WHERE public_id = ?", Integer.class, payableId)); + assertEquals(1, jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM audit_log WHERE action_code = 'PAYABLE_CREATE' AND object_public_id = ?", + Integer.class, payableId)); + + mockMvc.perform(post("/api/v1/contract-costs/contracts/{id}/payables", fixture.contractId()) + .cookie(project.cookies()).header("X-XSRF-TOKEN", project.csrfToken()) + .header("Idempotency-Key", createKey).contentType("application/json") + .content(body.replace("250.00", "251.00"))) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("IDEMPOTENCY_KEY_REUSED")); + + String confirmKey = idempotencyKey(); + for (int attempt = 0; attempt < 2; attempt++) { + mockMvc.perform(post("/api/v1/contract-costs/payables/{id}/confirm", payableId) + .cookie(finance.cookies()).header("X-XSRF-TOKEN", finance.csrfToken()) + .header("Idempotency-Key", confirmKey).contentType("application/json") + .content("{\"version\":0}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.payables[0].status").value("CONFIRMED")); + } + assertEquals(1, jdbcTemplate.queryForObject(""" + SELECT COUNT(*) FROM acc_event + WHERE event_type = 'PAYABLE' AND source_type = 'PAYABLE' AND source_public_id = ? + """, Integer.class, payableId)); + assertEquals(1, jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM audit_log WHERE action_code = 'PAYABLE_CONFIRM' AND object_public_id = ?", + Integer.class, payableId)); + } + + @Test + void administratorResourcesAreIsolatedToSystemAdmin() throws Exception { + ClientSession admin = loginAndSelect("admin", "SYSTEM_ADMIN"); + for (String resource : new String[]{"users", "roles", "scopes", "templates", "parameters"}) { + mockMvc.perform(get("/api/v1/admin/" + resource).cookie(admin.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data").isArray()); + } + ClientSession finance = loginAndSelect("finance", "FINANCE_MANAGER"); + mockMvc.perform(get("/api/v1/admin/users").cookie(finance.cookies())) + .andExpect(status().isForbidden()); + } + + private ContractFixture seedContractFixture() { + long userId = jdbcTemplate.queryForObject( + "SELECT id FROM iam_user WHERE username = 'finance'", Long.class); + String companyId = "00000000000000000000001001"; + String counterpartyId = "00000000000000000000001002"; + String projectId = "00000000000000000000001003"; + String contractId = "00000000000000000000001004"; + String payableId = "00000000000000000000001005"; + String invoiceId = "00000000000000000000001006"; + String accountId = "00000000000000000000001007"; + String paymentId = "00000000000000000000001008"; + String reversedInvoiceId = "00000000000000000000001009"; + String redLetterInvoiceId = "00000000000000000000001010"; + String otherCompanyId = "00000000000000000000001011"; + String otherCounterpartyId = "00000000000000000000001012"; + String otherProjectId = "00000000000000000000001013"; + String otherContractId = "00000000000000000000001014"; + String costCategoryId = jdbcTemplate.queryForObject( + "SELECT public_id FROM md_cost_category WHERE code = 'MATERIAL'", String.class); + + Integer existingFixture = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM md_company WHERE public_id = ?", Integer.class, companyId); + if (existingFixture != null && existingFixture > 0) { + return new ContractFixture(companyId, projectId, counterpartyId, contractId, + otherContractId, costCategoryId, payableId); + } + + jdbcTemplate.update(""" + INSERT INTO md_company + (public_id, business_no, name, status, created_by, updated_by) + VALUES (?, 'GS-PAGE13-001', '合同台账测试公司', 'ACTIVE', ?, ?) + """, companyId, userId, userId); + jdbcTemplate.update(""" + INSERT INTO md_counterparty + (public_id, business_no, counterparty_type, name, status, created_by, updated_by) + VALUES (?, 'GY-PAGE13-001', 'SUPPLIER', '合同台账测试供应商', 'ACTIVE', ?, ?) + """, counterpartyId, userId, userId); + jdbcTemplate.update(""" + INSERT INTO md_project + (public_id, company_id, business_no, name, owner_user_id, currency, status, created_by, updated_by) + SELECT ?, company.id, 'XM-PAGE13-001', '合同台账测试项目', ?, 'CNY', 'ACTIVE', ?, ? + FROM md_company company WHERE company.public_id = ? + """, projectId, userId, userId, userId, companyId); + jdbcTemplate.update(""" + INSERT INTO md_contract + (public_id, company_id, project_id, counterparty_id, business_no, name, + original_amount, approved_change_amount, currency, status, created_by, updated_by) + SELECT ?, company.id, project.id, counterparty.id, 'HT-PAGE13-001', '合同台账测试合同', + 1000.00, 100.00, 'CNY', 'ACTIVE', ?, ? + FROM md_company company + JOIN md_project project ON project.public_id = ? + JOIN md_counterparty counterparty ON counterparty.public_id = ? + WHERE company.public_id = ? + """, contractId, userId, userId, projectId, counterpartyId, companyId); + + jdbcTemplate.update(""" + INSERT INTO fin_payable + (public_id, business_no, company_id, project_id, contract_id, counterparty_id, + cost_category_id, business_date, amount, invoiced_amount, paid_amount, status, + created_by, updated_by) + SELECT ?, 'YF-PAGE13-001', company.id, project.id, contract.id, counterparty.id, + category.id, '2026-07-15', 600.00, 200.00, 150.00, 'PART_PAID', ?, ? + FROM md_company company + JOIN md_project project ON project.public_id = ? + JOIN md_contract contract ON contract.public_id = ? + JOIN md_counterparty counterparty ON counterparty.public_id = ? + JOIN md_cost_category category ON category.public_id = ? + WHERE company.public_id = ? + """, payableId, userId, userId, projectId, contractId, counterpartyId, costCategoryId, companyId); + jdbcTemplate.update(""" + INSERT INTO fin_invoice + (public_id, business_no, company_id, project_id, contract_id, counterparty_id, + invoice_type, amount, tax_rate, requested_date, invoice_no, issued_date, status, + created_by, updated_by) + SELECT ?, 'FP-PAGE13-001', company.id, project.id, contract.id, counterparty.id, + 'SPECIAL', 200.00, 0.13, '2026-07-10', 'INV-PAGE13-001', '2026-07-18', 'ISSUED', ?, ? + FROM md_company company + JOIN md_project project ON project.public_id = ? + JOIN md_contract contract ON contract.public_id = ? + JOIN md_counterparty counterparty ON counterparty.public_id = ? + WHERE company.public_id = ? + """, invoiceId, userId, userId, projectId, contractId, counterpartyId, companyId); + jdbcTemplate.update(""" + INSERT INTO fin_invoice + (public_id, business_no, company_id, project_id, contract_id, counterparty_id, + invoice_type, amount, tax_rate, requested_date, invoice_no, issued_date, status, + created_by, updated_by) + SELECT ?, 'FP-PAGE13-R01', company.id, project.id, contract.id, counterparty.id, + 'SPECIAL', 50.00, 0.13, '2026-07-09', 'INV-PAGE13-R01', '2026-07-09', + 'RED_LETTERED', ?, ? + FROM md_company company + JOIN md_project project ON project.public_id = ? + JOIN md_contract contract ON contract.public_id = ? + JOIN md_counterparty counterparty ON counterparty.public_id = ? + WHERE company.public_id = ? + """, reversedInvoiceId, userId, userId, projectId, contractId, counterpartyId, companyId); + jdbcTemplate.update(""" + INSERT INTO fin_invoice + (public_id, business_no, company_id, project_id, contract_id, counterparty_id, + invoice_type, amount, tax_rate, requested_date, invoice_no, issued_date, + original_invoice_id, status, created_by, updated_by) + SELECT ?, 'FP-PAGE13-R02', company.id, project.id, contract.id, counterparty.id, + 'RED_LETTER', 50.00, 0.13, '2026-07-09', 'INV-PAGE13-R02', '2026-07-09', + original.id, 'ISSUED', ?, ? + FROM md_company company + JOIN md_project project ON project.public_id = ? + JOIN md_contract contract ON contract.public_id = ? + JOIN md_counterparty counterparty ON counterparty.public_id = ? + JOIN fin_invoice original ON original.public_id = ? + WHERE company.public_id = ? + """, redLetterInvoiceId, userId, userId, projectId, contractId, counterpartyId, + reversedInvoiceId, companyId); + jdbcTemplate.update(""" + INSERT INTO md_bank_account_version + (public_id, owner_type, counterparty_id, account_category, account_name, bank_name, + account_no_ciphertext, account_no_hash, masked_account_no, version_no, valid_from, + status, created_by, updated_by) + SELECT ?, 'COUNTERPARTY', counterparty.id, 'BASIC', '测试收款账户', '测试银行', + UNHEX('0102'), REPEAT('a', 64), '****1001', 1, UTC_TIMESTAMP(3), 'ACTIVE', ?, ? + FROM md_counterparty counterparty WHERE counterparty.public_id = ? + """, accountId, userId, userId, counterpartyId); + jdbcTemplate.update(""" + INSERT INTO fin_payment_request + (public_id, business_no, company_id, project_id, contract_id, payable_id, supplier_id, + bank_account_version_id, requested_amount, approved_amount, currency, requested_date, + purpose, status, created_by, updated_by) + SELECT ?, 'FK-PAGE13-001', company.id, project.id, contract.id, payable.id, counterparty.id, + account.id, 150.00, 150.00, 'CNY', '2026-07-20', '测试付款', 'PAID', ?, ? + FROM md_company company + JOIN md_project project ON project.public_id = ? + JOIN md_contract contract ON contract.public_id = ? + JOIN fin_payable payable ON payable.public_id = ? + JOIN md_counterparty counterparty ON counterparty.public_id = ? + JOIN md_bank_account_version account ON account.public_id = ? + WHERE company.public_id = ? + """, paymentId, userId, userId, projectId, contractId, payableId, counterpartyId, accountId, companyId); + + jdbcTemplate.update(""" + INSERT INTO md_company + (public_id, business_no, name, status, created_by, updated_by) + VALUES (?, 'GS-PAGE13-002', '不可见测试公司', 'ACTIVE', ?, ?) + """, otherCompanyId, userId, userId); + jdbcTemplate.update(""" + INSERT INTO md_counterparty + (public_id, business_no, counterparty_type, name, status, created_by, updated_by) + VALUES (?, 'GY-PAGE13-002', 'SUPPLIER', '不可见测试供应商', 'ACTIVE', ?, ?) + """, otherCounterpartyId, userId, userId); + jdbcTemplate.update(""" + INSERT INTO md_project + (public_id, company_id, business_no, name, owner_user_id, currency, status, created_by, updated_by) + SELECT ?, company.id, 'XM-PAGE13-002', '不可见测试项目', ?, 'CNY', 'ACTIVE', ?, ? + FROM md_company company WHERE company.public_id = ? + """, otherProjectId, userId, userId, userId, otherCompanyId); + jdbcTemplate.update(""" + INSERT INTO md_contract + (public_id, company_id, project_id, counterparty_id, business_no, name, + original_amount, approved_change_amount, currency, status, created_by, updated_by) + SELECT ?, company.id, project.id, counterparty.id, 'HT-PAGE13-002', '不可见测试合同', + 500.00, 0.00, 'CNY', 'ACTIVE', ?, ? + FROM md_company company + JOIN md_project project ON project.public_id = ? + JOIN md_counterparty counterparty ON counterparty.public_id = ? + WHERE company.public_id = ? + """, otherContractId, userId, userId, otherProjectId, otherCounterpartyId, otherCompanyId); + + return new ContractFixture(companyId, projectId, counterpartyId, contractId, + otherContractId, costCategoryId, payableId); + } + + private ContractCommandFixture seedContractCommandFixture() { + return seedContractCommandFixture("CMD"); + } + + private ContractCommandFixture seedContractCommandFixture(String suffix) { + long userId = jdbcTemplate.queryForObject( + "SELECT id FROM iam_user WHERE username = 'finance'", Long.class); + String base = "00000000" + String.format("%016d", COMMAND_FIXTURE_SEQUENCE.incrementAndGet()); + String fixtureSuffix = suffix + "-" + base.substring(16); + String companyId = base + "01"; + String counterpartyId = base + "02"; + String projectId = base + "03"; + String contractId = base + "04"; + jdbcTemplate.update(""" + INSERT INTO md_company + (public_id, business_no, name, status, created_by, updated_by) + VALUES (?, ?, ?, 'ACTIVE', ?, ?) + """, companyId, "GS-PAGE13-" + fixtureSuffix, "合同命令测试公司-" + fixtureSuffix, userId, userId); + jdbcTemplate.update(""" + INSERT INTO md_counterparty + (public_id, business_no, counterparty_type, name, status, created_by, updated_by) + VALUES (?, ?, 'SUPPLIER', ?, 'ACTIVE', ?, ?) + """, counterpartyId, "GY-PAGE13-" + fixtureSuffix, "合同命令测试供应商-" + fixtureSuffix, userId, userId); + jdbcTemplate.update(""" + INSERT INTO md_project + (public_id, company_id, business_no, name, owner_user_id, currency, status, + created_by, updated_by) + SELECT ?, company.id, ?, ?, ?, 'CNY', 'ACTIVE', ?, ? + FROM md_company company WHERE company.public_id = ? + """, projectId, "XM-PAGE13-" + fixtureSuffix, "合同命令测试项目-" + fixtureSuffix, + userId, userId, userId, companyId); + jdbcTemplate.update(""" + INSERT INTO md_contract + (public_id, company_id, project_id, counterparty_id, business_no, name, + original_amount, currency, status, created_by, updated_by) + SELECT ?, company.id, project.id, counterparty.id, ?, ?, + 1000.00, 'CNY', 'ACTIVE', ?, ? + FROM md_company company + JOIN md_project project ON project.public_id = ? + JOIN md_counterparty counterparty ON counterparty.public_id = ? + WHERE company.public_id = ? + """, contractId, "HT-PAGE13-" + fixtureSuffix, "合同命令测试合同-" + fixtureSuffix, + userId, userId, projectId, counterpartyId, companyId); + String costCategoryId = jdbcTemplate.queryForObject( + "SELECT public_id FROM md_cost_category WHERE code = 'MATERIAL'", String.class); + return new ContractCommandFixture(companyId, projectId, contractId, costCategoryId, fixtureSuffix); + } + + private String approvedSource(ContractCommandFixture fixture, String formType, String discriminator) { + return sourceDocument(fixture, formType, discriminator, "APPROVED", "APPROVED"); + } + + private String sourceDocument(ContractCommandFixture fixture, String formType, String discriminator, + String documentStatus, String workflowStatus) { + long userId = jdbcTemplate.queryForObject( + "SELECT id FROM iam_user WHERE username = 'finance'", Long.class); + String base = fixture.contractId().substring(0, 24); + int sequence = Integer.parseInt(discriminator); + int typeOffset = "OA-04".equals(formType) ? 10 : 20; + String documentId = base + String.format("%02d", typeOffset + sequence); + String versionId = base + String.format("%02d", typeOffset + 30 + sequence); + String instanceId = base + String.format("%02d", typeOffset + 60 + sequence); + String sourceKey = fixture.suffix() + "-" + formType + "-" + discriminator; + + jdbcTemplate.update(""" + INSERT INTO src_source_document + (public_id, business_no, form_type, source_system, source_no, + normalized_source_no, source_version, normalized_source_version, + template_id, company_id, project_id, applicant_name, business_date, + status, created_by, updated_by) + SELECT ?, ?, ?, 'MANUAL', ?, ?, '1', '1', template.id, + company.id, project.id, '来源测试申请人', '2026-08-01', ?, ?, ? + FROM src_template template + JOIN md_company company ON company.public_id = ? + JOIN md_project project ON project.public_id = ? + WHERE template.form_type = ? AND template.template_version = 1 + """, documentId, "SRC-" + sourceKey, formType, "SRC-" + sourceKey, + "SRC-" + sourceKey, documentStatus, userId, userId, + fixture.companyId(), fixture.projectId(), formType); + long sourceDatabaseId = jdbcTemplate.queryForObject( + "SELECT id FROM src_source_document WHERE public_id = ?", Long.class, documentId); + jdbcTemplate.update(""" + INSERT INTO src_document_version + (public_id, source_document_id, version_no, template_id, snapshot_sha256, + payload_json, submitted_at, created_by) + SELECT ?, document.id, 1, document.template_id, REPEAT('a', 64), JSON_OBJECT(), + UTC_TIMESTAMP(3), ? + FROM src_source_document document WHERE document.id = ? + """, versionId, userId, sourceDatabaseId); + long sourceVersionId = jdbcTemplate.queryForObject( + "SELECT id FROM src_document_version WHERE public_id = ?", Long.class, versionId); + jdbcTemplate.update( + "UPDATE src_source_document SET current_version_id = ? WHERE id = ?", + sourceVersionId, sourceDatabaseId); + if (workflowStatus != null) { + jdbcTemplate.update(""" + INSERT INTO wf_instance + (public_id, definition_version_id, source_version_id, current_node_code, + status, created_by, updated_by) + SELECT ?, definition_version.id, ?, NULL, ?, ?, ? + FROM wf_definition_version definition_version + JOIN wf_definition definition ON definition.id = definition_version.definition_id + WHERE definition.code = 'SOURCE_STANDARD_REVIEW' + AND definition_version.status = 'ACTIVE' + """, instanceId, sourceVersionId, workflowStatus, userId, userId); + } + return documentId; + } + + private void replaceCurrentVersionWithoutApproval(String sourceDocumentId, String versionPublicId) { + long userId = jdbcTemplate.queryForObject( + "SELECT id FROM iam_user WHERE username = 'finance'", Long.class); + jdbcTemplate.update(""" + INSERT INTO src_document_version + (public_id, source_document_id, version_no, previous_version_id, template_id, + snapshot_sha256, payload_json, submitted_at, created_by) + SELECT ?, document.id, current_version.version_no + 1, current_version.id, + document.template_id, REPEAT('b', 64), JSON_OBJECT(), UTC_TIMESTAMP(3), ? + FROM src_source_document document + JOIN src_document_version current_version ON current_version.id = document.current_version_id + WHERE document.public_id = ? + """, versionPublicId, userId, sourceDocumentId); + jdbcTemplate.update(""" + UPDATE src_source_document document + JOIN src_document_version version ON version.public_id = ? + SET document.current_version_id = version.id + WHERE document.public_id = ? + """, versionPublicId, sourceDocumentId); + } + + private String currentSourceVersionPublicId(String sourceDocumentId) { + return jdbcTemplate.queryForObject(""" + SELECT version.public_id FROM src_source_document document + JOIN src_document_version version ON version.id = document.current_version_id + WHERE document.public_id = ? + """, String.class, sourceDocumentId); + } + + private void assertPayableSourceRejected(ClientSession session, ContractCommandFixture fixture, + String businessNo, String sourceDocumentId, + String errorCode) throws Exception { + mockMvc.perform(contractPost("/api/v1/contract-costs/contracts/{id}/payables", fixture.contractId()) + .cookie(session.cookies()).header("X-XSRF-TOKEN", session.csrfToken()) + .contentType("application/json") + .content(""" + {"businessNo":"%s","costCategoryId":"%s","sourceDocumentId":"%s", + "businessDate":"2026-08-10","amount":100.00,"contractVersion":0} + """.formatted(businessNo, fixture.costCategoryId(), sourceDocumentId))) + .andExpect(status().is(errorCode.equals("RESOURCE_NOT_FOUND") ? 404 : 422)) + .andExpect(jsonPath("$.code").value(errorCode)); + } + + private String createAndConfirmPayable(ContractCommandFixture fixture, ClientSession creator, + ClientSession confirmer, String businessNo, + String businessDate, String sourceDocumentId) throws Exception { + MvcResult created = mockMvc.perform(contractPost( + "/api/v1/contract-costs/contracts/{id}/payables", fixture.contractId()) + .cookie(creator.cookies()).header("X-XSRF-TOKEN", creator.csrfToken()) + .contentType("application/json") + .content(""" + {"businessNo":"%s","costCategoryId":"%s", + "sourceDocumentId":"%s","businessDate":"%s", + "amount":600.00,"contractVersion":0} + """.formatted(businessNo, fixture.costCategoryId(), sourceDocumentId, businessDate))) + .andExpect(status().isOk()).andReturn(); + String payableId = JsonPath.read(created.getResponse().getContentAsString(), + "$.data.payables[0].publicId"); + mockMvc.perform(contractPost("/api/v1/contract-costs/payables/{id}/confirm", payableId) + .cookie(confirmer.cookies()).header("X-XSRF-TOKEN", confirmer.csrfToken()) + .contentType("application/json").content("{\"version\":0}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.payables[0].status").value("CONFIRMED")); + return payableId; + } + + private void restrictDemoProjectRoleToCompany(String companyPublicId) { + jdbcTemplate.update(""" + DELETE scope FROM iam_scope scope + JOIN iam_user user_account ON user_account.id = scope.user_id + JOIN iam_role role ON role.id = scope.role_id + WHERE user_account.username = 'demo' AND role.code = 'PROJECT_MANAGER' + """); + for (String permissionCode : new String[]{"contractcost:ledger:view", "contractcost:payable:view"}) { + jdbcTemplate.update(""" + INSERT INTO iam_scope + (user_id, role_id, permission_id, scope_type, company_public_id, valid_from, status) + SELECT user_account.id, role.id, permission.id, 'COMPANY', ?, + '2020-01-01 00:00:00', 'ACTIVE' + FROM iam_user user_account + JOIN iam_role role ON role.code = 'PROJECT_MANAGER' + JOIN iam_permission permission ON permission.code = ? + WHERE user_account.username = 'demo' + """, companyPublicId, permissionCode); + } + } + + private void restoreGlobalScopes(String username, String roleCode) { + jdbcTemplate.update(""" + DELETE scope FROM iam_scope scope + JOIN iam_user user_account ON user_account.id = scope.user_id + JOIN iam_role role ON role.id = scope.role_id + WHERE user_account.username = ? AND role.code = ? + """, username, roleCode); + jdbcTemplate.update(""" + INSERT INTO iam_scope + (user_id, role_id, permission_id, scope_type, valid_from, status) + SELECT user_account.id, role.id, role_permission.permission_id, 'GLOBAL', + '2020-01-01 00:00:00', 'ACTIVE' + FROM iam_user user_account + JOIN iam_role role ON role.code = ? AND role.enabled = TRUE + JOIN iam_role_permission role_permission ON role_permission.role_id = role.id + WHERE user_account.username = ? + """, roleCode, username); + } + + private ClientSession loginAndSelect(String username, String role) throws Exception { + MvcResult csrf = mockMvc.perform(get("/api/v1/auth/csrf")).andExpect(status().isOk()).andReturn(); + Cookie csrfCookie = csrf.getResponse().getCookie("XSRF-TOKEN"); + assertNotNull(csrfCookie); + String csrfToken = JsonPath.read(csrf.getResponse().getContentAsString(), "$.data.token"); + MvcResult login = mockMvc.perform(post("/api/v1/auth/login") + .cookie(csrfCookie) + .header("X-XSRF-TOKEN", csrfToken) + .contentType("application/json") + .content("{\"username\":\"%s\",\"password\":\"LocalOnly@123\"}".formatted(username))) + .andExpect(status().isOk()).andReturn(); + Cookie sessionCookie = login.getResponse().getCookie("KAIDI_SESSION"); + assertNotNull(sessionCookie); + MvcResult selected = mockMvc.perform(post("/api/v1/auth/select-role") + .cookie(sessionCookie, csrfCookie) + .header("X-XSRF-TOKEN", csrfToken) + .contentType("application/json") + .content("{\"roleCode\":\"%s\"}".formatted(role))) + .andExpect(status().isOk()).andReturn(); + Cookie rotated = selected.getResponse().getCookie("KAIDI_SESSION"); + return new ClientSession(rotated == null ? sessionCookie : rotated, csrfCookie, csrfToken); + } + + private record ClientSession(Cookie sessionCookie, Cookie csrfCookie, String csrfToken) { + Cookie[] cookies() { + return new Cookie[]{sessionCookie, csrfCookie}; + } + } + + private record ContractFixture(String companyId, String projectId, String counterpartyId, + String contractId, String otherContractId, String costCategoryId, + String payableId) { + } + + private record ContractCommandFixture(String companyId, String projectId, String contractId, + String costCategoryId, String suffix) { + } +} diff --git a/backend/src/test/java/com/kaidi/finance/payment/PaymentIntegrationTest.java b/backend/src/test/java/com/kaidi/finance/payment/PaymentIntegrationTest.java new file mode 100644 index 0000000..e747433 --- /dev/null +++ b/backend/src/test/java/com/kaidi/finance/payment/PaymentIntegrationTest.java @@ -0,0 +1,1864 @@ +package com.kaidi.finance.payment; + +import static org.hamcrest.Matchers.hasItem; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.jayway.jsonpath.JsonPath; +import com.kaidi.finance.fund.application.FundLedgerApplicationService; +import com.kaidi.finance.fund.application.FundLedgerApplicationService.FundLedgerCommand; +import com.kaidi.finance.shared.api.BusinessException; +import com.kaidi.finance.shared.api.ErrorCode; +import jakarta.servlet.http.Cookie; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.transaction.support.TransactionTemplate; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +@Testcontainers(disabledWithoutDocker = true) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class PaymentIntegrationTest { + + private static String idempotencyKey() { + return "test-payment-" + java.util.UUID.randomUUID(); + } + + private static final String COMPANY_ID = "00000000000000000000001501"; + private static final String SUPPLIER_ID = "00000000000000000000001502"; + private static final String PROJECT_ID = "00000000000000000000001503"; + private static final String CONTRACT_ID = "00000000000000000000001504"; + private static final String PAYABLE_ID = "00000000000000000000001505"; + private static final String ACCOUNT_ID = "00000000000000000000001506"; + private static final String SUPPORT_FILE_ID = "00000000000000000000001507"; + private static final String RECEIPT_FILE_ID = "00000000000000000000001508"; + private static final String PENDING_FILE_ID = "00000000000000000000001509"; + private static final String OTHER_SCOPE_FILE_ID = "00000000000000000000001510"; + private static final String OTHER_COMPANY_ID = "00000000000000000000001511"; + private static final String OTHER_PROJECT_ID = "00000000000000000000001512"; + private static final String OTHER_SUPPLIER_ID = "00000000000000000000001513"; + private static final String OTHER_CONTRACT_ID = "00000000000000000000001514"; + private static final String OTHER_PAYABLE_ID = "00000000000000000000001515"; + private static final String OTHER_ACCOUNT_ID = "00000000000000000000001516"; + private static final String SOURCE_ID_PREFIX = "000000000000000000000016"; + private static final String ATTACHMENT_MATRIX_V2_ID = "00000000000000000000000361"; + + @Container + static final MySQLContainer MYSQL = new MySQLContainer<>("mysql:8.4") + .withDatabaseName("kaidi_finance_payment_test") + .withUsername("kaidi") + .withPassword("kaidi_test_password"); + + @DynamicPropertySource + static void database(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", MYSQL::getJdbcUrl); + registry.add("spring.datasource.username", MYSQL::getUsername); + registry.add("spring.datasource.password", MYSQL::getPassword); + registry.add("finance.bootstrap.enabled", () -> true); + registry.add("finance.bootstrap.password", () -> "LocalOnly@123"); + } + + @Autowired MockMvc mockMvc; + @Autowired JdbcTemplate jdbcTemplate; + @Autowired FundLedgerApplicationService fundLedgerService; + @Autowired TransactionTemplate transactionTemplate; + + @BeforeEach + void seedAndReset() { + long financeId = userId("finance"); + long projectUserId = userId("project"); + if (count("SELECT COUNT(*) FROM md_company WHERE public_id = ?", COMPANY_ID) == 0) { + jdbcTemplate.update("INSERT INTO md_company (public_id,business_no,name,status,created_by,updated_by) VALUES (?,'GS-P15','付款测试公司','ACTIVE',?,?)", COMPANY_ID, financeId, financeId); + jdbcTemplate.update("INSERT INTO md_company (public_id,business_no,name,status,created_by,updated_by) VALUES (?,'GS-P15-B','付款范围外公司','ACTIVE',?,?)", OTHER_COMPANY_ID, financeId, financeId); + jdbcTemplate.update("INSERT INTO md_counterparty (public_id,business_no,counterparty_type,name,status,created_by,updated_by) VALUES (?,'GY-P15','SUPPLIER','付款测试供应商','ACTIVE',?,?)", SUPPLIER_ID, financeId, financeId); + jdbcTemplate.update("INSERT INTO md_project (public_id,company_id,business_no,name,owner_user_id,currency,status,created_by,updated_by) SELECT ?,id,'XM-P15','付款测试项目',?,'CNY','ACTIVE',?,? FROM md_company WHERE public_id=?", PROJECT_ID, projectUserId, financeId, financeId, COMPANY_ID); + jdbcTemplate.update("INSERT INTO md_project (public_id,company_id,business_no,name,owner_user_id,currency,status,created_by,updated_by) SELECT ?,id,'XM-P15-B','付款范围外项目',?,'CNY','ACTIVE',?,? FROM md_company WHERE public_id=?", OTHER_PROJECT_ID, projectUserId, financeId, financeId, OTHER_COMPANY_ID); + jdbcTemplate.update("INSERT INTO md_contract (public_id,company_id,project_id,counterparty_id,business_no,name,original_amount,approved_change_amount,currency,status,created_by,updated_by) SELECT ?,c.id,p.id,s.id,'HT-P15','付款测试合同',10000,0,'CNY','ACTIVE',?,? FROM md_company c JOIN md_project p ON p.company_id=c.id JOIN md_counterparty s ON s.public_id=? WHERE c.public_id=?", CONTRACT_ID, financeId, financeId, SUPPLIER_ID, COMPANY_ID); + jdbcTemplate.update("INSERT INTO fin_payable (public_id,business_no,company_id,project_id,contract_id,counterparty_id,business_date,amount,status,created_by,updated_by) SELECT ?,'YF-P15',c.id,p.id,k.id,s.id,'2026-08-01',10000,'CONFIRMED',?,? FROM md_company c JOIN md_project p ON p.company_id=c.id JOIN md_contract k ON k.project_id=p.id JOIN md_counterparty s ON s.id=k.counterparty_id WHERE c.public_id=?", PAYABLE_ID, financeId, financeId, COMPANY_ID); + jdbcTemplate.update("INSERT INTO md_bank_account_version (public_id,owner_type,counterparty_id,account_category,account_name,bank_name,account_no_ciphertext,account_no_hash,masked_account_no,version_no,valid_from,status,created_by,updated_by) SELECT ?,'COUNTERPARTY',id,'BASIC','付款测试供应商','测试银行',X'00',REPEAT('a',64),'****1506',1,UTC_TIMESTAMP(3),'ACTIVE',?,? FROM md_counterparty WHERE public_id=?", ACCOUNT_ID, financeId, financeId, SUPPLIER_ID); + jdbcTemplate.update("INSERT INTO project_fund_control (project_id,currency,confirmed_receipt) SELECT id,'CNY',5000 FROM md_project WHERE public_id=?", PROJECT_ID); + jdbcTemplate.update("INSERT INTO project_fund_control (project_id,currency,confirmed_receipt) SELECT id,'CNY',5000 FROM md_project WHERE public_id=?", OTHER_PROJECT_ID); + insertFile(SUPPORT_FILE_ID, "payment-support.pdf", "payment/support.pdf", "AVAILABLE", financeId); + insertFile(RECEIPT_FILE_ID, "payment-receipt.pdf", "payment/receipt.pdf", "AVAILABLE", financeId); + insertFile(PENDING_FILE_ID, "payment-pending.pdf", "payment/pending.pdf", "SCANNING", financeId); + insertFile(OTHER_SCOPE_FILE_ID, "other-project.pdf", "payment/other-project.pdf", "AVAILABLE", + userId("demo")); + linkProjectFile(SUPPORT_FILE_ID, COMPANY_ID, PROJECT_ID, "PAYMENT_SUPPORT", financeId); + linkProjectFile(RECEIPT_FILE_ID, COMPANY_ID, PROJECT_ID, "PAYMENT_RECEIPT", financeId); + linkProjectFile(PENDING_FILE_ID, COMPANY_ID, PROJECT_ID, "PAYMENT_PENDING", financeId); + linkProjectFile(OTHER_SCOPE_FILE_ID, OTHER_COMPANY_ID, OTHER_PROJECT_ID, "PAYMENT_OTHER", financeId); + } + cleanupPaymentData(); + cleanupPaymentSources(); + resetAttachmentMatrix(); + restoreRolePermission("FINANCE_MANAGER", "payment:request:approve"); + for (String permission : List.of("payment:request:view", "payment:request:create", "payment:request:submit")) { + restoreGlobalScopeForRole(projectUserId, "PROJECT_MANAGER", permission); + } + for (String permission : List.of("payment:request:view", "payment:request:approve", + "payment:request:export")) { + restoreGlobalScopeForRole(financeId, "FINANCE_MANAGER", permission); + restoreGlobalScopeForRole(userId("demo"), "FINANCE_MANAGER", permission); + } + jdbcTemplate.update("UPDATE project_fund_control SET confirmed_receipt=5000,confirmed_deduction=0,approved_adjustment=0,frozen_amount=0,approved_unpaid=0,paid_amount=0,version=0 WHERE project_id=(SELECT id FROM md_project WHERE public_id=?)", PROJECT_ID); + jdbcTemplate.update("UPDATE fin_payable SET amount=10000,paid_amount=0,status='CONFIRMED',version=0,cost_category_id=NULL WHERE public_id=?", PAYABLE_ID); + jdbcTemplate.update("UPDATE md_contract SET original_amount=10000,approved_change_amount=0,settlement_amount=NULL,status='ACTIVE' WHERE public_id=?", CONTRACT_ID); + jdbcTemplate.update("UPDATE md_bank_account_version SET status='ACTIVE',valid_from='2020-01-01 00:00:00',valid_to=NULL WHERE public_id=?", ACCOUNT_ID); + jdbcTemplate.update("UPDATE file_object SET scan_status='SCANNING' WHERE public_id=?", PENDING_FILE_ID); + } + + @Test + void postExportUsesJsonFiltersAndRecordsAudit() throws Exception { + ClientSession project = loginAndSelect("project", "PROJECT_MANAGER"); + ClientSession finance = loginAndSelect("finance", "FINANCE_MANAGER"); + String includedId = createAndSubmit(project, "321.00", "付款导出命中", SUPPORT_FILE_ID, "2026-08-13"); + String excludedId = createAndSubmit(project, "654.00", "付款导出排除", SUPPORT_FILE_ID, "2026-08-14"); + String includedBusinessNo = jdbcTemplate.queryForObject( + "SELECT business_no FROM fin_payment_request WHERE public_id=?", String.class, includedId); + String excludedBusinessNo = jdbcTemplate.queryForObject( + "SELECT business_no FROM fin_payment_request WHERE public_id=?", String.class, excludedId); + jdbcTemplate.update( + "DELETE FROM audit_log WHERE action_code='PAYMENT_EXPORT' AND project_public_id=?", PROJECT_ID); + + MvcResult exported = mockMvc.perform(post("/api/v1/payments/exports") + .cookie(finance.cookies()) + .header("X-XSRF-TOKEN", finance.token()) + .contentType("application/json") + .content(""" + {"tab":"finance-check","keyword":"%s","sort":"updatedAt,desc"} + """.formatted(includedBusinessNo))) + .andExpect(status().isOk()) + .andReturn(); + + String contentType = exported.getResponse().getContentType(); + assertNotNull(contentType); + assertTrue(contentType.startsWith("text/csv")); + assertTrue(exported.getResponse().getHeader("Content-Disposition").contains("payments.csv")); + String csv = new String(exported.getResponse().getContentAsByteArray(), + java.nio.charset.StandardCharsets.UTF_8); + assertTrue(csv.startsWith("\uFEFF付款编号,公司,项目,合同,供应商")); + assertTrue(csv.contains(includedBusinessNo)); + assertTrue(!csv.contains(excludedBusinessNo)); + assertEquals(1, count("SELECT COUNT(*) FROM audit_log " + + "WHERE action_code='PAYMENT_EXPORT' AND project_public_id=? " + + "AND JSON_EXTRACT(after_json, '$.count')=1", PROJECT_ID)); + } + + @Test + void systemAdministratorListsAndManagesCrossOwnerDraftWhilePaymentSodStillApplies() throws Exception { + ClientSession project = loginAndSelect("project", "PROJECT_MANAGER"); + ClientSession admin = loginAndSelect("admin", "SYSTEM_ADMIN"); + String purpose = "管理员跨人员付款草稿"; + String paymentId = createTypedDraft(project, "125.00", purpose, "2026-08-13", + SUPPORT_FILE_ID, "MATERIAL_INVOICE"); + + mockMvc.perform(get("/api/v1/payments") + .cookie(admin.cookies()) + .param("tab", "all") + .param("status", "DRAFT") + .param("keyword", purpose)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[*].publicId", hasItem(paymentId))); + mockMvc.perform(get("/api/v1/payments") + .cookie(project.cookies()) + .param("tab", "all")) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("PERMISSION_DENIED")); + mockMvc.perform(get("/api/v1/payments/{id}", paymentId).cookie(admin.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.payment.allowedActions", hasItem("EDIT"))) + .andExpect(jsonPath("$.data.payment.allowedActions", hasItem("SUBMIT"))); + + mockMvc.perform(post("/api/v1/payments/{id}/submit", paymentId) + .cookie(admin.cookies()) + .header("X-XSRF-TOKEN", admin.token()) + .header("Idempotency-Key", idempotencyKey()) + .contentType("application/json") + .content("{\"version\":0}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("REVIEWING")); + mockMvc.perform(post("/api/v1/payments/{id}/approve", paymentId) + .cookie(admin.cookies()) + .header("X-XSRF-TOKEN", admin.token()) + .header("Idempotency-Key", idempotencyKey()) + .contentType("application/json") + .content("{\"version\":1}")) + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("SOD_VIOLATION")); + } + + @Test + void completeOfflinePaymentRefundAndRepaymentKeepFundsAndSeparation() throws Exception { + ClientSession project = loginAndSelect("project", "PROJECT_MANAGER"); + ClientSession finance = loginAndSelect("finance", "FINANCE_MANAGER"); + ClientSession verifier = loginAndSelect("demo", "FINANCE_MANAGER"); + + String paymentId = createAndSubmit(project, "1000.00", "完整付款闭环", SUPPORT_FILE_ID, "2026-08-02"); + mockMvc.perform(post("/api/v1/payments/{id}/approve", paymentId).cookie(finance.cookies()) + .header("X-XSRF-TOKEN", finance.token()).header("Idempotency-Key", idempotencyKey()).contentType("application/json") + .content("{\"version\":1}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("OFFLINE_PENDING")) + .andExpect(jsonPath("$.data.frozenAmount").value("1000.00")); + + mockMvc.perform(post("/api/v1/payments/{id}/confirm-offline-result", paymentId).cookie(finance.cookies()) + .header("X-XSRF-TOKEN", finance.token()).header("Idempotency-Key", idempotencyKey()).contentType("application/json") + .content("{\"version\":2,\"resultAt\":\"2026-08-03\",\"amount\":\"1000.00\",\"receiptFileIds\":[\"%s\"],\"remark\":\"线下转账成功\"}".formatted(RECEIPT_FILE_ID))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("RESULT_RECORDED")); + + mockMvc.perform(post("/api/v1/payments/{id}/verify-result", paymentId).cookie(finance.cookies()) + .header("X-XSRF-TOKEN", finance.token()).header("Idempotency-Key", idempotencyKey()).contentType("application/json") + .content("{\"version\":3,\"opinion\":\"本人不能复核\"}")) + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("SOD_VIOLATION")); + + mockMvc.perform(post("/api/v1/payments/{id}/verify-result", paymentId).cookie(verifier.cookies()) + .header("X-XSRF-TOKEN", verifier.token()).header("Idempotency-Key", idempotencyKey()).contentType("application/json") + .content("{\"version\":3,\"opinion\":\"回单和金额一致\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("PAID")); + assertMoney("1000.00", "SELECT paid_amount FROM project_fund_control WHERE project_id=(SELECT id FROM md_project WHERE public_id=?)", PROJECT_ID); + assertMoney("0.00", "SELECT frozen_amount FROM project_fund_control WHERE project_id=(SELECT id FROM md_project WHERE public_id=?)", PROJECT_ID); + assertMoney("1000.00", "SELECT paid_amount FROM fin_payable WHERE public_id=?", PAYABLE_ID); + + mockMvc.perform(post("/api/v1/payments/{id}/record-refund", paymentId).cookie(finance.cookies()) + .header("X-XSRF-TOKEN", finance.token()).header("Idempotency-Key", idempotencyKey()).contentType("application/json") + .content("{\"version\":4,\"resultAt\":\"2026-08-04\",\"amount\":\"1000.00\",\"reason\":\"收款账户退汇\",\"receiptFileIds\":[\"%s\"]}".formatted(RECEIPT_FILE_ID))) + .andExpect(status().isOk()).andExpect(jsonPath("$.data.status").value("RESULT_RECORDED")); + mockMvc.perform(post("/api/v1/payments/{id}/verify-result", paymentId).cookie(verifier.cookies()) + .header("X-XSRF-TOKEN", verifier.token()).header("Idempotency-Key", idempotencyKey()).contentType("application/json") + .content("{\"version\":5,\"opinion\":\"退汇凭证一致\"}")) + .andExpect(status().isOk()).andExpect(jsonPath("$.data.status").value("REFUNDED")); + assertMoney("0.00", "SELECT paid_amount FROM project_fund_control WHERE project_id=(SELECT id FROM md_project WHERE public_id=?)", PROJECT_ID); + + mockMvc.perform(post("/api/v1/payments/{id}/create-repayment", paymentId).cookie(project.cookies()) + .header("X-XSRF-TOKEN", project.token()).header("Idempotency-Key", idempotencyKey()).contentType("application/json") + .content("{\"version\":6,\"requestedDate\":\"2026-08-05\",\"requestedAmount\":\"1000.00\",\"purpose\":\"退汇后重付\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("DRAFT")) + .andExpect(jsonPath("$.data.originalPaymentId").value(paymentId)) + .andExpect(jsonPath("$.data.repaymentSequence").value(1)); + mockMvc.perform(post("/api/v1/payments/{id}/create-repayment", paymentId).cookie(project.cookies()) + .header("X-XSRF-TOKEN", project.token()).header("Idempotency-Key", idempotencyKey()).contentType("application/json") + .content("{\"version\":6,\"requestedDate\":\"2026-08-06\",\"requestedAmount\":\"1000.00\",\"purpose\":\"重复重付\"}")) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("DUPLICATE_SOURCE")); + assertEquals(1, count("SELECT COUNT(*) FROM fin_payment_request WHERE original_payment_id=(SELECT id FROM fin_payment_request WHERE public_id=?)", paymentId)); + assertTrue(count("SELECT COUNT(*) FROM acc_event WHERE source_type IN ('PAYMENT','PAYMENT_REFUND') AND company_id=(SELECT id FROM md_company WHERE public_id=?)", COMPANY_ID) >= 2); + assertEquals(1, count("SELECT COUNT(*) FROM acc_event WHERE event_type='PAYMENT_REFUND' AND source_public_id IN (SELECT public_id FROM fin_payment_result WHERE payment_id=(SELECT id FROM fin_payment_request WHERE public_id=?))", paymentId)); + } + + @Test + void returnedResultCanBeRecordedAgainWithoutChangingFundsOrAccounting() throws Exception { + ClientSession project = loginAndSelect("project", "PROJECT_MANAGER"); + ClientSession finance = loginAndSelect("finance", "FINANCE_MANAGER"); + ClientSession verifier = loginAndSelect("demo", "FINANCE_MANAGER"); + + String paymentId = createAndSubmit(project, "1000.00", "付款结果退回重登", SUPPORT_FILE_ID, "2026-08-12"); + mockMvc.perform(post("/api/v1/payments/{id}/approve", paymentId).cookie(finance.cookies()) + .header("X-XSRF-TOKEN", finance.token()).header("Idempotency-Key", idempotencyKey()) + .contentType("application/json").content("{\"version\":1}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("OFFLINE_PENDING")); + + mockMvc.perform(post("/api/v1/payments/{id}/confirm-offline-result", paymentId).cookie(finance.cookies()) + .header("X-XSRF-TOKEN", finance.token()).header("Idempotency-Key", idempotencyKey()) + .contentType("application/json") + .content("{\"version\":2,\"resultAt\":\"2026-08-12\",\"amount\":\"400.00\",\"receiptFileIds\":[\"%s\"]}".formatted(RECEIPT_FILE_ID))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("RESULT_RECORDED")); + + mockMvc.perform(get("/api/v1/payments/{id}", paymentId).cookie(verifier.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.payment.allowedActions", hasItem("VERIFY_RESULT"))) + .andExpect(jsonPath("$.data.payment.allowedActions", hasItem("RETURN_RESULT"))); + + mockMvc.perform(post("/api/v1/payments/{id}/return-result", paymentId).cookie(finance.cookies()) + .header("X-XSRF-TOKEN", finance.token()).header("Idempotency-Key", idempotencyKey()) + .contentType("application/json").content("{\"version\":3,\"opinion\":\"本人不能退回本人登记结果\"}")) + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("SOD_VIOLATION")); + + mockMvc.perform(post("/api/v1/payments/{id}/return-result", paymentId).cookie(verifier.cookies()) + .header("X-XSRF-TOKEN", verifier.token()).header("Idempotency-Key", idempotencyKey()) + .contentType("application/json").content("{\"version\":3,\"opinion\":\"回单金额不一致,请重新登记\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("OFFLINE_PENDING")) + .andExpect(jsonPath("$.data.paidAmount").value("0.00")) + .andExpect(jsonPath("$.data.frozenAmount").value("1000.00")); + assertMoney("0.00", "SELECT paid_amount FROM project_fund_control WHERE project_id=(SELECT id FROM md_project WHERE public_id=?)", PROJECT_ID); + assertMoney("1000.00", "SELECT frozen_amount FROM project_fund_control WHERE project_id=(SELECT id FROM md_project WHERE public_id=?)", PROJECT_ID); + assertMoney("0.00", "SELECT paid_amount FROM fin_payable WHERE public_id=?", PAYABLE_ID); + assertEquals(0, count("SELECT COUNT(*) FROM acc_event WHERE source_type='PAYMENT' AND source_public_id IN (SELECT public_id FROM fin_payment_result WHERE payment_id=(SELECT id FROM fin_payment_request WHERE public_id=?))", paymentId)); + assertEquals("RETURNED", jdbcTemplate.queryForObject( + "SELECT status FROM fin_payment_result WHERE payment_id=(SELECT id FROM fin_payment_request WHERE public_id=?) ORDER BY id DESC LIMIT 1", + String.class, paymentId)); + assertEquals("OFFLINE_PENDING", jdbcTemplate.queryForObject( + "SELECT recorded_from_status FROM fin_payment_result WHERE payment_id=(SELECT id FROM fin_payment_request WHERE public_id=?) ORDER BY id DESC LIMIT 1", + String.class, paymentId)); + assertEquals("回单金额不一致,请重新登记", jdbcTemplate.queryForObject( + "SELECT return_opinion FROM fin_payment_result WHERE payment_id=(SELECT id FROM fin_payment_request WHERE public_id=?) ORDER BY id DESC LIMIT 1", + String.class, paymentId)); + + mockMvc.perform(post("/api/v1/payments/{id}/confirm-offline-result", paymentId).cookie(finance.cookies()) + .header("X-XSRF-TOKEN", finance.token()).header("Idempotency-Key", idempotencyKey()) + .contentType("application/json") + .content("{\"version\":4,\"resultAt\":\"2026-08-12\",\"amount\":\"350.00\",\"receiptFileIds\":[\"%s\"]}".formatted(RECEIPT_FILE_ID))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("RESULT_RECORDED")); + mockMvc.perform(post("/api/v1/payments/{id}/verify-result", paymentId).cookie(verifier.cookies()) + .header("X-XSRF-TOKEN", verifier.token()).header("Idempotency-Key", idempotencyKey()) + .contentType("application/json").content("{\"version\":5,\"opinion\":\"重新登记结果正确\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("PART_PAID")) + .andExpect(jsonPath("$.data.paidAmount").value("350.00")) + .andExpect(jsonPath("$.data.frozenAmount").value("650.00")); + assertEquals(2, count("SELECT COUNT(*) FROM fin_payment_result WHERE payment_id=(SELECT id FROM fin_payment_request WHERE public_id=?)", paymentId)); + assertEquals(1, count("SELECT COUNT(*) FROM fin_payment_result WHERE payment_id=(SELECT id FROM fin_payment_request WHERE public_id=?) AND status='RETURNED'", paymentId)); + assertEquals(1, count("SELECT COUNT(*) FROM fin_payment_result WHERE payment_id=(SELECT id FROM fin_payment_request WHERE public_id=?) AND status='VERIFIED'", paymentId)); + assertEquals(1, count("SELECT COUNT(*) FROM acc_event WHERE source_type='PAYMENT' AND source_public_id IN (SELECT public_id FROM fin_payment_result WHERE payment_id=(SELECT id FROM fin_payment_request WHERE public_id=?))", paymentId)); + + mockMvc.perform(get("/api/v1/payments/{id}", paymentId).cookie(verifier.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.results[0].status").value("VERIFIED")) + .andExpect(jsonPath("$.data.results[1].status").value("RETURNED")) + .andExpect(jsonPath("$.data.results[1].recordedFromStatus").value("OFFLINE_PENDING")) + .andExpect(jsonPath("$.data.results[1].returnedByName").value("多岗位演示用户")) + .andExpect(jsonPath("$.data.results[1].returnOpinion").value("回单金额不一致,请重新登记")); + assertEquals(1, count("SELECT COUNT(*) FROM audit_log WHERE action_code='PAYMENT_RETURN_RESULT' AND object_public_id=? AND result_code='SUCCESS'", paymentId)); + } + + @Test + void fundLedgerTracksReservedAndPaidAmountsAndEnforcesProjectScope() throws Exception { + ClientSession project = loginAndSelect("project", "PROJECT_MANAGER"); + ClientSession finance = loginAndSelect("finance", "FINANCE_MANAGER"); + ClientSession verifier = loginAndSelect("demo", "FINANCE_MANAGER"); + String paymentId = createAndSubmit(project, "600.00", "资金流水验证", SUPPORT_FILE_ID, "2026-09-04"); + + mockMvc.perform(post("/api/v1/payments/{id}/approve", paymentId).cookie(finance.cookies()) + .header("X-XSRF-TOKEN", finance.token()).header("Idempotency-Key", idempotencyKey()) + .contentType("application/json").content("{\"version\":1}")) + .andExpect(status().isOk()); + mockMvc.perform(post("/api/v1/payments/{id}/confirm-offline-result", paymentId).cookie(finance.cookies()) + .header("X-XSRF-TOKEN", finance.token()).header("Idempotency-Key", idempotencyKey()) + .contentType("application/json") + .content("{\"version\":2,\"resultAt\":\"2026-09-04\",\"amount\":\"600.00\",\"receiptFileIds\":[\"%s\"]}" + .formatted(RECEIPT_FILE_ID))) + .andExpect(status().isOk()); + mockMvc.perform(post("/api/v1/payments/{id}/verify-result", paymentId).cookie(verifier.cookies()) + .header("X-XSRF-TOKEN", verifier.token()).header("Idempotency-Key", idempotencyKey()) + .contentType("application/json").content("{\"version\":3,\"opinion\":\"资金流水复核通过\"}")) + .andExpect(status().isOk()); + + assertEquals(1, count("SELECT COUNT(*) FROM fund_ledger WHERE source_public_id=? " + + "AND entry_type='PAYMENT_FUNDS_RESERVED'", paymentId)); + assertEquals(1, count("SELECT COUNT(*) FROM fund_ledger WHERE source_public_id=? " + + "AND entry_type='PAYMENT_PAID'", paymentId)); + assertMoney("-600.00", "SELECT delta_amount FROM fund_ledger WHERE source_public_id=? " + + "AND entry_type='PAYMENT_FUNDS_RESERVED'", paymentId); + assertMoney("0.00", "SELECT delta_amount FROM fund_ledger WHERE source_public_id=? " + + "AND entry_type='PAYMENT_PAID'", paymentId); + assertMoney("600.00", "SELECT frozen_amount_after FROM fund_ledger WHERE source_public_id=? " + + "AND entry_type='PAYMENT_FUNDS_RESERVED'", paymentId); + assertMoney("0.00", "SELECT frozen_amount_after FROM fund_ledger WHERE source_public_id=? " + + "AND entry_type='PAYMENT_PAID'", paymentId); + assertMoney("600.00", "SELECT paid_amount_after FROM fund_ledger WHERE source_public_id=? " + + "AND entry_type='PAYMENT_PAID'", paymentId); + assertEquals(2, count("SELECT COUNT(*) FROM audit_log WHERE action_code='FUND_LEDGER_APPEND' " + + "AND object_type='FUND_CONTROL' AND object_public_id=? AND result_code='SUCCESS' " + + "AND JSON_UNQUOTE(JSON_EXTRACT(after_json, '$.sourcePublicId'))=?", PROJECT_ID, paymentId)); + assertAuditMoney("0.00", PROJECT_ID, paymentId, "PAYMENT_FUNDS_RESERVED", "before", "frozenAmount"); + assertAuditMoney("600.00", PROJECT_ID, paymentId, "PAYMENT_FUNDS_RESERVED", "after", "frozenAmount"); + assertEquals("1", auditSnapshotValue(PROJECT_ID, paymentId, "PAYMENT_FUNDS_RESERVED", "after", "version")); + assertAuditMoney("600.00", PROJECT_ID, paymentId, "PAYMENT_PAID", "before", "frozenAmount"); + assertAuditMoney("0.00", PROJECT_ID, paymentId, "PAYMENT_PAID", "after", "frozenAmount"); + assertAuditMoney("600.00", PROJECT_ID, paymentId, "PAYMENT_PAID", "after", "paidAmount"); + assertNotNull(auditSnapshotValue(PROJECT_ID, paymentId, "PAYMENT_PAID", "after", "ledgerPublicId")); + + FundLedgerCommand replay = reservedFundCommand(paymentId, "付款批准冻结项目资金"); + transactionTemplate.executeWithoutResult(status -> fundLedgerService.append(replay)); + assertEquals(1, count("SELECT COUNT(*) FROM fund_ledger WHERE source_event_key=?", + replay.sourceEventKey())); + assertEquals(2, count("SELECT COUNT(*) FROM audit_log WHERE action_code='FUND_LEDGER_APPEND' " + + "AND object_public_id=? AND JSON_UNQUOTE(JSON_EXTRACT(after_json, '$.sourcePublicId'))=?", + PROJECT_ID, paymentId)); + + BusinessException duplicate = assertThrows(BusinessException.class, + () -> transactionTemplate.executeWithoutResult(status -> + fundLedgerService.append(reservedFundCommand(paymentId, "不一致的资金流水描述")))); + assertEquals(ErrorCode.DUPLICATE_SOURCE, duplicate.code()); + + mockMvc.perform(get("/api/v1/funds/ledger").param("projectId", PROJECT_ID) + .param("currency", "CNY").param("entryType", "PAYMENT_FUNDS_RESERVED") + .param("page", "1").param("size", "20").cookie(finance.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.control.projectId").value(PROJECT_ID)) + .andExpect(jsonPath("$.data.control.frozenAmount").value("0.00")) + .andExpect(jsonPath("$.data.control.paidAmount").value("600.00")) + .andExpect(jsonPath("$.data.items[0].sourcePublicId").value(paymentId)) + .andExpect(jsonPath("$.data.items[0].deltaAmount").value("-600.00")) + .andExpect(jsonPath("$.data.items[0].confirmedDeductionDelta").value("0.00")) + .andExpect(jsonPath("$.data.items[0].approvedAdjustmentAfter").value("0.00")) + .andExpect(jsonPath("$.data.items[0].approvedUnpaidAfter").value("0.00")) + .andExpect(jsonPath("$.data.items[0].frozenAmountAfter").value("600.00")) + .andExpect(jsonPath("$.data.meta.totalElements").value(1)); + + mockMvc.perform(get("/api/v1/funds/ledger").param("projectId", PROJECT_ID) + .param("currency", "CNY").param("sortBy", "eventAmount").param("sortDirection", "asc") + .cookie(finance.cookies())) + .andExpect(status().isOk()); + mockMvc.perform(get("/api/v1/funds/ledger").param("projectId", PROJECT_ID) + .param("currency", "CNY").param("sortBy", "projectId").cookie(finance.cookies())) + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("VALIDATION_FAILED")); + + long financeId = userId("finance"); + restrictScope(financeId, "payment:request:view", OTHER_PROJECT_ID, null); + try { + mockMvc.perform(get("/api/v1/funds/ledger").param("projectId", PROJECT_ID) + .param("currency", "CNY").cookie(finance.cookies())) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("DATA_SCOPE_DENIED")); + } finally { + restoreGlobalScope(financeId, "payment:request:view"); + } + } + + @Test + void paymentReferencesRespectProjectScope() throws Exception { + long financeId = userId("finance"); + seedOutOfScopeReferences(financeId); + ClientSession finance = loginAndSelect("finance", "FINANCE_MANAGER"); + restrictScope(financeId, "payment:request:view", PROJECT_ID, null); + try { + MvcResult response = mockMvc.perform(get("/api/v1/payments/references").cookie(finance.cookies())) + .andExpect(status().isOk()) + .andReturn(); + String body = response.getResponse().getContentAsString(); + assertVisibleAndHidden(body, "$.data.suppliers[*].publicId", SUPPLIER_ID, OTHER_SUPPLIER_ID); + assertVisibleAndHidden(body, "$.data.contracts[*].publicId", CONTRACT_ID, OTHER_CONTRACT_ID); + assertVisibleAndHidden(body, "$.data.payables[*].publicId", PAYABLE_ID, OTHER_PAYABLE_ID); + assertVisibleAndHidden(body, "$.data.bankAccounts[*].publicId", ACCOUNT_ID, OTHER_ACCOUNT_ID); + } finally { + restoreGlobalScope(financeId, "payment:request:view"); + cleanupOutOfScopeReferences(); + } + } + + @Test + void paymentAttachmentReferencesMustBelongToActorOrTargetProject() throws Exception { + ClientSession project = loginAndSelect("project", "PROJECT_MANAGER"); + String rejectedBody = """ + {"companyId":"%s","projectId":"%s","contractId":"%s","payableId":"%s", + "supplierId":"%s","bankAccountVersionId":"%s","requestedAmount":"100.00", + "currency":"CNY","requestedDate":"2026-08-26","purpose":"跨项目附件应阻断", + "attachmentFileIds":["%s"]} + """.formatted(COMPANY_ID, PROJECT_ID, CONTRACT_ID, PAYABLE_ID, SUPPLIER_ID, ACCOUNT_ID, + OTHER_SCOPE_FILE_ID); + + mockMvc.perform(post("/api/v1/payments").cookie(project.cookies()) + .header("X-XSRF-TOKEN", project.token()).header("Idempotency-Key", idempotencyKey()).contentType("application/json") + .content(rejectedBody)) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("DATA_SCOPE_DENIED")); + assertEquals(0, count("SELECT COUNT(*) FROM fin_payment_request WHERE project_id=" + + "(SELECT id FROM md_project WHERE public_id=?)", PROJECT_ID)); + + String accepted = createAndSubmit(project, "100.00", "项目已关联附件允许使用", SUPPORT_FILE_ID, + "2026-08-26"); + assertEquals(1, count("SELECT COUNT(*) FROM fin_payment_attachment attachment " + + "JOIN fin_payment_request payment ON payment.id=attachment.payment_id " + + "WHERE payment.public_id=? AND attachment.active=TRUE", accepted)); + } + + @Test + void paymentCreateIdempotencyReplaysAndApprovalFailureDoesNotCache() throws Exception { + ClientSession project = loginAndSelect("project", "PROJECT_MANAGER"); + ClientSession finance = loginAndSelect("finance", "FINANCE_MANAGER"); + String suffix = Long.toString(System.nanoTime()); + String body = """ + {"companyId":"%s","projectId":"%s","contractId":"%s","payableId":"%s", + "supplierId":"%s","bankAccountVersionId":"%s","requestedAmount":"100.00", + "currency":"CNY","requestedDate":"2026-08-20","purpose":"幂等创建-%s", + "attachmentFileIds":["%s"]} + """.formatted(COMPANY_ID, PROJECT_ID, CONTRACT_ID, PAYABLE_ID, SUPPLIER_ID, ACCOUNT_ID, + suffix, SUPPORT_FILE_ID); + String createKey = "payment-create-" + suffix; + MvcResult first = mockMvc.perform(post("/api/v1/payments").cookie(project.cookies()) + .header("X-XSRF-TOKEN", project.token()).header("Idempotency-Key", createKey) + .contentType("application/json").content(body)) + .andExpect(status().isOk()).andReturn(); + String paymentId = JsonPath.read(first.getResponse().getContentAsString(), "$.data.publicId"); + mockMvc.perform(post("/api/v1/payments").cookie(project.cookies()) + .header("X-XSRF-TOKEN", project.token()).header("Idempotency-Key", createKey) + .contentType("application/json").content(body)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.publicId").value(paymentId)); + assertEquals(1, count("SELECT COUNT(*) FROM fin_payment_request WHERE project_id=(SELECT id FROM md_project WHERE public_id=?)", PROJECT_ID)); + String changedBody = body.replace("幂等创建-" + suffix, "幂等创建-修改"); + mockMvc.perform(post("/api/v1/payments").cookie(project.cookies()) + .header("X-XSRF-TOKEN", project.token()).header("Idempotency-Key", createKey) + .contentType("application/json").content(changedBody)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("IDEMPOTENCY_KEY_REUSED")); + + mockMvc.perform(post("/api/v1/payments/{id}/submit", paymentId).cookie(project.cookies()) + .header("X-XSRF-TOKEN", project.token()).header("Idempotency-Key", idempotencyKey()).contentType("application/json") + .content("{\"version\":0}")) + .andExpect(status().isOk()); + jdbcTemplate.update("UPDATE md_contract SET original_amount=50 WHERE public_id=?", CONTRACT_ID); + String approveKey = "payment-approve-" + suffix; + mockMvc.perform(post("/api/v1/payments/{id}/approve", paymentId).cookie(finance.cookies()) + .header("X-XSRF-TOKEN", finance.token()).header("Idempotency-Key", approveKey) + .contentType("application/json").content("{\"version\":1}")) + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("CONTRACT_LIMIT_EXCEEDED")); + assertTrue(count("SELECT COUNT(*) FROM fin_payment_check check_row WHERE check_row.payment_id=(SELECT id FROM fin_payment_request WHERE public_id=?)", paymentId) > 0); + jdbcTemplate.update("UPDATE md_contract SET original_amount=10000 WHERE public_id=?", CONTRACT_ID); + mockMvc.perform(post("/api/v1/payments/{id}/approve", paymentId).cookie(finance.cookies()) + .header("X-XSRF-TOKEN", finance.token()).header("Idempotency-Key", approveKey) + .contentType("application/json").content("{\"version\":1}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("OFFLINE_PENDING")); + } + + @Test + void idempotencyReplayMustRecheckChangedDataScope() throws Exception { + ClientSession project = loginAndSelect("project", "PROJECT_MANAGER"); + ClientSession finance = loginAndSelect("finance", "FINANCE_MANAGER"); + String paymentId = createAndSubmit(project, "100.00", "范围变更后的幂等重放", SUPPORT_FILE_ID, "2026-08-27"); + String key = "scope-replay-" + System.nanoTime(); + String body = "{\"version\":1}"; + + mockMvc.perform(post("/api/v1/payments/{id}/approve", paymentId).cookie(finance.cookies()) + .header("X-XSRF-TOKEN", finance.token()).header("Idempotency-Key", key) + .contentType("application/json").content(body)) + .andExpect(status().isOk()).andExpect(jsonPath("$.data.status").value("OFFLINE_PENDING")); + + long financeId = userId("finance"); + restrictScopeForRole(financeId, "FINANCE_MANAGER", "payment:request:approve", OTHER_PROJECT_ID, null); + try { + mockMvc.perform(post("/api/v1/payments/{id}/approve", paymentId).cookie(finance.cookies()) + .header("X-XSRF-TOKEN", finance.token()).header("Idempotency-Key", key) + .contentType("application/json").content(body)) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("DATA_SCOPE_DENIED")); + } finally { + restoreGlobalScope(financeId, "payment:request:approve"); + } + } + + @Test + void partialOfflineResultsKeepPaidAndFrozenBalancesUntilFullyPaid() throws Exception { + ClientSession project = loginAndSelect("project", "PROJECT_MANAGER"); + ClientSession finance = loginAndSelect("finance", "FINANCE_MANAGER"); + ClientSession verifier = loginAndSelect("demo", "FINANCE_MANAGER"); + + String paymentId = createAndSubmit(project, "1000.00", "部分付款闭环", SUPPORT_FILE_ID, "2026-08-11"); + mockMvc.perform(post("/api/v1/payments/{id}/approve", paymentId).cookie(finance.cookies()) + .header("X-XSRF-TOKEN", finance.token()).header("Idempotency-Key", idempotencyKey()).contentType("application/json") + .content("{\"version\":1}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("OFFLINE_PENDING")); + + mockMvc.perform(post("/api/v1/payments/{id}/confirm-offline-result", paymentId).cookie(finance.cookies()) + .header("X-XSRF-TOKEN", finance.token()).header("Idempotency-Key", idempotencyKey()).contentType("application/json") + .content("{\"version\":2,\"resultAt\":\"2026-08-12\",\"amount\":\"400.00\",\"receiptFileIds\":[\"%s\"]}".formatted(RECEIPT_FILE_ID))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("RESULT_RECORDED")); + mockMvc.perform(post("/api/v1/payments/{id}/verify-result", paymentId).cookie(verifier.cookies()) + .header("X-XSRF-TOKEN", verifier.token()).header("Idempotency-Key", idempotencyKey()).contentType("application/json") + .content("{\"version\":3,\"opinion\":\"核验首笔部分付款\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("PART_PAID")) + .andExpect(jsonPath("$.data.paidAmount").value("400.00")) + .andExpect(jsonPath("$.data.frozenAmount").value("600.00")); + assertMoney("400.00", "SELECT paid_amount FROM project_fund_control WHERE project_id=(SELECT id FROM md_project WHERE public_id=?)", PROJECT_ID); + assertMoney("600.00", "SELECT frozen_amount FROM project_fund_control WHERE project_id=(SELECT id FROM md_project WHERE public_id=?)", PROJECT_ID); + assertMoney("400.00", "SELECT paid_amount FROM fin_payable WHERE public_id=?", PAYABLE_ID); + + mockMvc.perform(post("/api/v1/payments/{id}/confirm-offline-result", paymentId).cookie(finance.cookies()) + .header("X-XSRF-TOKEN", finance.token()).header("Idempotency-Key", idempotencyKey()).contentType("application/json") + .content("{\"version\":4,\"resultAt\":\"2026-08-13\",\"amount\":\"700.00\",\"receiptFileIds\":[\"%s\"]}".formatted(RECEIPT_FILE_ID))) + .andExpect(status().isUnprocessableEntity()); + + mockMvc.perform(post("/api/v1/payments/{id}/confirm-offline-result", paymentId).cookie(finance.cookies()) + .header("X-XSRF-TOKEN", finance.token()).header("Idempotency-Key", idempotencyKey()).contentType("application/json") + .content("{\"version\":4,\"resultAt\":\"2026-08-13\",\"amount\":\"600.00\",\"receiptFileIds\":[\"%s\"]}".formatted(RECEIPT_FILE_ID))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("RESULT_RECORDED")); + mockMvc.perform(post("/api/v1/payments/{id}/verify-result", paymentId).cookie(verifier.cookies()) + .header("X-XSRF-TOKEN", verifier.token()).header("Idempotency-Key", idempotencyKey()).contentType("application/json") + .content("{\"version\":5,\"opinion\":\"核验剩余部分付款\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("PAID")) + .andExpect(jsonPath("$.data.paidAmount").value("1000.00")) + .andExpect(jsonPath("$.data.frozenAmount").value("0.00")); + assertMoney("1000.00", "SELECT paid_amount FROM project_fund_control WHERE project_id=(SELECT id FROM md_project WHERE public_id=?)", PROJECT_ID); + assertMoney("0.00", "SELECT frozen_amount FROM project_fund_control WHERE project_id=(SELECT id FROM md_project WHERE public_id=?)", PROJECT_ID); + assertMoney("1000.00", "SELECT paid_amount FROM fin_payable WHERE public_id=?", PAYABLE_ID); + assertEquals(2, count("SELECT COUNT(*) FROM acc_event WHERE source_type='PAYMENT' AND source_public_id IN (SELECT public_id FROM fin_payment_result WHERE payment_id=(SELECT id FROM fin_payment_request WHERE public_id=?))", paymentId)); + } + + @Test + void failureAfterPartialPaymentReleasesOnlyResidualAndLimitsRepayment() throws Exception { + ClientSession project = loginAndSelect("project", "PROJECT_MANAGER"); + ClientSession finance = loginAndSelect("finance", "FINANCE_MANAGER"); + ClientSession verifier = loginAndSelect("demo", "FINANCE_MANAGER"); + + String paymentId = createAndSubmit(project, "1000.00", "部分付款后失败", SUPPORT_FILE_ID, "2026-08-14"); + mockMvc.perform(post("/api/v1/payments/{id}/approve", paymentId).cookie(finance.cookies()) + .header("X-XSRF-TOKEN", finance.token()).header("Idempotency-Key", idempotencyKey()).contentType("application/json") + .content("{\"version\":1}")) + .andExpect(status().isOk()); + mockMvc.perform(post("/api/v1/payments/{id}/confirm-offline-result", paymentId).cookie(finance.cookies()) + .header("X-XSRF-TOKEN", finance.token()).header("Idempotency-Key", idempotencyKey()).contentType("application/json") + .content("{\"version\":2,\"resultAt\":\"2026-08-15\",\"amount\":\"400.00\",\"receiptFileIds\":[\"%s\"]}".formatted(RECEIPT_FILE_ID))) + .andExpect(status().isOk()); + mockMvc.perform(post("/api/v1/payments/{id}/verify-result", paymentId).cookie(verifier.cookies()) + .header("X-XSRF-TOKEN", verifier.token()).header("Idempotency-Key", idempotencyKey()).contentType("application/json") + .content("{\"version\":3,\"opinion\":\"核验部分成功\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("PART_PAID")); + + mockMvc.perform(post("/api/v1/payments/{id}/record-failure", paymentId).cookie(finance.cookies()) + .header("X-XSRF-TOKEN", finance.token()).header("Idempotency-Key", idempotencyKey()).contentType("application/json") + .content("{\"version\":4,\"resultAt\":\"2026-08-16\",\"reason\":\"剩余付款执行失败\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("RESULT_RECORDED")); + mockMvc.perform(post("/api/v1/payments/{id}/verify-result", paymentId).cookie(verifier.cookies()) + .header("X-XSRF-TOKEN", verifier.token()).header("Idempotency-Key", idempotencyKey()).contentType("application/json") + .content("{\"version\":5,\"opinion\":\"失败结果属实\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("FAILED")) + .andExpect(jsonPath("$.data.paidAmount").value("400.00")) + .andExpect(jsonPath("$.data.frozenAmount").value("0.00")); + assertMoney("400.00", "SELECT paid_amount FROM project_fund_control WHERE project_id=(SELECT id FROM md_project WHERE public_id=?)", PROJECT_ID); + assertMoney("0.00", "SELECT frozen_amount FROM project_fund_control WHERE project_id=(SELECT id FROM md_project WHERE public_id=?)", PROJECT_ID); + assertMoney("400.00", "SELECT paid_amount FROM fin_payable WHERE public_id=?", PAYABLE_ID); + + mockMvc.perform(post("/api/v1/payments/{id}/create-repayment", paymentId).cookie(project.cookies()) + .header("X-XSRF-TOKEN", project.token()).header("Idempotency-Key", idempotencyKey()).contentType("application/json") + .content("{\"version\":6,\"requestedDate\":\"2026-08-17\",\"requestedAmount\":\"700.00\",\"purpose\":\"失败后超额重付\"}")) + .andExpect(status().isUnprocessableEntity()); + mockMvc.perform(post("/api/v1/payments/{id}/create-repayment", paymentId).cookie(project.cookies()) + .header("X-XSRF-TOKEN", project.token()).header("Idempotency-Key", idempotencyKey()).contentType("application/json") + .content("{\"version\":6,\"requestedDate\":\"2026-08-17\",\"requestedAmount\":\"600.00\",\"purpose\":\"失败后剩余重付\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.requestedAmount").value("600.00")) + .andExpect(jsonPath("$.data.originalPaymentId").value(paymentId)); + } + + @Test + void refundAfterPartialPaymentReversesPaidAndReleasesResidualFrozenAmount() throws Exception { + ClientSession project = loginAndSelect("project", "PROJECT_MANAGER"); + ClientSession finance = loginAndSelect("finance", "FINANCE_MANAGER"); + ClientSession verifier = loginAndSelect("demo", "FINANCE_MANAGER"); + + String paymentId = createAndSubmit(project, "1000.00", "部分付款后退汇", SUPPORT_FILE_ID, "2026-08-18"); + mockMvc.perform(post("/api/v1/payments/{id}/approve", paymentId).cookie(finance.cookies()) + .header("X-XSRF-TOKEN", finance.token()).header("Idempotency-Key", idempotencyKey()).contentType("application/json") + .content("{\"version\":1}")) + .andExpect(status().isOk()); + mockMvc.perform(post("/api/v1/payments/{id}/confirm-offline-result", paymentId).cookie(finance.cookies()) + .header("X-XSRF-TOKEN", finance.token()).header("Idempotency-Key", idempotencyKey()).contentType("application/json") + .content("{\"version\":2,\"resultAt\":\"2026-08-19\",\"amount\":\"400.00\",\"receiptFileIds\":[\"%s\"]}".formatted(RECEIPT_FILE_ID))) + .andExpect(status().isOk()); + mockMvc.perform(post("/api/v1/payments/{id}/verify-result", paymentId).cookie(verifier.cookies()) + .header("X-XSRF-TOKEN", verifier.token()).header("Idempotency-Key", idempotencyKey()).contentType("application/json") + .content("{\"version\":3,\"opinion\":\"核验部分成功\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("PART_PAID")) + .andExpect(jsonPath("$.data.allowedActions", hasItem("RECORD_REFUND"))); + + mockMvc.perform(post("/api/v1/payments/{id}/record-refund", paymentId).cookie(finance.cookies()) + .header("X-XSRF-TOKEN", finance.token()).header("Idempotency-Key", idempotencyKey()).contentType("application/json") + .content("{\"version\":4,\"resultAt\":\"2026-08-20\",\"amount\":\"400.00\",\"reason\":\"首笔付款退汇\",\"receiptFileIds\":[\"%s\"]}".formatted(RECEIPT_FILE_ID))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("RESULT_RECORDED")); + mockMvc.perform(post("/api/v1/payments/{id}/verify-result", paymentId).cookie(verifier.cookies()) + .header("X-XSRF-TOKEN", verifier.token()).header("Idempotency-Key", idempotencyKey()).contentType("application/json") + .content("{\"version\":5,\"opinion\":\"部分付款退汇凭证一致\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("REFUNDED")) + .andExpect(jsonPath("$.data.paidAmount").value("0.00")) + .andExpect(jsonPath("$.data.frozenAmount").value("0.00")); + assertMoney("0.00", "SELECT paid_amount FROM project_fund_control WHERE project_id=(SELECT id FROM md_project WHERE public_id=?)", PROJECT_ID); + assertMoney("0.00", "SELECT frozen_amount FROM project_fund_control WHERE project_id=(SELECT id FROM md_project WHERE public_id=?)", PROJECT_ID); + assertMoney("0.00", "SELECT paid_amount FROM fin_payable WHERE public_id=?", PAYABLE_ID); + assertEquals("CONFIRMED", jdbcTemplate.queryForObject( + "SELECT status FROM fin_payable WHERE public_id=?", String.class, PAYABLE_ID)); + + mockMvc.perform(post("/api/v1/payments/{id}/create-repayment", paymentId).cookie(project.cookies()) + .header("X-XSRF-TOKEN", project.token()).header("Idempotency-Key", idempotencyKey()).contentType("application/json") + .content("{\"version\":6,\"requestedDate\":\"2026-08-21\",\"requestedAmount\":\"1000.00\",\"purpose\":\"部分退汇后全额重付\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.requestedAmount").value("1000.00")); + } + + @Test + void refundMustReleaseTheWholeOutstandingPortion() throws Exception { + ClientSession project = loginAndSelect("project", "PROJECT_MANAGER"); + ClientSession finance = loginAndSelect("finance", "FINANCE_MANAGER"); + String paymentId = createAndSubmit(project, "1000.00", "退汇金额边界", SUPPORT_FILE_ID, "2026-08-24"); + mockMvc.perform(post("/api/v1/payments/{id}/approve", paymentId).cookie(finance.cookies()) + .header("X-XSRF-TOKEN", finance.token()).header("Idempotency-Key", idempotencyKey()).contentType("application/json") + .content("{\"version\":1}")) + .andExpect(status().isOk()); + mockMvc.perform(post("/api/v1/payments/{id}/record-refund", paymentId).cookie(finance.cookies()) + .header("X-XSRF-TOKEN", finance.token()).header("Idempotency-Key", idempotencyKey()).contentType("application/json") + .content("{\"version\":2,\"resultAt\":\"2026-08-25\",\"amount\":\"400.00\",\"reason\":\"部分退汇不应释放全部冻结\",\"receiptFileIds\":[\"%s\"]}".formatted(RECEIPT_FILE_ID))) + .andExpect(status().isUnprocessableEntity()); + assertMoney("1000.00", "SELECT frozen_amount FROM project_fund_control WHERE project_id=(SELECT id FROM md_project WHERE public_id=?)", PROJECT_ID); + assertEquals("OFFLINE_PENDING", jdbcTemplate.queryForObject( + "SELECT status FROM fin_payment_request WHERE public_id=?", String.class, paymentId)); + } + + @Test + void approvalPersistsFundsAndAttachmentBlockingChecks() throws Exception { + ClientSession project = loginAndSelect("project", "PROJECT_MANAGER"); + ClientSession finance = loginAndSelect("finance", "FINANCE_MANAGER"); + + String overFund = createAndSubmit(project, "6000.00", "余额不足申请", SUPPORT_FILE_ID, "2026-08-06"); + mockMvc.perform(post("/api/v1/payments/{id}/approve", overFund).cookie(finance.cookies()) + .header("X-XSRF-TOKEN", finance.token()).header("Idempotency-Key", idempotencyKey()).contentType("application/json").content("{\"version\":1}")) + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("FUNDS_INSUFFICIENT")); + mockMvc.perform(get("/api/v1/payments/{id}/checks", overFund).cookie(finance.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[?(@.code == 'PROJECT_FUNDS')].result", hasItem("BLOCK"))); + + String noAttachment = createAndSubmit(project, "100.00", "缺少附件申请", null, "2026-08-07"); + mockMvc.perform(post("/api/v1/payments/{id}/approve", noAttachment).cookie(finance.cookies()) + .header("X-XSRF-TOKEN", finance.token()).header("Idempotency-Key", idempotencyKey()).contentType("application/json").content("{\"version\":1}")) + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("ATTACHMENT_REQUIRED")); + + String pendingAttachment = createAndSubmit(project, "120.00", "附件扫描中申请", PENDING_FILE_ID, "2026-08-08"); + mockMvc.perform(post("/api/v1/payments/{id}/approve", pendingAttachment).cookie(finance.cookies()) + .header("X-XSRF-TOKEN", finance.token()).header("Idempotency-Key", idempotencyKey()).contentType("application/json").content("{\"version\":1}")) + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("FILE_REJECTED")); + } + + @Test + void attachmentMatrixChecksRequiredTypesSafetyAndSavedRuleSnapshot() throws Exception { + ClientSession project = loginAndSelect("project", "PROJECT_MANAGER"); + ClientSession finance = loginAndSelect("finance", "FINANCE_MANAGER"); + ClientSession admin = loginAndSelect("admin", "SYSTEM_ADMIN"); + useMaterialCostCategory(); + String historicalLegacy = createAndSubmit(project, "80.00", "矩阵发布前旧格式付款", SUPPORT_FILE_ID, + "2026-08-27"); + assertEquals(0, count("SELECT COUNT(*) FROM fin_payment_request WHERE public_id=? AND attachment_rule_version_no IS NOT NULL", + historicalLegacy)); + updateAndPublishAttachmentMatrixVersionOne(admin); + + mockMvc.perform(post("/api/v1/payments/{id}/approve", historicalLegacy).cookie(finance.cookies()) + .header("X-XSRF-TOKEN", finance.token()).header("Idempotency-Key", idempotencyKey()) + .contentType("application/json").content("{\"version\":1}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("OFFLINE_PENDING")); + + mockMvc.perform(get("/api/v1/payments/references").cookie(project.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.attachmentRules[0].costCategoryCode").value("MATERIAL")) + .andExpect(jsonPath("$.data.attachmentRules[0].versionNo").value(1)) + .andExpect(jsonPath("$.data.attachmentRules[0].requirements.length()").value(2)); + + mockMvc.perform(post("/api/v1/payments").cookie(project.cookies()) + .header("X-XSRF-TOKEN", project.token()).header("Idempotency-Key", idempotencyKey()) + .contentType("application/json").content(legacyPaymentBody("90.00", "旧附件格式应尽早阻断", + "2026-08-28", SUPPORT_FILE_ID))) + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("VALIDATION_FAILED")); + + mockMvc.perform(post("/api/v1/payments").cookie(project.cookies()) + .header("X-XSRF-TOKEN", project.token()).header("Idempotency-Key", idempotencyKey()) + .contentType("application/json").content(typedPaymentBody("95.00", "矩阵外资料类型应阻断", + "2026-08-28", SUPPORT_FILE_ID, "PAYMENT_SUPPORT"))) + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("VALIDATION_FAILED")); + + String missing = createAndSubmitTyped(project, "100.00", "材料付款缺送货单", "2026-08-29", + SUPPORT_FILE_ID, "MATERIAL_INVOICE"); + approveBlocked(finance, missing, "ATTACHMENT_REQUIRED"); + mockMvc.perform(get("/api/v1/payments/{id}/checks", missing).cookie(finance.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[?(@.code == 'ATTACHMENTS')].result", hasItem("BLOCK"))) + .andExpect(jsonPath("$.data[?(@.code == 'ATTACHMENTS')].message", + hasItem("材料费缺少必需资料:送货单"))); + + String unsafe = createAndSubmitTyped(project, "110.00", "材料付款送货单扫描中", "2026-08-30", + SUPPORT_FILE_ID, "MATERIAL_INVOICE", PENDING_FILE_ID, "DELIVERY_NOTE"); + approveBlocked(finance, unsafe, "FILE_REJECTED"); + mockMvc.perform(get("/api/v1/payments/{id}/checks", unsafe).cookie(finance.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[?(@.code == 'ATTACHMENTS')].message", + hasItem("以下资料尚未通过文件检查:送货单"))); + + String savedWithVersionOne = createTypedDraft(project, "120.00", "材料付款规则快照", "2026-08-31", + SUPPORT_FILE_ID, "MATERIAL_INVOICE", RECEIPT_FILE_ID, "DELIVERY_NOTE"); + assertEquals(1, jdbcTemplate.queryForObject( + "SELECT attachment_rule_version_no FROM fin_payment_request WHERE public_id=?", + Integer.class, savedWithVersionOne)); + + activateAttachmentMatrixVersionTwo(); + submitDraft(project, savedWithVersionOne); + mockMvc.perform(post("/api/v1/payments/{id}/approve", savedWithVersionOne).cookie(finance.cookies()) + .header("X-XSRF-TOKEN", finance.token()).header("Idempotency-Key", idempotencyKey()) + .contentType("application/json").content("{\"version\":1}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("OFFLINE_PENDING")); + mockMvc.perform(get("/api/v1/payments/{id}/checks", savedWithVersionOne).cookie(finance.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[?(@.code == 'ATTACHMENTS')].message", + hasItem("材料费必需资料齐全,规则 V1"))); + + String createdWithVersionTwo = createAndSubmitTyped(project, "130.00", "材料付款新规则", "2026-09-01", + SUPPORT_FILE_ID, "MATERIAL_INVOICE", RECEIPT_FILE_ID, "DELIVERY_NOTE"); + assertEquals(2, jdbcTemplate.queryForObject( + "SELECT attachment_rule_version_no FROM fin_payment_request WHERE public_id=?", + Integer.class, createdWithVersionTwo)); + approveBlocked(finance, createdWithVersionTwo, "ATTACHMENT_REQUIRED"); + mockMvc.perform(get("/api/v1/payments/{id}/checks", createdWithVersionTwo).cookie(finance.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[?(@.code == 'ATTACHMENTS')].message", + hasItem("材料费缺少必需资料:现场验收照片"))); + } + + @Test + void attachmentNotApplicableRequiresIndependentFinanceReviewAndIsConsumedOnce() throws Exception { + ClientSession project = loginAndSelect("project", "PROJECT_MANAGER"); + ClientSession finance = loginAndSelect("finance", "FINANCE_MANAGER"); + ClientSession admin = loginAndSelect("admin", "SYSTEM_ADMIN"); + useMaterialCostCategory(); + updateAndPublishAttachmentExceptionMatrix(admin); + + String paymentId = createDraftWithAttachmentException(project, "140.00", "材料附件不涉及闭环", + "2026-09-04", "DELIVERY_NOTE", "本批材料由供应商直接送达,不产生送货单"); + submitDraft(project, paymentId); + + approveBlocked(finance, paymentId, "ATTACHMENT_REQUIRED"); + mockMvc.perform(get("/api/v1/payments/{id}", paymentId).cookie(project.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.attachmentExceptions[0].allowedActions").isEmpty()); + mockMvc.perform(get("/api/v1/payments/{id}", paymentId).cookie(finance.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.attachmentExceptions[0].allowedActions").value(hasItem("REVIEW"))); + String exceptionId = jdbcTemplate.queryForObject(""" + SELECT exception_item.public_id + FROM fin_payment_attachment_exception exception_item + JOIN fin_payment_request payment ON payment.id=exception_item.payment_id + WHERE payment.public_id=? AND exception_item.active=TRUE + """, String.class, paymentId); + mockMvc.perform(post("/api/v1/payments/{id}/attachment-exceptions/{exceptionId}/review", + paymentId, exceptionId).cookie(finance.cookies()) + .header("X-XSRF-TOKEN", finance.token()).header("Idempotency-Key", idempotencyKey()) + .contentType("application/json") + .content("{\"version\":0,\"decision\":\"APPROVE\",\"opinion\":\"资料场景核实,同意本次不涉及\"}")) + .andExpect(status().isOk()); + + mockMvc.perform(post("/api/v1/payments/{id}/approve", paymentId).cookie(finance.cookies()) + .header("X-XSRF-TOKEN", finance.token()).header("Idempotency-Key", idempotencyKey()) + .contentType("application/json").content("{\"version\":1}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("OFFLINE_PENDING")); + mockMvc.perform(get("/api/v1/payments/{id}", paymentId).cookie(finance.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.attachmentExceptions[0].documentName").value("送货单")) + .andExpect(jsonPath("$.data.attachmentExceptions[0].status").value("APPROVED")) + .andExpect(jsonPath("$.data.attachmentExceptions[0].consumedAt").isNotEmpty()); + + mockMvc.perform(post("/api/v1/payments").cookie(project.cookies()) + .header("X-XSRF-TOKEN", project.token()).header("Idempotency-Key", idempotencyKey()) + .contentType("application/json") + .content(paymentBodyWithAttachmentException("150.00", "硬性附件不能例外", "2026-09-05", + "MATERIAL_INVOICE", "申请跳过材料发票"))) + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("VALIDATION_FAILED")); + } + + @Test + void returningPaymentInvalidatesReviewedAttachmentExceptionBeforeResubmission() throws Exception { + ClientSession project = loginAndSelect("project", "PROJECT_MANAGER"); + ClientSession finance = loginAndSelect("finance", "FINANCE_MANAGER"); + ClientSession admin = loginAndSelect("admin", "SYSTEM_ADMIN"); + useMaterialCostCategory(); + updateAndPublishAttachmentExceptionMatrix(admin); + + String paymentId = createDraftWithAttachmentException(project, "145.00", "退回后附件例外必须重审", + "2026-09-06", "DELIVERY_NOTE", "供应商直送,不产生送货单"); + submitDraft(project, paymentId); + approveBlocked(finance, paymentId, "ATTACHMENT_REQUIRED"); + String exceptionId = jdbcTemplate.queryForObject(""" + SELECT exception_item.public_id + FROM fin_payment_attachment_exception exception_item + JOIN fin_payment_request payment ON payment.id=exception_item.payment_id + WHERE payment.public_id=? AND exception_item.active=TRUE + """, String.class, paymentId); + + mockMvc.perform(post("/api/v1/payments/{id}/attachment-exceptions/{exceptionId}/review", + paymentId, exceptionId).cookie(finance.cookies()) + .header("X-XSRF-TOKEN", finance.token()).header("Idempotency-Key", idempotencyKey()) + .contentType("application/json") + .content("{\"version\":0,\"decision\":\"APPROVE\",\"opinion\":\"退回前独立复核通过\"}")) + .andExpect(status().isOk()); + + mockMvc.perform(post("/api/v1/payments/{id}/return", paymentId).cookie(finance.cookies()) + .header("X-XSRF-TOKEN", finance.token()).header("Idempotency-Key", idempotencyKey()) + .contentType("application/json") + .content("{\"version\":1,\"opinion\":\"请补充并重新提交\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("RETURNED")); + + assertEquals(1, count("SELECT COUNT(*) FROM fin_payment_attachment_exception exception_item " + + "JOIN fin_payment_request payment ON payment.id=exception_item.payment_id " + + "WHERE payment.public_id=? AND exception_item.status='APPROVED' AND exception_item.active=FALSE", paymentId)); + + mockMvc.perform(post("/api/v1/payments/{id}/submit", paymentId).cookie(project.cookies()) + .header("X-XSRF-TOKEN", project.token()).header("Idempotency-Key", idempotencyKey()) + .contentType("application/json").content("{\"version\":2}")) + .andExpect(status().isOk()).andExpect(jsonPath("$.data.status").value("REVIEWING")); + mockMvc.perform(post("/api/v1/payments/{id}/approve", paymentId).cookie(finance.cookies()) + .header("X-XSRF-TOKEN", finance.token()).header("Idempotency-Key", idempotencyKey()) + .contentType("application/json").content("{\"version\":3}")) + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("ATTACHMENT_REQUIRED")); + assertEquals("REVIEWING", jdbcTemplate.queryForObject( + "SELECT status FROM fin_payment_request WHERE public_id=?", String.class, paymentId)); + } + + @Test + void approvalCountsOtherFrozenPaymentsAgainstContractAndPayableLimits() throws Exception { + ClientSession project = loginAndSelect("project", "PROJECT_MANAGER"); + ClientSession finance = loginAndSelect("finance", "FINANCE_MANAGER"); + jdbcTemplate.update("UPDATE md_contract SET original_amount=1000 WHERE public_id=?", CONTRACT_ID); + jdbcTemplate.update("UPDATE fin_payable SET amount=1000 WHERE public_id=?", PAYABLE_ID); + + String first = createAndSubmit(project, "600.00", "合同应付冻结占用一", SUPPORT_FILE_ID, "2026-08-22"); + mockMvc.perform(post("/api/v1/payments/{id}/approve", first).cookie(finance.cookies()) + .header("X-XSRF-TOKEN", finance.token()).header("Idempotency-Key", idempotencyKey()).contentType("application/json") + .content("{\"version\":1}")) + .andExpect(status().isOk()); + + String second = createAndSubmit(project, "500.00", "合同应付冻结占用二", SUPPORT_FILE_ID, "2026-08-23"); + approveBlocked(finance, second, "CONTRACT_LIMIT_EXCEEDED"); + + jdbcTemplate.update("UPDATE md_contract SET original_amount=10000 WHERE public_id=?", CONTRACT_ID); + approveBlocked(finance, second, "PAYABLE_LIMIT_EXCEEDED"); + assertMoney("600.00", "SELECT frozen_amount FROM project_fund_control WHERE project_id=(SELECT id FROM md_project WHERE public_id=?)", PROJECT_ID); + assertEquals("REVIEWING", jdbcTemplate.queryForObject( + "SELECT status FROM fin_payment_request WHERE public_id=?", String.class, second)); + } + + @Test + void concurrentApprovalsCannotOverspendProjectFunds() throws Exception { + ClientSession project = loginAndSelect("project", "PROJECT_MANAGER"); + ClientSession finance = loginAndSelect("finance", "FINANCE_MANAGER"); + ClientSession verifier = loginAndSelect("demo", "FINANCE_MANAGER"); + jdbcTemplate.update(""" + UPDATE project_fund_control + SET confirmed_receipt=1000,confirmed_deduction=0,approved_adjustment=0, + frozen_amount=0,approved_unpaid=0,paid_amount=0,version=0 + WHERE project_id=(SELECT id FROM md_project WHERE public_id=?) + """, PROJECT_ID); + + String first = createAndSubmit(project, "600.00", "并发余额竞争一", SUPPORT_FILE_ID, "2026-09-02"); + String second = createAndSubmit(project, "600.00", "并发余额竞争二", SUPPORT_FILE_ID, "2026-09-03"); + CountDownLatch ready = new CountDownLatch(2); + CountDownLatch start = new CountDownLatch(1); + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + Future firstResult = executor.submit( + () -> concurrentApproval(first, finance, ready, start)); + Future secondResult = executor.submit( + () -> concurrentApproval(second, verifier, ready, start)); + assertTrue(ready.await(5, TimeUnit.SECONDS), "并发批准请求未按时就绪"); + start.countDown(); + + List results = List.of( + firstResult.get(15, TimeUnit.SECONDS), secondResult.get(15, TimeUnit.SECONDS)); + String responses = results.stream() + .map(item -> "status=" + item.getResponse().getStatus() + + ", body=" + new String(item.getResponse().getContentAsByteArray(), + java.nio.charset.StandardCharsets.UTF_8) + + ", exception=" + item.getResolvedException()) + .collect(java.util.stream.Collectors.joining("\n")); + assertEquals(1, results.stream().filter(item -> item.getResponse().getStatus() == 200).count(), responses); + MvcResult blocked = results.stream() + .filter(item -> item.getResponse().getStatus() == 422) + .findFirst().orElseThrow(() -> new AssertionError(responses)); + assertEquals("FUNDS_INSUFFICIENT", + JsonPath.read(blocked.getResponse().getContentAsString(), "$.code")); + } finally { + start.countDown(); + executor.shutdownNow(); + assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS), "并发批准线程未按时结束"); + } + + assertMoney("600.00", "SELECT frozen_amount FROM project_fund_control " + + "WHERE project_id=(SELECT id FROM md_project WHERE public_id=?)", PROJECT_ID); + assertEquals(1, count("SELECT COUNT(*) FROM fin_payment_request " + + "WHERE public_id IN (?,?) AND status='OFFLINE_PENDING'", first, second)); + assertEquals(1, count("SELECT COUNT(*) FROM fin_payment_request " + + "WHERE public_id IN (?,?) AND status='REVIEWING'", first, second)); + } + + @Test + void approvalEnforcesContractPayableAccountRiskAndDuplicateChecks() throws Exception { + ClientSession project = loginAndSelect("project", "PROJECT_MANAGER"); + ClientSession finance = loginAndSelect("finance", "FINANCE_MANAGER"); + String paymentId = createAndSubmit(project, "100.00", "逐项检查申请", SUPPORT_FILE_ID, "2026-08-09"); + + jdbcTemplate.update("UPDATE md_contract SET original_amount=50 WHERE public_id=?", CONTRACT_ID); + approveBlocked(finance, paymentId, "CONTRACT_LIMIT_EXCEEDED"); + jdbcTemplate.update("UPDATE md_contract SET original_amount=10000 WHERE public_id=?", CONTRACT_ID); + jdbcTemplate.update("UPDATE fin_payable SET amount=50 WHERE public_id=?", PAYABLE_ID); + approveBlocked(finance, paymentId, "PAYABLE_LIMIT_EXCEEDED"); + jdbcTemplate.update("UPDATE fin_payable SET amount=10000 WHERE public_id=?", PAYABLE_ID); + jdbcTemplate.update("UPDATE md_bank_account_version SET status='DISABLED' WHERE public_id=?", ACCOUNT_ID); + approveBlocked(finance, paymentId, "ACCOUNT_NOT_ACTIVE"); + jdbcTemplate.update("UPDATE md_bank_account_version SET status='ACTIVE' WHERE public_id=?", ACCOUNT_ID); + insertRisk(); + approveBlocked(finance, paymentId, "PROJECT_RISK_BLOCKED"); + jdbcTemplate.update("DELETE FROM project_risk_flag WHERE project_id=(SELECT id FROM md_project WHERE public_id=?)", PROJECT_ID); + + mockMvc.perform(post("/api/v1/payments/{id}/approve", paymentId).cookie(finance.cookies()) + .header("X-XSRF-TOKEN", finance.token()).header("Idempotency-Key", idempotencyKey()).contentType("application/json").content("{\"version\":1}")) + .andExpect(status().isOk()); + String duplicate = createAndSubmit(project, "100.00", "逐项检查申请", SUPPORT_FILE_ID, "2026-08-09"); + mockMvc.perform(post("/api/v1/payments/{id}/approve", duplicate).cookie(finance.cookies()) + .header("X-XSRF-TOKEN", finance.token()).header("Idempotency-Key", idempotencyKey()).contentType("application/json").content("{\"version\":1}")) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("DUPLICATE_SOURCE")); + } + + @Test + void mediumDuplicateProducesWarningWithoutException() throws Exception { + ClientSession project = loginAndSelect("project", "PROJECT_MANAGER"); + ClientSession finance = loginAndSelect("finance", "FINANCE_MANAGER"); + String first = createAndSubmit(project, "100.00", "近似付款基准", SUPPORT_FILE_ID, "2026-08-01"); + mockMvc.perform(post("/api/v1/payments/{id}/approve", first).cookie(finance.cookies()) + .header("X-XSRF-TOKEN", finance.token()).header("Idempotency-Key", idempotencyKey()) + .contentType("application/json").content("{\"version\":1}")) + .andExpect(status().isOk()); + + String candidate = createAndSubmit(project, "100.00", "不同付款事由", SUPPORT_FILE_ID, "2026-08-05"); + mockMvc.perform(post("/api/v1/payments/{id}/approve", candidate).cookie(finance.cookies()) + .header("X-XSRF-TOKEN", finance.token()).header("Idempotency-Key", idempotencyKey()) + .contentType("application/json").content("{\"version\":1}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("OFFLINE_PENDING")); + + assertEquals(1, count("SELECT COUNT(*) FROM fin_payment_check check_row " + + "JOIN fin_payment_request payment ON payment.id=check_row.payment_id " + + "WHERE payment.public_id=? AND check_row.check_code='DUPLICATE' " + + "AND check_row.result='WARNING'", candidate)); + assertEquals(0, count("SELECT COUNT(*) FROM fin_payment_control_exception exception_item " + + "JOIN fin_payment_request payment ON payment.id=exception_item.payment_id " + + "WHERE payment.public_id=?", candidate)); + } + + @Test + void duplicateExceptionRequiresIndependentReviewAndIsConsumedOnce() throws Exception { + ClientSession project = loginAndSelect("project", "PROJECT_MANAGER"); + ClientSession finance = loginAndSelect("finance", "FINANCE_MANAGER"); + String first = createAndSubmit(project, "100.00", "重复付款例外", SUPPORT_FILE_ID, "2026-08-10"); + mockMvc.perform(post("/api/v1/payments/{id}/approve", first).cookie(finance.cookies()) + .header("X-XSRF-TOKEN", finance.token()).header("Idempotency-Key", idempotencyKey()) + .contentType("application/json").content("{\"version\":1}")) + .andExpect(status().isOk()); + String duplicate = createAndSubmit(project, "100.00", "重复付款例外", SUPPORT_FILE_ID, "2026-08-10"); + approveBlocked(finance, duplicate, "DUPLICATE_SOURCE"); + + requestControlException(project, duplicate, "DUPLICATE", "业务确认需再次支付"); + String exceptionId = controlExceptionId(duplicate, "DUPLICATE"); + reviewControlException(finance, duplicate, exceptionId, "APPROVE", "已核验对应不同结算批次"); + + mockMvc.perform(post("/api/v1/payments/{id}/approve", duplicate).cookie(finance.cookies()) + .header("X-XSRF-TOKEN", finance.token()).header("Idempotency-Key", idempotencyKey()) + .contentType("application/json").content("{\"version\":1}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("OFFLINE_PENDING")); + + assertEquals(1, count("SELECT COUNT(*) FROM fin_payment_control_exception exception_item " + + "JOIN fin_payment_request payment ON payment.id=exception_item.payment_id " + + "WHERE payment.public_id=? AND exception_item.control_code='DUPLICATE' " + + "AND exception_item.status='APPROVED' AND exception_item.consumed_at IS NOT NULL", duplicate)); + assertEquals(1, count("SELECT COUNT(*) FROM fin_payment_check check_row " + + "JOIN fin_payment_request payment ON payment.id=check_row.payment_id " + + "WHERE payment.public_id=? AND check_row.check_code='DUPLICATE' " + + "AND check_row.result='WARNING'", duplicate)); + } + + @Test + void projectRiskExceptionCanBeReviewedAndConsumed() throws Exception { + ClientSession project = loginAndSelect("project", "PROJECT_MANAGER"); + ClientSession finance = loginAndSelect("finance", "FINANCE_MANAGER"); + String paymentId = createAndSubmit(project, "100.00", "项目风险例外", SUPPORT_FILE_ID, "2026-08-11"); + insertRisk(); + approveBlocked(finance, paymentId, "PROJECT_RISK_BLOCKED"); + + requestControlException(project, paymentId, "PROJECT_RISK", "风险已有线下处置方案"); + String exceptionId = controlExceptionId(paymentId, "PROJECT_RISK"); + reviewControlException(finance, paymentId, exceptionId, "APPROVE", "处置责任和期限已确认"); + mockMvc.perform(post("/api/v1/payments/{id}/approve", paymentId).cookie(finance.cookies()) + .header("X-XSRF-TOKEN", finance.token()).header("Idempotency-Key", idempotencyKey()) + .contentType("application/json").content("{\"version\":1}")) + .andExpect(status().isOk()); + + assertEquals(1, count("SELECT COUNT(*) FROM fin_payment_control_exception exception_item " + + "JOIN fin_payment_request payment ON payment.id=exception_item.payment_id " + + "WHERE payment.public_id=? AND exception_item.control_code='PROJECT_RISK' " + + "AND exception_item.consumed_at IS NOT NULL", paymentId)); + assertEquals(1, count("SELECT COUNT(*) FROM fin_payment_check check_row " + + "JOIN fin_payment_request payment ON payment.id=check_row.payment_id " + + "WHERE payment.public_id=? AND check_row.check_code='PROJECT_RISK' " + + "AND check_row.result='WARNING'", paymentId)); + } + + @Test + void hardControlCannotRequestException() throws Exception { + ClientSession project = loginAndSelect("project", "PROJECT_MANAGER"); + String paymentId = createAndSubmit(project, "100.00", "硬控制例外拒绝", SUPPORT_FILE_ID, "2026-08-12"); + mockMvc.perform(post("/api/v1/payments/{id}/control-exceptions", paymentId).cookie(project.cookies()) + .header("X-XSRF-TOKEN", project.token()).header("Idempotency-Key", idempotencyKey()) + .contentType("application/json") + .content("{\"paymentVersion\":1,\"controlCode\":\"ACCOUNT_ACTIVE\"," + + "\"reason\":\"尝试申请硬控制例外\",\"validUntil\":\"%s\"}" + .formatted(LocalDate.now().plusDays(1)))) + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("VALIDATION_FAILED")); + assertEquals(0, count("SELECT COUNT(*) FROM fin_payment_control_exception exception_item " + + "JOIN fin_payment_request payment ON payment.id=exception_item.payment_id " + + "WHERE payment.public_id=?", paymentId)); + } + + @Test + void expiredPendingControlExceptionCanBeReplacedWithNextVersion() throws Exception { + ClientSession project = loginAndSelect("project", "PROJECT_MANAGER"); + ClientSession finance = loginAndSelect("finance", "FINANCE_MANAGER"); + String first = createAndSubmit(project, "100.00", "到期例外基准", SUPPORT_FILE_ID, "2026-08-13"); + mockMvc.perform(post("/api/v1/payments/{id}/approve", first).cookie(finance.cookies()) + .header("X-XSRF-TOKEN", finance.token()).header("Idempotency-Key", idempotencyKey()) + .contentType("application/json").content("{\"version\":1}")) + .andExpect(status().isOk()); + String duplicate = createAndSubmit(project, "100.00", "到期例外基准", SUPPORT_FILE_ID, "2026-08-13"); + approveBlocked(finance, duplicate, "DUPLICATE_SOURCE"); + requestControlException(project, duplicate, "DUPLICATE", "第一版例外"); + jdbcTemplate.update("UPDATE fin_payment_control_exception exception_item " + + "JOIN fin_payment_request payment ON payment.id=exception_item.payment_id " + + "SET exception_item.valid_until=? " + + "WHERE payment.public_id=? AND exception_item.control_code='DUPLICATE'", + LocalDate.now().minusDays(1), duplicate); + + requestControlException(project, duplicate, "DUPLICATE", "到期后的第二版例外"); + + assertEquals(2, count("SELECT COUNT(*) FROM fin_payment_control_exception exception_item " + + "JOIN fin_payment_request payment ON payment.id=exception_item.payment_id " + + "WHERE payment.public_id=? AND exception_item.control_code='DUPLICATE'", duplicate)); + assertEquals(1, count("SELECT COUNT(*) FROM fin_payment_control_exception exception_item " + + "JOIN fin_payment_request payment ON payment.id=exception_item.payment_id " + + "WHERE payment.public_id=? AND exception_item.control_code='DUPLICATE' " + + "AND exception_item.active=TRUE AND exception_item.version_no=2", duplicate)); + assertEquals(1, count("SELECT COUNT(*) FROM fin_payment_control_exception exception_item " + + "JOIN fin_payment_request payment ON payment.id=exception_item.payment_id " + + "WHERE payment.public_id=? AND exception_item.control_code='DUPLICATE' " + + "AND exception_item.active=FALSE AND exception_item.status='PENDING' " + + "AND exception_item.version_no=1", duplicate)); + } + + @Test + void paymentSourceMustBeApprovedCurrentOa06WithinScopeAndIsRecheckedAtApproval() throws Exception { + ClientSession project = loginAndSelect("project", "PROJECT_MANAGER"); + ClientSession finance = loginAndSelect("finance", "FINANCE_MANAGER"); + + String wrongType = approvedPaymentSource("OA-04", "01", COMPANY_ID, PROJECT_ID); + createWithSource(project, wrongType, "错误来源类型", "2026-08-29") + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("VALIDATION_FAILED")); + + String draft = paymentSource("OA-06", "02", "DRAFT", null, COMPANY_ID, PROJECT_ID); + createWithSource(project, draft, "未审批来源", "2026-08-30") + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("VALIDATION_FAILED")); + + String crossScope = approvedPaymentSource("OA-06", "03", OTHER_COMPANY_ID, OTHER_PROJECT_ID); + createWithSource(project, crossScope, "跨范围来源", "2026-08-31") + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("VALIDATION_FAILED")); + + String approved = approvedPaymentSource("OA-06", "04", COMPANY_ID, PROJECT_ID); + String paymentId = JsonPath.read(createWithSource(project, approved, "批准来源", "2026-09-01") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.sourceDocumentId").value(approved)) + .andReturn().getResponse().getContentAsString(), "$.data.publicId"); + mockMvc.perform(post("/api/v1/payments/{id}/submit", paymentId).cookie(project.cookies()) + .header("X-XSRF-TOKEN", project.token()).header("Idempotency-Key", idempotencyKey()) + .contentType("application/json").content("{\"version\":0}")) + .andExpect(status().isOk()); + + replacePaymentSourceCurrentVersionWithoutApproval(approved, SOURCE_ID_PREFIX + "94"); + mockMvc.perform(post("/api/v1/payments/{id}/approve", paymentId).cookie(finance.cookies()) + .header("X-XSRF-TOKEN", finance.token()).header("Idempotency-Key", idempotencyKey()) + .contentType("application/json").content("{\"version\":1}")) + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("INVALID_STATE_TRANSITION")); + assertEquals("BLOCK", jdbcTemplate.queryForObject(""" + SELECT check_row.result FROM fin_payment_check check_row + JOIN fin_payment_request payment ON payment.id = check_row.payment_id + WHERE payment.public_id = ? AND check_row.check_code = 'SOURCE_APPROVED' + ORDER BY check_row.checked_at DESC LIMIT 1 + """, String.class, paymentId)); + assertEquals("REVIEWING", jdbcTemplate.queryForObject( + "SELECT status FROM fin_payment_request WHERE public_id=?", String.class, paymentId)); + } + + @Test + void versionAmountLimitAndProjectScopeAreEnforced() throws Exception { + ClientSession project = loginAndSelect("project", "PROJECT_MANAGER"); + ClientSession finance = loginAndSelect("finance", "FINANCE_MANAGER"); + String paymentId = createAndSubmit(project, "1000.00", "权限检查申请", SUPPORT_FILE_ID, "2026-08-10"); + + mockMvc.perform(post("/api/v1/payments/{id}/approve", paymentId).cookie(finance.cookies()) + .header("X-XSRF-TOKEN", finance.token()).header("Idempotency-Key", idempotencyKey()).contentType("application/json").content("{\"version\":0}")) + .andExpect(status().isConflict()).andExpect(jsonPath("$.code").value("VERSION_CONFLICT")); + + long financeId = userId("finance"); + restrictScope(financeId, "payment:request:approve", OTHER_PROJECT_ID, new BigDecimal("500.00")); + try { + mockMvc.perform(post("/api/v1/payments/{id}/approve", paymentId).cookie(finance.cookies()) + .header("X-XSRF-TOKEN", finance.token()).header("Idempotency-Key", idempotencyKey()).contentType("application/json").content("{\"version\":1}")) + .andExpect(status().isForbidden()).andExpect(jsonPath("$.code").value("DATA_SCOPE_DENIED")); + restrictScope(financeId, "payment:request:approve", PROJECT_ID, new BigDecimal("500.00")); + mockMvc.perform(post("/api/v1/payments/{id}/approve", paymentId).cookie(finance.cookies()) + .header("X-XSRF-TOKEN", finance.token()).header("Idempotency-Key", idempotencyKey()).contentType("application/json").content("{\"version\":1}")) + .andExpect(status().isForbidden()).andExpect(jsonPath("$.code").value("AMOUNT_LIMIT_EXCEEDED")); + } finally { + restoreGlobalScope(financeId, "payment:request:approve"); + } + + long demoId = userId("demo"); + restrictScope(demoId, "payment:request:view", OTHER_PROJECT_ID, null); + ClientSession demo = loginAndSelect("demo", "FINANCE_MANAGER"); + try { + mockMvc.perform(get("/api/v1/payments/{id}", paymentId).cookie(demo.cookies())) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("RESOURCE_NOT_FOUND")); + } finally { + restoreGlobalScope(demoId, "payment:request:view"); + } + } + + @Test + void reviewPermissionExposesReturnWithoutApprovePermission() throws Exception { + ClientSession project = loginAndSelect("project", "PROJECT_MANAGER"); + ClientSession finance = loginAndSelect("finance", "FINANCE_MANAGER"); + String paymentId = createAndSubmit(project, "100.00", "仅复核权限退回测试", SUPPORT_FILE_ID, "2026-08-28"); + try { + removeRolePermission("FINANCE_MANAGER", "payment:request:approve"); + + mockMvc.perform(get("/api/v1/payments/{id}", paymentId).cookie(finance.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.payment.allowedActions", hasItem("RETURN"))) + .andExpect(jsonPath("$.data.payment.allowedActions", org.hamcrest.Matchers.not(hasItem("APPROVE")))); + + mockMvc.perform(post("/api/v1/payments/{id}/return", paymentId).cookie(finance.cookies()) + .header("X-XSRF-TOKEN", finance.token()).header("Idempotency-Key", idempotencyKey()) + .contentType("application/json").content("{\"version\":1,\"opinion\":\"资料需补充\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("RETURNED")); + } finally { + restoreRolePermission("FINANCE_MANAGER", "payment:request:approve"); + } + } + + @Test + void allowedActionsAndVoidRecheckCreateScope() throws Exception { + ClientSession project = loginAndSelect("project", "PROJECT_MANAGER"); + String paymentId = JsonPath.read(mockMvc.perform(post("/api/v1/payments").cookie(project.cookies()) + .header("X-XSRF-TOKEN", project.token()).header("Idempotency-Key", idempotencyKey()) + .contentType("application/json") + .content(legacyPaymentBody("100.00", "动作范围校验", "2026-09-06", SUPPORT_FILE_ID))) + .andExpect(status().isOk()).andReturn().getResponse().getContentAsString(), "$.data.publicId"); + + long projectUserId = userId("project"); + restrictScopeForRole(projectUserId, "PROJECT_MANAGER", "payment:request:create", OTHER_PROJECT_ID, null); + try { + mockMvc.perform(get("/api/v1/payments/{id}", paymentId).cookie(project.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.payment.allowedActions", + org.hamcrest.Matchers.not(hasItem("EDIT")))) + .andExpect(jsonPath("$.data.payment.allowedActions", + org.hamcrest.Matchers.not(hasItem("VOID")))); + + mockMvc.perform(post("/api/v1/payments/{id}/void", paymentId).cookie(project.cookies()) + .header("X-XSRF-TOKEN", project.token()).header("Idempotency-Key", idempotencyKey()) + .contentType("application/json").content("{\"version\":0,\"opinion\":\"范围已撤销\"}")) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("DATA_SCOPE_DENIED")); + assertEquals("DRAFT", jdbcTemplate.queryForObject( + "SELECT status FROM fin_payment_request WHERE public_id=?", String.class, paymentId)); + } finally { + restoreGlobalScopeForRole(projectUserId, "PROJECT_MANAGER", "payment:request:create"); + } + } + + @Test + void submitterCannotSeeApprovalOrReturnActions() throws Exception { + ClientSession finance = loginAndSelect("finance", "FINANCE_MANAGER"); + String paymentId = createAndSubmit(finance, "100.00", "提交人职责分离动作隐藏", SUPPORT_FILE_ID, "2026-09-07"); + + mockMvc.perform(get("/api/v1/payments/{id}", paymentId).cookie(finance.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.payment.allowedActions", + org.hamcrest.Matchers.not(hasItem("APPROVE")))) + .andExpect(jsonPath("$.data.payment.allowedActions", + org.hamcrest.Matchers.not(hasItem("RETURN")))); + } + + private String createAndSubmit(ClientSession session, String amount, String purpose, + String fileId, String requestedDate) throws Exception { + String attachments = fileId == null ? "[]" : "[\"%s\"]".formatted(fileId); + String body = """ + {"companyId":"%s","projectId":"%s","contractId":"%s","payableId":"%s", + "supplierId":"%s","bankAccountVersionId":"%s","requestedAmount":"%s", + "currency":"CNY","requestedDate":"%s","purpose":"%s","attachmentFileIds":%s} + """.formatted(COMPANY_ID, PROJECT_ID, CONTRACT_ID, PAYABLE_ID, SUPPLIER_ID, ACCOUNT_ID, + amount, requestedDate, purpose, attachments); + String paymentId = JsonPath.read(mockMvc.perform(post("/api/v1/payments").cookie(session.cookies()) + .header("X-XSRF-TOKEN", session.token()).header("Idempotency-Key", idempotencyKey()).contentType("application/json").content(body)) + .andExpect(status().isOk()).andExpect(jsonPath("$.data.status").value("DRAFT")) + .andReturn().getResponse().getContentAsString(), "$.data.publicId"); + mockMvc.perform(post("/api/v1/payments/{id}/submit", paymentId).cookie(session.cookies()) + .header("X-XSRF-TOKEN", session.token()).header("Idempotency-Key", idempotencyKey()).contentType("application/json") + .content("{\"version\":0}")) + .andExpect(status().isOk()).andExpect(jsonPath("$.data.status").value("REVIEWING")); + return paymentId; + } + + private String auditSnapshotValue(String projectPublicId, String sourcePublicId, String entryType, + String side, String field) { + String column = "before".equals(side) ? "before_json" : "after_json"; + return jdbcTemplate.queryForObject( + "SELECT JSON_UNQUOTE(JSON_EXTRACT(" + column + ", '$." + field + "')) " + + "FROM audit_log WHERE action_code='FUND_LEDGER_APPEND' AND object_type='FUND_CONTROL' " + + "AND object_public_id=? AND JSON_UNQUOTE(JSON_EXTRACT(after_json, '$.entryType'))=? " + + "AND JSON_UNQUOTE(JSON_EXTRACT(after_json, '$.sourcePublicId'))=? " + + "ORDER BY created_at DESC LIMIT 1", + String.class, projectPublicId, entryType, sourcePublicId); + } + + private FundLedgerCommand reservedFundCommand(String paymentId, String description) { + long projectId = jdbcTemplate.queryForObject( + "SELECT id FROM md_project WHERE public_id=?", Long.class, PROJECT_ID); + String businessNo = jdbcTemplate.queryForObject( + "SELECT business_no FROM fin_payment_request WHERE public_id=?", String.class, paymentId); + BigDecimal zero = BigDecimal.ZERO.setScale(2); + return new FundLedgerCommand(projectId, "CNY", "PAYMENT_APPROVE:" + paymentId, + "PAYMENT_FUNDS_RESERVED", "PAYMENT", paymentId, businessNo, description, + new BigDecimal("600.00"), zero, zero, zero, new BigDecimal("600.00"), zero, zero, + userId("finance")); + } + + private void assertAuditMoney(String expected, String projectPublicId, String sourcePublicId, + String entryType, String side, String field) { + assertEquals(0, new BigDecimal(expected).compareTo(new BigDecimal( + auditSnapshotValue(projectPublicId, sourcePublicId, entryType, side, field)))); + } + + private String createAndSubmitTyped(ClientSession session, String amount, String purpose, + String requestedDate, String... fileAndDocumentTypes) throws Exception { + String paymentId = createTypedDraft(session, amount, purpose, requestedDate, fileAndDocumentTypes); + submitDraft(session, paymentId); + return paymentId; + } + + private String createTypedDraft(ClientSession session, String amount, String purpose, + String requestedDate, String... fileAndDocumentTypes) throws Exception { + if (fileAndDocumentTypes.length % 2 != 0) { + throw new IllegalArgumentException("附件文件和资料类型必须成对提供"); + } + List attachments = new java.util.ArrayList<>(); + for (int index = 0; index < fileAndDocumentTypes.length; index += 2) { + attachments.add("{\"fileId\":\"%s\",\"documentType\":\"%s\"}".formatted( + fileAndDocumentTypes[index], fileAndDocumentTypes[index + 1])); + } + String body = """ + {"companyId":"%s","projectId":"%s","contractId":"%s","payableId":"%s", + "supplierId":"%s","bankAccountVersionId":"%s","requestedAmount":"%s", + "currency":"CNY","requestedDate":"%s","purpose":"%s","attachments":[%s]} + """.formatted(COMPANY_ID, PROJECT_ID, CONTRACT_ID, PAYABLE_ID, SUPPLIER_ID, ACCOUNT_ID, + amount, requestedDate, purpose, String.join(",", attachments)); + String paymentId = JsonPath.read(mockMvc.perform(post("/api/v1/payments").cookie(session.cookies()) + .header("X-XSRF-TOKEN", session.token()).header("Idempotency-Key", idempotencyKey()) + .contentType("application/json").content(body)) + .andExpect(status().isOk()).andExpect(jsonPath("$.data.status").value("DRAFT")) + .andReturn().getResponse().getContentAsString(), "$.data.publicId"); + return paymentId; + } + + private String createDraftWithAttachmentException(ClientSession session, String amount, String purpose, + String requestedDate, String documentType, + String reason) throws Exception { + String paymentId = JsonPath.read(mockMvc.perform(post("/api/v1/payments").cookie(session.cookies()) + .header("X-XSRF-TOKEN", session.token()).header("Idempotency-Key", idempotencyKey()) + .contentType("application/json") + .content(paymentBodyWithAttachmentException(amount, purpose, requestedDate, documentType, reason))) + .andExpect(status().isOk()).andExpect(jsonPath("$.data.status").value("DRAFT")) + .andReturn().getResponse().getContentAsString(), "$.data.publicId"); + return paymentId; + } + + private String paymentBodyWithAttachmentException(String amount, String purpose, String requestedDate, + String documentType, String reason) { + return """ + {"companyId":"%s","projectId":"%s","contractId":"%s","payableId":"%s", + "supplierId":"%s","bankAccountVersionId":"%s","requestedAmount":"%s", + "currency":"CNY","requestedDate":"%s","purpose":"%s", + "attachments":[{"fileId":"%s","documentType":"MATERIAL_INVOICE"}], + "attachmentExceptions":[{"documentType":"%s","reason":"%s"}]} + """.formatted(COMPANY_ID, PROJECT_ID, CONTRACT_ID, PAYABLE_ID, SUPPLIER_ID, ACCOUNT_ID, + amount, requestedDate, purpose, SUPPORT_FILE_ID, documentType, reason); + } + + private void submitDraft(ClientSession session, String paymentId) throws Exception { + mockMvc.perform(post("/api/v1/payments/{id}/submit", paymentId).cookie(session.cookies()) + .header("X-XSRF-TOKEN", session.token()).header("Idempotency-Key", idempotencyKey()) + .contentType("application/json").content("{\"version\":0}")) + .andExpect(status().isOk()).andExpect(jsonPath("$.data.status").value("REVIEWING")); + } + + private String legacyPaymentBody(String amount, String purpose, String requestedDate, String fileId) { + return """ + {"companyId":"%s","projectId":"%s","contractId":"%s","payableId":"%s", + "supplierId":"%s","bankAccountVersionId":"%s","requestedAmount":"%s", + "currency":"CNY","requestedDate":"%s","purpose":"%s","attachmentFileIds":["%s"]} + """.formatted(COMPANY_ID, PROJECT_ID, CONTRACT_ID, PAYABLE_ID, SUPPLIER_ID, ACCOUNT_ID, + amount, requestedDate, purpose, fileId); + } + + private String typedPaymentBody(String amount, String purpose, String requestedDate, + String fileId, String documentType) { + return """ + {"companyId":"%s","projectId":"%s","contractId":"%s","payableId":"%s", + "supplierId":"%s","bankAccountVersionId":"%s","requestedAmount":"%s", + "currency":"CNY","requestedDate":"%s","purpose":"%s", + "attachments":[{"fileId":"%s","documentType":"%s"}]} + """.formatted(COMPANY_ID, PROJECT_ID, CONTRACT_ID, PAYABLE_ID, SUPPLIER_ID, ACCOUNT_ID, + amount, requestedDate, purpose, fileId, documentType); + } + + private org.springframework.test.web.servlet.ResultActions createWithSource( + ClientSession session, String sourceDocumentId, String purpose, String requestedDate) throws Exception { + String body = """ + {"companyId":"%s","projectId":"%s","contractId":"%s","payableId":"%s", + "supplierId":"%s","bankAccountVersionId":"%s","sourceDocumentId":"%s", + "requestedAmount":"100.00","currency":"CNY","requestedDate":"%s", + "purpose":"%s","attachmentFileIds":["%s"]} + """.formatted(COMPANY_ID, PROJECT_ID, CONTRACT_ID, PAYABLE_ID, SUPPLIER_ID, ACCOUNT_ID, + sourceDocumentId, requestedDate, purpose, SUPPORT_FILE_ID); + return mockMvc.perform(post("/api/v1/payments").cookie(session.cookies()) + .header("X-XSRF-TOKEN", session.token()).header("Idempotency-Key", idempotencyKey()) + .contentType("application/json").content(body)); + } + + private String approvedPaymentSource(String formType, String discriminator, + String companyId, String projectId) { + return paymentSource(formType, discriminator, "APPROVED", "APPROVED", companyId, projectId); + } + + private String paymentSource(String formType, String discriminator, String documentStatus, + String workflowStatus, String companyId, String projectId) { + long financeId = userId("finance"); + int sequence = Integer.parseInt(discriminator); + String documentId = SOURCE_ID_PREFIX + String.format("%02d", sequence); + String versionId = SOURCE_ID_PREFIX + String.format("%02d", sequence + 30); + String instanceId = SOURCE_ID_PREFIX + String.format("%02d", sequence + 60); + String sourceNo = "P15-" + formType + "-" + discriminator; + jdbcTemplate.update(""" + INSERT INTO src_source_document + (public_id, business_no, form_type, source_system, source_no, + normalized_source_no, source_version, normalized_source_version, + template_id, company_id, project_id, applicant_name, business_date, + status, created_by, updated_by) + SELECT ?, ?, ?, 'MANUAL', ?, ?, '1', '1', template.id, + company.id, project.id, '付款来源测试申请人', '2026-08-01', ?, ?, ? + FROM src_template template + JOIN md_company company ON company.public_id = ? + JOIN md_project project ON project.public_id = ? + WHERE template.form_type = ? AND template.template_version = 1 + """, documentId, "SRC-" + sourceNo, formType, sourceNo, sourceNo, documentStatus, + financeId, financeId, companyId, projectId, formType); + long sourceId = jdbcTemplate.queryForObject( + "SELECT id FROM src_source_document WHERE public_id=?", Long.class, documentId); + jdbcTemplate.update(""" + INSERT INTO src_document_version + (public_id, source_document_id, version_no, template_id, snapshot_sha256, + payload_json, submitted_at, created_by) + SELECT ?, document.id, 1, document.template_id, REPEAT('a', 64), JSON_OBJECT(), + UTC_TIMESTAMP(3), ? + FROM src_source_document document WHERE document.id = ? + """, versionId, financeId, sourceId); + long versionDatabaseId = jdbcTemplate.queryForObject( + "SELECT id FROM src_document_version WHERE public_id=?", Long.class, versionId); + jdbcTemplate.update("UPDATE src_source_document SET current_version_id=? WHERE id=?", + versionDatabaseId, sourceId); + if (workflowStatus != null) { + jdbcTemplate.update(""" + INSERT INTO wf_instance + (public_id, definition_version_id, source_version_id, current_node_code, + status, created_by, updated_by) + SELECT ?, definition_version.id, ?, NULL, ?, ?, ? + FROM wf_definition_version definition_version + JOIN wf_definition definition ON definition.id = definition_version.definition_id + WHERE definition.code = 'SOURCE_STANDARD_REVIEW' + AND definition_version.status = 'ACTIVE' + """, instanceId, versionDatabaseId, workflowStatus, financeId, financeId); + } + return documentId; + } + + private void replacePaymentSourceCurrentVersionWithoutApproval(String sourceDocumentId, + String versionPublicId) { + long financeId = userId("finance"); + jdbcTemplate.update(""" + INSERT INTO src_document_version + (public_id, source_document_id, version_no, previous_version_id, template_id, + snapshot_sha256, payload_json, submitted_at, created_by) + SELECT ?, document.id, current_version.version_no + 1, current_version.id, + document.template_id, REPEAT('b', 64), JSON_OBJECT(), UTC_TIMESTAMP(3), ? + FROM src_source_document document + JOIN src_document_version current_version ON current_version.id = document.current_version_id + WHERE document.public_id = ? + """, versionPublicId, financeId, sourceDocumentId); + jdbcTemplate.update(""" + UPDATE src_source_document document + JOIN src_document_version version ON version.public_id = ? + SET document.current_version_id = version.id + WHERE document.public_id = ? + """, versionPublicId, sourceDocumentId); + } + + private void approveBlocked(ClientSession finance, String paymentId, String code) throws Exception { + mockMvc.perform(post("/api/v1/payments/{id}/approve", paymentId).cookie(finance.cookies()) + .header("X-XSRF-TOKEN", finance.token()).header("Idempotency-Key", idempotencyKey()).contentType("application/json").content("{\"version\":1}")) + .andExpect("DUPLICATE_SOURCE".equals(code) ? status().isConflict() : status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value(code)); + } + + private void requestControlException(ClientSession session, String paymentId, String controlCode, + String reason) throws Exception { + mockMvc.perform(post("/api/v1/payments/{id}/control-exceptions", paymentId).cookie(session.cookies()) + .header("X-XSRF-TOKEN", session.token()).header("Idempotency-Key", idempotencyKey()) + .contentType("application/json") + .content(("{\"paymentVersion\":1,\"controlCode\":\"%s\",\"reason\":\"%s\"," + + "\"validUntil\":\"%s\"}").formatted(controlCode, reason, LocalDate.now().plusDays(7)))) + .andExpect(status().isOk()); + } + + private String controlExceptionId(String paymentId, String controlCode) { + return jdbcTemplate.queryForObject("SELECT exception_item.public_id " + + "FROM fin_payment_control_exception exception_item " + + "JOIN fin_payment_request payment ON payment.id=exception_item.payment_id " + + "WHERE payment.public_id=? AND exception_item.control_code=? AND exception_item.active=TRUE", + String.class, paymentId, controlCode); + } + + private void reviewControlException(ClientSession session, String paymentId, String exceptionId, + String decision, String opinion) throws Exception { + mockMvc.perform(post("/api/v1/payments/{paymentId}/control-exceptions/{exceptionId}/review", + paymentId, exceptionId).cookie(session.cookies()) + .header("X-XSRF-TOKEN", session.token()).header("Idempotency-Key", idempotencyKey()) + .contentType("application/json") + .content("{\"paymentVersion\":1,\"version\":0,\"decision\":\"%s\",\"opinion\":\"%s\"}" + .formatted(decision, opinion))) + .andExpect(status().isOk()); + } + + private MvcResult concurrentApproval(String paymentId, ClientSession session, + CountDownLatch ready, CountDownLatch start) throws Exception { + ready.countDown(); + if (!start.await(5, TimeUnit.SECONDS)) { + throw new IllegalStateException("并发批准启动信号超时"); + } + return mockMvc.perform(post("/api/v1/payments/{id}/approve", paymentId).cookie(session.cookies()) + .header("X-XSRF-TOKEN", session.token()).header("Idempotency-Key", idempotencyKey()) + .contentType("application/json").content("{\"version\":1}")) + .andReturn(); + } + + private void insertRisk() { + long financeId = userId("finance"); + jdbcTemplate.update("INSERT INTO project_risk_flag (public_id,project_id,risk_type,reason,effective_from,blocking,status,created_by,updated_by) SELECT '00000000000000000000001521',id,'LITIGATION','测试涉诉阻断',DATE_SUB(UTC_TIMESTAMP(3), INTERVAL 1 MINUTE),TRUE,'ACTIVE',?,? FROM md_project WHERE public_id=?", financeId, financeId, PROJECT_ID); + } + + private void seedOutOfScopeReferences(long actorId) { + if (count("SELECT COUNT(*) FROM md_counterparty WHERE public_id=?", OTHER_SUPPLIER_ID) > 0) return; + jdbcTemplate.update("INSERT INTO md_counterparty (public_id,business_no,counterparty_type,name,status,created_by,updated_by) VALUES (?,'GY-P15-B','SUPPLIER','范围外付款供应商','ACTIVE',?,?)", OTHER_SUPPLIER_ID, actorId, actorId); + jdbcTemplate.update("INSERT INTO md_contract (public_id,company_id,project_id,counterparty_id,business_no,name,original_amount,approved_change_amount,currency,status,created_by,updated_by) SELECT ?,c.id,p.id,s.id,'HT-P15-B','范围外付款合同',10000,0,'CNY','ACTIVE',?,? FROM md_company c JOIN md_project p ON p.company_id=c.id JOIN md_counterparty s ON s.public_id=? WHERE c.public_id=?", OTHER_CONTRACT_ID, actorId, actorId, OTHER_SUPPLIER_ID, OTHER_COMPANY_ID); + jdbcTemplate.update("INSERT INTO fin_payable (public_id,business_no,company_id,project_id,contract_id,counterparty_id,business_date,amount,status,created_by,updated_by) SELECT ?,'YF-P15-B',c.id,p.id,k.id,s.id,'2026-08-01',10000,'CONFIRMED',?,? FROM md_company c JOIN md_project p ON p.company_id=c.id JOIN md_contract k ON k.project_id=p.id JOIN md_counterparty s ON s.id=k.counterparty_id WHERE c.public_id=?", OTHER_PAYABLE_ID, actorId, actorId, OTHER_COMPANY_ID); + jdbcTemplate.update("INSERT INTO md_bank_account_version (public_id,owner_type,counterparty_id,account_category,account_name,bank_name,account_no_ciphertext,account_no_hash,masked_account_no,version_no,valid_from,status,created_by,updated_by) SELECT ?,'COUNTERPARTY',id,'BASIC','范围外付款供应商','范围外银行',X'00',REPEAT('c',64),'****1516',1,UTC_TIMESTAMP(3),'ACTIVE',?,? FROM md_counterparty WHERE public_id=?", OTHER_ACCOUNT_ID, actorId, actorId, OTHER_SUPPLIER_ID); + } + + private void cleanupOutOfScopeReferences() { + jdbcTemplate.update("DELETE FROM md_bank_account_version WHERE public_id=?", OTHER_ACCOUNT_ID); + jdbcTemplate.update("DELETE FROM fin_payable WHERE public_id=?", OTHER_PAYABLE_ID); + jdbcTemplate.update("DELETE FROM md_contract WHERE public_id=?", OTHER_CONTRACT_ID); + jdbcTemplate.update("DELETE FROM md_counterparty WHERE public_id=?", OTHER_SUPPLIER_ID); + } + + private void assertVisibleAndHidden(String body, String jsonPath, String visibleId, String hiddenId) { + List ids = JsonPath.read(body, jsonPath); + assertTrue(ids.contains(visibleId), "范围内引用应返回: " + visibleId); + assertTrue(!ids.contains(hiddenId), "范围外引用不得返回: " + hiddenId); + } + + private void restrictScope(long userId, String permissionCode, String projectId, BigDecimal amountLimit) { + restrictScopeForRole(userId, "FINANCE_MANAGER", permissionCode, projectId, amountLimit); + } + + private void restrictScopeForRole(long userId, String roleCode, String permissionCode, String projectId, + BigDecimal amountLimit) { + jdbcTemplate.update("DELETE scope FROM iam_scope scope JOIN iam_role role ON role.id=scope.role_id " + + "JOIN iam_permission permission ON permission.id=scope.permission_id " + + "WHERE scope.user_id=? AND role.code=? AND permission.code=?", userId, roleCode, permissionCode); + jdbcTemplate.update("INSERT INTO iam_scope (user_id,role_id,permission_id,scope_type,project_public_id,amount_limit,valid_from,status) " + + "SELECT ?,role.id,permission.id,'PROJECT',?,?,'2020-01-01 00:00:00','ACTIVE' FROM iam_role role " + + "JOIN iam_permission permission WHERE role.code=? AND permission.code=?", userId, projectId, + amountLimit, roleCode, permissionCode); + } + + private void restoreGlobalScope(long userId, String permissionCode) { + restoreGlobalScopeForRole(userId, "FINANCE_MANAGER", permissionCode); + } + + private void restoreGlobalScopeForRole(long userId, String roleCode, String permissionCode) { + jdbcTemplate.update("DELETE scope FROM iam_scope scope JOIN iam_role role ON role.id=scope.role_id JOIN iam_permission permission ON permission.id=scope.permission_id WHERE scope.user_id=? AND role.code=? AND permission.code=?", userId, roleCode, permissionCode); + jdbcTemplate.update("INSERT INTO iam_scope (user_id,role_id,permission_id,scope_type,valid_from,status) SELECT ?,role.id,permission.id,'GLOBAL','2020-01-01 00:00:00','ACTIVE' FROM iam_role role JOIN iam_permission permission WHERE role.code=? AND permission.code=?", userId, roleCode, permissionCode); + } + + private void removeRolePermission(String roleCode, String permissionCode) { + jdbcTemplate.update("DELETE assignment FROM iam_role_permission assignment " + + "JOIN iam_role role ON role.id=assignment.role_id " + + "JOIN iam_permission permission ON permission.id=assignment.permission_id " + + "WHERE role.code=? AND permission.code=?", roleCode, permissionCode); + } + + private void restoreRolePermission(String roleCode, String permissionCode) { + jdbcTemplate.update("INSERT IGNORE INTO iam_role_permission (role_id,permission_id) " + + "SELECT role.id,permission.id FROM iam_role role JOIN iam_permission permission " + + "WHERE role.code=? AND permission.code=?", roleCode, permissionCode); + } + + private void resetAttachmentMatrix() { + jdbcTemplate.update("DELETE FROM sys_parameter_version WHERE parameter_group='PAYMENT_ATTACHMENT_MATRIX' AND version_no > 1"); + jdbcTemplate.update("UPDATE sys_parameter_version SET status='DRAFT',effective_at=NULL,version=0,updated_by=NULL WHERE parameter_group='PAYMENT_ATTACHMENT_MATRIX' AND version_no=1"); + } + + private void useMaterialCostCategory() { + jdbcTemplate.update(""" + UPDATE fin_payable + SET cost_category_id=(SELECT id FROM md_cost_category WHERE code='MATERIAL') + WHERE public_id=? + """, PAYABLE_ID); + } + + private void updateAndPublishAttachmentMatrixVersionOne(ClientSession admin) throws Exception { + String escapedMatrix = materialAttachmentMatrix(false).replace("\\", "\\\\") + .replace("\"", "\\\"").replace("\n", "\\n"); + mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch( + "/api/v1/admin/parameters/{id}", "00000000000000000000000360") + .cookie(admin.cookies()).header("X-XSRF-TOKEN", admin.token()) + .contentType("application/json") + .content("{\"version\":0,\"valueJson\":\"%s\"}".formatted(escapedMatrix))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("DRAFT")) + .andExpect(jsonPath("$.data.version").value(1)); + mockMvc.perform(post("/api/v1/admin/parameters/{id}/publish", "00000000000000000000000360") + .cookie(admin.cookies()).header("X-XSRF-TOKEN", admin.token()) + .contentType("application/json").content("{\"version\":1,\"reason\":\"测试生效矩阵\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("ACTIVE")); + } + + private void activateAttachmentMatrixVersionTwo() { + jdbcTemplate.update("UPDATE sys_parameter_version SET status='RETIRED',version=version+1 WHERE parameter_group='PAYMENT_ATTACHMENT_MATRIX' AND version_no=1"); + jdbcTemplate.update(""" + INSERT INTO sys_parameter_version + (public_id,parameter_group,version_no,value_json,status,effective_at,created_by) + VALUES (?,'PAYMENT_ATTACHMENT_MATRIX',2,CAST(? AS JSON),'ACTIVE',UTC_TIMESTAMP(3), + '00000000000000000000000000') + """, ATTACHMENT_MATRIX_V2_ID, materialAttachmentMatrix(true)); + } + + private void updateAndPublishAttachmentExceptionMatrix(ClientSession admin) throws Exception { + String escapedMatrix = materialAttachmentMatrixWithException().replace("\\", "\\\\") + .replace("\"", "\\\"").replace("\n", "\\n"); + mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch( + "/api/v1/admin/parameters/{id}", "00000000000000000000000360") + .cookie(admin.cookies()).header("X-XSRF-TOKEN", admin.token()) + .contentType("application/json") + .content("{\"version\":0,\"valueJson\":\"%s\"}".formatted(escapedMatrix))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("DRAFT")); + mockMvc.perform(post("/api/v1/admin/parameters/{id}/publish", "00000000000000000000000360") + .cookie(admin.cookies()).header("X-XSRF-TOKEN", admin.token()) + .contentType("application/json").content("{\"version\":1,\"reason\":\"附件例外闭环已启用\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("ACTIVE")); + } + + private String materialAttachmentMatrixWithException() { + return """ + {"schemaVersion":1,"categories":[{ + "costCategoryCode":"MATERIAL","costCategoryName":"材料费","requirements":[ + {"documentType":"MATERIAL_INVOICE","name":"材料发票","required":true,"allowNotApplicable":false}, + {"documentType":"DELIVERY_NOTE","name":"送货单","required":true,"allowNotApplicable":true} + ] + }]} + """; + } + + private String materialAttachmentMatrix(boolean includeSitePhoto) { + String sitePhoto = includeSitePhoto + ? ",{\"documentType\":\"SITE_ACCEPTANCE_PHOTO\",\"name\":\"现场验收照片\",\"required\":true,\"allowNotApplicable\":false}" + : ""; + return """ + {"schemaVersion":1,"categories":[{ + "costCategoryCode":"MATERIAL","costCategoryName":"材料费","requirements":[ + {"documentType":"MATERIAL_INVOICE","name":"材料发票","required":true,"allowNotApplicable":false}, + {"documentType":"DELIVERY_NOTE","name":"送货单","required":true,"allowNotApplicable":false}%s + ] + }]} + """.formatted(sitePhoto); + } + + private void cleanupPaymentData() { + jdbcTemplate.update("DELETE FROM audit_log WHERE action_code='FUND_LEDGER_APPEND' AND object_public_id=?", PROJECT_ID); + jdbcTemplate.update("DELETE FROM fund_ledger WHERE project_id=(SELECT id FROM md_project WHERE public_id=?) " + + "AND entry_type <> 'OPENING_BALANCE'", PROJECT_ID); + jdbcTemplate.update("DELETE FROM acc_event WHERE company_id=(SELECT id FROM md_company WHERE public_id=?) AND source_type IN ('PAYMENT','PAYMENT_REFUND')", COMPANY_ID); + jdbcTemplate.update("DELETE refund FROM fin_payment_refund refund JOIN fin_payment_result result ON result.id=refund.payment_result_id JOIN fin_payment_request payment ON payment.id=result.payment_id WHERE payment.project_id=(SELECT id FROM md_project WHERE public_id=?)", PROJECT_ID); + jdbcTemplate.update("DELETE result_file FROM fin_payment_result_file result_file JOIN fin_payment_result result ON result.id=result_file.payment_result_id JOIN fin_payment_request payment ON payment.id=result.payment_id WHERE payment.project_id=(SELECT id FROM md_project WHERE public_id=?)", PROJECT_ID); + jdbcTemplate.update("DELETE result FROM fin_payment_result result JOIN fin_payment_request payment ON payment.id=result.payment_id WHERE payment.project_id=(SELECT id FROM md_project WHERE public_id=?)", PROJECT_ID); + jdbcTemplate.update("DELETE payment_check FROM fin_payment_check payment_check JOIN fin_payment_request payment ON payment.id=payment_check.payment_id WHERE payment.project_id=(SELECT id FROM md_project WHERE public_id=?)", PROJECT_ID); + jdbcTemplate.update("DELETE exception_item FROM fin_payment_attachment_exception exception_item JOIN fin_payment_request payment ON payment.id=exception_item.payment_id WHERE payment.project_id=(SELECT id FROM md_project WHERE public_id=?)", PROJECT_ID); + jdbcTemplate.update("DELETE exception_item FROM fin_payment_control_exception exception_item JOIN fin_payment_request payment ON payment.id=exception_item.payment_id WHERE payment.project_id=(SELECT id FROM md_project WHERE public_id=?)", PROJECT_ID); + jdbcTemplate.update("DELETE attachment FROM fin_payment_attachment attachment JOIN fin_payment_request payment ON payment.id=attachment.payment_id WHERE payment.project_id=(SELECT id FROM md_project WHERE public_id=?)", PROJECT_ID); + jdbcTemplate.update("DELETE FROM fin_payment_request WHERE original_payment_id IS NOT NULL AND project_id=(SELECT id FROM md_project WHERE public_id=?)", PROJECT_ID); + jdbcTemplate.update("DELETE FROM fin_payment_request WHERE project_id=(SELECT id FROM md_project WHERE public_id=?)", PROJECT_ID); + jdbcTemplate.update("DELETE FROM project_risk_flag WHERE project_id=(SELECT id FROM md_project WHERE public_id=?)", PROJECT_ID); + } + + private void cleanupPaymentSources() { + jdbcTemplate.update(""" + DELETE instance FROM wf_instance instance + JOIN src_document_version version ON version.id = instance.source_version_id + JOIN src_source_document document ON document.id = version.source_document_id + WHERE document.business_no LIKE 'SRC-P15-%' + """); + jdbcTemplate.update(""" + UPDATE src_source_document SET current_version_id = NULL + WHERE business_no LIKE 'SRC-P15-%' + """); + jdbcTemplate.update(""" + UPDATE src_document_version version + JOIN src_source_document document ON document.id = version.source_document_id + SET version.previous_version_id = NULL + WHERE document.business_no LIKE 'SRC-P15-%' + """); + jdbcTemplate.update(""" + DELETE version FROM src_document_version version + JOIN src_source_document document ON document.id = version.source_document_id + WHERE document.business_no LIKE 'SRC-P15-%' + """); + jdbcTemplate.update("DELETE FROM src_source_document WHERE business_no LIKE 'SRC-P15-%'"); + } + + private void insertFile(String publicId, String name, String storageKey, String scanStatus, long uploaderId) { + String uploaderPublicId = jdbcTemplate.queryForObject("SELECT public_id FROM iam_user WHERE id=?", String.class, uploaderId); + jdbcTemplate.update("INSERT INTO file_object (public_id,original_name,storage_key,media_type,extension,size_bytes,sha256,scan_status,uploaded_by) VALUES (?, ?, ?, 'application/pdf', 'pdf', 10, REPEAT('b',64), ?, ?)", publicId, name, storageKey, scanStatus, uploaderPublicId); + } + + private void linkProjectFile(String filePublicId, String companyPublicId, String projectPublicId, + String documentType, long linkedBy) { + String linkedByPublicId = jdbcTemplate.queryForObject( + "SELECT public_id FROM iam_user WHERE id=?", String.class, linkedBy); + jdbcTemplate.update(""" + INSERT INTO file_link ( + file_id, object_type, object_public_id, series_public_id, company_public_id, project_public_id, + document_type, version_no, active, linked_by + ) SELECT file.id, 'PROJECT', ?, file.public_id, ?, ?, ?, 1, TRUE, ? + FROM file_object file WHERE file.public_id=? + """, projectPublicId, companyPublicId, projectPublicId, documentType, linkedByPublicId, filePublicId); + } + + private long userId(String username) { + return jdbcTemplate.queryForObject("SELECT id FROM iam_user WHERE username=?", Long.class, username); + } + + private int count(String sql, Object... args) { + return jdbcTemplate.queryForObject(sql, Integer.class, args); + } + + private void assertMoney(String expected, String sql, Object... args) { + BigDecimal actual = jdbcTemplate.queryForObject(sql, BigDecimal.class, args); + assertNotNull(actual); + assertEquals(0, new BigDecimal(expected).compareTo(actual)); + } + + private ClientSession loginAndSelect(String username, String role) throws Exception { + MvcResult csrf = mockMvc.perform(get("/api/v1/auth/csrf")).andExpect(status().isOk()).andReturn(); + Cookie csrfCookie = csrf.getResponse().getCookie("XSRF-TOKEN"); + assertNotNull(csrfCookie); + String token = JsonPath.read(csrf.getResponse().getContentAsString(), "$.data.token"); + MvcResult login = mockMvc.perform(post("/api/v1/auth/login").cookie(csrfCookie) + .header("X-XSRF-TOKEN", token).contentType("application/json") + .content("{\"username\":\"%s\",\"password\":\"LocalOnly@123\"}".formatted(username))) + .andExpect(status().isOk()).andReturn(); + Cookie session = login.getResponse().getCookie("KAIDI_SESSION"); + assertNotNull(session); + MvcResult selected = mockMvc.perform(post("/api/v1/auth/select-role").cookie(session, csrfCookie) + .header("X-XSRF-TOKEN", token).contentType("application/json") + .content("{\"roleCode\":\"%s\"}".formatted(role))) + .andExpect(status().isOk()).andReturn(); + Cookie rotated = selected.getResponse().getCookie("KAIDI_SESSION"); + return new ClientSession(rotated == null ? session : rotated, csrfCookie, token); + } + + private record ClientSession(Cookie session, Cookie csrf, String token) { + Cookie[] cookies() { + return new Cookie[]{session, csrf}; + } + } +} diff --git a/backend/src/test/java/com/kaidi/finance/project/ProjectIntegrationTest.java b/backend/src/test/java/com/kaidi/finance/project/ProjectIntegrationTest.java new file mode 100644 index 0000000..ed578de --- /dev/null +++ b/backend/src/test/java/com/kaidi/finance/project/ProjectIntegrationTest.java @@ -0,0 +1,1110 @@ +package com.kaidi.finance.project; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.hamcrest.Matchers.hasItem; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.hamcrest.Matchers.containsString; + +import com.jayway.jsonpath.JsonPath; +import jakarta.servlet.http.Cookie; +import java.nio.charset.StandardCharsets; +import java.util.Locale; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.context.annotation.Primary; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.mock.web.MockMultipartFile; +import com.kaidi.finance.shared.file.FileScanner; +import java.nio.file.Path; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +@Testcontainers(disabledWithoutDocker = true) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@Import(ProjectIntegrationTest.ScannerTestConfiguration.class) +class ProjectIntegrationTest { + + private static final AtomicInteger SEQUENCE = new AtomicInteger(); + + private static String idempotencyKey() { + return "test-project-file-" + java.util.UUID.randomUUID(); + } + + @Container + static final MySQLContainer MYSQL = new MySQLContainer<>("mysql:8.4") + .withDatabaseName("kaidi_finance_project_test") + .withUsername("kaidi") + .withPassword("kaidi_test_password"); + + @DynamicPropertySource + static void configureDatabase(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", MYSQL::getJdbcUrl); + registry.add("spring.datasource.username", MYSQL::getUsername); + registry.add("spring.datasource.password", MYSQL::getPassword); + registry.add("finance.bootstrap.enabled", () -> true); + registry.add("finance.bootstrap.password", () -> "LocalOnly@123"); + } + + @Autowired + MockMvc mockMvc; + + @Autowired + JdbcTemplate jdbcTemplate; + + @AfterEach + void restoreProjectViewScope() { + jdbcTemplate.update(""" + DELETE scope + FROM iam_scope scope + JOIN iam_user user_account ON user_account.id = scope.user_id + JOIN iam_role role ON role.id = scope.role_id + JOIN iam_permission permission ON permission.id = scope.permission_id + WHERE user_account.username = 'project' + AND role.code = 'PROJECT_MANAGER' + AND permission.code = 'project:project:view' + """); + jdbcTemplate.update(""" + INSERT INTO iam_scope ( + user_id, role_id, permission_id, scope_type, company_public_id, project_public_id, + amount_limit, status + ) + SELECT user_account.id, role.id, permission.id, 'GLOBAL', NULL, NULL, NULL, 'ACTIVE' + FROM iam_user user_account + JOIN iam_role role ON role.code = 'PROJECT_MANAGER' + JOIN iam_permission permission ON permission.code = 'project:project:view' + WHERE user_account.username = 'project' + """); + jdbcTemplate.update(""" + DELETE scope + FROM iam_scope scope + JOIN iam_user user_account ON user_account.id = scope.user_id + JOIN iam_role role ON role.id = scope.role_id + JOIN iam_permission permission ON permission.id = scope.permission_id + WHERE user_account.username = 'project' + AND role.code = 'PROJECT_MANAGER' + AND permission.code = 'project:project:export' + """); + jdbcTemplate.update(""" + INSERT INTO iam_scope ( + user_id, role_id, permission_id, scope_type, company_public_id, project_public_id, + amount_limit, status + ) + SELECT user_account.id, role.id, permission.id, 'GLOBAL', NULL, NULL, NULL, 'ACTIVE' + FROM iam_user user_account + JOIN iam_role role ON role.code = 'PROJECT_MANAGER' + JOIN iam_permission permission ON permission.code = 'project:project:export' + WHERE user_account.username = 'project' + """); + } + + @Test + void createListDetailUpdateAndReferencesFormACompleteLoop() throws Exception { + Fixtures fixtures = fixtures(); + ClientSession projectUser = loginAndSelect("project", "PROJECT_MANAGER"); + + mockMvc.perform(get("/api/v1/projects/references").cookie(projectUser.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.companies[?(@.publicId == '%s')]".formatted(fixtures.companyId())).exists()) + .andExpect(jsonPath("$.data.customers[?(@.publicId == '%s')]".formatted(fixtures.customerId())).exists()) + .andExpect(jsonPath("$.data.owners[?(@.publicId == '%s')]".formatted(fixtures.ownerId())).exists()); + + String suffix = nextSuffix(); + MvcResult created = postJson("/api/v1/projects", projectUser, projectPayload( + "P-" + suffix, "测试项目 " + suffix, fixtures, "2026-01-01", "2026-12-31")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("DRAFT")) + .andExpect(jsonPath("$.data.stage").value("INITIATION")) + .andExpect(jsonPath("$.data.overview.contractAmount").value("0.00")) + .andExpect(jsonPath("$.data.allowedActions[?(@ == 'EDIT')]").exists()) + .andReturn(); + String projectId = JsonPath.read(created.getResponse().getContentAsString(), "$.data.publicId"); + + mockMvc.perform(get("/api/v1/projects") + .cookie(projectUser.cookies()) + .param("keyword", suffix) + .param("sort", "businessNo,asc") + .param("page", "1") + .param("size", "20")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.meta.totalElements").value(1)) + .andExpect(jsonPath("$.data[0].publicId").value(projectId)) + .andExpect(jsonPath("$.data[0].company.publicId").value(fixtures.companyId())); + + patchJson("/api/v1/projects/" + projectId, projectUser, """ + {"name":"更新后的测试项目","description":"更新说明","customerId":"%s", + "ownerId":"%s","startDate":"2026-02-01","endDate":"2026-11-30","version":0} + """.formatted(fixtures.customerId(), fixtures.ownerId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.name").value("更新后的测试项目")) + .andExpect(jsonPath("$.data.version").value(1)); + + mockMvc.perform(get("/api/v1/projects/{id}", projectId).cookie(projectUser.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.description").value("更新说明")) + .andExpect(jsonPath("$.data.timeline[?(@.actionCode == 'PROJECT_CREATE')]").exists()) + .andExpect(jsonPath("$.data.timeline[?(@.actionCode == 'PROJECT_UPDATE')]").exists()); + assertEquals(fixtures.companyId(), jdbcTemplate.queryForObject( + "SELECT company_public_id FROM audit_log WHERE action_code='PROJECT_CREATE' AND object_public_id=?", + String.class, projectId)); + assertEquals(projectId, jdbcTemplate.queryForObject( + "SELECT project_public_id FROM audit_log WHERE action_code='PROJECT_UPDATE' AND object_public_id=?", + String.class, projectId)); + } + + @Test + void duplicatesInvalidReferencesDatesAndStaleVersionsAreRejected() throws Exception { + Fixtures fixtures = fixtures(); + ClientSession projectUser = loginAndSelect("project", "PROJECT_MANAGER"); + String suffix = nextSuffix(); + String payload = projectPayload("DUP-" + suffix, "冲突项目", fixtures, "2026-01-01", "2026-12-31"); + MvcResult created = postJson("/api/v1/projects", projectUser, payload) + .andExpect(status().isOk()).andReturn(); + String projectId = JsonPath.read(created.getResponse().getContentAsString(), "$.data.publicId"); + + postJson("/api/v1/projects", projectUser, payload) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("DUPLICATE_RESOURCE")); + postJson("/api/v1/projects", projectUser, projectPayload( + "DATE-" + suffix, "日期错误", fixtures, "2026-12-31", "2026-01-01")) + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("VALIDATION_FAILED")); + + String missingId = "00000000000000000000009999"; + postJson("/api/v1/projects", projectUser, """ + {"businessNo":"REF-%s","name":"引用错误","companyId":"%s","customerId":"%s", + "ownerId":"%s","currency":"CNY"} + """.formatted(suffix, fixtures.companyId(), missingId, fixtures.ownerId())) + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("VALIDATION_FAILED")); + + patchJson("/api/v1/projects/" + projectId, projectUser, """ + {"name":"第一次更新","customerId":"%s","ownerId":"%s","version":0} + """.formatted(fixtures.customerId(), fixtures.ownerId())) + .andExpect(status().isOk()); + patchJson("/api/v1/projects/" + projectId, projectUser, """ + {"name":"过期更新","customerId":"%s","ownerId":"%s","version":0} + """.formatted(fixtures.customerId(), fixtures.ownerId())) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("VERSION_CONFLICT")); + } + + @Test + void financeConfirmationIsRequiredBeforeArchiveGenerationAndUsesScopeVersionAndAudit() throws Exception { + Fixtures fixtures = fixtures(); + ClientSession projectUser = loginAndSelect("project", "PROJECT_MANAGER"); + ClientSession finance = loginAndSelect("finance", "FINANCE_MANAGER"); + ClientSession archive = loginAndSelect("archive", "ARCHIVE_MANAGER"); + String suffix = nextSuffix(); + MvcResult created = postJson("/api/v1/projects", projectUser, + projectPayload("FIN-GATE-" + suffix, "财务确认门禁项目 " + suffix, fixtures, null, null)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.financeStatus").value("PENDING")) + .andReturn(); + String projectId = JsonPath.read(created.getResponse().getContentAsString(), "$.data.publicId"); + + postJson("/api/v1/archive/packages", archive, + "{\"projectId\":\"%s\"}".formatted(projectId), idempotencyKey()) + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("INVALID_STATE_TRANSITION")); + + postJson("/api/v1/projects/" + projectId + "/finance-complete", projectUser, + "{\"version\":0}", idempotencyKey()) + .andExpect(status().isForbidden()); + + long financeUserId = jdbcTemplate.queryForObject( + "SELECT id FROM iam_user WHERE username='finance'", Long.class); + jdbcTemplate.update(""" + DELETE scope FROM iam_scope scope + JOIN iam_role role ON role.id=scope.role_id + JOIN iam_permission permission ON permission.id=scope.permission_id + WHERE scope.user_id=? AND role.code='FINANCE_MANAGER' + AND permission.code='project:project:finance-complete' + """, financeUserId); + try { + postJson("/api/v1/projects/" + projectId + "/finance-complete", finance, + "{\"version\":0}", idempotencyKey()) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("DATA_SCOPE_DENIED")); + } finally { + jdbcTemplate.update(""" + INSERT INTO iam_scope (user_id,role_id,permission_id,scope_type,status) + SELECT ?,role.id,permission.id,'GLOBAL','ACTIVE' + FROM iam_role role JOIN iam_permission permission + WHERE role.code='FINANCE_MANAGER' + AND permission.code='project:project:finance-complete' + """, financeUserId); + } + + postJson("/api/v1/projects/" + projectId + "/finance-complete", finance, + "{\"version\":0}", idempotencyKey()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.financeStatus").value("COMPLETED")) + .andExpect(jsonPath("$.data.allowedActions[?(@ == 'CONFIRM_FINANCE')]").doesNotExist()); + + postJson("/api/v1/projects/" + projectId + "/finance-complete", finance, + "{\"version\":0}", idempotencyKey()) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("VERSION_CONFLICT")); + + String packageId = JsonPath.read(postJson("/api/v1/archive/packages", finance, + "{\"projectId\":\"%s\"}".formatted(projectId), idempotencyKey()) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(), "$.data.publicId"); + assertNotNull(packageId); + assertEquals(1, jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM audit_log WHERE action_code='PROJECT_FINANCE_CONFIRM' AND object_public_id=?", + Integer.class, projectId)); + } + + @Test + void riskFlagsValidateOverlapAndResolveWithOptimisticLocking() throws Exception { + Fixtures fixtures = fixtures(); + ClientSession projectUser = loginAndSelect("project", "PROJECT_MANAGER"); + ClientSession finance = loginAndSelect("finance", "FINANCE_MANAGER"); + String suffix = nextSuffix(); + MvcResult created = postJson("/api/v1/projects", projectUser, projectPayload( + "RISK-" + suffix, "风险项目", fixtures, "2026-01-01", "2026-12-31")) + .andExpect(status().isOk()).andReturn(); + String projectId = JsonPath.read(created.getResponse().getContentAsString(), "$.data.publicId"); + + postJson("/api/v1/projects/" + projectId + "/risk-flags", finance, """ + {"riskType":"UNKNOWN","reason":"非法类型","blocking":true} + """) + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("VALIDATION_FAILED")); + + MvcResult riskCreated = postJson("/api/v1/projects/" + projectId + "/risk-flags", finance, """ + {"riskType":"LITIGATION","reason":"待核实诉讼事项", + "effectiveFrom":"2026-01-01T00:00:00+08:00","effectiveUntil":"2026-12-31T23:59:59+08:00", + "blocking":true} + """) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("ACTIVE")) + .andExpect(jsonPath("$.data.allowedActions[?(@ == 'RESOLVE')]").exists()) + .andReturn(); + String riskId = JsonPath.read(riskCreated.getResponse().getContentAsString(), "$.data.publicId"); + + postJson("/api/v1/projects/" + projectId + "/risk-flags", finance, """ + {"riskType":"LITIGATION","reason":"重复期间", + "effectiveFrom":"2026-06-01T00:00:00+08:00","effectiveUntil":"2027-01-01T00:00:00+08:00", + "blocking":false} + """) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("DUPLICATE_RESOURCE")); + + postJson("/api/v1/projects/" + projectId + "/risk-flags/" + riskId + "/resolve", finance, + "{\"version\":0,\"reason\":\"证据复核完成\"}") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("RESOLVED")) + .andExpect(jsonPath("$.data.version").value(1)); + postJson("/api/v1/projects/" + projectId + "/risk-flags/" + riskId + "/resolve", finance, + "{\"version\":0,\"reason\":\"重复解除\"}") + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("VERSION_CONFLICT")); + + mockMvc.perform(get("/api/v1/projects/{id}", projectId).cookie(finance.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.riskFlags[0].resolvedReason").value("证据复核完成")) + .andExpect(jsonPath("$.data.timeline[?(@.actionCode == 'PROJECT_RISK_FLAG_CREATE')]").exists()) + .andExpect(jsonPath("$.data.timeline[?(@.actionCode == 'PROJECT_RISK_FLAG_RESOLVE')]").exists()); + } + + @Test + void page08SubresourceRoutesUseRealPagedQueriesAndKeepRiskPermissionSeparate() throws Exception { + Fixtures fixtures = fixtures(); + ClientSession projectUser = loginAndSelect("project", "PROJECT_MANAGER"); + ClientSession finance = loginAndSelect("finance", "FINANCE_MANAGER"); + ClientSession archive = loginAndSelect("archive", "ARCHIVE_MANAGER"); + String suffix = nextSuffix(); + MvcResult created = postJson("/api/v1/projects", projectUser, projectPayload( + "SUB-" + suffix, "子资源项目", fixtures, null, null)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.overview.receiptCount").value(0)) + .andExpect(jsonPath("$.data.overview.invoiceCount").value(0)) + .andExpect(jsonPath("$.data.overview.paymentCount").value(0)) + .andExpect(jsonPath("$.data.overview.progressCount").value(0)) + .andExpect(jsonPath("$.data.overview.peopleCount").value(0)) + .andExpect(jsonPath("$.data.overview.accountingCount").value(0)) + .andExpect(jsonPath("$.data.overview.voucherCount").value(0)) + .andReturn(); + String projectId = JsonPath.read(created.getResponse().getContentAsString(), "$.data.publicId"); + String lowercaseProjectId = projectId.toLowerCase(Locale.ROOT); + + // The public-id contract is case-insensitive at URL boundaries. The + // project gate must normalize once and pass the canonical ID to each + // delegated bounded context. + mockMvc.perform(get("/api/v1/projects/{id}/forms", lowercaseProjectId).cookie(finance.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.meta.page").value(1)); + mockMvc.perform(get("/api/v1/projects/{id}/timeline", lowercaseProjectId).cookie(finance.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.meta.page").value(1)); + + mockMvc.perform(get("/api/v1/projects/{id}/forms", projectId).cookie(finance.cookies())) + .andExpect(status().isOk()).andExpect(jsonPath("$.meta.page").value(1)); + mockMvc.perform(get("/api/v1/projects/{id}/contracts", projectId).cookie(finance.cookies())) + .andExpect(status().isOk()).andExpect(jsonPath("$.data.items").isArray()); + mockMvc.perform(get("/api/v1/projects/{id}/receipts", projectId).cookie(finance.cookies())) + .andExpect(status().isOk()).andExpect(jsonPath("$.data").isArray()); + mockMvc.perform(get("/api/v1/projects/{id}/invoices", projectId).cookie(finance.cookies())) + .andExpect(status().isOk()).andExpect(jsonPath("$.data").isArray()); + mockMvc.perform(get("/api/v1/projects/{id}/payments", projectId).cookie(finance.cookies())) + .andExpect(status().isOk()).andExpect(jsonPath("$.data").isArray()); + mockMvc.perform(get("/api/v1/projects/{id}/progress", projectId).cookie(finance.cookies())) + .andExpect(status().isOk()).andExpect(jsonPath("$.data").isArray()); + mockMvc.perform(get("/api/v1/projects/{id}/progress", projectId) + .param("view", "acceptance").cookie(finance.cookies())) + .andExpect(status().isOk()).andExpect(jsonPath("$.data").isArray()); + mockMvc.perform(get("/api/v1/projects/{id}/progress", projectId) + .param("view", "team").cookie(finance.cookies())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("QUERY_PARAMETER_INVALID")); + mockMvc.perform(get("/api/v1/projects/{id}/people", projectId).cookie(finance.cookies())) + .andExpect(status().isOk()).andExpect(jsonPath("$.data").isArray()); + mockMvc.perform(get("/api/v1/projects/{id}/people", projectId) + .param("view", "team").cookie(finance.cookies())) + .andExpect(status().isOk()).andExpect(jsonPath("$.data").isArray()); + mockMvc.perform(get("/api/v1/projects/{id}/people", projectId) + .param("view", "certificate-fees").cookie(finance.cookies())) + .andExpect(status().isOk()).andExpect(jsonPath("$.data").isArray()); + mockMvc.perform(get("/api/v1/projects/{id}/accounting", projectId).cookie(finance.cookies())) + .andExpect(status().isOk()).andExpect(jsonPath("$.data").isArray()); + mockMvc.perform(get("/api/v1/projects/{id}/archives", projectId).cookie(finance.cookies())) + .andExpect(status().isOk()).andExpect(jsonPath("$.data").isArray()); + mockMvc.perform(get("/api/v1/projects/{id}/timeline", projectId).cookie(finance.cookies())) + .andExpect(status().isOk()).andExpect(jsonPath("$.data").isArray()); + mockMvc.perform(get("/api/v1/projects/{id}/risk-flags", projectId).cookie(finance.cookies())) + .andExpect(status().isOk()).andExpect(jsonPath("$.data").isArray()); + + // Archive users can open the project shell and archive tab, but the + // risk subresource remains protected by project:risk-flag:view. + mockMvc.perform(get("/api/v1/projects/{id}/risk-flags", projectId).cookie(archive.cookies())) + .andExpect(status().isForbidden()); + mockMvc.perform(get("/api/v1/projects/{id}/archives", projectId).cookie(archive.cookies())) + .andExpect(status().isOk()); + } + + @Test + void page08ArchiveDrilldownIncludesArchivedPackages() throws Exception { + Fixtures fixtures = fixtures(); + ClientSession projectUser = loginAndSelect("project", "PROJECT_MANAGER"); + ClientSession finance = loginAndSelect("finance", "FINANCE_MANAGER"); + String suffix = nextSuffix(); + String projectId = JsonPath.read(postJson("/api/v1/projects", projectUser, projectPayload( + "ARCHIVE-DRILL-" + suffix, "归档穿透项目 " + suffix, fixtures, null, null)) + .andExpect(status().isOk()).andReturn().getResponse().getContentAsString(), "$.data.publicId"); + + postJson("/api/v1/projects/" + projectId + "/finance-complete", finance, + "{\"version\":0}", idempotencyKey()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.financeStatus").value("COMPLETED")); + + MvcResult packageCreated = postJson("/api/v1/archive/packages", finance, + "{\"projectId\":\"%s\",\"retentionYears\":10}".formatted(projectId), idempotencyKey()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("PREPARING")) + .andReturn(); + String packageId = JsonPath.read(packageCreated.getResponse().getContentAsString(), "$.data.publicId"); + + // Use the persisted terminal state to isolate the read-model contract + // under test; the archive workflow itself is covered by archive tests. + assertEquals(1, jdbcTemplate.update( + "UPDATE arc_package SET status='ARCHIVED', archived_at=UTC_TIMESTAMP(3), updated_at=UTC_TIMESTAMP(3) WHERE public_id=?", + packageId)); + + mockMvc.perform(get("/api/v1/projects/{id}/archives", projectId) + .param("view", "packages").cookie(finance.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[?(@.publicId == '%s')]".formatted(packageId)).exists()); + + // The standalone archive workbench keeps its prepare-only default. + mockMvc.perform(get("/api/v1/archive/packages").param("projectId", projectId) + .param("view", "prepare").cookie(finance.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[?(@.publicId == '%s')]".formatted(packageId)).doesNotExist()); + } + + @Test + void page08SubresourcesRejectKnownProjectsOutsideTheirOwnDomainScope() throws Exception { + Fixtures fixtures = fixtures(); + ClientSession projectUser = loginAndSelect("project", "PROJECT_MANAGER"); + ClientSession finance = loginAndSelect("finance", "FINANCE_MANAGER"); + String suffix = nextSuffix(); + String allowedProjectId = JsonPath.read(postJson("/api/v1/projects", projectUser, projectPayload( + "TAB-SCOPE-A-" + suffix, "页签范围项目 A", fixtures, null, null)) + .andExpect(status().isOk()).andReturn().getResponse().getContentAsString(), "$.data.publicId"); + String deniedProjectId = JsonPath.read(postJson("/api/v1/projects", projectUser, projectPayload( + "TAB-SCOPE-B-" + suffix, "页签范围项目 B", fixtures, null, null)) + .andExpect(status().isOk()).andReturn().getResponse().getContentAsString(), "$.data.publicId"); + + jdbcTemplate.update(""" + DELETE scope + FROM iam_scope scope + JOIN iam_user user_account ON user_account.id = scope.user_id + JOIN iam_role role ON role.id = scope.role_id + JOIN iam_permission permission ON permission.id = scope.permission_id + WHERE user_account.username = 'finance' + AND role.code = 'FINANCE_MANAGER' + AND permission.code = 'receivable:receipt:view' + """); + jdbcTemplate.update(""" + INSERT INTO iam_scope ( + user_id, role_id, permission_id, scope_type, company_public_id, project_public_id, + amount_limit, status + ) + SELECT user_account.id, role.id, permission.id, 'PROJECT', ?, ?, NULL, 'ACTIVE' + FROM iam_user user_account + JOIN iam_role role ON role.code = 'FINANCE_MANAGER' + JOIN iam_permission permission ON permission.code = 'receivable:receipt:view' + WHERE user_account.username = 'finance' + """, fixtures.companyId(), allowedProjectId); + try { + mockMvc.perform(get("/api/v1/projects/{id}/receipts", allowedProjectId).cookie(finance.cookies())) + .andExpect(status().isOk()); + mockMvc.perform(get("/api/v1/projects/{id}/receipts", deniedProjectId).cookie(finance.cookies())) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("DATA_SCOPE_DENIED")); + } finally { + jdbcTemplate.update(""" + DELETE scope + FROM iam_scope scope + JOIN iam_user user_account ON user_account.id = scope.user_id + JOIN iam_role role ON role.id = scope.role_id + JOIN iam_permission permission ON permission.id = scope.permission_id + WHERE user_account.username = 'finance' + AND role.code = 'FINANCE_MANAGER' + AND permission.code = 'receivable:receipt:view' + """); + jdbcTemplate.update(""" + INSERT INTO iam_scope ( + user_id, role_id, permission_id, scope_type, company_public_id, project_public_id, + amount_limit, status + ) + SELECT user_account.id, role.id, permission.id, 'GLOBAL', NULL, NULL, NULL, 'ACTIVE' + FROM iam_user user_account + JOIN iam_role role ON role.code = 'FINANCE_MANAGER' + JOIN iam_permission permission ON permission.code = 'receivable:receipt:view' + WHERE user_account.username = 'finance' + """); + } + } + + @Test + void projectReferencesRespectCompanyAndProjectDataScope() throws Exception { + Fixtures first = fixtures(); + ClientSession projectUser = loginAndSelect("project", "PROJECT_MANAGER"); + String suffix = nextSuffix(); + String firstProjectId = JsonPath.read(postJson("/api/v1/projects", projectUser, + projectPayload("REF-A-" + suffix, "范围项目 A " + suffix, first, null, null)) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(), "$.data.publicId"); + + ClientSession finance = loginAndSelect("finance", "FINANCE_MANAGER"); + ClientSession reviewer = loginAndSelect("demo", "FINANCE_MANAGER"); + MvcResult companyCreated = postJson("/api/v1/masterdata/companies", finance, """ + {"businessNo":"PC-REF-B-%s","name":"范围公司 B %s","taxNo":"PC-REF-TAX-%s"} + """.formatted(suffix, suffix, suffix)).andExpect(status().isOk()).andReturn(); + String secondCompanyId = JsonPath.read(companyCreated.getResponse().getContentAsString(), "$.data.publicId"); + postJson("/api/v1/masterdata/companies/" + secondCompanyId + "/submit", finance, "{\"version\":0}") + .andExpect(status().isOk()); + postJson("/api/v1/masterdata/companies/" + secondCompanyId + "/review", reviewer, + "{\"version\":1,\"decision\":\"APPROVE\",\"opinion\":\"通过\"}") + .andExpect(status().isOk()); + + MvcResult customerCreated = postJson("/api/v1/masterdata/counterparties", finance, """ + {"businessNo":"CU-REF-B-%s","type":"CUSTOMER","name":"范围客户 B %s","taxNo":"CU-REF-TAX-%s"} + """.formatted(suffix, suffix, suffix)).andExpect(status().isOk()).andReturn(); + String secondCustomerId = JsonPath.read(customerCreated.getResponse().getContentAsString(), "$.data.publicId"); + postJson("/api/v1/masterdata/counterparties/" + secondCustomerId + "/submit", finance, "{\"version\":0}") + .andExpect(status().isOk()); + postJson("/api/v1/masterdata/counterparties/" + secondCustomerId + "/review", reviewer, + "{\"version\":1,\"decision\":\"APPROVE\",\"opinion\":\"通过\"}") + .andExpect(status().isOk()); + + String demoOwnerId = jdbcTemplate.queryForObject( + "SELECT public_id FROM iam_user WHERE username = 'demo'", String.class); + String secondProjectId = JsonPath.read(postJson("/api/v1/projects", projectUser, + projectPayload("REF-B-" + suffix, "范围项目 B " + suffix, + new Fixtures(secondCompanyId, secondCustomerId, demoOwnerId), null, null)) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(), "$.data.publicId"); + + jdbcTemplate.update(""" + DELETE scope + FROM iam_scope scope + JOIN iam_user user_account ON user_account.id = scope.user_id + JOIN iam_role role ON role.id = scope.role_id + JOIN iam_permission permission ON permission.id = scope.permission_id + WHERE user_account.username = 'project' + AND role.code = 'PROJECT_MANAGER' + AND permission.code = 'project:project:view' + """); + jdbcTemplate.update(""" + INSERT INTO iam_scope ( + user_id, role_id, permission_id, scope_type, company_public_id, project_public_id, + amount_limit, status + ) + SELECT user_account.id, role.id, permission.id, 'PROJECT', ?, ?, NULL, 'ACTIVE' + FROM iam_user user_account + JOIN iam_role role ON role.code = 'PROJECT_MANAGER' + JOIN iam_permission permission ON permission.code = 'project:project:view' + WHERE user_account.username = 'project' + """, first.companyId(), firstProjectId); + + mockMvc.perform(get("/api/v1/projects/references").cookie(projectUser.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.companies[?(@.publicId == '%s')]".formatted(first.companyId())).exists()) + .andExpect(jsonPath("$.data.companies[?(@.publicId == '%s')]".formatted(secondCompanyId)).doesNotExist()) + .andExpect(jsonPath("$.data.customers[?(@.publicId == '%s')]".formatted(first.customerId())).exists()) + .andExpect(jsonPath("$.data.customers[?(@.publicId == '%s')]".formatted(secondCustomerId)).doesNotExist()) + .andExpect(jsonPath("$.data.owners[?(@.publicId == '%s')]".formatted(first.ownerId())).exists()) + .andExpect(jsonPath("$.data.owners[?(@.publicId == '%s')]".formatted(demoOwnerId)).doesNotExist()); + + jdbcTemplate.update(""" + DELETE scope + FROM iam_scope scope + JOIN iam_user user_account ON user_account.id = scope.user_id + JOIN iam_role role ON role.id = scope.role_id + JOIN iam_permission permission ON permission.id = scope.permission_id + WHERE user_account.username = 'project' + AND role.code = 'PROJECT_MANAGER' + AND permission.code = 'project:project:view' + """); + jdbcTemplate.update(""" + INSERT INTO iam_scope ( + user_id, role_id, permission_id, scope_type, company_public_id, project_public_id, + amount_limit, status + ) + SELECT user_account.id, role.id, permission.id, 'COMPANY', ?, NULL, NULL, 'ACTIVE' + FROM iam_user user_account + JOIN iam_role role ON role.code = 'PROJECT_MANAGER' + JOIN iam_permission permission ON permission.code = 'project:project:view' + WHERE user_account.username = 'project' + """, first.companyId()); + mockMvc.perform(get("/api/v1/projects/references").cookie(projectUser.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.companies[?(@.publicId == '%s')]".formatted(first.companyId())).exists()) + .andExpect(jsonPath("$.data.companies[?(@.publicId == '%s')]".formatted(secondCompanyId)).doesNotExist()) + .andExpect(jsonPath("$.data.customers[?(@.publicId == '%s')]".formatted(first.customerId())).exists()) + .andExpect(jsonPath("$.data.customers[?(@.publicId == '%s')]".formatted(secondCustomerId)).doesNotExist()); + + jdbcTemplate.update(""" + DELETE scope + FROM iam_scope scope + JOIN iam_user user_account ON user_account.id = scope.user_id + JOIN iam_role role ON role.id = scope.role_id + JOIN iam_permission permission ON permission.id = scope.permission_id + WHERE user_account.username = 'project' + AND role.code = 'PROJECT_MANAGER' + AND permission.code = 'project:project:view' + """); + jdbcTemplate.update(""" + INSERT INTO iam_scope ( + user_id, role_id, permission_id, scope_type, company_public_id, project_public_id, + amount_limit, status + ) + SELECT user_account.id, role.id, permission.id, 'GLOBAL', NULL, NULL, NULL, 'ACTIVE' + FROM iam_user user_account + JOIN iam_role role ON role.code = 'PROJECT_MANAGER' + JOIN iam_permission permission ON permission.code = 'project:project:view' + WHERE user_account.username = 'project' + """); + mockMvc.perform(get("/api/v1/projects/references").cookie(projectUser.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.companies[?(@.publicId == '%s')]".formatted(first.companyId())).exists()) + .andExpect(jsonPath("$.data.companies[?(@.publicId == '%s')]".formatted(secondCompanyId)).exists()) + .andExpect(jsonPath("$.data.customers[?(@.publicId == '%s')]".formatted(secondCustomerId)).exists()) + .andExpect(jsonPath("$.data.owners[?(@.publicId == '%s')]".formatted(demoOwnerId)).exists()); + + // A malformed PROJECT grant must not cross the company boundary just + // because the public project id happens to be valid. + jdbcTemplate.update(""" + DELETE scope + FROM iam_scope scope + JOIN iam_user user_account ON user_account.id = scope.user_id + JOIN iam_role role ON role.id = scope.role_id + JOIN iam_permission permission ON permission.id = scope.permission_id + WHERE user_account.username = 'project' + AND role.code = 'PROJECT_MANAGER' + AND permission.code = 'project:project:view' + """); + jdbcTemplate.update(""" + INSERT INTO iam_scope ( + user_id, role_id, permission_id, scope_type, company_public_id, project_public_id, + amount_limit, status + ) + SELECT user_account.id, role.id, permission.id, 'PROJECT', ?, ?, NULL, 'ACTIVE' + FROM iam_user user_account + JOIN iam_role role ON role.code = 'PROJECT_MANAGER' + JOIN iam_permission permission ON permission.code = 'project:project:view' + WHERE user_account.username = 'project' + """, secondCompanyId, firstProjectId); + mockMvc.perform(get("/api/v1/projects/references").cookie(projectUser.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.companies[?(@.publicId == '%s')]".formatted(first.companyId())).doesNotExist()) + .andExpect(jsonPath("$.data.customers[?(@.publicId == '%s')]".formatted(first.customerId())).doesNotExist()) + .andExpect(jsonPath("$.data.owners[?(@.publicId == '%s')]".formatted(first.ownerId())).doesNotExist()); + mockMvc.perform(get("/api/v1/projects").cookie(projectUser.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.meta.totalElements").value(0)); + + assertNotNull(secondProjectId); + } + + @Test + void projectListAndDetailEnforceProjectScope() throws Exception { + Fixtures fixtures = fixtures(); + ClientSession projectUser = loginAndSelect("project", "PROJECT_MANAGER"); + String suffix = nextSuffix(); + MvcResult created = postJson("/api/v1/projects", projectUser, projectPayload( + "SCOPE-" + suffix, "范围项目", fixtures, null, null)) + .andExpect(status().isOk()).andReturn(); + String projectId = JsonPath.read(created.getResponse().getContentAsString(), "$.data.publicId"); + + jdbcTemplate.update(""" + DELETE scope + FROM iam_scope scope + JOIN iam_user user_account ON user_account.id = scope.user_id + JOIN iam_role role ON role.id = scope.role_id + JOIN iam_permission permission ON permission.id = scope.permission_id + WHERE user_account.username = 'project' + AND role.code = 'PROJECT_MANAGER' + AND permission.code = 'project:project:view' + """); + + mockMvc.perform(get("/api/v1/projects/{id}", projectId).cookie(projectUser.cookies())) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("DATA_SCOPE_DENIED")); + mockMvc.perform(get("/api/v1/projects/{id}/forms", projectId).cookie(projectUser.cookies())) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("DATA_SCOPE_DENIED")); + mockMvc.perform(get("/api/v1/projects/{id}/timeline", projectId).cookie(projectUser.cookies())) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("DATA_SCOPE_DENIED")); + mockMvc.perform(get("/api/v1/projects").cookie(projectUser.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.meta.totalElements").value(0)); + } + + @Test + void projectListMasksFinanceAggregatesWithoutDomainViewScopes() throws Exception { + Fixtures fixtures = fixtures(); + ClientSession projectUser = loginAndSelect("project", "PROJECT_MANAGER"); + String suffix = nextSuffix(); + MvcResult created = postJson("/api/v1/projects", projectUser, projectPayload( + "MASK-" + suffix, "金额隔离项目 " + suffix, fixtures, null, null)) + .andExpect(status().isOk()).andReturn(); + String projectId = JsonPath.read(created.getResponse().getContentAsString(), "$.data.publicId"); + long financeUserId = jdbcTemplate.queryForObject( + "SELECT id FROM iam_user WHERE username = 'finance'", Long.class); + + jdbcTemplate.update(""" + UPDATE project_fund_control fund + JOIN md_project project ON project.id = fund.project_id + SET fund.confirmed_receipt = 800.00, fund.paid_amount = 250.00 + WHERE project.public_id = ? + """, projectId); + jdbcTemplate.update(""" + INSERT INTO md_contract + (public_id, company_id, project_id, counterparty_id, business_no, name, + original_amount, approved_change_amount, currency, status, created_by, updated_by) + SELECT ?, project.company_id, project.id, counterparty.id, ?, '金额隔离合同', + 1234.56, 65.44, 'CNY', 'ACTIVE', ?, ? + FROM md_project project + JOIN md_counterparty counterparty ON counterparty.public_id = ? + WHERE project.public_id = ? + """, "00000000000000000000" + suffix, "HT-MASK-" + suffix, + financeUserId, financeUserId, fixtures.customerId(), projectId); + + ClientSession archive = loginAndSelect("archive", "ARCHIVE_MANAGER"); + mockMvc.perform(get("/api/v1/projects") + .param("keyword", suffix).param("page", "1").param("size", "20") + .cookie(archive.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.meta.totalElements").value(1)) + .andExpect(jsonPath("$.data[0].contractAmount").value("0.00")) + .andExpect(jsonPath("$.data[0].receivedAmount").value("0.00")) + .andExpect(jsonPath("$.data[0].paidAmount").value("0.00")); + + ClientSession finance = loginAndSelect("finance", "FINANCE_MANAGER"); + mockMvc.perform(get("/api/v1/projects") + .param("keyword", suffix).param("page", "1").param("size", "20") + .cookie(finance.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[0].contractAmount").value("1300.00")) + .andExpect(jsonPath("$.data[0].receivedAmount").value("800.00")) + .andExpect(jsonPath("$.data[0].paidAmount").value("250.00")); + } + + @Test + void projectExportUsesTheSameFiltersScopeMaskingAndExportLog() throws Exception { + Fixtures fixtures = fixtures(); + ClientSession projectUser = loginAndSelect("project", "PROJECT_MANAGER"); + ClientSession finance = loginAndSelect("finance", "FINANCE_MANAGER"); + String suffix = nextSuffix(); + postJson("/api/v1/projects", projectUser, projectPayload( + "EXPORT-" + suffix, "导出项目 " + suffix, fixtures, null, null)) + .andExpect(status().isOk()); + + MvcResult exported = postJson("/api/v1/projects/exports", finance, """ + { + "keyword":"EXPORT-%s", + "sort":"businessNo,asc", + "columns":["businessNo","name","company","contractAmount","status"] + } + """.formatted(suffix)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.rowCount").value(1)) + .andExpect(jsonPath("$.data.sha256").value(org.hamcrest.Matchers.hasLength(64))) + .andExpect(jsonPath("$.data.filterHash").value(org.hamcrest.Matchers.hasLength(64))) + .andExpect(jsonPath("$.data.content").value(containsString("EXPORT-" + suffix))) + .andExpect(jsonPath("$.data.content").value(containsString("\uFEFF"))) + .andReturn(); + String exportId = JsonPath.read(exported.getResponse().getContentAsString(), "$.data.exportId"); + assertEquals(1, jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM export_log WHERE public_id=? AND resource_type='PROJECT_LIST'", + Integer.class, exportId)); + assertEquals(64, jdbcTemplate.queryForObject( + "SELECT CHAR_LENGTH(sha256) FROM export_log WHERE public_id=?", Integer.class, exportId)); + } + + @Test + void projectExportNeutralizesSpreadsheetFormulaCells() throws Exception { + Fixtures fixtures = fixtures(); + ClientSession projectUser = loginAndSelect("project", "PROJECT_MANAGER"); + ClientSession finance = loginAndSelect("finance", "FINANCE_MANAGER"); + String suffix = nextSuffix(); + String formulaName = "=1+1-" + suffix; + postJson("/api/v1/projects", projectUser, projectPayload( + "FORMULA-" + suffix, formulaName, fixtures, null, null)) + .andExpect(status().isOk()); + + postJson("/api/v1/projects/exports", finance, """ + { + "keyword":"FORMULA-%s", + "columns":["businessNo","name"] + } + """.formatted(suffix)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.content").value(containsString("\"'=1+1-" + suffix))); + } + + @Test + void projectExportHonorsItsOwnProjectScopeInsteadOfOnlyTheListViewScope() throws Exception { + Fixtures fixtures = fixtures(); + ClientSession projectUser = loginAndSelect("project", "PROJECT_MANAGER"); + String suffix = nextSuffix(); + String firstProjectId = JsonPath.read(postJson("/api/v1/projects", projectUser, projectPayload( + "EXPORT-SCOPE-A-" + suffix, "导出范围项目 " + suffix, fixtures, null, null)) + .andExpect(status().isOk()).andReturn().getResponse().getContentAsString(), "$.data.publicId"); + postJson("/api/v1/projects", projectUser, projectPayload( + "EXPORT-SCOPE-B-" + suffix, "导出范围项目 " + suffix, fixtures, null, null)) + .andExpect(status().isOk()); + + jdbcTemplate.update(""" + DELETE scope + FROM iam_scope scope + JOIN iam_user user_account ON user_account.id = scope.user_id + JOIN iam_role role ON role.id = scope.role_id + JOIN iam_permission permission ON permission.id = scope.permission_id + WHERE user_account.username = 'project' + AND role.code = 'PROJECT_MANAGER' + AND permission.code = 'project:project:export' + """); + jdbcTemplate.update(""" + INSERT INTO iam_scope ( + user_id, role_id, permission_id, scope_type, company_public_id, project_public_id, + amount_limit, status + ) + SELECT user_account.id, role.id, permission.id, 'PROJECT', ?, ?, NULL, 'ACTIVE' + FROM iam_user user_account + JOIN iam_role role ON role.code = 'PROJECT_MANAGER' + JOIN iam_permission permission ON permission.code = 'project:project:export' + WHERE user_account.username = 'project' + """, fixtures.companyId(), firstProjectId); + + mockMvc.perform(get("/api/v1/projects") + .param("keyword", "导出范围项目 " + suffix).cookie(projectUser.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.meta.totalElements").value(2)); + postJson("/api/v1/projects/exports", projectUser, """ + {"keyword":"导出范围项目 %s","columns":["businessNo"]} + """.formatted(suffix)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.rowCount").value(1)) + .andExpect(jsonPath("$.data.content").value(containsString("EXPORT-SCOPE-A-" + suffix))) + .andExpect(jsonPath("$.data.content").value(org.hamcrest.Matchers.not( + containsString("EXPORT-SCOPE-B-" + suffix)))); + } + + @Test + void projectImportStagesValidatesAndConfirmsProjectsAtomically() throws Exception { + Fixtures fixtures = fixtures(); + ClientSession projectUser = loginAndSelect("project", "PROJECT_MANAGER"); + ClientSession admin = loginAndSelect("admin", "SYSTEM_ADMIN"); + String suffix = nextSuffix(); + String csv = "项目编号,项目名称,项目说明,公司名称或公共ID,客户名称或公共ID,负责人名称或公共ID,开始日期,结束日期,币种\n" + + "PIMP-" + suffix + ",项目导入测试 " + suffix + ",两阶段项目导入," + + fixtures.companyId() + "," + fixtures.customerId() + "," + fixtures.ownerId() + + ",2026-09-01,2027-08-31,CNY\n"; + MvcResult upload = mockMvc.perform(multipart("/api/v1/files") + .file(new MockMultipartFile("file", "project-import-" + suffix + ".csv", "text/csv", csv.getBytes())) + .cookie(projectUser.cookies()) + .header("X-XSRF-TOKEN", projectUser.csrfToken()) + .header("Idempotency-Key", idempotencyKey())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.scanStatus").value("AVAILABLE")) + .andReturn(); + String fileId = JsonPath.read(upload.getResponse().getContentAsString(), "$.data.publicId"); + String sha256 = JsonPath.read(upload.getResponse().getContentAsString(), "$.data.sha256"); + + MvcResult batch = postJson("/api/v1/projects/imports", projectUser, """ + { + "sourceFileId":"%s", + "fileSha256":"%s" + } + """.formatted(fileId, sha256), "project-import-create-" + suffix) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("READY")) + .andExpect(jsonPath("$.data.validRows").value(1)) + .andExpect(jsonPath("$.data.errorRows").value(0)) + .andReturn(); + String batchId = JsonPath.read(batch.getResponse().getContentAsString(), "$.data.publicId"); + + mockMvc.perform(get("/api/v1/projects/imports/{id}", batchId).cookie(admin.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("READY")); + + String confirmKey = "project-import-confirm-" + suffix; + MvcResult confirmed = postJson("/api/v1/projects/imports/" + batchId + "/confirm", admin, + "{\"version\":0}", confirmKey) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.length()").value(1)) + .andExpect(jsonPath("$.data[0].businessNo").value("PIMP-" + suffix)) + .andExpect(jsonPath("$.data[0].status").value("DRAFT")) + .andReturn(); + String projectId = JsonPath.read(confirmed.getResponse().getContentAsString(), "$.data[0].publicId"); + assertEquals(1, jdbcTemplate.queryForObject(""" + SELECT COUNT(*) FROM project_fund_control control + JOIN md_project project ON project.id = control.project_id + WHERE project.public_id = ? AND control.currency = 'CNY' + """, Integer.class, projectId)); + mockMvc.perform(get("/api/v1/projects/imports/{id}", batchId).cookie(projectUser.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("CONFIRMED")) + .andExpect(jsonPath("$.data.version").value(1)); + postJson("/api/v1/projects/imports/" + batchId + "/confirm", admin, "{\"version\":0}", confirmKey) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[0].publicId").value(projectId)); + postJson("/api/v1/projects/imports/" + batchId + "/confirm", admin, "{\"version\":0}", + "project-import-confirm-retry-" + suffix) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("VERSION_CONFLICT")); + } + + @Test + void projectImportRejectsMalformedUtf8BeforeCreatingBatch() throws Exception { + ClientSession projectUser = loginAndSelect("project", "PROJECT_MANAGER"); + String suffix = nextSuffix(); + byte[] prefix = ("项目编号,项目名称,公司,负责人,币种\nBAD-" + suffix + ",") + .getBytes(StandardCharsets.UTF_8); + byte[] malformed = java.util.Arrays.copyOf(prefix, prefix.length + 2); + malformed[prefix.length] = (byte) 0xC3; + malformed[prefix.length + 1] = 0x28; + MvcResult upload = uploadCsv(projectUser, "project-import-malformed-" + suffix + ".csv", malformed); + String fileId = JsonPath.read(upload.getResponse().getContentAsString(), "$.data.publicId"); + String sha256 = JsonPath.read(upload.getResponse().getContentAsString(), "$.data.sha256"); + + postJson("/api/v1/projects/imports", projectUser, """ + {"sourceFileId":"%s","fileSha256":"%s"} + """.formatted(fileId, sha256), "project-import-malformed-" + suffix) + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("VALIDATION_FAILED")) + .andExpect(jsonPath("$.detail").value(containsString("UTF-8"))); + assertEquals(0, jdbcTemplate.queryForObject(""" + SELECT COUNT(*) FROM project_import_batch batch + JOIN file_object file ON file.id = batch.source_file_id + WHERE file.public_id = ? + """, Integer.class, fileId)); + } + + @Test + void projectImportTurnsOversizedCellsIntoRowIssuesInsteadOfDatabaseErrors() throws Exception { + Fixtures fixtures = fixtures(); + ClientSession projectUser = loginAndSelect("project", "PROJECT_MANAGER"); + String suffix = nextSuffix(); + String csv = "项目编号,项目名称,项目说明,公司名称或公共ID,负责人名称或公共ID,币种\n" + + "B".repeat(65) + "," + "N".repeat(201) + "," + "D".repeat(1001) + "," + + fixtures.companyId() + "," + fixtures.ownerId() + ",CNY\n"; + MvcResult upload = uploadCsv(projectUser, "project-import-long-" + suffix + ".csv", + csv.getBytes(StandardCharsets.UTF_8)); + String fileId = JsonPath.read(upload.getResponse().getContentAsString(), "$.data.publicId"); + String sha256 = JsonPath.read(upload.getResponse().getContentAsString(), "$.data.sha256"); + + postJson("/api/v1/projects/imports", projectUser, """ + {"sourceFileId":"%s","fileSha256":"%s"} + """.formatted(fileId, sha256), "project-import-long-fields-" + suffix) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("INVALID")) + .andExpect(jsonPath("$.data.validRows").value(0)) + .andExpect(jsonPath("$.data.errorRows").value(1)) + .andExpect(jsonPath("$.data.issues[?(@.fieldCode == 'businessNo')].message", + hasItem("项目编号最多 64 个字符"))) + .andExpect(jsonPath("$.data.issues[?(@.fieldCode == 'name')].message", + hasItem("项目名称最多 200 个字符"))) + .andExpect(jsonPath("$.data.issues[?(@.fieldCode == 'description')].message", + hasItem("项目说明最多 1000 个字符"))); + } + + private Fixtures fixtures() throws Exception { + ClientSession finance = loginAndSelect("finance", "FINANCE_MANAGER"); + ClientSession reviewer = loginAndSelect("demo", "FINANCE_MANAGER"); + String suffix = nextSuffix(); + + MvcResult companyCreated = postJson("/api/v1/masterdata/companies", finance, """ + {"businessNo":"PC-%s","name":"项目测试公司 %s","taxNo":"PC-TAX-%s"} + """.formatted(suffix, suffix, suffix)).andExpect(status().isOk()).andReturn(); + String companyId = JsonPath.read(companyCreated.getResponse().getContentAsString(), "$.data.publicId"); + postJson("/api/v1/masterdata/companies/" + companyId + "/submit", finance, "{\"version\":0}") + .andExpect(status().isOk()); + postJson("/api/v1/masterdata/companies/" + companyId + "/review", reviewer, + "{\"version\":1,\"decision\":\"APPROVE\",\"opinion\":\"通过\"}") + .andExpect(status().isOk()); + + MvcResult customerCreated = postJson("/api/v1/masterdata/counterparties", finance, """ + {"businessNo":"CU-%s","type":"CUSTOMER","name":"测试客户 %s","taxNo":"CU-TAX-%s"} + """.formatted(suffix, suffix, suffix)).andExpect(status().isOk()).andReturn(); + String customerId = JsonPath.read(customerCreated.getResponse().getContentAsString(), "$.data.publicId"); + postJson("/api/v1/masterdata/counterparties/" + customerId + "/submit", finance, "{\"version\":0}") + .andExpect(status().isOk()); + postJson("/api/v1/masterdata/counterparties/" + customerId + "/review", reviewer, + "{\"version\":1,\"decision\":\"APPROVE\",\"opinion\":\"通过\"}") + .andExpect(status().isOk()); + + String ownerId = jdbcTemplate.queryForObject( + "SELECT public_id FROM iam_user WHERE username = 'project'", String.class); + return new Fixtures(companyId, customerId, ownerId); + } + + private MvcResult uploadCsv(ClientSession session, String fileName, byte[] content) throws Exception { + return mockMvc.perform(multipart("/api/v1/files") + .file(new MockMultipartFile("file", fileName, "text/csv", content)) + .cookie(session.cookies()) + .header("X-XSRF-TOKEN", session.csrfToken()) + .header("Idempotency-Key", idempotencyKey())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.scanStatus").value("AVAILABLE")) + .andReturn(); + } + + private String projectPayload(String businessNo, String name, Fixtures fixtures, + String startDate, String endDate) { + String dates = startDate == null ? "" : ",\"startDate\":\"%s\",\"endDate\":\"%s\"".formatted(startDate, endDate); + return """ + {"businessNo":"%s","name":"%s","description":"项目测试说明","companyId":"%s", + "customerId":"%s","ownerId":"%s","currency":"CNY"%s} + """.formatted(businessNo, name, fixtures.companyId(), fixtures.customerId(), fixtures.ownerId(), dates); + } + + private org.springframework.test.web.servlet.ResultActions postJson(String path, ClientSession session, + String json) throws Exception { + return postJson(path, session, json, null); + } + + private org.springframework.test.web.servlet.ResultActions postJson(String path, ClientSession session, + String json, String idempotencyKey) throws Exception { + var builder = post(path) + .cookie(session.cookies()) + .header("X-XSRF-TOKEN", session.csrfToken()) + .contentType("application/json") + .content(json); + if (idempotencyKey != null) builder.header("Idempotency-Key", idempotencyKey); + return mockMvc.perform(builder); + } + + private org.springframework.test.web.servlet.ResultActions patchJson(String path, ClientSession session, + String json) throws Exception { + return mockMvc.perform(patch(path) + .cookie(session.cookies()) + .header("X-XSRF-TOKEN", session.csrfToken()) + .contentType("application/json") + .content(json)); + } + + private ClientSession loginAndSelect(String username, String role) throws Exception { + MvcResult csrf = mockMvc.perform(get("/api/v1/auth/csrf")).andExpect(status().isOk()).andReturn(); + Cookie csrfCookie = csrf.getResponse().getCookie("XSRF-TOKEN"); + assertNotNull(csrfCookie); + String csrfToken = JsonPath.read(csrf.getResponse().getContentAsString(), "$.data.token"); + MvcResult login = mockMvc.perform(post("/api/v1/auth/login") + .cookie(csrfCookie) + .header("X-XSRF-TOKEN", csrfToken) + .contentType("application/json") + .content("{\"username\":\"%s\",\"password\":\"LocalOnly@123\"}".formatted(username))) + .andExpect(status().isOk()).andReturn(); + Cookie sessionCookie = login.getResponse().getCookie("KAIDI_SESSION"); + assertNotNull(sessionCookie); + MvcResult selected = mockMvc.perform(post("/api/v1/auth/select-role") + .cookie(sessionCookie, csrfCookie) + .header("X-XSRF-TOKEN", csrfToken) + .contentType("application/json") + .content("{\"roleCode\":\"%s\"}".formatted(role))) + .andExpect(status().isOk()).andReturn(); + Cookie rotated = selected.getResponse().getCookie("KAIDI_SESSION"); + return new ClientSession(rotated == null ? sessionCookie : rotated, csrfCookie, csrfToken); + } + + private String nextSuffix() { + return "%06d".formatted(SEQUENCE.incrementAndGet()); + } + + private record Fixtures(String companyId, String customerId, String ownerId) { + } + + private record ClientSession(Cookie sessionCookie, Cookie csrfCookie, String csrfToken) { + Cookie[] cookies() { + return new Cookie[]{sessionCookie, csrfCookie}; + } + } + + @TestConfiguration(proxyBeanMethods = false) + static class ScannerTestConfiguration { + + @Bean + @Primary + TestFileScanner testFileScanner() { + return new TestFileScanner(); + } + } + + static final class TestFileScanner implements FileScanner { + + @Override + public ScanResult scan(Path path) { + return ScanResult.clean("test scanner: clean"); + } + } +} diff --git a/backend/src/test/java/com/kaidi/finance/receivable/ReceivableIntegrationTest.java b/backend/src/test/java/com/kaidi/finance/receivable/ReceivableIntegrationTest.java new file mode 100644 index 0000000..f0f43c8 --- /dev/null +++ b/backend/src/test/java/com/kaidi/finance/receivable/ReceivableIntegrationTest.java @@ -0,0 +1,444 @@ +package com.kaidi.finance.receivable; + +import static org.hamcrest.Matchers.hasItem; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.jayway.jsonpath.JsonPath; +import jakarta.servlet.http.Cookie; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.ResultActions; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +@Testcontainers(disabledWithoutDocker = true) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class ReceivableIntegrationTest { + + private static String idempotencyKey() { + return "test-receivable-" + java.util.UUID.randomUUID(); + } + + @Container + static final MySQLContainer MYSQL = new MySQLContainer<>("mysql:8.4") + .withDatabaseName("kaidi_finance_receivable_test") + .withUsername("kaidi") + .withPassword("kaidi_test_password"); + + @DynamicPropertySource + static void database(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", MYSQL::getJdbcUrl); + registry.add("spring.datasource.username", MYSQL::getUsername); + registry.add("spring.datasource.password", MYSQL::getPassword); + registry.add("finance.bootstrap.enabled", () -> true); + registry.add("finance.bootstrap.password", () -> "LocalOnly@123"); + } + + @Autowired MockMvc mockMvc; + @Autowired JdbcTemplate jdbcTemplate; + + @BeforeEach + void seed() { + Integer count = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM md_company WHERE business_no = 'GS-P14'", Integer.class); + if (count != null && count > 0) return; + long userId = jdbcTemplate.queryForObject("SELECT id FROM iam_user WHERE username = 'finance'", Long.class); + jdbcTemplate.update("INSERT INTO md_company (public_id,business_no,name,status,created_by,updated_by) VALUES ('00000000000000000000001401','GS-P14','收款测试公司','ACTIVE',?,?)", userId, userId); + jdbcTemplate.update("INSERT INTO md_counterparty (public_id,business_no,counterparty_type,name,status,created_by,updated_by) VALUES ('00000000000000000000001402','GY-P14','SUPPLIER','收款测试单位','ACTIVE',?,?)", userId, userId); + jdbcTemplate.update("INSERT INTO md_project (public_id,company_id,business_no,name,owner_user_id,currency,status,created_by,updated_by) SELECT '00000000000000000000001403',id,'XM-P14','收款测试项目',?,'CNY','ACTIVE',?,? FROM md_company WHERE business_no='GS-P14'", userId, userId, userId); + jdbcTemplate.update("INSERT INTO md_contract (public_id,company_id,project_id,counterparty_id,business_no,name,original_amount,approved_change_amount,currency,status,created_by,updated_by) SELECT '00000000000000000000001404',c.id,p.id,x.id,'HT-P14','收款测试合同',10000,0,'CNY','ACTIVE',?,? FROM md_company c JOIN md_project p ON p.company_id=c.id JOIN md_counterparty x ON x.public_id='00000000000000000000001402' WHERE c.business_no='GS-P14'", userId, userId); + } + + @Test + void receiptAndInvoiceCommandsKeepStateAndAccountingEvent() throws Exception { + ClientSession finance = loginAndSelect("finance", "FINANCE_MANAGER"); + ClientSession demo = loginAndSelect("demo", "FINANCE_MANAGER"); + String invoiceId = issueInvoice(finance, demo, "1000", "FP-P14-001", "2026-08-02"); + String receivableId = jdbcTemplate.queryForObject( + "SELECT public_id FROM fin_receivable WHERE source_invoice_id = (SELECT id FROM fin_invoice WHERE public_id = ?)", + String.class, invoiceId); + assertNotNull(receivableId); + mockMvc.perform(get("/api/v1/receivables").param("status", "OPEN").cookie(finance.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[*].publicId", hasItem(receivableId))) + .andExpect(jsonPath("$.data[?(@.publicId == '%s')].outstandingAmount".formatted(receivableId), hasItem("1000.00"))); + + String body = """ + {"companyId":"00000000000000000000001401","projectId":"00000000000000000000001403","customerId":"00000000000000000000001402","receiptDate":"2026-08-01","amount":1000,"currency":"CNY","payerName":"测试客户","referenceNo":"BANK-14"} + """; + String receiptId = JsonPath.read(mockMvc.perform(post("/api/v1/receipts").cookie(demo.cookies()).header("Idempotency-Key", idempotencyKey()).header("X-XSRF-TOKEN", demo.token()) + .contentType("application/json").content(body)).andExpect(status().isOk()).andReturn().getResponse().getContentAsString(), "$.data.publicId"); + mockMvc.perform(get("/api/v1/receipts") + .param("counterpartyId", "00000000000000000000001402") + .param("page", "1").param("size", "20").cookie(finance.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[*].publicId", hasItem(receiptId))) + .andExpect(jsonPath("$.meta.totalElements").isNumber()); + String confirmKey = idempotencyKey(); + mockMvc.perform(post("/api/v1/receipts/{id}/confirm", receiptId).cookie(finance.cookies()).header("Idempotency-Key", confirmKey).header("X-XSRF-TOKEN", finance.token()) + .contentType("application/json").content("{\"version\":0}")) + .andExpect(status().isOk()).andExpect(jsonPath("$.data.status").value("CONFIRMED")) + .andExpect(jsonPath("$.data.allowedActions", hasItem("ALLOCATE"))) + .andExpect(jsonPath("$.data.allowedActions", hasItem("VOID_RECEIPT"))); + mockMvc.perform(post("/api/v1/receipts/{id}/confirm", receiptId).cookie(finance.cookies()).header("Idempotency-Key", confirmKey).header("X-XSRF-TOKEN", finance.token()) + .contentType("application/json").content("{\"version\":0}")) + .andExpect(status().isOk()).andExpect(jsonPath("$.data.status").value("CONFIRMED")); + assertEquals(1, jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM fund_ledger WHERE source_public_id=? AND entry_type='RECEIPT_CONFIRMED'", + Integer.class, receiptId)); + mockMvc.perform(post("/api/v1/receipts/{id}/allocations", receiptId).cookie(finance.cookies()).header("Idempotency-Key", idempotencyKey()).header("X-XSRF-TOKEN", finance.token()) + .contentType("application/json").content("{\"receivableId\":\"%s\",\"amount\":600,\"version\":1}".formatted(receivableId))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.receipt.status").value("PARTIALLY_ALLOCATED")) + .andExpect(jsonPath("$.data.allocations[0].receivable.publicId").value(receivableId)) + .andExpect(jsonPath("$.data.allocations[0].sequenceNo").value(1)) + .andExpect(jsonPath("$.data.allocations[0].receivableOutstandingAmount").value("400.00")); + assertEquals(new java.math.BigDecimal("400.00"), jdbcTemplate.queryForObject( + "SELECT outstanding_amount FROM fin_receivable WHERE public_id = ?", java.math.BigDecimal.class, receivableId)); + assertTrue(jdbcTemplate.queryForObject("SELECT COUNT(*) FROM acc_event WHERE source_public_id = ?", Integer.class, receiptId) > 0); + assertTrue(jdbcTemplate.queryForObject("SELECT COUNT(*) FROM acc_event WHERE source_public_id = ?", Integer.class, invoiceId) > 0); + assertEquals(new java.math.BigDecimal("1000.00"), jdbcTemplate.queryForObject( + "SELECT delta_amount FROM fund_ledger WHERE source_public_id=? AND entry_type='RECEIPT_CONFIRMED'", + java.math.BigDecimal.class, receiptId)); + } + + @Test + void receiptCommandsEnforceSeparationAllocationLimitAndVersion() throws Exception { + ClientSession submitter = loginAndSelect("finance", "FINANCE_MANAGER"); + ClientSession reviewer = loginAndSelect("demo", "FINANCE_MANAGER"); + String invoiceId = issueInvoice(submitter, reviewer, "100", "FP-P14-ALLOC-LIMIT", "2026-08-04"); + String receivableId = jdbcTemplate.queryForObject( + "SELECT public_id FROM fin_receivable WHERE source_invoice_id = (SELECT id FROM fin_invoice WHERE public_id = ?)", + String.class, invoiceId); + ClientSession creator = loginAndSelect("demo", "FINANCE_MANAGER"); + String receiptBody = """ + {"companyId":"00000000000000000000001401","projectId":"00000000000000000000001403","customerId":"00000000000000000000001402","receiptDate":"2026-08-04","amount":100,"currency":"CNY","payerName":"测试客户","referenceNo":"BANK-14-NEG"} + """; + String receiptId = JsonPath.read(mockMvc.perform(post("/api/v1/receipts").cookie(creator.cookies()) + .header("Idempotency-Key", idempotencyKey()).header("X-XSRF-TOKEN", creator.token()).contentType("application/json").content(receiptBody)) + .andExpect(status().isOk()).andReturn().getResponse().getContentAsString(), "$.data.publicId"); + + mockMvc.perform(post("/api/v1/receipts/{id}/confirm", receiptId).cookie(creator.cookies()) + .header("Idempotency-Key", idempotencyKey()).header("X-XSRF-TOKEN", creator.token()).contentType("application/json") + .content("{\"version\":0}")) + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("SOD_VIOLATION")); + + ClientSession confirmer = loginAndSelect("finance", "FINANCE_MANAGER"); + mockMvc.perform(post("/api/v1/receipts/{id}/confirm", receiptId).cookie(confirmer.cookies()) + .header("Idempotency-Key", idempotencyKey()).header("X-XSRF-TOKEN", confirmer.token()).contentType("application/json") + .content("{\"version\":0}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("CONFIRMED")); + + mockMvc.perform(post("/api/v1/receipts/{id}/allocations", receiptId).cookie(confirmer.cookies()) + .header("Idempotency-Key", idempotencyKey()).header("X-XSRF-TOKEN", confirmer.token()).contentType("application/json") + .content("{\"receivableId\":\"%s\",\"amount\":100.01,\"version\":1}".formatted(receivableId))) + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("VALIDATION_FAILED")); + + mockMvc.perform(post("/api/v1/receipts/{id}/allocations", receiptId).cookie(confirmer.cookies()) + .header("Idempotency-Key", idempotencyKey()).header("X-XSRF-TOKEN", confirmer.token()).contentType("application/json") + .content("{\"receivableId\":\"%s\",\"amount\":50,\"version\":0}".formatted(receivableId))) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("VERSION_CONFLICT")); + + String voidableId = createReceipt(creator, "BANK-14-VOID", "50", "2026-08-08"); + mockMvc.perform(post("/api/v1/receipts/{id}/confirm", voidableId).cookie(confirmer.cookies()) + .header("Idempotency-Key", idempotencyKey()).header("X-XSRF-TOKEN", confirmer.token()).contentType("application/json").content("{\"version\":0}")) + .andExpect(status().isOk()); + mockMvc.perform(post("/api/v1/receipts/{id}/void", voidableId).cookie(confirmer.cookies()) + .header("Idempotency-Key", idempotencyKey()).header("X-XSRF-TOKEN", confirmer.token()).contentType("application/json") + .content("{\"version\":1,\"reason\":\"重复到账,人工确认作废\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("VOID")) + .andExpect(jsonPath("$.data.voidReason").value("重复到账,人工确认作废")); + assertEquals("VOID", jdbcTemplate.queryForObject( + "SELECT status FROM acc_event WHERE source_public_id = ?", String.class, voidableId)); + assertEquals(2, jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM fund_ledger WHERE source_public_id=?", Integer.class, voidableId)); + assertEquals(new java.math.BigDecimal("0.00"), jdbcTemplate.queryForObject( + "SELECT SUM(delta_amount) FROM fund_ledger WHERE source_public_id=?", + java.math.BigDecimal.class, voidableId)); + } + + @Test + void receiptAllocationsSupportManyToManySettlement() throws Exception { + ClientSession finance = loginAndSelect("finance", "FINANCE_MANAGER"); + ClientSession demo = loginAndSelect("demo", "FINANCE_MANAGER"); + String firstInvoice = issueInvoice(finance, demo, "400", "FP-P14-MULTI-1", "2026-08-09"); + String secondInvoice = issueInvoice(finance, demo, "600", "FP-P14-MULTI-2", "2026-08-10"); + String firstReceivable = receivableForInvoice(firstInvoice); + String secondReceivable = receivableForInvoice(secondInvoice); + + String firstReceipt = createReceipt(demo, "BANK-14-MULTI-1", "700", "2026-08-11"); + confirmReceipt(firstReceipt, finance, 0); + allocate(firstReceipt, firstReceivable, "300", 1, finance) + .andExpect(jsonPath("$.data.receipt.status").value("PARTIALLY_ALLOCATED")); + allocate(firstReceipt, secondReceivable, "400", 2, finance) + .andExpect(jsonPath("$.data.receipt.status").value("ALLOCATED")); + + String secondReceipt = createReceipt(demo, "BANK-14-MULTI-2", "300", "2026-08-12"); + confirmReceipt(secondReceipt, finance, 0); + allocate(secondReceipt, firstReceivable, "100", 1, finance); + allocate(secondReceipt, secondReceivable, "200", 2, finance) + .andExpect(jsonPath("$.data.receipt.status").value("ALLOCATED")); + + assertEquals(2, jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM fin_receipt_allocation WHERE receivable_id = (SELECT id FROM fin_receivable WHERE public_id = ?)", + Integer.class, firstReceivable)); + assertEquals(0, jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM fin_receivable WHERE public_id IN (?, ?) AND status <> 'SETTLED'", + Integer.class, firstReceivable, secondReceivable)); + } + + @Test + void invoiceCommandsEnforceReviewSeparationAndPreventRepeatedRedLetter() throws Exception { + ClientSession submitter = loginAndSelect("finance", "FINANCE_MANAGER"); + String invoiceBody = "{" + + "\"companyId\":\"00000000000000000000001401\",\"projectId\":\"00000000000000000000001403\",\"contractId\":\"00000000000000000000001404\"," + + "\"counterpartyId\":\"00000000000000000000001402\",\"invoiceType\":\"SPECIAL\",\"amount\":200,\"taxRate\":0.13,\"requestedDate\":\"2026-08-05\"}"; + String invoiceId = JsonPath.read(mockMvc.perform(post("/api/v1/invoices").cookie(submitter.cookies()) + .header("Idempotency-Key", idempotencyKey()).header("X-XSRF-TOKEN", submitter.token()).contentType("application/json").content(invoiceBody)) + .andExpect(status().isOk()).andReturn().getResponse().getContentAsString(), "$.data.publicId"); + mockMvc.perform(post("/api/v1/invoices/{id}/submit", invoiceId).cookie(submitter.cookies()) + .header("Idempotency-Key", idempotencyKey()).header("X-XSRF-TOKEN", submitter.token()).contentType("application/json") + .content("{\"version\":0}")) + .andExpect(status().isOk()).andExpect(jsonPath("$.data.status").value("REVIEWING")); + + mockMvc.perform(post("/api/v1/invoices/{id}/review", invoiceId).cookie(submitter.cookies()) + .header("Idempotency-Key", idempotencyKey()).header("X-XSRF-TOKEN", submitter.token()).contentType("application/json") + .content("{\"version\":1,\"decision\":\"APPROVE\",\"opinion\":\"同人不应复核\"}")) + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("SOD_VIOLATION")); + + ClientSession reviewer = loginAndSelect("demo", "FINANCE_MANAGER"); + mockMvc.perform(post("/api/v1/invoices/{id}/review", invoiceId).cookie(reviewer.cookies()) + .header("Idempotency-Key", idempotencyKey()).header("X-XSRF-TOKEN", reviewer.token()).contentType("application/json") + .content("{\"version\":1,\"decision\":\"APPROVE\",\"opinion\":\"通过\"}")) + .andExpect(status().isOk()).andExpect(jsonPath("$.data.status").value("RESULT_PENDING")); + mockMvc.perform(post("/api/v1/invoices/{id}/record-result", invoiceId).cookie(submitter.cookies()) + .header("Idempotency-Key", idempotencyKey()).header("X-XSRF-TOKEN", submitter.token()).contentType("application/json") + .content("{\"version\":2,\"invoiceNo\":\"FP-P14-NEG-001\",\"issuedDate\":\"2026-08-06\"}")) + .andExpect(status().isOk()).andExpect(jsonPath("$.data.status").value("ISSUED")); + String redLetterId = JsonPath.read(mockMvc.perform(post("/api/v1/invoices/{id}/red-letter", invoiceId).cookie(reviewer.cookies()) + .header("Idempotency-Key", idempotencyKey()).header("X-XSRF-TOKEN", reviewer.token()).contentType("application/json") + .content("{\"version\":3,\"invoiceNo\":\"FP-P14-NEG-RED-001\",\"issuedDate\":\"2026-08-07\",\"reason\":\"测试红冲\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.originalInvoiceId").value(invoiceId)) + .andReturn().getResponse().getContentAsString(), "$.data.publicId"); + assertEquals("VOID", jdbcTemplate.queryForObject( + "SELECT status FROM fin_receivable WHERE source_invoice_id = (SELECT id FROM fin_invoice WHERE public_id = ?)", + String.class, invoiceId)); + assertEquals("VOID", jdbcTemplate.queryForObject( + "SELECT status FROM acc_event WHERE source_public_id = ?", String.class, invoiceId)); + assertEquals(0, jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM acc_event WHERE source_public_id = ?", Integer.class, redLetterId)); + + String original = mockMvc.perform(get("/api/v1/invoices/{id}", invoiceId).cookie(reviewer.cookies())) + .andExpect(status().isOk()).andReturn().getResponse().getContentAsString(); + Number currentVersion = JsonPath.read(original, "$.data.version"); + mockMvc.perform(post("/api/v1/invoices/{id}/red-letter", invoiceId).cookie(reviewer.cookies()) + .header("Idempotency-Key", idempotencyKey()).header("X-XSRF-TOKEN", reviewer.token()).contentType("application/json") + .content("{\"version\":%d,\"invoiceNo\":\"FP-P14-NEG-RED-002\",\"issuedDate\":\"2026-08-07\",\"reason\":\"重复红冲\"}".formatted(currentVersion.longValue()))) + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("INVALID_STATE_TRANSITION")); + } + + @Test + void invoiceAndReceiptControlsRejectContractOverrunAndDuplicateExternalNumbers() throws Exception { + ClientSession finance = loginAndSelect("finance", "FINANCE_MANAGER"); + ClientSession demo = loginAndSelect("demo", "FINANCE_MANAGER"); + + String overLimit = createInvoice(finance, "20000", "2026-08-13"); + submitInvoice(overLimit, finance, 0); + mockMvc.perform(post("/api/v1/invoices/{id}/review", overLimit).cookie(demo.cookies()) + .header("Idempotency-Key", idempotencyKey()).header("X-XSRF-TOKEN", demo.token()).contentType("application/json") + .content("{\"version\":1,\"decision\":\"APPROVE\",\"opinion\":\"额度测试\"}")) + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("CONTRACT_LIMIT_EXCEEDED")); + + issueInvoice(finance, demo, "50", "FP-P14-DUPLICATE", "2026-08-14"); + String duplicateInvoice = createInvoice(finance, "50", "2026-08-15"); + submitInvoice(duplicateInvoice, finance, 0); + reviewInvoice(duplicateInvoice, demo, 1); + mockMvc.perform(post("/api/v1/invoices/{id}/record-result", duplicateInvoice).cookie(finance.cookies()) + .header("Idempotency-Key", idempotencyKey()).header("X-XSRF-TOKEN", finance.token()).contentType("application/json") + .content("{\"version\":2,\"invoiceNo\":\"FP-P14-DUPLICATE\",\"issuedDate\":\"2026-08-15\"}")) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("DUPLICATE_RESOURCE")); + + createReceipt(demo, "BANK-P14-DUPLICATE", "10", "2026-08-16"); + String duplicateReceiptBody = """ + {"companyId":"00000000000000000000001401","projectId":"00000000000000000000001403","customerId":"00000000000000000000001402","receiptDate":"2026-08-16","amount":10,"currency":"CNY","payerName":"测试客户","referenceNo":"BANK-P14-DUPLICATE"} + """; + mockMvc.perform(post("/api/v1/receipts").cookie(demo.cookies()).header("Idempotency-Key", idempotencyKey()).header("X-XSRF-TOKEN", demo.token()) + .contentType("application/json").content(duplicateReceiptBody)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("DUPLICATE_RESOURCE")); + } + + @Test + void projectManagerCannotReadReceivableResources() throws Exception { + ClientSession project = loginAndSelect("project", "PROJECT_MANAGER"); + mockMvc.perform(get("/api/v1/receipts").cookie(project.cookies())) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("PERMISSION_DENIED")); + } + + @Test + void receiptCommandsRequireAndEnforceIdempotencyKeys() throws Exception { + ClientSession finance = loginAndSelect("finance", "FINANCE_MANAGER"); + String body = """ + {"companyId":"00000000000000000000001401","projectId":"00000000000000000000001403", + "customerId":"00000000000000000000001402","receiptDate":"2026-08-28","amount":88, + "currency":"CNY","payerName":"幂等测试客户","referenceNo":"BANK-P14-IDEM"} + """; + + mockMvc.perform(post("/api/v1/receipts").cookie(finance.cookies()) + .header("X-XSRF-TOKEN", finance.token()).contentType("application/json").content(body)) + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("VALIDATION_FAILED")); + + String key = idempotencyKey(); + MvcResult first = mockMvc.perform(post("/api/v1/receipts").cookie(finance.cookies()) + .header("Idempotency-Key", key).header("X-XSRF-TOKEN", finance.token()) + .contentType("application/json").content(body)) + .andExpect(status().isOk()).andReturn(); + String receiptId = JsonPath.read(first.getResponse().getContentAsString(), "$.data.publicId"); + mockMvc.perform(post("/api/v1/receipts").cookie(finance.cookies()) + .header("Idempotency-Key", key).header("X-XSRF-TOKEN", finance.token()) + .contentType("application/json").content(body)) + .andExpect(status().isOk()).andExpect(jsonPath("$.data.publicId").value(receiptId)); + assertEquals(1, jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM fin_receipt WHERE reference_no='BANK-P14-IDEM'", Integer.class)); + + mockMvc.perform(post("/api/v1/receipts").cookie(finance.cookies()) + .header("Idempotency-Key", key).header("X-XSRF-TOKEN", finance.token()) + .contentType("application/json").content(body.replace("88", "89"))) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("IDEMPOTENCY_KEY_REUSED")); + } + + @Test + void exportsValidateStatusFiltersBeforeQueryingScopedRows() throws Exception { + ClientSession finance = loginAndSelect("finance", "FINANCE_MANAGER"); + + mockMvc.perform(get("/api/v1/receipts/export").param("status", "NOT_A_RECEIPT_STATE") + .cookie(finance.cookies())) + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("VALIDATION_FAILED")); + + mockMvc.perform(get("/api/v1/invoices/export").param("status", "NOT_AN_INVOICE_STATE") + .cookie(finance.cookies())) + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("VALIDATION_FAILED")); + } + + private String createInvoice(ClientSession session, String amount, String requestedDate) throws Exception { + String body = """ + {"companyId":"00000000000000000000001401","projectId":"00000000000000000000001403","contractId":"00000000000000000000001404","counterpartyId":"00000000000000000000001402","invoiceType":"SPECIAL","amount":%s,"taxRate":0.13,"requestedDate":"%s"} + """.formatted(amount, requestedDate); + return JsonPath.read(mockMvc.perform(post("/api/v1/invoices").cookie(session.cookies()) + .header("Idempotency-Key", idempotencyKey()).header("X-XSRF-TOKEN", session.token()).contentType("application/json").content(body)) + .andExpect(status().isOk()).andReturn().getResponse().getContentAsString(), "$.data.publicId"); + } + + private void submitInvoice(String invoiceId, ClientSession session, long version) throws Exception { + mockMvc.perform(post("/api/v1/invoices/{id}/submit", invoiceId).cookie(session.cookies()) + .header("Idempotency-Key", idempotencyKey()).header("X-XSRF-TOKEN", session.token()).contentType("application/json") + .content("{\"version\":%d}".formatted(version))) + .andExpect(status().isOk()).andExpect(jsonPath("$.data.status").value("REVIEWING")); + } + + private void reviewInvoice(String invoiceId, ClientSession reviewer, long version) throws Exception { + mockMvc.perform(post("/api/v1/invoices/{id}/review", invoiceId).cookie(reviewer.cookies()) + .header("Idempotency-Key", idempotencyKey()).header("X-XSRF-TOKEN", reviewer.token()).contentType("application/json") + .content("{\"version\":%d,\"decision\":\"APPROVE\",\"opinion\":\"通过\"}".formatted(version))) + .andExpect(status().isOk()).andExpect(jsonPath("$.data.status").value("RESULT_PENDING")); + } + + private String issueInvoice(ClientSession submitter, ClientSession reviewer, String amount, + String invoiceNo, String issuedDate) throws Exception { + String invoiceId = createInvoice(submitter, amount, issuedDate); + submitInvoice(invoiceId, submitter, 0); + reviewInvoice(invoiceId, reviewer, 1); + mockMvc.perform(post("/api/v1/invoices/{id}/record-result", invoiceId).cookie(submitter.cookies()) + .header("Idempotency-Key", idempotencyKey()).header("X-XSRF-TOKEN", submitter.token()).contentType("application/json") + .content("{\"version\":2,\"invoiceNo\":\"%s\",\"issuedDate\":\"%s\"}".formatted(invoiceNo, issuedDate))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("ISSUED")) + .andExpect(jsonPath("$.data.allowedActions", hasItem("RED_LETTER"))); + return invoiceId; + } + + private String createReceipt(ClientSession creator, String referenceNo, String amount, + String receiptDate) throws Exception { + String body = """ + {"companyId":"00000000000000000000001401","projectId":"00000000000000000000001403","customerId":"00000000000000000000001402","receiptDate":"%s","amount":%s,"currency":"CNY","payerName":"测试客户","referenceNo":"%s"} + """.formatted(receiptDate, amount, referenceNo); + return JsonPath.read(mockMvc.perform(post("/api/v1/receipts").cookie(creator.cookies()) + .header("Idempotency-Key", idempotencyKey()).header("X-XSRF-TOKEN", creator.token()).contentType("application/json").content(body)) + .andExpect(status().isOk()).andReturn().getResponse().getContentAsString(), "$.data.publicId"); + } + + private void confirmReceipt(String receiptId, ClientSession confirmer, long version) throws Exception { + mockMvc.perform(post("/api/v1/receipts/{id}/confirm", receiptId).cookie(confirmer.cookies()) + .header("Idempotency-Key", idempotencyKey()).header("X-XSRF-TOKEN", confirmer.token()).contentType("application/json") + .content("{\"version\":%d}".formatted(version))) + .andExpect(status().isOk()).andExpect(jsonPath("$.data.status").value("CONFIRMED")); + } + + private ResultActions allocate(String receiptId, String receivableId, String amount, long version, + ClientSession allocator) throws Exception { + return mockMvc.perform(post("/api/v1/receipts/{id}/allocations", receiptId).cookie(allocator.cookies()) + .header("Idempotency-Key", idempotencyKey()).header("X-XSRF-TOKEN", allocator.token()).contentType("application/json") + .content("{\"receivableId\":\"%s\",\"amount\":%s,\"version\":%d}" + .formatted(receivableId, amount, version))) + .andExpect(status().isOk()); + } + + private String receivableForInvoice(String invoiceId) { + return jdbcTemplate.queryForObject( + "SELECT public_id FROM fin_receivable WHERE source_invoice_id = (SELECT id FROM fin_invoice WHERE public_id = ?)", + String.class, invoiceId); + } + + private ClientSession loginAndSelect(String username, String role) throws Exception { + MvcResult csrf = mockMvc.perform(get("/api/v1/auth/csrf")).andExpect(status().isOk()).andReturn(); + Cookie csrfCookie = csrf.getResponse().getCookie("XSRF-TOKEN"); + assertNotNull(csrfCookie); + String token = JsonPath.read(csrf.getResponse().getContentAsString(), "$.data.token"); + MvcResult login = mockMvc.perform(post("/api/v1/auth/login").cookie(csrfCookie).header("X-XSRF-TOKEN", token) + .contentType("application/json").content("{\"username\":\"%s\",\"password\":\"LocalOnly@123\"}".formatted(username))) + .andExpect(status().isOk()).andReturn(); + Cookie session = login.getResponse().getCookie("KAIDI_SESSION"); + assertNotNull(session); + MvcResult selected = mockMvc.perform(post("/api/v1/auth/select-role").cookie(session, csrfCookie).header("X-XSRF-TOKEN", token) + .contentType("application/json").content("{\"roleCode\":\"%s\"}".formatted(role))).andExpect(status().isOk()).andReturn(); + Cookie rotated = selected.getResponse().getCookie("KAIDI_SESSION"); + return new ClientSession(rotated == null ? session : rotated, csrfCookie, token); + } + + private record ClientSession(Cookie session, Cookie csrf, String token) { Cookie[] cookies() { return new Cookie[]{session, csrf}; } } +} diff --git a/backend/src/test/java/com/kaidi/finance/reporting/ReportingIntegrationTest.java b/backend/src/test/java/com/kaidi/finance/reporting/ReportingIntegrationTest.java new file mode 100644 index 0000000..0bfc2ad --- /dev/null +++ b/backend/src/test/java/com/kaidi/finance/reporting/ReportingIntegrationTest.java @@ -0,0 +1,661 @@ +package com.kaidi.finance.reporting; + +import static org.hamcrest.Matchers.greaterThanOrEqualTo; +import static org.hamcrest.Matchers.blankOrNullString; +import static org.hamcrest.Matchers.hasItem; +import static org.hamcrest.Matchers.hasItems; +import static org.hamcrest.Matchers.not; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.jayway.jsonpath.JsonPath; +import jakarta.servlet.http.Cookie; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.LocalDate; +import java.util.HexFormat; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +@Testcontainers(disabledWithoutDocker = true) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class ReportingIntegrationTest { + + private static final String PROJECT_ID = "80000000000000000000000003"; + private static final String CONTRACT_ID = "80000000000000000000000004"; + private static final String RECEIPT_ID = "80000000000000000000000014"; + private static final String PAYMENT_ID = "80000000000000000000000017"; + private static final String WORKFLOW_TASK_ID = "80000000000000000000000009"; + private static final String ARCHIVE_PACKAGE_ID = "80000000000000000000000022"; + private static final String SOURCE_DOCUMENT_ID = "80000000000000000000000005"; + private static final String[] REPORT_CODES = {"project-ledger", "contract-execution", "receipt-invoice", + "payment-progress", "workflow-duration", "archive-completeness"}; + + @Container + static final MySQLContainer MYSQL = new MySQLContainer<>("mysql:8.4") + .withDatabaseName("kaidi_reporting_test") + .withUsername("kaidi") + .withPassword("kaidi_test_password"); + + @DynamicPropertySource + static void configureDatabase(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", MYSQL::getJdbcUrl); + registry.add("spring.datasource.username", MYSQL::getUsername); + registry.add("spring.datasource.password", MYSQL::getPassword); + registry.add("finance.bootstrap.enabled", () -> true); + registry.add("finance.bootstrap.password", () -> "LocalOnly@123"); + } + + @Autowired + MockMvc mockMvc; + + @Autowired + JdbcTemplate jdbcTemplate; + + @Test + @Sql("/reporting-fixture.sql") + void sixReportsCalculateTraceExportAndExpireScope() throws Exception { + ClientSession finance = loginAndSelect("finance", "FINANCE_MANAGER"); + DateRange month = currentMonth(); + + MvcResult project = report(finance, "project-ledger", month) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.length()").value(1)) + .andExpect(jsonPath("$.data[0].rowId").value(PROJECT_ID)) + .andExpect(jsonPath("$.data[0].contractAmount").value("1100.00")) + .andExpect(jsonPath("$.data[0].receiptAmount").value("300.00")) + .andExpect(jsonPath("$.data[0].costAmount").value("600.00")) + .andExpect(jsonPath("$.data[0].paymentAmount").value("350.00")) + .andExpect(jsonPath("$.data[0].completedVoucherCount").value(1)) + .andExpect(jsonPath("$.data[0].completeness").value("75.00")) + .andExpect(jsonPath("$.data[0].values").doesNotExist()) + .andExpect(jsonPath("$.meta.definitionVersion").value("REPORT-DRAFT-V3")) + .andExpect(jsonPath("$.meta.filterHash", not(blankOrNullString()))) + .andExpect(jsonPath("$.meta.summary.metricTotal").value("1100.00")) + .andExpect(jsonPath("$.meta.allowedActions", hasItem("EXPORT"))) + .andReturn(); + String projectHash = JsonPath.read(project.getResponse().getContentAsString(), "$.meta.filterHash"); + + MvcResult contract = report(finance, "contract-execution", month) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.length()").value(1)) + .andExpect(jsonPath("$.data[0].rowId").value(CONTRACT_ID)) + .andExpect(jsonPath("$.data[0].invoicedAmount").value("200.00")) + .andExpect(jsonPath("$.data[0].pendingPayableAmount").value("75.00")) + .andExpect(jsonPath("$.data[0].payableAmount").value("600.00")) + .andExpect(jsonPath("$.data[0].paidAmount").value("150.00")) + .andExpect(jsonPath("$.data[0].availableAmount").value("750.00")) + .andExpect(jsonPath("$.meta.summary.metricTotal").value("900.00")) + .andReturn(); + String contractHash = JsonPath.read(contract.getResponse().getContentAsString(), "$.meta.filterHash"); + assertNotEquals(projectHash, contractHash); + + report(finance, "receipt-invoice", month) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.length()").value(2)) + .andExpect(jsonPath("$.data[*].recordType", hasItems("RECEIPT", "INVOICE"))) + .andExpect(jsonPath("$.data[*].status", hasItem("PARTIALLY_ALLOCATED"))) + .andExpect(jsonPath("$.data[*].amount", hasItems("300.00", "200.00"))) + .andExpect(jsonPath("$.data[*].outstandingAmount", hasItem("700.00"))) + .andExpect(jsonPath("$.meta.summary.metricTotal").value("500.00")); + + report(finance, "payment-progress", month) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.length()").value(1)) + .andExpect(jsonPath("$.data[0].rowId").value(PAYMENT_ID)) + .andExpect(jsonPath("$.data[0].offlineResult").value("REFUND")) + .andExpect(jsonPath("$.data[0].resultAmount").value("350.00")) + .andExpect(jsonPath("$.data[0].status").value("PART_PAID")) + .andExpect(jsonPath("$.data[0].receiptFileCount").value(1)) + .andExpect(jsonPath("$.data[0].accountingStatus").value("COMPLETED")); + + report(finance, "workflow-duration", month) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.length()").value(1)) + .andExpect(jsonPath("$.data[0].rowId").value(WORKFLOW_TASK_ID)) + .andExpect(jsonPath("$.data[0].completedAt").doesNotExist()) + .andExpect(jsonPath("$.data[0].durationSeconds", greaterThanOrEqualTo(120))) + .andExpect(jsonPath("$.data[0].returnCount").value(1)) + .andExpect(jsonPath("$.data[0].pendingTaskCount").value(1)); + + report(finance, "archive-completeness", month) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.length()").value(1)) + .andExpect(jsonPath("$.data[0].rowId").value(ARCHIVE_PACKAGE_ID)) + .andExpect(jsonPath("$.data[0].packageVersion").value(2)) + .andExpect(jsonPath("$.data[0].completeness").value("75.00")) + .andExpect(jsonPath("$.data[0].missingCount").value(1)) + .andExpect(jsonPath("$.data[0].pendingCount").value(1)) + .andExpect(jsonPath("$.data[0].notApplicableCount").value(1)) + .andExpect(jsonPath("$.data[0].presentCount").value(1)) + .andExpect(jsonPath("$.data[0].totalItemCount").value(4)) + .andExpect(jsonPath("$.data[0].frozen").isBoolean()) + .andExpect(jsonPath("$.meta.summary.metricTotal").value("75.00")); + + drilldown(finance, "project-ledger", PROJECT_ID, month) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.filterHash").value(projectHash)) + .andExpect(jsonPath("$.data.definitionVersion").value("REPORT-DRAFT-V3")) + .andExpect(jsonPath("$.data.groups[*].code", hasItems("CONTRACT", "COST", "PAYMENT", "ARCHIVE"))) + .andExpect(jsonPath("$.data.relatedRecords[*].objectType", hasItems("CONTRACT", "PAYABLE", + "PAYMENT_RESULT", "VOUCHER", "ARCHIVE_PACKAGE"))) + .andExpect(jsonPath("$.data.sourceForms[0].publicId").value(SOURCE_DOCUMENT_ID)) + .andExpect(jsonPath("$.data.approvals[0].action").value("APPROVE")) + .andExpect(jsonPath("$.data.attachments[0].fileId").value("80000000000000000000000023")); + + assertEquals(1, jdbcTemplate.update(""" + UPDATE fin_payable SET business_date = DATE_SUB(DATE_FORMAT(CURRENT_DATE(), '%Y-%m-01'), INTERVAL 1 DAY) + WHERE public_id = '80000000000000000000000013' + """)); + drilldown(finance, "project-ledger", PROJECT_ID, month) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.relatedRecords[*].publicId", + not(hasItem("80000000000000000000000013")))); + + removeRolePermission("FINANCE_MANAGER", "archive:file:view"); + drilldown(finance, "project-ledger", PROJECT_ID, month) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.source.rowId").value(PROJECT_ID)) + .andExpect(jsonPath("$.data.source.values").doesNotExist()) + .andExpect(jsonPath("$.data.attachments.length()").value(0)); + restoreRolePermission("FINANCE_MANAGER", "archive:file:view"); + + restrictFinanceArchiveFileViewToMismatchedProjectCompany(); + drilldown(finance, "project-ledger", PROJECT_ID, month) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.relatedRecords.length()", greaterThanOrEqualTo(1))) + .andExpect(jsonPath("$.data.attachments.length()").value(0)); + restoreFinanceArchiveFileViewScope(); + + drilldown(finance, "receipt-invoice", RECEIPT_ID, month) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.attachments.length()").value(0)); + + drilldown(finance, "payment-progress", PAYMENT_ID, month) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.relatedRecords[0].objectType").value("PAYMENT_RESULT")) + .andExpect(jsonPath("$.data.relatedRecords.length()").value(3)) + .andExpect(jsonPath("$.data.relatedRecords[*].publicId", + not(hasItem("80000000000000000000000033")))) + .andExpect(jsonPath("$.data.sourceForms[*].publicId", hasItem(SOURCE_DOCUMENT_ID))) + .andExpect(jsonPath("$.data.approvals[*].action", hasItem("APPROVE"))) + .andExpect(jsonPath("$.data.attachments.length()").value(2)); + + drilldown(finance, "workflow-duration", WORKFLOW_TASK_ID, month) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.sourceForms[0].exactVersion").value(true)) + .andExpect(jsonPath("$.data.relatedRecords[0].status").value("RETURN")) + .andExpect(jsonPath("$.data.approvals[0].sourceVersionId").value("80000000000000000000000006")) + .andExpect(jsonPath("$.data.attachments.length()").value(1)) + .andExpect(jsonPath("$.data.attachments[0].fileId").value("80000000000000000000000023")); + + String prepareBody = """ + {"dateFrom":"%s","dateTo":"%s","columns":["businessNo","contractAmount"], + "sort":"updatedAt,desc;rowId,asc","expectedFilterHash":"%s", + "expectedDefinitionVersion":"REPORT-DRAFT-V3"} + """.formatted(month.from(), month.to(), projectHash); + restrictFinanceReportExportToProject(); + MvcResult prepared = mockMvc.perform(post("/api/v1/reports/project-ledger/exports/prepare") + .cookie(finance.cookies()) + .header("X-XSRF-TOKEN", finance.csrfToken()) + .contentType("application/json") + .content(prepareBody)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.definitionVersion").value("REPORT-DRAFT-V3")) + .andExpect(jsonPath("$.data.filterHash").value(projectHash)) + .andExpect(jsonPath("$.data.status").value("PREPARED")) + .andExpect(jsonPath("$.data.estimatedRows").value(1)) + .andExpect(jsonPath("$.data.sort").value("updatedAt,desc;rowId,asc")) + .andReturn(); + String exportId = JsonPath.read(prepared.getResponse().getContentAsString(), "$.data.exportId"); + assertEquals(0, jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM export_log WHERE resource_type='REPORT_PROJECT_LEDGER'", Integer.class)); + MvcResult export = mockMvc.perform(post("/api/v1/reports/project-ledger/exports/confirm") + .cookie(finance.cookies()).header("X-XSRF-TOKEN", finance.csrfToken()) + .contentType("application/json") + .content("{\"exportId\":\"%s\",\"maskingConfirmed\":true}".formatted(exportId))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.exportId").value(exportId)) + .andExpect(jsonPath("$.data.rowCount").value(1)) + .andExpect(jsonPath("$.data.sha256", not(blankOrNullString()))) + .andReturn(); + String csv = JsonPath.read(export.getResponse().getContentAsString(), "$.data.content"); + String exportSha256 = JsonPath.read(export.getResponse().getContentAsString(), "$.data.sha256"); + String actualSha256 = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(csv.getBytes(StandardCharsets.UTF_8))); + assertEquals(actualSha256, exportSha256); + assertTrue(csv.contains("businessNo,contractAmount")); + assertTrue(csv.contains("XM-REPORT-001")); + Integer exportLogs = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM export_log WHERE resource_type = 'REPORT_PROJECT_LEDGER'", Integer.class); + assertNotNull(exportLogs); + assertEquals(1, exportLogs); + assertEquals("COMPLETED", jdbcTemplate.queryForObject( + "SELECT status FROM report_export_request WHERE public_id=?", String.class, exportId)); + + String viewScopeExportId = JsonPath.read(mockMvc.perform( + post("/api/v1/reports/project-ledger/exports/prepare") + .cookie(finance.cookies()).header("X-XSRF-TOKEN", finance.csrfToken()) + .contentType("application/json").content(prepareBody)) + .andExpect(status().isOk()).andReturn().getResponse().getContentAsString(), "$.data.exportId"); + restrictFinanceReportViewToProject(); + mockMvc.perform(post("/api/v1/reports/project-ledger/exports/confirm") + .cookie(finance.cookies()).header("X-XSRF-TOKEN", finance.csrfToken()) + .contentType("application/json") + .content("{\"exportId\":\"%s\",\"maskingConfirmed\":true}".formatted(viewScopeExportId))) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("VERSION_CONFLICT")); + assertEquals(1, jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM export_log WHERE resource_type='REPORT_PROJECT_LEDGER'", Integer.class)); + restoreFinanceReportViewScopes(); + + String exportScopeExportId = JsonPath.read(mockMvc.perform( + post("/api/v1/reports/project-ledger/exports/prepare") + .cookie(finance.cookies()).header("X-XSRF-TOKEN", finance.csrfToken()) + .contentType("application/json").content(prepareBody)) + .andExpect(status().isOk()).andReturn().getResponse().getContentAsString(), "$.data.exportId"); + expireFinanceReportExportScopes(); + mockMvc.perform(post("/api/v1/reports/project-ledger/exports/confirm") + .cookie(finance.cookies()).header("X-XSRF-TOKEN", finance.csrfToken()) + .contentType("application/json") + .content("{\"exportId\":\"%s\",\"maskingConfirmed\":true}".formatted(exportScopeExportId))) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("VERSION_CONFLICT")); + assertEquals(1, jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM export_log WHERE resource_type='REPORT_PROJECT_LEDGER'", Integer.class)); + restoreFinanceReportExportScopes(); + + String expiredExportId = JsonPath.read(mockMvc.perform( + post("/api/v1/reports/project-ledger/exports/prepare") + .cookie(finance.cookies()).header("X-XSRF-TOKEN", finance.csrfToken()) + .contentType("application/json").content(prepareBody)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.filterHash").value(projectHash)) + .andExpect(jsonPath("$.data.estimatedRows").value(1)) + .andReturn().getResponse().getContentAsString(), "$.data.exportId"); + assertEquals(1, jdbcTemplate.update( + "UPDATE report_export_request SET expires_at=DATE_SUB(UTC_TIMESTAMP(3), INTERVAL 1 SECOND) WHERE public_id=?", + expiredExportId)); + mockMvc.perform(post("/api/v1/reports/project-ledger/exports/confirm") + .cookie(finance.cookies()).header("X-XSRF-TOKEN", finance.csrfToken()) + .contentType("application/json") + .content("{\"exportId\":\"%s\",\"maskingConfirmed\":true}".formatted(expiredExportId))) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("VERSION_CONFLICT")); + assertEquals("EXPIRED", jdbcTemplate.queryForObject( + "SELECT status FROM report_export_request WHERE public_id=?", String.class, expiredExportId)); + assertEquals(1, jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM export_log WHERE resource_type='REPORT_PROJECT_LEDGER'", Integer.class)); + + MvcResult sortedReceipt = mockMvc.perform(get("/api/v1/reports/receipt-invoice") + .param("dateFrom", month.from()).param("dateTo", month.to()) + .param("sort", "businessNo,asc").param("page", "1").param("size", "20") + .cookie(finance.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.meta.sort").value("businessNo,asc;rowId,asc")) + .andReturn(); + String receiptHash = JsonPath.read(sortedReceipt.getResponse().getContentAsString(), "$.meta.filterHash"); + String firstReceiptBusinessNo = JsonPath.read(sortedReceipt.getResponse().getContentAsString(), + "$.data[0].businessNo"); + String receiptPrepareBody = """ + {"dateFrom":"%s","dateTo":"%s","columns":["businessNo","projectName"], + "sort":"businessNo,asc","expectedFilterHash":"%s", + "expectedDefinitionVersion":"REPORT-DRAFT-V3"} + """.formatted(month.from(), month.to(), receiptHash); + String receiptExportId = JsonPath.read(mockMvc.perform( + post("/api/v1/reports/receipt-invoice/exports/prepare") + .cookie(finance.cookies()).header("X-XSRF-TOKEN", finance.csrfToken()) + .contentType("application/json").content(receiptPrepareBody)) + .andExpect(status().isOk()).andReturn().getResponse().getContentAsString(), "$.data.exportId"); + String receiptCsv = JsonPath.read(mockMvc.perform( + post("/api/v1/reports/receipt-invoice/exports/confirm") + .cookie(finance.cookies()).header("X-XSRF-TOKEN", finance.csrfToken()) + .contentType("application/json") + .content("{\"exportId\":\"%s\",\"maskingConfirmed\":true}".formatted(receiptExportId))) + .andExpect(status().isOk()).andReturn().getResponse().getContentAsString(), "$.data.content"); + assertTrue(receiptCsv.contains("'="), "CSV must neutralize spreadsheet formula prefixes"); + assertTrue(receiptCsv.split("\\R")[1].contains(firstReceiptBusinessNo), + "CSV order must match the frozen list sort"); + + mockMvc.perform(post("/api/v1/reports/project-ledger/exports/confirm") + .cookie(finance.cookies()) + .header("X-XSRF-TOKEN", finance.csrfToken()) + .contentType("application/json") + .content("{\"exportId\":\"%s\",\"maskingConfirmed\":false}".formatted(exportId))) + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("VALIDATION_FAILED")); + + mockMvc.perform(post("/api/v1/reports/project-ledger/exports/confirm") + .cookie(finance.cookies()).header("X-XSRF-TOKEN", finance.csrfToken()) + .contentType("application/json") + .content("{\"exportId\":\"%s\",\"maskingConfirmed\":true}".formatted(exportId))) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("VERSION_CONFLICT")); + + mockMvc.perform(post("/api/v1/reports/project-ledger/exports/prepare") + .cookie(finance.cookies()).header("X-XSRF-TOKEN", finance.csrfToken()) + .contentType("application/json") + .content("{\"dateFrom\":\"%s\",\"dateTo\":\"%s\"}".formatted(month.from(), month.to()))) + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("VALIDATION_FAILED")); + + mockMvc.perform(get("/api/v1/reports/project-ledger") + .param("dateFrom", month.from()).param("dateTo", month.to()) + .param("sort", "unknownField,asc").cookie(finance.cookies())) + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("VALIDATION_FAILED")); + mockMvc.perform(post("/api/v1/reports/project-ledger/exports/prepare") + .cookie(finance.cookies()).header("X-XSRF-TOKEN", finance.csrfToken()) + .contentType("application/json") + .content(""" + {"dateFrom":"%s","dateTo":"%s","sort":"unknownField,asc", + "expectedFilterHash":"%s","expectedDefinitionVersion":"REPORT-DRAFT-V3"} + """.formatted(month.from(), month.to(), projectHash))) + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("VALIDATION_FAILED")); + + mockMvc.perform(get("/api/v1/reports/not-a-report").cookie(finance.cookies())) + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("VALIDATION_FAILED")); + mockMvc.perform(get("/api/v1/reports/project-ledger") + .param("dateFrom", month.to()).param("dateTo", month.from()) + .cookie(finance.cookies())) + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("VALIDATION_FAILED")); + mockMvc.perform(get("/api/v1/reports/project-ledger") + .param("status", "PAID").cookie(finance.cookies())) + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("VALIDATION_FAILED")); + + restrictFinanceReportViewToMismatchedProjectCompany(); + drilldown(finance, "project-ledger", PROJECT_ID, month) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("RESOURCE_NOT_FOUND")); + for (String code : REPORT_CODES) { + report(finance, code, month) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.length()").value(0)); + } + restoreFinanceReportViewScopes(); + report(finance, "project-ledger", month) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[0].rowId").value(PROJECT_ID)); + + insertMixedCurrencyContract(); + report(finance, "contract-execution", month) + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("VALIDATION_FAILED")); + report(finance, "project-ledger", month) + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("VALIDATION_FAILED")); + + expireFinanceReportViewScopes(); + for (String code : REPORT_CODES) { + report(finance, code, month) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.length()").value(0)); + } + } + + private org.springframework.test.web.servlet.ResultActions report(ClientSession session, String code, + DateRange month) throws Exception { + return mockMvc.perform(get("/api/v1/reports/{code}", code) + .param("dateFrom", month.from()).param("dateTo", month.to()) + .param("page", "1").param("size", "20").cookie(session.cookies())); + } + + private org.springframework.test.web.servlet.ResultActions drilldown(ClientSession session, String code, + String rowId, DateRange month) + throws Exception { + return mockMvc.perform(get("/api/v1/reports/{code}/{rowId}/drilldown", code, rowId) + .param("dateFrom", month.from()).param("dateTo", month.to()).cookie(session.cookies())); + } + + private void expireFinanceReportViewScopes() { + int updated = jdbcTemplate.update(""" + UPDATE iam_scope scope + JOIN iam_user user_account ON user_account.id = scope.user_id + JOIN iam_role role ON role.id = scope.role_id + JOIN iam_permission permission ON permission.id = scope.permission_id + SET scope.valid_from = DATE_SUB(UTC_TIMESTAMP(3), INTERVAL 2 SECOND), + scope.valid_to = DATE_SUB(UTC_TIMESTAMP(3), INTERVAL 1 SECOND) + WHERE user_account.username = 'finance' AND role.code = 'FINANCE_MANAGER' + AND permission.code = 'report:finance:view' + """); + assertTrue(updated > 0); + } + + private void expireFinanceReportExportScopes() { + int updated = jdbcTemplate.update(""" + UPDATE iam_scope scope + JOIN iam_user user_account ON user_account.id = scope.user_id + JOIN iam_role role ON role.id = scope.role_id + JOIN iam_permission permission ON permission.id = scope.permission_id + SET scope.valid_from = DATE_SUB(UTC_TIMESTAMP(3), INTERVAL 2 SECOND), + scope.valid_to = DATE_SUB(UTC_TIMESTAMP(3), INTERVAL 1 SECOND) + WHERE user_account.username = 'finance' AND role.code = 'FINANCE_MANAGER' + AND permission.code = 'report:finance:export' + """); + assertTrue(updated > 0); + } + + private void restrictFinanceReportViewToMismatchedProjectCompany() { + int updated = jdbcTemplate.update(""" + UPDATE iam_scope scope + JOIN iam_user user_account ON user_account.id = scope.user_id + JOIN iam_role role ON role.id = scope.role_id + JOIN iam_permission permission ON permission.id = scope.permission_id + SET scope.scope_type = 'PROJECT', + scope.company_public_id = '80000000000000000000000030', + scope.project_public_id = '80000000000000000000000003', + scope.valid_from = DATE_SUB(UTC_TIMESTAMP(3), INTERVAL 1 SECOND), + scope.valid_to = NULL + WHERE user_account.username = 'finance' AND role.code = 'FINANCE_MANAGER' + AND permission.code = 'report:finance:view' + """); + assertTrue(updated > 0); + } + + private void restrictFinanceReportViewToProject() { + int updated = jdbcTemplate.update(""" + UPDATE iam_scope scope + JOIN iam_user user_account ON user_account.id = scope.user_id + JOIN iam_role role ON role.id = scope.role_id + JOIN iam_permission permission ON permission.id = scope.permission_id + SET scope.scope_type = 'PROJECT', + scope.company_public_id = '80000000000000000000000001', + scope.project_public_id = '80000000000000000000000003', + scope.valid_from = DATE_SUB(UTC_TIMESTAMP(3), INTERVAL 1 SECOND), + scope.valid_to = NULL + WHERE user_account.username = 'finance' AND role.code = 'FINANCE_MANAGER' + AND permission.code = 'report:finance:view' + """); + assertTrue(updated > 0); + } + + private void restrictFinanceReportExportToProject() { + int updated = jdbcTemplate.update(""" + UPDATE iam_scope scope + JOIN iam_user user_account ON user_account.id = scope.user_id + JOIN iam_role role ON role.id = scope.role_id + JOIN iam_permission permission ON permission.id = scope.permission_id + SET scope.scope_type = 'PROJECT', + scope.company_public_id = '80000000000000000000000001', + scope.project_public_id = '80000000000000000000000003', + scope.valid_from = DATE_SUB(UTC_TIMESTAMP(3), INTERVAL 1 SECOND), + scope.valid_to = NULL + WHERE user_account.username = 'finance' AND role.code = 'FINANCE_MANAGER' + AND permission.code = 'report:finance:export' + """); + assertTrue(updated > 0); + assertEquals("GLOBAL", jdbcTemplate.queryForObject(""" + SELECT scope.scope_type FROM iam_scope scope + JOIN iam_user user_account ON user_account.id = scope.user_id + JOIN iam_role role ON role.id = scope.role_id + JOIN iam_permission permission ON permission.id = scope.permission_id + WHERE user_account.username = 'finance' AND role.code = 'FINANCE_MANAGER' + AND permission.code = 'report:finance:view' LIMIT 1 + """, String.class)); + } + + private void restoreFinanceReportViewScopes() { + int updated = jdbcTemplate.update(""" + UPDATE iam_scope scope + JOIN iam_user user_account ON user_account.id = scope.user_id + JOIN iam_role role ON role.id = scope.role_id + JOIN iam_permission permission ON permission.id = scope.permission_id + SET scope.scope_type = 'GLOBAL', + scope.company_public_id = NULL, + scope.project_public_id = NULL, + scope.valid_from = DATE_SUB(UTC_TIMESTAMP(3), INTERVAL 1 SECOND), + scope.valid_to = NULL + WHERE user_account.username = 'finance' AND role.code = 'FINANCE_MANAGER' + AND permission.code = 'report:finance:view' + """); + assertTrue(updated > 0); + } + + private void restoreFinanceReportExportScopes() { + int updated = jdbcTemplate.update(""" + UPDATE iam_scope scope + JOIN iam_user user_account ON user_account.id = scope.user_id + JOIN iam_role role ON role.id = scope.role_id + JOIN iam_permission permission ON permission.id = scope.permission_id + SET scope.scope_type = 'GLOBAL', + scope.company_public_id = NULL, + scope.project_public_id = NULL, + scope.valid_from = DATE_SUB(UTC_TIMESTAMP(3), INTERVAL 1 SECOND), + scope.valid_to = NULL + WHERE user_account.username = 'finance' AND role.code = 'FINANCE_MANAGER' + AND permission.code = 'report:finance:export' + """); + assertTrue(updated > 0); + } + + private void insertMixedCurrencyContract() { + Long financeId = jdbcTemplate.queryForObject("SELECT id FROM iam_user WHERE username='finance'", Long.class); + assertNotNull(financeId); + assertEquals(1, jdbcTemplate.update(""" + INSERT INTO md_contract + (public_id, company_id, project_id, counterparty_id, business_no, name, original_amount, + approved_change_amount, currency, status, created_by, updated_by) + SELECT '80000000000000000000000044', company.id, project.id, counterparty.id, + 'HT-REPORT-USD-001', '报表混币种合同', 100.00, 0.00, 'USD', 'ACTIVE', ?, ? + FROM md_company company + JOIN md_project project ON project.public_id = ? + JOIN md_counterparty counterparty ON counterparty.public_id = ? + WHERE company.public_id = ? + """, financeId, financeId, PROJECT_ID, "80000000000000000000000002", + "80000000000000000000000001")); + } + + private void restrictFinanceArchiveFileViewToMismatchedProjectCompany() { + int updated = jdbcTemplate.update(""" + UPDATE iam_scope scope + JOIN iam_user user_account ON user_account.id = scope.user_id + JOIN iam_role role ON role.id = scope.role_id + JOIN iam_permission permission ON permission.id = scope.permission_id + SET scope.scope_type = 'PROJECT', + scope.company_public_id = '80000000000000000000000030', + scope.project_public_id = '80000000000000000000000003', + scope.valid_from = DATE_SUB(UTC_TIMESTAMP(3), INTERVAL 1 SECOND), + scope.valid_to = NULL + WHERE user_account.username = 'finance' AND role.code = 'FINANCE_MANAGER' + AND permission.code = 'archive:file:view' + """); + assertTrue(updated > 0); + } + + private void restoreFinanceArchiveFileViewScope() { + int updated = jdbcTemplate.update(""" + UPDATE iam_scope scope + JOIN iam_user user_account ON user_account.id = scope.user_id + JOIN iam_role role ON role.id = scope.role_id + JOIN iam_permission permission ON permission.id = scope.permission_id + SET scope.scope_type = 'GLOBAL', scope.company_public_id = NULL, + scope.project_public_id = NULL, + scope.valid_from = DATE_SUB(UTC_TIMESTAMP(3), INTERVAL 1 SECOND), + scope.valid_to = NULL + WHERE user_account.username = 'finance' AND role.code = 'FINANCE_MANAGER' + AND permission.code = 'archive:file:view' + """); + assertTrue(updated > 0); + } + + private void removeRolePermission(String roleCode, String permissionCode) { + int deleted = jdbcTemplate.update(""" + DELETE role_permission FROM iam_role_permission role_permission + JOIN iam_role role ON role.id = role_permission.role_id + JOIN iam_permission permission ON permission.id = role_permission.permission_id + WHERE role.code = ? AND permission.code = ? + """, roleCode, permissionCode); + assertEquals(1, deleted); + } + + private void restoreRolePermission(String roleCode, String permissionCode) { + assertEquals(1, jdbcTemplate.update(""" + INSERT INTO iam_role_permission (role_id, permission_id) + SELECT role.id, permission.id FROM iam_role role JOIN iam_permission permission + WHERE role.code = ? AND permission.code = ? + """, roleCode, permissionCode)); + } + + private ClientSession loginAndSelect(String username, String role) throws Exception { + MvcResult csrf = mockMvc.perform(get("/api/v1/auth/csrf")).andExpect(status().isOk()).andReturn(); + Cookie csrfCookie = csrf.getResponse().getCookie("XSRF-TOKEN"); + assertNotNull(csrfCookie); + String csrfToken = JsonPath.read(csrf.getResponse().getContentAsString(), "$.data.token"); + MvcResult login = mockMvc.perform(post("/api/v1/auth/login") + .cookie(csrfCookie) + .header("X-XSRF-TOKEN", csrfToken) + .contentType("application/json") + .content("{\"username\":\"%s\",\"password\":\"LocalOnly@123\"}".formatted(username))) + .andExpect(status().isOk()).andReturn(); + Cookie sessionCookie = login.getResponse().getCookie("KAIDI_SESSION"); + assertNotNull(sessionCookie); + MvcResult selected = mockMvc.perform(post("/api/v1/auth/select-role") + .cookie(sessionCookie, csrfCookie) + .header("X-XSRF-TOKEN", csrfToken) + .contentType("application/json") + .content("{\"roleCode\":\"%s\"}".formatted(role))) + .andExpect(status().isOk()).andReturn(); + Cookie rotated = selected.getResponse().getCookie("KAIDI_SESSION"); + return new ClientSession(rotated == null ? sessionCookie : rotated, csrfCookie, csrfToken); + } + + private static DateRange currentMonth() { + LocalDate today = LocalDate.now(); + return new DateRange(today.withDayOfMonth(1).toString(), today.withDayOfMonth(today.lengthOfMonth()).toString()); + } + + private record DateRange(String from, String to) { + } + + private record ClientSession(Cookie sessionCookie, Cookie csrfCookie, String csrfToken) { + Cookie[] cookies() { + return new Cookie[]{sessionCookie, csrfCookie}; + } + } +} diff --git a/backend/src/test/java/com/kaidi/finance/setup/SetupApplicationIntegrationTest.java b/backend/src/test/java/com/kaidi/finance/setup/SetupApplicationIntegrationTest.java new file mode 100644 index 0000000..631def9 --- /dev/null +++ b/backend/src/test/java/com/kaidi/finance/setup/SetupApplicationIntegrationTest.java @@ -0,0 +1,141 @@ +package com.kaidi.finance.setup; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fasterxml.jackson.databind.JsonNode; +import com.kaidi.setup.SetupApplication; +import java.nio.file.Files; +import java.nio.file.Path; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.ResultSet; +import java.sql.Statement; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +@SpringBootTest(classes = SetupApplication.class, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@Testcontainers(disabledWithoutDocker = true) +class SetupApplicationIntegrationTest { + + private static final String SETUP_CODE = "SETUP-CODE-TEST"; + private static final String SETUP_CODE_SHA256 = + "9158568c96987884c8141d363daceffda86bb17083054b36105934090aa7e166"; + private static final Path STATE_ROOT = Path.of("target", "setup-integration-" + UUID.randomUUID()); + + @Container + static final MySQLContainer MYSQL = new MySQLContainer<>("mysql:8.4") + .withDatabaseName("kaidi_finance") + .withUsername("kaidi") + .withPassword("setup-database-password"); + + @DynamicPropertySource + static void properties(DynamicPropertyRegistry registry) { + registry.add("finance.setup.enabled", () -> true); + registry.add("finance.setup.token-sha256", () -> SETUP_CODE_SHA256); + registry.add("finance.setup.env-file", () -> STATE_ROOT.resolve("application.env").toString()); + registry.add("finance.setup.marker-file", () -> STATE_ROOT.resolve("locked").toString()); + registry.add("finance.setup.restart-after-complete", () -> false); + registry.add("spring.profiles.active", () -> "setup"); + } + + @Autowired + private TestRestTemplate rest; + + @LocalServerPort + private int port; + + @Test + void firstRunTestsDatabaseCreatesAdministratorAndLocksWizard() throws Exception { + ResponseEntity initial = rest.getForEntity(url("/api/v1/setup/status"), JsonNode.class); + assertThat(initial.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(initial.getBody().path("data").path("required").asBoolean()).isTrue(); + + Map database = databaseRequest("wrong-code"); + ResponseEntity denied = rest.postForEntity(url("/api/v1/setup/test-connection"), + new HttpEntity<>(database), JsonNode.class); + assertThat(denied.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + assertThat(denied.getBody().path("code").asText()).isEqualTo("SETUP_CODE_INVALID"); + + database.put("setupCode", SETUP_CODE); + ResponseEntity tested = rest.postForEntity(url("/api/v1/setup/test-connection"), + new HttpEntity<>(database), JsonNode.class); + assertThat(tested.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(tested.getBody().path("data").path("successful").asBoolean()).isTrue(); + assertThat(tested.getBody().path("data").path("schemaReady").asBoolean()).isTrue(); + + Map complete = new LinkedHashMap<>(database); + complete.put("adminUsername", "setup-admin"); + complete.put("adminDisplayName", "首次安装管理员"); + complete.put("adminPassword", "SetupAdmin@2026Strong"); + complete.put("adminPasswordConfirmation", "SetupAdmin@2026Strong"); + ResponseEntity completed = rest.postForEntity(url("/api/v1/setup/complete"), + new HttpEntity<>(complete), JsonNode.class); + assertThat(completed.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(completed.getBody().path("data").path("completed").asBoolean()).isTrue(); + + try (Connection connection = DriverManager.getConnection(MYSQL.getJdbcUrl(), MYSQL.getUsername(), + MYSQL.getPassword()); Statement statement = connection.createStatement()) { + try (ResultSet result = statement.executeQuery(""" + SELECT COUNT(*) + FROM iam_user user_account + JOIN iam_user_role user_role ON user_role.user_id = user_account.id + JOIN iam_role role ON role.id = user_role.role_id + WHERE user_account.username = 'setup-admin' AND role.code = 'SYSTEM_ADMIN' + """)) { + result.next(); + assertThat(result.getInt(1)).isEqualTo(1); + } + try (ResultSet result = statement.executeQuery(""" + SELECT COUNT(*) + FROM iam_scope scope_grant + JOIN iam_user user_account ON user_account.id = scope_grant.user_id + WHERE user_account.username = 'setup-admin' AND scope_grant.scope_type = 'GLOBAL' + """)) { + result.next(); + assertThat(result.getInt(1)).isGreaterThan(0); + } + } + + String environment = Files.readString(STATE_ROOT.resolve("application.env")); + assertThat(environment).contains("FINANCE_SETUP_ENABLED=false") + .contains("DB_URL=\"jdbc:mysql://") + .contains("DB_USERNAME=\"" + MYSQL.getUsername() + "\"") + .doesNotContain("SetupAdmin@2026Strong") + .doesNotContain(SETUP_CODE); + assertThat(Files.exists(STATE_ROOT.resolve("locked"))).isTrue(); + + ResponseEntity locked = rest.getForEntity(url("/api/v1/setup/status"), JsonNode.class); + assertThat(locked.getBody().path("data").path("required").asBoolean()).isFalse(); + assertThat(locked.getBody().path("data").path("locked").asBoolean()).isTrue(); + } + + private Map databaseRequest(String setupCode) { + Map request = new LinkedHashMap<>(); + request.put("setupCode", setupCode); + request.put("databaseType", "MYSQL"); + request.put("host", MYSQL.getHost()); + request.put("port", MYSQL.getFirstMappedPort()); + request.put("database", MYSQL.getDatabaseName()); + request.put("username", MYSQL.getUsername()); + request.put("password", MYSQL.getPassword()); + return request; + } + + private String url(String path) { + return "http://127.0.0.1:" + port + path; + } +} diff --git a/backend/src/test/java/com/kaidi/finance/setup/SetupContextSmokeTest.java b/backend/src/test/java/com/kaidi/finance/setup/SetupContextSmokeTest.java new file mode 100644 index 0000000..b57e68d --- /dev/null +++ b/backend/src/test/java/com/kaidi/finance/setup/SetupContextSmokeTest.java @@ -0,0 +1,59 @@ +package com.kaidi.finance.setup; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fasterxml.jackson.databind.JsonNode; +import com.kaidi.setup.SetupApplication; +import java.nio.file.Path; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.context.ActiveProfiles; + +@SpringBootTest(classes = SetupApplication.class, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("setup") +class SetupContextSmokeTest { + + private static final Path STATE_ROOT = Path.of("target", "setup-context-smoke-" + UUID.randomUUID()); + + @DynamicPropertySource + static void properties(DynamicPropertyRegistry registry) { + registry.add("finance.setup.enabled", () -> true); + registry.add("finance.setup.token-sha256", () -> + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + registry.add("finance.setup.env-file", () -> STATE_ROOT.resolve("application.env").toString()); + registry.add("finance.setup.marker-file", () -> STATE_ROOT.resolve("locked").toString()); + registry.add("finance.setup.restart-after-complete", () -> false); + } + + @Autowired + private TestRestTemplate rest; + + @LocalServerPort + private int port; + + @Test + void startsWithoutBusinessDatabaseAndExposesOnlySetupStatus() { + ResponseEntity status = rest.getForEntity(url("/api/v1/setup/status"), JsonNode.class); + + assertThat(status.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(status.getBody()).isNotNull(); + assertThat(status.getBody().path("data").path("required").asBoolean()).isTrue(); + assertThat(status.getBody().path("data").path("supportedDatabaseTypes").toString()) + .isEqualTo("[\"MYSQL\"]"); + + ResponseEntity business = rest.getForEntity(url("/api/v1/auth/session"), JsonNode.class); + assertThat(business.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + } + + private String url(String path) { + return "http://127.0.0.1:" + port + path; + } +} diff --git a/backend/src/test/java/com/kaidi/finance/shared/audit/DeniedAccessAuditServiceTest.java b/backend/src/test/java/com/kaidi/finance/shared/audit/DeniedAccessAuditServiceTest.java new file mode 100644 index 0000000..e1385ab --- /dev/null +++ b/backend/src/test/java/com/kaidi/finance/shared/audit/DeniedAccessAuditServiceTest.java @@ -0,0 +1,59 @@ +package com.kaidi.finance.shared.audit; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.mock.web.MockHttpServletRequest; + +class DeniedAccessAuditServiceTest { + + @Test + void recordsOnlyOncePerRequest() { + DeniedAccessAuditWriter writer = org.mockito.Mockito.mock(DeniedAccessAuditWriter.class); + DeniedAccessAuditService service = new DeniedAccessAuditService(writer); + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/v1/restricted"); + + service.record(request, HttpStatus.FORBIDDEN, "PERMISSION_DENIED"); + service.record(request, HttpStatus.FORBIDDEN, "DATA_SCOPE_DENIED"); + + verify(writer, times(1)).record("GET", "/api/v1/restricted", 403, "PERMISSION_DENIED"); + } + + @Test + void ignoresResponsesThatAreNotAuthenticationOrAuthorizationDenials() { + DeniedAccessAuditWriter writer = org.mockito.Mockito.mock(DeniedAccessAuditWriter.class); + DeniedAccessAuditService service = new DeniedAccessAuditService(writer); + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/v1/forms"); + + service.record(request, HttpStatus.UNPROCESSABLE_ENTITY, "VALIDATION_FAILED"); + + verifyNoInteractions(writer); + } + + @Test + void auditFailureDoesNotReplaceTheOriginalDeniedResponse() { + DeniedAccessAuditWriter writer = org.mockito.Mockito.mock(DeniedAccessAuditWriter.class); + DeniedAccessAuditService service = new DeniedAccessAuditService(writer); + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/v1/restricted"); + doThrow(new IllegalStateException("database unavailable")) + .when(writer).record("GET", "/api/v1/restricted", 403, "PERMISSION_DENIED"); + + assertDoesNotThrow(() -> service.record(request, HttpStatus.FORBIDDEN, "PERMISSION_DENIED")); + } + + @Test + void doesNotDuplicateTheExistingLoginFailureAudit() { + DeniedAccessAuditWriter writer = org.mockito.Mockito.mock(DeniedAccessAuditWriter.class); + DeniedAccessAuditService service = new DeniedAccessAuditService(writer); + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/v1/auth/login"); + + service.record(request, HttpStatus.UNAUTHORIZED, "AUTH_BAD_CREDENTIALS"); + + verifyNoInteractions(writer); + } +} diff --git a/backend/src/test/java/com/kaidi/finance/shared/file/FileIntegrationTest.java b/backend/src/test/java/com/kaidi/finance/shared/file/FileIntegrationTest.java new file mode 100644 index 0000000..d3ba5ed --- /dev/null +++ b/backend/src/test/java/com/kaidi/finance/shared/file/FileIntegrationTest.java @@ -0,0 +1,390 @@ +package com.kaidi.finance.shared.file; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import jakarta.servlet.http.Cookie; +import java.nio.file.Path; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +@Testcontainers(disabledWithoutDocker = true) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@Import(FileIntegrationTest.ScannerTestConfiguration.class) +class FileIntegrationTest { + + private static String idempotencyKey() { + return "test-file-upload-" + java.util.UUID.randomUUID(); + } + + @Container + static final MySQLContainer MYSQL = new MySQLContainer<>("mysql:8.4") + .withDatabaseName("kaidi_finance_file_test") + .withUsername("kaidi") + .withPassword("kaidi_test_password"); + + @DynamicPropertySource + static void configureDatabase(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", MYSQL::getJdbcUrl); + registry.add("spring.datasource.username", MYSQL::getUsername); + registry.add("spring.datasource.password", MYSQL::getPassword); + registry.add("finance.bootstrap.enabled", () -> true); + registry.add("finance.bootstrap.password", () -> "LocalOnly@123"); + } + + @Autowired + MockMvc mockMvc; + + @Autowired + JdbcTemplate jdbcTemplate; + + @Autowired + TestFileScanner fileScanner; + + @Autowired + FileStorageProperties storageProperties; + + @BeforeEach + void resetScanner() { + fileScanner.reset(); + } + + @Test + void uploadAndDownloadUseAuthenticatedRoleAndControlledPath() throws Exception { + ClientSession session = loginAndSelectProjectRole(); + MockMultipartFile file = new MockMultipartFile("file", "evidence.csv", "text/csv", + "project,amount\nP-001,100.00\n".getBytes()); + + MvcResult upload = mockMvc.perform(multipart("/api/v1/files") + .file(file) + .cookie(session.cookies()) + .header("X-XSRF-TOKEN", session.csrfToken()).header("Idempotency-Key", idempotencyKey())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.publicId").isNotEmpty()) + .andExpect(jsonPath("$.data.sha256").isNotEmpty()) + .andExpect(jsonPath("$.data.scanStatus").value("AVAILABLE")) + .andReturn(); + String publicId = com.jayway.jsonpath.JsonPath.read(upload.getResponse().getContentAsString(), + "$.data.publicId"); + + mockMvc.perform(get("/api/v1/files/{id}/content", publicId).cookie(session.cookies())) + .andExpect(status().isOk()) + .andExpect(header().string("Content-Disposition", org.hamcrest.Matchers.containsString("evidence.csv"))) + .andExpect(header().string("X-Content-Type-Options", "nosniff")); + } + + @Test + void genericFileUrlsDoNotExposeAnotherUsersUnlinkedFile() throws Exception { + ClientSession uploader = loginAndSelect("project", "PROJECT_MANAGER"); + MockMultipartFile file = new MockMultipartFile("file", "private.csv", "text/csv", + "private,amount\nP-PRIVATE,1.00\n".getBytes()); + MvcResult upload = mockMvc.perform(multipart("/api/v1/files") + .file(file).cookie(uploader.cookies()).header("X-XSRF-TOKEN", uploader.csrfToken()) + .header("Idempotency-Key", idempotencyKey())) + .andExpect(status().isOk()).andReturn(); + String publicId = com.jayway.jsonpath.JsonPath.read(upload.getResponse().getContentAsString(), + "$.data.publicId"); + ClientSession finance = loginAndSelect("finance", "FINANCE_MANAGER"); + + mockMvc.perform(get("/api/v1/files/{id}", publicId).cookie(finance.cookies())) + .andExpect(status().isForbidden()).andExpect(jsonPath("$.code").value("DATA_SCOPE_DENIED")); + mockMvc.perform(get("/api/v1/files/{id}/content", publicId).cookie(finance.cookies())) + .andExpect(status().isForbidden()).andExpect(jsonPath("$.code").value("DATA_SCOPE_DENIED")); + } + + @Test + void emptyAndUnsupportedFilesAreRejected() throws Exception { + ClientSession session = loginAndSelectProjectRole(); + MockMultipartFile empty = new MockMultipartFile("file", "empty.csv", "text/csv", new byte[0]); + mockMvc.perform(multipart("/api/v1/files").file(empty).cookie(session.cookies()) + .header("X-XSRF-TOKEN", session.csrfToken()).header("Idempotency-Key", idempotencyKey())) + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("FILE_EMPTY")); + + MockMultipartFile executable = new MockMultipartFile("file", "payload.exe", + "application/octet-stream", new byte[]{1, 2, 3}); + mockMvc.perform(multipart("/api/v1/files").file(executable).cookie(session.cookies()) + .header("X-XSRF-TOKEN", session.csrfToken()).header("Idempotency-Key", idempotencyKey())) + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("FILE_TYPE_NOT_ALLOWED")); + } + + @Test + void infectedFilesAreRejectedAndCannotBeDownloaded() throws Exception { + ClientSession session = loginAndSelectProjectRole(); + fileScanner.result = FileScanner.ScanResult.infected("Eicar-Test-Signature FOUND"); + MockMultipartFile file = new MockMultipartFile("file", "infected.csv", "text/csv", + "malicious-test-content".getBytes()); + + mockMvc.perform(multipart("/api/v1/files") + .file(file) + .cookie(session.cookies()) + .header("X-XSRF-TOKEN", session.csrfToken()).header("Idempotency-Key", idempotencyKey())) + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("FILE_REJECTED")); + + String publicId = latestFileId(); + org.junit.jupiter.api.Assertions.assertEquals("REJECTED", latestScanStatus()); + mockMvc.perform(get("/api/v1/files/{id}/content", publicId).cookie(session.cookies())) + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("FILE_REJECTED")); + } + + @Test + void unavailableScannerQuarantinesFileAndBlocksDownload() throws Exception { + ClientSession session = loginAndSelectProjectRole(); + fileScanner.result = FileScanner.ScanResult.unavailable("scanner offline"); + MockMultipartFile file = new MockMultipartFile("file", "pending.csv", "text/csv", + "project,amount\nP-002,200.00\n".getBytes()); + String key = idempotencyKey(); + + mockMvc.perform(multipart("/api/v1/files") + .file(file) + .cookie(session.cookies()) + .header("X-XSRF-TOKEN", session.csrfToken()).header("Idempotency-Key", key)) + .andExpect(status().isServiceUnavailable()) + .andExpect(jsonPath("$.code").value("FILE_SCANNER_UNAVAILABLE")); + + String publicId = latestFileId(); + org.junit.jupiter.api.Assertions.assertEquals("QUARANTINED", latestScanStatus()); + mockMvc.perform(multipart("/api/v1/files") + .file(file) + .cookie(session.cookies()) + .header("X-XSRF-TOKEN", session.csrfToken()).header("Idempotency-Key", key)) + .andExpect(status().isServiceUnavailable()) + .andExpect(jsonPath("$.code").value("FILE_SCANNER_UNAVAILABLE")); + org.junit.jupiter.api.Assertions.assertEquals(1, jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM file_object WHERE public_id=?", Integer.class, publicId)); + org.junit.jupiter.api.Assertions.assertEquals(1, jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM sys_idempotency_record WHERE idempotency_key=? AND state='COMPLETED' " + + "AND response_status=503", Integer.class, key)); + org.junit.jupiter.api.Assertions.assertEquals(1, fileScanner.calls()); + mockMvc.perform(get("/api/v1/files/{id}/content", publicId).cookie(session.cookies())) + .andExpect(status().isServiceUnavailable()) + .andExpect(jsonPath("$.code").value("FILE_SCANNER_UNAVAILABLE")); + + String quarantinedStorageKey = jdbcTemplate.queryForObject( + "SELECT storage_key FROM file_object WHERE public_id=?", String.class, publicId); + org.junit.jupiter.api.Assertions.assertTrue(quarantinedStorageKey.startsWith(".quarantine/")); + + String unavailableRescanKey = idempotencyKey(); + mockMvc.perform(post("/api/v1/files/{id}/rescan", publicId).cookie(session.cookies()) + .header("X-XSRF-TOKEN", session.csrfToken()).header("Idempotency-Key", unavailableRescanKey)) + .andExpect(status().isServiceUnavailable()) + .andExpect(jsonPath("$.code").value("FILE_SCANNER_UNAVAILABLE")); + mockMvc.perform(post("/api/v1/files/{id}/rescan", publicId).cookie(session.cookies()) + .header("X-XSRF-TOKEN", session.csrfToken()).header("Idempotency-Key", unavailableRescanKey)) + .andExpect(status().isServiceUnavailable()) + .andExpect(jsonPath("$.code").value("FILE_SCANNER_UNAVAILABLE")); + org.junit.jupiter.api.Assertions.assertEquals(2, fileScanner.calls()); + org.junit.jupiter.api.Assertions.assertEquals(1, jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM audit_log WHERE object_public_id=? AND action_code='FILE_RESCAN' " + + "AND result_code='UNAVAILABLE'", Integer.class, publicId)); + + fileScanner.result = FileScanner.ScanResult.clean("scanner recovered"); + String rescanKey = idempotencyKey(); + mockMvc.perform(post("/api/v1/files/{id}/rescan", publicId).cookie(session.cookies()) + .header("X-XSRF-TOKEN", session.csrfToken()).header("Idempotency-Key", rescanKey)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.publicId").value(publicId)) + .andExpect(jsonPath("$.data.scanStatus").value("AVAILABLE")) + .andExpect(jsonPath("$.data.scanDetail").value("scanner recovered")); + mockMvc.perform(post("/api/v1/files/{id}/rescan", publicId).cookie(session.cookies()) + .header("X-XSRF-TOKEN", session.csrfToken()).header("Idempotency-Key", rescanKey)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.scanStatus").value("AVAILABLE")); + org.junit.jupiter.api.Assertions.assertEquals(3, fileScanner.calls()); + org.junit.jupiter.api.Assertions.assertEquals("AVAILABLE", latestScanStatus()); + String availableStorageKey = jdbcTemplate.queryForObject( + "SELECT storage_key FROM file_object WHERE public_id=?", String.class, publicId); + org.junit.jupiter.api.Assertions.assertFalse(availableStorageKey.startsWith(".quarantine/")); + mockMvc.perform(get("/api/v1/files/{id}/content", publicId).cookie(session.cookies())) + .andExpect(status().isOk()); + org.junit.jupiter.api.Assertions.assertEquals(1, jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM audit_log WHERE object_public_id=? AND action_code='FILE_RESCAN' " + + "AND result_code='SUCCESS'", Integer.class, publicId)); + org.junit.jupiter.api.Assertions.assertEquals(1, jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM sys_idempotency_record WHERE idempotency_key=? AND state='COMPLETED' " + + "AND response_status=200", Integer.class, rescanKey)); + mockMvc.perform(post("/api/v1/files/{id}/rescan", publicId).cookie(session.cookies()) + .header("X-XSRF-TOKEN", session.csrfToken()).header("Idempotency-Key", unavailableRescanKey)) + .andExpect(status().isServiceUnavailable()) + .andExpect(jsonPath("$.code").value("FILE_SCANNER_UNAVAILABLE")); + org.junit.jupiter.api.Assertions.assertEquals(3, fileScanner.calls()); + } + + @Test + void infectedQuarantinedFileBecomesRejectedAfterRescan() throws Exception { + ClientSession session = loginAndSelectProjectRole(); + fileScanner.result = FileScanner.ScanResult.unavailable("scanner offline"); + MockMultipartFile file = new MockMultipartFile("file", "rescan-infected.csv", "text/csv", + "infected-after-recovery".getBytes()); + mockMvc.perform(multipart("/api/v1/files").file(file).cookie(session.cookies()) + .header("X-XSRF-TOKEN", session.csrfToken()).header("Idempotency-Key", idempotencyKey())) + .andExpect(status().isServiceUnavailable()); + String publicId = latestFileId(); + + fileScanner.result = FileScanner.ScanResult.infected("Eicar-Test-Signature FOUND"); + String rescanKey = idempotencyKey(); + mockMvc.perform(post("/api/v1/files/{id}/rescan", publicId).cookie(session.cookies()) + .header("X-XSRF-TOKEN", session.csrfToken()).header("Idempotency-Key", rescanKey)) + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("FILE_REJECTED")); + mockMvc.perform(post("/api/v1/files/{id}/rescan", publicId).cookie(session.cookies()) + .header("X-XSRF-TOKEN", session.csrfToken()).header("Idempotency-Key", rescanKey)) + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("FILE_REJECTED")); + org.junit.jupiter.api.Assertions.assertEquals(2, fileScanner.calls()); + org.junit.jupiter.api.Assertions.assertEquals("REJECTED", latestScanStatus()); + mockMvc.perform(get("/api/v1/files/{id}/content", publicId).cookie(session.cookies())) + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("FILE_REJECTED")); + org.junit.jupiter.api.Assertions.assertEquals(1, jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM audit_log WHERE object_public_id=? AND action_code='FILE_RESCAN' " + + "AND result_code='REJECTED'", Integer.class, publicId)); + org.junit.jupiter.api.Assertions.assertEquals(1, jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM sys_idempotency_record WHERE idempotency_key=? AND state='COMPLETED' " + + "AND response_status=422", Integer.class, rescanKey)); + } + + @Test + void pendingPromotionRecoversWhenFileWasMovedBeforeDatabaseCommit() throws Exception { + ClientSession session = loginAndSelectProjectRole(); + fileScanner.result = FileScanner.ScanResult.unavailable("scanner offline"); + MockMultipartFile file = new MockMultipartFile("file", "recover-promotion.csv", "text/csv", + "recoverable-content".getBytes()); + mockMvc.perform(multipart("/api/v1/files").file(file).cookie(session.cookies()) + .header("X-XSRF-TOKEN", session.csrfToken()).header("Idempotency-Key", idempotencyKey())) + .andExpect(status().isServiceUnavailable()); + String publicId = latestFileId(); + String sourceKey = jdbcTemplate.queryForObject( + "SELECT storage_key FROM file_object WHERE public_id=?", String.class, publicId); + String targetKey = java.time.LocalDate.now(java.time.ZoneOffset.UTC) + "/" + publicId + ".csv"; + jdbcTemplate.update("UPDATE file_object SET promotion_storage_key=?, scan_detail='scanner recovered', " + + "scanned_at=UTC_TIMESTAMP() WHERE public_id=?", targetKey, publicId); + Path source = storageProperties.rootPath().resolve(sourceKey); + Path target = storageProperties.rootPath().resolve(targetKey); + java.nio.file.Files.createDirectories(target.getParent()); + java.nio.file.Files.move(source, target, java.nio.file.StandardCopyOption.ATOMIC_MOVE); + + int callsBeforeRecovery = fileScanner.calls(); + mockMvc.perform(post("/api/v1/files/{id}/rescan", publicId).cookie(session.cookies()) + .header("X-XSRF-TOKEN", session.csrfToken()).header("Idempotency-Key", idempotencyKey())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.scanStatus").value("AVAILABLE")); + + org.junit.jupiter.api.Assertions.assertEquals(callsBeforeRecovery, fileScanner.calls()); + org.junit.jupiter.api.Assertions.assertEquals("AVAILABLE", latestScanStatus()); + org.junit.jupiter.api.Assertions.assertEquals(targetKey, jdbcTemplate.queryForObject( + "SELECT storage_key FROM file_object WHERE public_id=?", String.class, publicId)); + org.junit.jupiter.api.Assertions.assertEquals(0, jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM file_object WHERE public_id=? AND promotion_storage_key IS NOT NULL", + Integer.class, publicId)); + mockMvc.perform(get("/api/v1/files/{id}/content", publicId).cookie(session.cookies())) + .andExpect(status().isOk()); + } + + private String latestFileId() { + return jdbcTemplate.queryForObject("SELECT public_id FROM file_object ORDER BY id DESC LIMIT 1", String.class); + } + + private String latestScanStatus() { + return jdbcTemplate.queryForObject("SELECT scan_status FROM file_object ORDER BY id DESC LIMIT 1", String.class); + } + + private ClientSession loginAndSelectProjectRole() throws Exception { + return loginAndSelect("project", "PROJECT_MANAGER"); + } + + private ClientSession loginAndSelect(String username, String role) throws Exception { + MvcResult csrf = mockMvc.perform(get("/api/v1/auth/csrf")) + .andExpect(status().isOk()) + .andReturn(); + Cookie csrfCookie = csrf.getResponse().getCookie("XSRF-TOKEN"); + assertNotNull(csrfCookie, "CSRF endpoint must issue the readable CSRF cookie"); + String csrfToken = com.jayway.jsonpath.JsonPath.read(csrf.getResponse().getContentAsString(), + "$.data.token"); + MvcResult login = mockMvc.perform(post("/api/v1/auth/login") + .cookie(csrfCookie) + .header("X-XSRF-TOKEN", csrfToken) + .contentType("application/json") + .content("{\"username\":\"%s\",\"password\":\"LocalOnly@123\"}".formatted(username))) + .andExpect(status().isOk()) + .andReturn(); + Cookie session = login.getResponse().getCookie("KAIDI_SESSION"); + assertNotNull(session, "login must issue the Spring Session cookie"); + MvcResult selected = mockMvc.perform(post("/api/v1/auth/select-role") + .cookie(session, csrfCookie) + .header("X-XSRF-TOKEN", csrfToken) + .contentType("application/json") + .content("{\"roleCode\":\"%s\"}".formatted(role))) + .andExpect(status().isOk()) + .andReturn(); + Cookie rotated = selected.getResponse().getCookie("KAIDI_SESSION"); + return new ClientSession(rotated == null ? session : rotated, csrfCookie, csrfToken); + } + + private record ClientSession(Cookie sessionCookie, Cookie csrfCookie, String csrfToken) { + Cookie[] cookies() { + return new Cookie[]{sessionCookie, csrfCookie}; + } + } + + @TestConfiguration(proxyBeanMethods = false) + static class ScannerTestConfiguration { + + @Bean + @Primary + TestFileScanner testFileScanner() { + return new TestFileScanner(); + } + } + + static final class TestFileScanner implements FileScanner { + + private final AtomicInteger calls = new AtomicInteger(); + private ScanResult result = ScanResult.clean("test scanner: clean"); + + @Override + public ScanResult scan(Path path) { + calls.incrementAndGet(); + return result; + } + + int calls() { + return calls.get(); + } + + void reset() { + calls.set(0); + result = ScanResult.clean("test scanner: clean"); + } + } +} diff --git a/backend/src/test/java/com/kaidi/finance/shared/file/FileScannerConfigurationTest.java b/backend/src/test/java/com/kaidi/finance/shared/file/FileScannerConfigurationTest.java new file mode 100644 index 0000000..6243555 --- /dev/null +++ b/backend/src/test/java/com/kaidi/finance/shared/file/FileScannerConfigurationTest.java @@ -0,0 +1,40 @@ +package com.kaidi.finance.shared.file; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.nio.file.Path; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.core.env.MapPropertySource; + +class FileScannerConfigurationTest { + + @Test + void localProfileAllowsTrustedFixtureUploadsWhenExternalScannerIsDisabled() { + try (AnnotationConfigApplicationContext context = context("local", false)) { + FileScanner.ScanResult result = context.getBean(FileScanner.class).scan(Path.of("fixture.pdf")); + assertEquals(FileScanner.Status.CLEAN, result.status()); + } + } + + @Test + void nonLocalProfileKeepsUploadsQuarantinedWhenScannerIsDisabled() { + try (AnnotationConfigApplicationContext context = context("production", false)) { + FileScanner.ScanResult result = context.getBean(FileScanner.class).scan(Path.of("fixture.pdf")); + assertEquals(FileScanner.Status.UNAVAILABLE, result.status()); + } + } + + private AnnotationConfigApplicationContext context(String profile, boolean scannerEnabled) { + AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(); + context.getEnvironment().setActiveProfiles(profile); + context.getEnvironment().getPropertySources().addFirst(new MapPropertySource( + "test", + Map.of("finance.storage.scanner.enabled", Boolean.toString(scannerEnabled)) + )); + context.register(FileScannerConfiguration.class); + context.refresh(); + return context; + } +} diff --git a/backend/src/test/java/com/kaidi/finance/shared/security/FieldEncryptionKeyTest.java b/backend/src/test/java/com/kaidi/finance/shared/security/FieldEncryptionKeyTest.java new file mode 100644 index 0000000..96a9460 --- /dev/null +++ b/backend/src/test/java/com/kaidi/finance/shared/security/FieldEncryptionKeyTest.java @@ -0,0 +1,30 @@ +package com.kaidi.finance.shared.security; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.Base64; +import org.junit.jupiter.api.Test; + +class FieldEncryptionKeyTest { + + @Test + void rejectsTheKnownDevelopmentKeyWhenProductionGuardIsEnabled() { + assertThrows(IllegalStateException.class, + () -> FieldEncryptionKey.decode(FieldEncryptionKey.LOCAL_DEVELOPMENT_DEFAULT, true)); + } + + @Test + void acceptsAValidInjectedKey() { + byte[] expected = new byte[32]; + for (int index = 0; index < expected.length; index++) expected[index] = (byte) index; + assertArrayEquals(expected, FieldEncryptionKey.decode(Base64.getEncoder().encodeToString(expected), true)); + } + + @Test + void rejectsMissingAndMalformedKeys() { + assertThrows(IllegalStateException.class, () -> FieldEncryptionKey.decode(null, true)); + assertThrows(IllegalStateException.class, () -> FieldEncryptionKey.decode("not-base64", true)); + assertThrows(IllegalStateException.class, () -> FieldEncryptionKey.decode("AA==", true)); + } +} diff --git a/backend/src/test/java/com/kaidi/finance/shared/security/ProductionFieldEncryptionConfigurationTest.java b/backend/src/test/java/com/kaidi/finance/shared/security/ProductionFieldEncryptionConfigurationTest.java new file mode 100644 index 0000000..456db69 --- /dev/null +++ b/backend/src/test/java/com/kaidi/finance/shared/security/ProductionFieldEncryptionConfigurationTest.java @@ -0,0 +1,85 @@ +package com.kaidi.finance.shared.security; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.kaidi.finance.masterdata.application.BankAccountProtector; +import com.kaidi.finance.source.application.SensitiveValueCipher; +import java.io.IOException; +import java.util.Base64; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.springframework.beans.BeansException; +import org.springframework.boot.env.YamlPropertySourceLoader; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.core.env.MapPropertySource; +import org.springframework.core.env.PropertySource; +import org.springframework.core.io.ClassPathResource; + +class ProductionFieldEncryptionConfigurationTest { + + @Test + void productionProfileRejectsAMissingFieldEncryptionKey() { + assertThrows(BeansException.class, () -> refreshProductionContext(null)); + } + + @Test + void productionProfileRejectsTheLocalDevelopmentKey() { + assertThrows(BeansException.class, + () -> refreshProductionContext(FieldEncryptionKey.LOCAL_DEVELOPMENT_DEFAULT)); + } + + @Test + void productionProfileAcceptsAnInjectedAes256KeyForBothSensitiveComponents() { + byte[] key = new byte[32]; + java.util.Arrays.fill(key, (byte) 0x5a); + String encodedKey = Base64.getEncoder().encodeToString(key); + + try (AnnotationConfigApplicationContext context = refreshProductionContext(encodedKey)) { + assertTrue(context.getEnvironment().getProperty("finance.crypto.reject-default", Boolean.class)); + assertTrue(context.containsBeanDefinition("bankAccountProtector")); + assertTrue(context.containsBeanDefinition("sensitiveValueCipher")); + assertFalse(encodedKey.equals(FieldEncryptionKey.LOCAL_DEVELOPMENT_DEFAULT)); + } + } + + private AnnotationConfigApplicationContext refreshProductionContext(String encodedKey) { + AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(); + try { + context.getEnvironment().setActiveProfiles("production"); + loadConfiguration(context, "application.yml", false); + loadConfiguration(context, "application-production.yml", true); + if (encodedKey != null) { + context.getEnvironment().getPropertySources().addFirst(new MapPropertySource( + "injected-field-key", Map.of("FIELD_ENCRYPTION_KEY", encodedKey) + )); + } + context.registerBean(ObjectMapper.class, () -> new ObjectMapper()); + context.register(BankAccountProtector.class, SensitiveValueCipher.class); + context.refresh(); + return context; + } catch (RuntimeException exception) { + context.close(); + throw exception; + } + } + + private void loadConfiguration(AnnotationConfigApplicationContext context, String resource, + boolean highestPrecedence) { + try { + for (PropertySource propertySource : new YamlPropertySourceLoader() + .load(resource, new ClassPathResource(resource))) { + if (highestPrecedence) { + context.getEnvironment().getPropertySources().addFirst(propertySource); + } else { + context.getEnvironment().getPropertySources().addLast(propertySource); + } + } + } catch (IOException exception) { + context.close(); + throw new IllegalStateException("Failed to load " + resource, exception); + } + } +} diff --git a/backend/src/test/java/com/kaidi/finance/source/SourceWorkflowIntegrationTest.java b/backend/src/test/java/com/kaidi/finance/source/SourceWorkflowIntegrationTest.java new file mode 100644 index 0000000..d725906 --- /dev/null +++ b/backend/src/test/java/com/kaidi/finance/source/SourceWorkflowIntegrationTest.java @@ -0,0 +1,3262 @@ +package com.kaidi.finance.source; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.jayway.jsonpath.JsonPath; +import com.kaidi.finance.archive.infrastructure.ArchiveMapper; +import com.kaidi.finance.shared.file.FileScanner; +import jakarta.servlet.http.Cookie; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +@Testcontainers(disabledWithoutDocker = true) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@Import(SourceWorkflowIntegrationTest.ScannerTestConfiguration.class) +class SourceWorkflowIntegrationTest { + + private static final AtomicInteger SEQUENCE = new AtomicInteger(); + + private static String idempotencyKey() { + return "test-source-file-" + java.util.UUID.randomUUID(); + } + + @Container + static final MySQLContainer MYSQL = new MySQLContainer<>("mysql:8.4") + .withDatabaseName("kaidi_finance_source_test") + .withUsername("kaidi") + .withPassword("kaidi_test_password"); + + @DynamicPropertySource + static void configureDatabase(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", MYSQL::getJdbcUrl); + registry.add("spring.datasource.username", MYSQL::getUsername); + registry.add("spring.datasource.password", MYSQL::getPassword); + registry.add("finance.bootstrap.enabled", () -> true); + registry.add("finance.bootstrap.password", () -> "LocalOnly@123"); + } + + @Autowired + MockMvc mockMvc; + + @Autowired + JdbcTemplate jdbcTemplate; + + @Autowired + ArchiveMapper archiveMapper; + + @Test + void templatesExposeAllFourteenVersionedDefinitions() throws Exception { + ClientSession project = loginAndSelect("project", "PROJECT_MANAGER"); + + mockMvc.perform(get("/api/v1/source/templates").cookie(project.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.length()").value(14)) + .andExpect(jsonPath("$.data[?(@.formType == 'OA-01')].groupName").value("项目准备")) + .andExpect(jsonPath("$.data[?(@.formType == 'OA-05')].templateVersion").value(2)) + .andExpect(jsonPath("$.data[?(@.formType == 'OA-04')].templateVersion").value(2)) + .andExpect(jsonPath("$.data[?(@.formType == 'OA-06')].templateVersion").value(2)) + .andExpect(jsonPath("$.data[?(@.formType == 'OA-07')].templateVersion").value(2)) + .andExpect(jsonPath("$.data[?(@.formType == 'OA-08')].templateVersion").value(2)) + .andExpect(jsonPath("$.data[?(@.formType == 'OA-09')].canCreate").value(true)) + .andExpect(jsonPath("$.data[?(@.formType == 'OA-01')].fields").isNotEmpty()) + .andExpect(jsonPath("$.data[?(@.formType == 'OA-08')].fields").isNotEmpty()) + .andExpect(jsonPath("$.data[?(@.formType == 'OA-08')].fields[?(@.code == 'contractId')].dataType") + .value("REFERENCE")); + + mockMvc.perform(get("/api/v1/source/templates/OA-01/versions/1").cookie(project.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.fields[?(@.code == 'awardAmount')].dataType").value("DECIMAL")) + .andExpect(jsonPath("$.data.fields[?(@.code == 'winningPeople')].repeatable").value(true)); + + mockMvc.perform(get("/api/v1/source/templates/OA-08/versions/2").cookie(project.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.fields[?(@.code == 'contractId')].dataType").value("REFERENCE")) + .andExpect(jsonPath("$.data.fields[?(@.code == 'counterpartyId')].dataType").value("REFERENCE")) + .andExpect(jsonPath("$.data.fields[?(@.code == 'invoiceType')].dataType").value("ENUM")) + .andExpect(jsonPath("$.data.fields[?(@.code == 'taxRate')].numericScale").value(6)); + + mockMvc.perform(get("/api/v1/source/templates/OA-04/versions/2").cookie(project.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.fields[?(@.code == 'contractId')].dataType").value("REFERENCE")) + .andExpect(jsonPath("$.data.fields[?(@.code == 'supplierId')].dataType").value("REFERENCE")) + .andExpect(jsonPath("$.data.fields[?(@.code == 'costCategoryId')].dataType").value("REFERENCE")) + .andExpect(jsonPath("$.data.fields[?(@.code == 'invoiceDetails')].options[0].required").value(true)); + + mockMvc.perform(get("/api/v1/source/templates/OA-06/versions/2").cookie(project.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.fields[?(@.code == 'contractId')].dataType").value("REFERENCE")) + .andExpect(jsonPath("$.data.fields[?(@.code == 'supplierId')].dataType").value("REFERENCE")) + .andExpect(jsonPath("$.data.fields[?(@.code == 'payableId')].dataType").value("REFERENCE")) + .andExpect(jsonPath("$.data.fields[?(@.code == 'paymentDetails')].repeatable").value(false)); + + String oa02ColumnRuleCountSql = """ + SELECT COUNT(*) + FROM src_template template + JOIN src_template_field field ON field.template_id = template.id + WHERE template.form_type = 'OA-02' + AND template.template_version = ? + AND field.field_code = 'bankAccounts' + AND JSON_SEARCH(field.options_json, 'one', 'accountCategory', NULL, '$[*].code') IS NOT NULL + """; + assertEquals(0, jdbcTemplate.queryForObject(oa02ColumnRuleCountSql, Integer.class, 1)); + assertEquals(1, jdbcTemplate.queryForObject(oa02ColumnRuleCountSql, Integer.class, 2)); + } + + @Test + void sourceFormListFiltersByGroupVersionBeijingCreationDateAndStableSort() throws Exception { + Fixtures fixtures = fixtures(); + ClientSession project = loginAndSelect("project", "PROJECT_MANAGER"); + String suffix = nextSuffix(); + String keyword = "LIST-" + suffix; + + MvcResult first = postJson("/api/v1/source/forms", project, sourcePayload( + "OA-05", 2, keyword + "-BEFORE", fixtures, "{}" + )).andExpect(status().isOk()).andReturn(); + MvcResult second = postJson("/api/v1/source/forms", project, sourcePayload( + "OA-05", 2, keyword + "-BOUNDARY", fixtures, "{}" + )).andExpect(status().isOk()).andReturn(); + MvcResult third = postJson("/api/v1/source/forms", project, sourcePayload( + "OA-14", 1, keyword + "-OTHER-GROUP", fixtures, "{}" + )).andExpect(status().isOk()).andReturn(); + String firstId = JsonPath.read(first.getResponse().getContentAsString(), "$.data.summary.publicId"); + String secondId = JsonPath.read(second.getResponse().getContentAsString(), "$.data.summary.publicId"); + String thirdId = JsonPath.read(third.getResponse().getContentAsString(), "$.data.summary.publicId"); + + jdbcTemplate.update( + "UPDATE src_source_document SET business_no = ?, created_at = ? WHERE public_id = ?", + keyword + "-B", java.sql.Timestamp.valueOf("2026-08-10 15:59:59"), firstId + ); + jdbcTemplate.update( + "UPDATE src_source_document SET business_no = ?, created_at = ? WHERE public_id = ?", + keyword + "-A", java.sql.Timestamp.valueOf("2026-08-10 16:00:00"), secondId + ); + jdbcTemplate.update( + "UPDATE src_source_document SET business_no = ?, created_at = ? WHERE public_id = ?", + keyword + "-C", java.sql.Timestamp.valueOf("2026-08-10 16:30:00"), thirdId + ); + + MvcResult groupAscending = mockMvc.perform(get("/api/v1/source/forms") + .cookie(project.cookies()) + .param("groupCode", "INCOME_EXPENSE") + .param("keyword", keyword) + .param("sort", "businessNo,asc") + .param("size", "100")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.meta.totalElements").value(2)) + .andReturn(); + assertEquals( + List.of(secondId, firstId), + JsonPath.read(groupAscending.getResponse().getContentAsString(), "$.data[*].publicId") + ); + + MvcResult groupDescending = mockMvc.perform(get("/api/v1/source/forms") + .cookie(project.cookies()) + .param("groupCode", "INCOME_EXPENSE") + .param("keyword", keyword) + .param("sort", "businessNo,desc") + .param("size", "100")) + .andExpect(status().isOk()) + .andReturn(); + assertEquals( + List.of(firstId, secondId), + JsonPath.read(groupDescending.getResponse().getContentAsString(), "$.data[*].publicId") + ); + + mockMvc.perform(get("/api/v1/source/forms") + .cookie(project.cookies()) + .param("templateVersion", "1") + .param("keyword", keyword)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.length()").value(1)) + .andExpect(jsonPath("$.data[0].publicId").value(thirdId)); + + mockMvc.perform(get("/api/v1/source/forms") + .cookie(project.cookies()) + .param("formType", "OA-05") + .param("keyword", keyword) + .param("createdDateFrom", "2026-08-11") + .param("createdDateTo", "2026-08-11")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.length()").value(1)) + .andExpect(jsonPath("$.data[0].publicId").value(secondId)); + + mockMvc.perform(get("/api/v1/source/forms") + .cookie(project.cookies()) + .param("createdDateFrom", "2026-08-12") + .param("createdDateTo", "2026-08-11")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("QUERY_PARAMETER_INVALID")); + mockMvc.perform(get("/api/v1/source/forms") + .cookie(project.cookies()) + .param("sort", "createdAt,sideways")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("QUERY_PARAMETER_INVALID")); + } + + @Test + void systemAdministratorCanManageCrossOwnerDraftButCannotActAsWorkflowApprover() throws Exception { + Fixtures fixtures = fixtures(); + ClientSession project = loginAndSelect("project", "PROJECT_MANAGER"); + ClientSession admin = loginAndSelect("admin", "SYSTEM_ADMIN"); + String sourceNo = "ADMIN-CROSS-OWNER-" + nextSuffix(); + String evidenceFileId = uploadFile(project, "admin-cross-owner.pdf"); + + MvcResult created = postJson("/api/v1/source/forms", project, sourcePayload( + "OA-05", sourceNo, fixtures, receiptValues(evidenceFileId, "超级管理员代管草稿") + )).andExpect(status().isOk()).andReturn(); + String documentId = JsonPath.read(created.getResponse().getContentAsString(), "$.data.summary.publicId"); + + mockMvc.perform(get("/api/v1/source/forms/{id}", documentId).cookie(admin.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.summary.allowedActions").value(org.hamcrest.Matchers.hasItem("SUBMIT"))); + postJson("/api/v1/source/forms/" + documentId + "/validate", admin, "{}") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.valid").value(true)) + .andExpect(jsonPath("$.data.canSubmit").value(true)); + postJson("/api/v1/source/forms/" + documentId + "/submit", admin, "{\"version\":0}") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.summary.status").value("PROJECT_REVIEW")); + + Map task = jdbcTemplate.queryForMap(""" + SELECT task.public_id, task.assignee_id, document.created_by + FROM wf_task task + JOIN wf_instance instance ON instance.id = task.instance_id + JOIN src_document_version version ON version.id = instance.source_version_id + JOIN src_source_document document ON document.id = version.source_document_id + WHERE document.public_id = ? AND task.status = 'PENDING' + """, documentId); + assertNotEquals(task.get("created_by"), task.get("assignee_id")); + + String taskId = (String) task.get("public_id"); + mockMvc.perform(get("/api/v1/workflow/tasks/{id}", taskId).cookie(admin.cookies())) + .andExpect(status().isOk()); + mockMvc.perform(get("/api/v1/workbenches/project").cookie(admin.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.todos[*].publicId", + org.hamcrest.Matchers.hasItem(taskId))); + mockMvc.perform(get("/api/v1/workbenches/finance").cookie(admin.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.todos[*].publicId", + org.hamcrest.Matchers.hasItem(taskId))); + postJson("/api/v1/workflow/tasks/" + taskId + "/approve", admin, + "{\"version\":0,\"opinion\":\"管理员不得冒充节点处理人\",\"returnPoints\":[]}") + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("PERMISSION_DENIED")); + } + + @Test + void approvedOa01KeepsActiveProjectAvailableForDownstreamBusiness() throws Exception { + Fixtures fixtures = fixtures(); + ClientSession project = loginAndSelect("project", "PROJECT_MANAGER"); + ClientSession projectReviewer = loginAndSelect("demo", "PROJECT_MANAGER"); + ClientSession financeReviewer = loginAndSelect("finance", "FINANCE_MANAGER"); + String sourceNo = "INIT-" + nextSuffix(); + String values = """ + {"awardDate":"2026-08-01","constructionUnit":"测试建设单位", + "projectAddress":"测试项目地址","awardAmount":1000000.00,"isEpc":false, + "costBudget":[{"originalAmount":700000.00,"incrementAmount":0, + "totalBudget":700000.00,"labor":200000.00,"material":300000.00, + "machinery":100000.00,"tax":60000.00,"other":40000.00}], + "paymentTerms":[{"description":"验收后付款","ratio":100,"amount":1000000.00, + "settlementMethod":"银行转账"}]} + """; + + MvcResult created = postJson("/api/v1/source/forms", project, sourcePayload( + "OA-01", sourceNo, fixtures, values + )).andExpect(status().isOk()).andReturn(); + String documentId = JsonPath.read(created.getResponse().getContentAsString(), "$.data.summary.publicId"); + postJson("/api/v1/source/forms/" + documentId + "/submit", project, "{\"version\":0}") + .andExpect(status().isOk()); + + MvcResult projectTasks = mockMvc.perform(get("/api/v1/workflow/tasks") + .cookie(projectReviewer.cookies()).param("view", "TODO")) + .andExpect(status().isOk()).andReturn(); + String projectTaskId = taskForDocument(projectTasks, documentId); + postJson("/api/v1/workflow/tasks/" + projectTaskId + "/approve", projectReviewer, + "{\"version\":0,\"opinion\":\"立项资料完整\",\"returnPoints\":[]}") + .andExpect(status().isOk()); + + MvcResult financeTasks = mockMvc.perform(get("/api/v1/workflow/tasks") + .cookie(financeReviewer.cookies()).param("view", "TODO")) + .andExpect(status().isOk()).andReturn(); + String financeTaskId = taskForDocument(financeTasks, documentId); + postJson("/api/v1/workflow/tasks/" + financeTaskId + "/approve", financeReviewer, + "{\"version\":0,\"opinion\":\"预算及付款约定复核通过\",\"returnPoints\":[]}") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.form.summary.status").value("APPROVED")); + + mockMvc.perform(get("/api/v1/projects/{id}", fixtures.projectId()).cookie(project.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("ACTIVE")); + mockMvc.perform(get("/api/v1/masterdata/references").cookie(financeReviewer.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.projects[?(@.publicId == '%s')]".formatted(fixtures.projectId())).exists()); + } + + @Test + void approvedOa02CreatesReviewingSupplierAndAccountsForSeparateReview() throws Exception { + Fixtures fixtures = fixtures(); + ClientSession project = loginAndSelect("project", "PROJECT_MANAGER"); + ClientSession projectReviewer = loginAndSelect("demo", "PROJECT_MANAGER"); + ClientSession financeReviewer = loginAndSelect("finance", "FINANCE_MANAGER"); + ClientSession independentFinanceReviewer = loginAndSelect("demo", "FINANCE_MANAGER"); + String suffix = nextSuffix(); + String sourceNo = "SUPPLIER-" + suffix; + String firstAccountNo = "62220000" + suffix; + String secondAccountNo = "62220001" + suffix; + String qualificationFileId = uploadFile(project, "oa02-qualification-" + suffix + ".pdf"); + String values = """ + {"supplierName":"投影供应商 %s","taxNo":"OA02-TAX-%s", + "contact":[{"name":"供应商联系人","phone":"1380000%s","product":"建筑材料"}], + "supplierType":"材料供应商","purchaseAmount":500000.00, + "bankAccounts":[ + {"accountName":"投影供应商 %s","bankName":"测试银行一支行","accountNo":"%s", + "accountCategory":"GENERAL","validFrom":"2026-08-01","reason":"日常结算"}, + {"accountName":"投影供应商 %s","bankName":"测试银行二支行","accountNo":"%s", + "accountCategory":"SPECIAL","validFrom":"2026-08-01","validTo":"2027-12-31", + "reason":"专项结算"}], + "qualifications":[{"name":"营业执照","certificateNo":"CERT-%s", + "validUntil":"2028-12-31","fileId":"%s"}]} + """.formatted( + suffix, suffix, suffix, suffix, firstAccountNo, suffix, secondAccountNo, suffix, + qualificationFileId + ); + + MvcResult created = postJson("/api/v1/source/forms", project, + sourcePayload("OA-02", 2, sourceNo, fixtures, values)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.summary.templateVersion").value(2)) + .andReturn(); + String documentId = JsonPath.read(created.getResponse().getContentAsString(), "$.data.summary.publicId"); + postJson("/api/v1/source/forms/" + documentId + "/submit", project, "{\"version\":0}") + .andExpect(status().isOk()); + assertEquals(0, projectionCount("md_counterparty", documentId)); + + String projectTaskId = pendingTaskForDocument(projectReviewer, documentId); + postJson("/api/v1/workflow/tasks/" + projectTaskId + "/approve", projectReviewer, + "{\"version\":0,\"opinion\":\"供应商资料初审通过\",\"returnPoints\":[]}") + .andExpect(status().isOk()); + assertEquals(0, projectionCount("md_counterparty", documentId)); + + String financeTaskId = pendingTaskForDocument(financeReviewer, documentId); + String approvalBody = "{\"version\":0,\"opinion\":\"税号账户资质审核通过\",\"returnPoints\":[]}"; + String approvalKey = idempotencyKey(); + postJson("/api/v1/workflow/tasks/" + financeTaskId + "/approve", financeReviewer, + approvalBody, approvalKey) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.form.summary.status").value("APPROVED")); + postJson("/api/v1/workflow/tasks/" + financeTaskId + "/approve", financeReviewer, + approvalBody, approvalKey) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.form.summary.status").value("APPROVED")); + + Map supplier = jdbcTemplate.queryForMap(""" + SELECT counterparty.public_id, counterparty.status, counterparty.version, + counterparty.submitted_by, counterparty.source_version_id, + document.current_version_id + FROM md_counterparty counterparty + JOIN src_source_document document ON document.id = counterparty.source_document_id + WHERE document.public_id = ? + """, documentId); + String supplierId = String.valueOf(supplier.get("public_id")); + assertEquals("REVIEWING", supplier.get("status")); + assertEquals(supplier.get("current_version_id"), supplier.get("source_version_id")); + assertEquals(2, projectionCount("md_bank_account_version", documentId)); + assertEquals(2, jdbcTemplate.queryForObject(""" + SELECT COUNT(DISTINCT account_no_hash) + FROM md_bank_account_version account + JOIN src_source_document document ON document.id = account.source_document_id + WHERE document.public_id = ? AND account.status = 'REVIEWING' + """, Integer.class, documentId)); + String storedPayload = jdbcTemplate.queryForObject(""" + SELECT CAST(version.payload_json AS CHAR) + FROM src_document_version version + JOIN src_source_document document ON document.current_version_id = version.id + WHERE document.public_id = ? + """, String.class, documentId); + assertNotNull(storedPayload); + assertFalse(storedPayload.contains(firstAccountNo)); + + postJson("/api/v1/masterdata/counterparties/" + supplierId + "/review", financeReviewer, + "{\"version\":0,\"decision\":\"APPROVE\",\"opinion\":\"本人不得复核\"}") + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("SOD_VIOLATION")); + + String accountId = jdbcTemplate.queryForObject(""" + SELECT account.public_id + FROM md_bank_account_version account + JOIN src_source_document document ON document.id = account.source_document_id + WHERE document.public_id = ? + ORDER BY account.source_row_no + LIMIT 1 + """, String.class, documentId); + mockMvc.perform(get("/api/v1/masterdata/counterparties/" + supplierId).cookie(financeReviewer.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("REVIEWING")); + mockMvc.perform(get("/api/v1/masterdata/bank-accounts/" + accountId).cookie(financeReviewer.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("REVIEWING")); + postJson("/api/v1/masterdata/bank-accounts/" + accountId + "/review", independentFinanceReviewer, + "{\"version\":0,\"decision\":\"APPROVE\",\"opinion\":\"账户复核通过\"}") + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("INVALID_STATE_TRANSITION")); + + postJson("/api/v1/masterdata/counterparties/" + supplierId + "/review", independentFinanceReviewer, + "{\"version\":0,\"decision\":\"APPROVE\",\"opinion\":\"供应商独立复核通过\"}") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("ACTIVE")); + postJson("/api/v1/masterdata/bank-accounts/" + accountId + "/review", independentFinanceReviewer, + "{\"version\":0,\"decision\":\"APPROVE\",\"opinion\":\"账户独立复核通过\"}") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("ACTIVE")); + mockMvc.perform(get("/api/v1/masterdata/counterparties/" + supplierId).cookie(financeReviewer.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("ACTIVE")); + mockMvc.perform(get("/api/v1/masterdata/bank-accounts/" + accountId).cookie(financeReviewer.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("ACTIVE")); + } + + @Test + void oa02SourceApplicantCannotReviewOwnProjectedMasterData() throws Exception { + Fixtures fixtures = fixtures(); + ClientSession applicant = loginAndSelect("demo", "PROJECT_MANAGER"); + ClientSession projectReviewer = loginAndSelect("project", "PROJECT_MANAGER"); + ClientSession financeReviewer = loginAndSelect("finance", "FINANCE_MANAGER"); + ClientSession applicantAsFinance = loginAndSelect("demo", "FINANCE_MANAGER"); + String suffix = nextSuffix(); + String qualificationFileId = uploadFile(applicant, "oa02-sod-qualification-" + suffix + ".pdf"); + String values = """ + {"supplierName":"申请人隔离供应商 %s","taxNo":"OA02-SOD-TAX-%s", + "contact":[{"name":"隔离联系人","phone":"1380000%s"}], + "supplierType":"材料供应商","purchaseAmount":1000.00, + "bankAccounts":[ + {"accountName":"申请人隔离供应商 %s","bankName":"隔离测试银行", + "accountNo":"63330000%s","accountCategory":"GENERAL","validFrom":"2026-08-01"}], + "qualifications":[{"name":"营业执照","certificateNo":"SOD-CERT-%s", + "validUntil":"2028-12-31","fileId":"%s"}]} + """.formatted(suffix, suffix, suffix, suffix, suffix, suffix, qualificationFileId); + + MvcResult created = postJson("/api/v1/source/forms", applicant, + sourcePayload("OA-02", 2, "SUPPLIER-SOD-" + suffix, fixtures, values)) + .andExpect(status().isOk()) + .andReturn(); + String documentId = JsonPath.read(created.getResponse().getContentAsString(), "$.data.summary.publicId"); + postJson("/api/v1/source/forms/" + documentId + "/submit", applicant, "{\"version\":0}") + .andExpect(status().isOk()); + String projectTaskId = pendingTaskForDocument(projectReviewer, documentId); + postJson("/api/v1/workflow/tasks/" + projectTaskId + "/approve", projectReviewer, + "{\"version\":0,\"opinion\":\"项目复核通过\",\"returnPoints\":[]}") + .andExpect(status().isOk()); + String financeTaskId = pendingTaskForDocument(financeReviewer, documentId); + postJson("/api/v1/workflow/tasks/" + financeTaskId + "/approve", financeReviewer, + "{\"version\":0,\"opinion\":\"财务复核通过\",\"returnPoints\":[]}") + .andExpect(status().isOk()); + + String supplierId = jdbcTemplate.queryForObject(""" + SELECT counterparty.public_id + FROM md_counterparty counterparty + JOIN src_source_document document ON document.id = counterparty.source_document_id + WHERE document.public_id = ? + """, String.class, documentId); + String accountId = jdbcTemplate.queryForObject(""" + SELECT account.public_id + FROM md_bank_account_version account + JOIN src_source_document document ON document.id = account.source_document_id + WHERE document.public_id = ? + """, String.class, documentId); + postJson("/api/v1/masterdata/counterparties/" + supplierId + "/review", applicantAsFinance, + "{\"version\":0,\"decision\":\"APPROVE\",\"opinion\":\"申请人不得复核\"}") + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("SOD_VIOLATION")); + postJson("/api/v1/masterdata/bank-accounts/" + accountId + "/review", applicantAsFinance, + "{\"version\":0,\"decision\":\"APPROVE\",\"opinion\":\"申请人不得复核账户\"}") + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("SOD_VIOLATION")); + } + + @Test + void oa02RejectsOverlongContactPhoneBeforeWorkflowStarts() throws Exception { + Fixtures fixtures = fixtures(); + ClientSession project = loginAndSelect("project", "PROJECT_MANAGER"); + String suffix = nextSuffix(); + String qualificationFileId = uploadFile(project, "oa02-phone-qualification-" + suffix + ".pdf"); + String values = """ + {"supplierName":"电话边界供应商 %s","taxNo":"OA02-PHONE-TAX-%s", + "contact":[{"name":"边界联系人","phone":"%s"}], + "supplierType":"材料供应商","purchaseAmount":1000.00, + "bankAccounts":[ + {"accountName":"电话边界供应商 %s","bankName":"边界测试银行", + "accountNo":"64440000%s","accountCategory":"GENERAL","validFrom":"2026-08-01"}], + "qualifications":[{"name":"营业执照","certificateNo":"PHONE-CERT-%s", + "validUntil":"2028-12-31","fileId":"%s"}]} + """.formatted(suffix, suffix, "1".repeat(33), suffix, suffix, suffix, qualificationFileId); + + MvcResult created = postJson("/api/v1/source/forms", project, + sourcePayload("OA-02", 2, "SUPPLIER-PHONE-" + suffix, fixtures, values)) + .andExpect(status().isOk()) + .andReturn(); + String documentId = JsonPath.read(created.getResponse().getContentAsString(), "$.data.summary.publicId"); + postJson("/api/v1/source/forms/" + documentId + "/validate", project, "{}") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.valid").value(false)) + .andExpect(jsonPath("$.data.errors[?(@.fieldCode == 'contact')].message") + .value(org.hamcrest.Matchers.hasItem("电话不能超过 32 个字符"))); + postJson("/api/v1/source/forms/" + documentId + "/submit", project, "{\"version\":0}") + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("VALIDATION_FAILED")); + assertEquals(0, projectionCount("md_counterparty", documentId)); + } + + @Test + void approvedOa07CreatesReviewingCompanyBankAccountForIndependentReview() throws Exception { + Fixtures fixtures = fixtures(); + ClientSession project = loginAndSelect("project", "PROJECT_MANAGER"); + ClientSession projectReviewer = loginAndSelect("demo", "PROJECT_MANAGER"); + ClientSession financeReviewer = loginAndSelect("finance", "FINANCE_MANAGER"); + ClientSession independentFinanceReviewer = loginAndSelect("demo", "FINANCE_MANAGER"); + String suffix = nextSuffix(); + String accountNo = "65550000" + suffix; + String openingMaterialFileId = uploadFile(project, "oa07-opening-material-" + suffix + ".pdf"); + String values = """ + {"openingReason":"项目收支专用账户","reconciliationCycle":"每月", + "accountName":"公司项目专户 %s","bankName":"公司账户测试银行", + "accountNo":"%s","accountCategory":"PROJECT", + "openingMaterials":[{"materialType":"开户申请书","complete":true, + "fileId":"%s","remark":"已核验打码资料"}]} + """.formatted(suffix, accountNo, openingMaterialFileId); + + MvcResult created = postJson("/api/v1/source/forms", project, + sourcePayload("OA-07", 2, "COMPANY-ACCOUNT-" + suffix, fixtures, values)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.summary.templateVersion").value(2)) + .andReturn(); + String documentId = JsonPath.read(created.getResponse().getContentAsString(), "$.data.summary.publicId"); + postJson("/api/v1/source/forms/" + documentId + "/submit", project, "{\"version\":0}") + .andExpect(status().isOk()); + assertEquals(0, projectionCount("md_bank_account_version", documentId)); + + String projectTaskId = pendingTaskForDocument(projectReviewer, documentId); + postJson("/api/v1/workflow/tasks/" + projectTaskId + "/approve", projectReviewer, + "{\"version\":0,\"opinion\":\"开户资料初审通过\",\"returnPoints\":[]}") + .andExpect(status().isOk()); + assertEquals(0, projectionCount("md_bank_account_version", documentId)); + + String financeTaskId = pendingTaskForDocument(financeReviewer, documentId); + String approvalBody = "{\"version\":0,\"opinion\":\"公司账户资料审核通过\",\"returnPoints\":[]}"; + String approvalKey = idempotencyKey(); + postJson("/api/v1/workflow/tasks/" + financeTaskId + "/approve", financeReviewer, + approvalBody, approvalKey) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.form.summary.status").value("APPROVED")); + postJson("/api/v1/workflow/tasks/" + financeTaskId + "/approve", financeReviewer, + approvalBody, approvalKey) + .andExpect(status().isOk()); + assertEquals(1, projectionCount("md_bank_account_version", documentId)); + + Map account = jdbcTemplate.queryForMap(""" + SELECT bank.public_id, bank.owner_type, bank.company_id, bank.account_category, + bank.masked_account_no, bank.status, bank.version, bank.source_version_id, + document.company_id AS document_company_id, + document.current_version_id + FROM md_bank_account_version bank + JOIN src_source_document document ON document.id = bank.source_document_id + WHERE document.public_id = ? + """, documentId); + String accountId = String.valueOf(account.get("public_id")); + assertEquals("COMPANY", account.get("owner_type")); + assertEquals("PROJECT", account.get("account_category")); + assertEquals("REVIEWING", account.get("status")); + assertEquals(account.get("document_company_id"), account.get("company_id")); + assertEquals(account.get("current_version_id"), account.get("source_version_id")); + assertTrue(String.valueOf(account.get("masked_account_no")).endsWith(accountNo.substring(accountNo.length() - 4))); + String storedPayload = jdbcTemplate.queryForObject(""" + SELECT CAST(version.payload_json AS CHAR) + FROM src_document_version version + JOIN src_source_document document ON document.current_version_id = version.id + WHERE document.public_id = ? + """, String.class, documentId); + assertNotNull(storedPayload); + assertFalse(storedPayload.contains(accountNo)); + + postJson("/api/v1/masterdata/bank-accounts/" + accountId + "/review", financeReviewer, + "{\"version\":0,\"decision\":\"APPROVE\",\"opinion\":\"终审人不得复核\"}") + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("SOD_VIOLATION")); + postJson("/api/v1/masterdata/bank-accounts/" + accountId + "/review", independentFinanceReviewer, + "{\"version\":0,\"decision\":\"APPROVE\",\"opinion\":\"公司账户独立复核通过\"}") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.ownerType").value("COMPANY")) + .andExpect(jsonPath("$.data.status").value("ACTIVE")); + } + + @Test + void approvedOa03CreatesOneActiveContractWithSourceBackReference() throws Exception { + Fixtures fixtures = fixtures(); + SupplierFixture supplier = activeSupplier(fixtures); + ClientSession project = loginAndSelect("project", "PROJECT_MANAGER"); + ClientSession projectReviewer = loginAndSelect("demo", "PROJECT_MANAGER"); + ClientSession financeReviewer = loginAndSelect("finance", "FINANCE_MANAGER"); + String suffix = nextSuffix(); + String sourceNo = "CONTRACT-" + suffix; + String contractNo = "OA03-CONTRACT-" + suffix; + String values = oa03Values(supplier, contractNo, "投影成本合同 " + suffix, "368000.50"); + + mockMvc.perform(get("/api/v1/source/forms/references").cookie(project.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.suppliers[?(@.publicId == '%s')]".formatted(supplier.publicId())).exists()); + + MvcResult created = postJson("/api/v1/source/forms", project, + sourcePayload("OA-03", 2, sourceNo, fixtures, values)) + .andExpect(status().isOk()).andReturn(); + String documentId = JsonPath.read(created.getResponse().getContentAsString(), "$.data.summary.publicId"); + postJson("/api/v1/source/forms/" + documentId + "/submit", project, "{\"version\":0}") + .andExpect(status().isOk()); + assertEquals(0, projectionCount("md_contract", documentId)); + + String projectTaskId = pendingTaskForDocument(projectReviewer, documentId); + postJson("/api/v1/workflow/tasks/" + projectTaskId + "/approve", projectReviewer, + "{\"version\":0,\"opinion\":\"合同及量价初审通过\",\"returnPoints\":[]}") + .andExpect(status().isOk()); + assertEquals(0, projectionCount("md_contract", documentId)); + + String financeTaskId = pendingTaskForDocument(financeReviewer, documentId); + String approvalBody = "{\"version\":0,\"opinion\":\"合同额度财务审核通过\",\"returnPoints\":[]}"; + String approvalKey = idempotencyKey(); + postJson("/api/v1/workflow/tasks/" + financeTaskId + "/approve", financeReviewer, + approvalBody, approvalKey) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.form.summary.status").value("APPROVED")); + postJson("/api/v1/workflow/tasks/" + financeTaskId + "/approve", financeReviewer, + approvalBody, approvalKey) + .andExpect(status().isOk()); + + Map contract = jdbcTemplate.queryForMap(""" + SELECT contract.public_id, contract.business_no, contract.name, contract.original_amount, + contract.approved_change_amount, contract.settlement_amount, contract.currency, + contract.status, contract.source_version_id, document.current_version_id, + counterparty.public_id AS supplier_public_id + FROM md_contract contract + JOIN src_source_document document ON document.id = contract.source_document_id + JOIN md_counterparty counterparty ON counterparty.id = contract.counterparty_id + WHERE document.public_id = ? + """, documentId); + assertEquals(contractNo, contract.get("business_no")); + assertEquals("投影成本合同 " + suffix, contract.get("name")); + assertEquals("368000.50", contract.get("original_amount").toString()); + assertEquals("0.00", contract.get("approved_change_amount").toString()); + assertEquals(null, contract.get("settlement_amount")); + assertEquals("CNY", contract.get("currency")); + assertEquals("ACTIVE", contract.get("status")); + assertEquals(supplier.publicId(), contract.get("supplier_public_id")); + assertEquals(contract.get("current_version_id"), contract.get("source_version_id")); + assertEquals(1, projectionCount("md_contract", documentId)); + mockMvc.perform(get("/api/v1/contract-costs/contracts/{id}", contract.get("public_id")) + .cookie(financeReviewer.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.contract.originalAmount").value("368000.50")) + .andExpect(jsonPath("$.data.contract.approvedChangeAmount").value("0.00")) + .andExpect(jsonPath("$.data.contract.settlementAmount").doesNotExist()) + .andExpect(jsonPath("$.data.contract.availableAmount").value("368000.50")); + } + + @Test + void approvedOa04CreatesOneConfirmedPayableAndAccountingEvent() throws Exception { + Fixtures fixtures = fixtures(); + SupplierFixture supplier = activeSupplier(fixtures); + SupplierContractFixture contract = activeSupplierContract(fixtures, supplier, "25000.00"); + ClientSession applicant = loginAndSelect("project", "PROJECT_MANAGER"); + ClientSession projectReviewer = loginAndSelect("demo", "PROJECT_MANAGER"); + ClientSession financeReviewer = loginAndSelect("finance", "FINANCE_MANAGER"); + String sourceNo = "PAYABLE-" + nextSuffix(); + String values = oa04Values( + supplier, contract, materialCostCategoryId(), "8000.00", "3000.25", "1999.75", + "{\"attachmentType\":\"发票及验收资料\",\"required\":false," + + "\"notApplicableReason\":\"集成测试不上传业务附件\"}" + ); + + mockMvc.perform(get("/api/v1/source/forms/references").cookie(applicant.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.contracts[?(@.publicId == '%s')]".formatted(contract.publicId())).exists()) + .andExpect(jsonPath("$.data.costCategories[?(@.publicId == '%s')]" + .formatted(materialCostCategoryId())).exists()); + + MvcResult created = postJson("/api/v1/source/forms", applicant, + sourcePayload("OA-04", 2, sourceNo, fixtures, values)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.summary.templateVersion").value(2)) + .andReturn(); + String documentId = JsonPath.read(created.getResponse().getContentAsString(), "$.data.summary.publicId"); + postJson("/api/v1/source/forms/" + documentId + "/submit", applicant, "{\"version\":0}") + .andExpect(status().isOk()); + assertEquals(0, projectionCount("fin_payable", documentId)); + + String projectTaskId = pendingTaskForDocument(projectReviewer, documentId); + postJson("/api/v1/workflow/tasks/" + projectTaskId + "/approve", projectReviewer, + "{\"version\":0,\"opinion\":\"合同、发票及附件初审通过\",\"returnPoints\":[]}") + .andExpect(status().isOk()); + assertEquals(0, projectionCount("fin_payable", documentId)); + + long contractVersionBefore = jdbcTemplate.queryForObject( + "SELECT version FROM md_contract WHERE public_id = ?", Long.class, contract.publicId() + ); + String financeTaskId = pendingTaskForDocument(financeReviewer, documentId); + String approvalBody = "{\"version\":0,\"opinion\":\"应付金额与发票核对通过\",\"returnPoints\":[]}"; + String approvalKey = idempotencyKey(); + postJson("/api/v1/workflow/tasks/" + financeTaskId + "/approve", financeReviewer, + approvalBody, approvalKey) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.form.summary.status").value("APPROVED")); + postJson("/api/v1/workflow/tasks/" + financeTaskId + "/approve", financeReviewer, + approvalBody, approvalKey) + .andExpect(status().isOk()); + postJson("/api/v1/workflow/tasks/" + financeTaskId + "/approve", financeReviewer, + approvalBody, idempotencyKey()) + .andExpect(status().isConflict()); + + Map payable = jdbcTemplate.queryForMap(""" + SELECT payable.public_id, payable.business_no, payable.amount, payable.invoiced_amount, + payable.status, payable.business_date, payable.created_by, payable.confirmed_by, + payable.source_version_id, document.current_version_id, + contract.public_id AS contract_public_id, + counterparty.public_id AS supplier_public_id, + category.public_id AS category_public_id + FROM fin_payable payable + JOIN src_source_document document ON document.id = payable.source_document_id + JOIN md_contract contract ON contract.id = payable.contract_id + JOIN md_counterparty counterparty ON counterparty.id = payable.counterparty_id + JOIN md_cost_category category ON category.id = payable.cost_category_id + WHERE document.public_id = ? + """, documentId); + String payableId = String.valueOf(payable.get("public_id")); + assertTrue(String.valueOf(payable.get("business_no")).startsWith("PBL-")); + assertEquals("8000.00", payable.get("amount").toString()); + assertEquals("5000.00", payable.get("invoiced_amount").toString()); + assertEquals("CONFIRMED", payable.get("status")); + assertEquals("2026-08-08", payable.get("business_date").toString()); + assertFalse(payable.get("created_by").equals(payable.get("confirmed_by"))); + assertEquals(payable.get("current_version_id"), payable.get("source_version_id")); + assertEquals(contract.publicId(), payable.get("contract_public_id")); + assertEquals(supplier.publicId(), payable.get("supplier_public_id")); + assertEquals(materialCostCategoryId(), payable.get("category_public_id")); + assertEquals(1, projectionCount("fin_payable", documentId)); + assertEquals(contractVersionBefore + 1, jdbcTemplate.queryForObject( + "SELECT version FROM md_contract WHERE public_id = ?", Long.class, contract.publicId() + )); + assertEquals(1, jdbcTemplate.queryForObject(""" + SELECT COUNT(*) FROM acc_event + WHERE event_type = 'PAYABLE' AND source_type = 'PAYABLE' + AND source_public_id = ? AND amount = 8000.00 AND currency = 'CNY' AND status = 'PENDING' + """, Integer.class, payableId)); + } + + @Test + void attachmentMatrixRequiresEvidenceOnlyForRequiredRows() throws Exception { + Fixtures fixtures = fixtures(); + SupplierFixture supplier = activeSupplier(fixtures); + SupplierContractFixture contract = activeSupplierContract(fixtures, supplier, "25000.00"); + ClientSession applicant = loginAndSelect("project", "PROJECT_MANAGER"); + + String optionalSourceNo = "OPTIONAL-MATRIX-" + nextSuffix(); + String optionalValues = oa04Values( + supplier, contract, materialCostCategoryId(), "8000.00", "3000.00", null, + "{\"attachmentType\":\"补充说明\",\"required\":false}" + ); + MvcResult optional = postJson("/api/v1/source/forms", applicant, + sourcePayload("OA-04", 2, optionalSourceNo, fixtures, optionalValues)) + .andExpect(status().isOk()).andReturn(); + String optionalDocumentId = JsonPath.read( + optional.getResponse().getContentAsString(), "$.data.summary.publicId" + ); + postJson("/api/v1/source/forms/" + optionalDocumentId + "/validate", applicant, "{}") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.valid").value(true)) + .andExpect(jsonPath("$.data.errors").isEmpty()); + + String requiredSourceNo = "REQUIRED-MATRIX-" + nextSuffix(); + String requiredValues = oa04Values( + supplier, contract, materialCostCategoryId(), "8000.00", "3000.00", null, + "{\"attachmentType\":\"发票及验收资料\",\"required\":true}" + ); + MvcResult required = postJson("/api/v1/source/forms", applicant, + sourcePayload("OA-04", 2, requiredSourceNo, fixtures, requiredValues)) + .andExpect(status().isOk()).andReturn(); + String requiredDocumentId = JsonPath.read( + required.getResponse().getContentAsString(), "$.data.summary.publicId" + ); + postJson("/api/v1/source/forms/" + requiredDocumentId + "/validate", applicant, "{}") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.valid").value(false)) + .andExpect(jsonPath("$.data.errors[?(@.fieldCode == 'attachmentMatrix' && @.rowNo == 1)].message") + .value(org.hamcrest.Matchers.hasItem("必需附件必须上传文件或填写不涉及原因"))); + postJson("/api/v1/source/forms/" + requiredDocumentId + "/submit", applicant, "{\"version\":0}") + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("VALIDATION_FAILED")); + } + + @Test + void approvedOa11CreatesOneConfirmedSettlementIdempotently() throws Exception { + Fixtures fixtures = fixtures(); + SupplierFixture supplier = activeSupplier(fixtures); + SupplierContractFixture contract = activeSupplierContract(fixtures, supplier, "25000.00"); + ClientSession applicant = loginAndSelect("project", "PROJECT_MANAGER"); + ClientSession projectReviewer = loginAndSelect("demo", "PROJECT_MANAGER"); + ClientSession financeReviewer = loginAndSelect("finance", "FINANCE_MANAGER"); + approveOa04ForContract( + fixtures, supplier, contract, applicant, projectReviewer, financeReviewer, + "8000.00", "3000.25", "1999.75" + ); + String settlementFileId = uploadFile(applicant, "oa11-settlement.pdf"); + String values = oa11Values( + contract, supplier, "25000.00", "20000.00", "5000.00", "0.00", settlementFileId + ); + + MvcResult created = postJson("/api/v1/source/forms", applicant, + sourcePayload("OA-11", 1, "SETTLEMENT-" + nextSuffix(), fixtures, values)) + .andExpect(status().isOk()).andReturn(); + String documentId = JsonPath.read(created.getResponse().getContentAsString(), "$.data.summary.publicId"); + postJson("/api/v1/source/forms/" + documentId + "/submit", applicant, "{\"version\":0}") + .andExpect(status().isOk()); + String projectTaskId = pendingTaskForDocument(projectReviewer, documentId); + postJson("/api/v1/workflow/tasks/" + projectTaskId + "/approve", projectReviewer, + "{\"version\":0,\"opinion\":\"结算资料初审通过\",\"returnPoints\":[]}") + .andExpect(status().isOk()); + + long contractVersionBefore = jdbcTemplate.queryForObject( + "SELECT version FROM md_contract WHERE public_id = ?", Long.class, contract.publicId() + ); + String financeTaskId = pendingTaskForDocument(financeReviewer, documentId); + String approvalBody = "{\"version\":0,\"opinion\":\"结算金额核对通过\",\"returnPoints\":[]}"; + String approvalKey = idempotencyKey(); + postJson("/api/v1/workflow/tasks/" + financeTaskId + "/approve", financeReviewer, + approvalBody, approvalKey) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.form.summary.status").value("APPROVED")); + postJson("/api/v1/workflow/tasks/" + financeTaskId + "/approve", financeReviewer, + approvalBody, approvalKey).andExpect(status().isOk()); + postJson("/api/v1/workflow/tasks/" + financeTaskId + "/approve", financeReviewer, + approvalBody, idempotencyKey()).andExpect(status().isConflict()); + + Map settlement = jdbcTemplate.queryForMap(""" + SELECT settlement.public_id, settlement.settlement_no, settlement.settlement_amount, + settlement.settlement_date, settlement.status, settlement.source_version_id, + document.current_version_id, contract.public_id AS contract_public_id + FROM fin_contract_settlement settlement + JOIN src_source_document document ON document.id = settlement.source_document_id + JOIN md_contract contract ON contract.id = settlement.contract_id + WHERE document.public_id = ? + """, documentId); + assertTrue(String.valueOf(settlement.get("settlement_no")).startsWith("OA11-")); + assertEquals("20000.00", settlement.get("settlement_amount").toString()); + assertEquals("2026-08-08", settlement.get("settlement_date").toString()); + assertEquals("CONFIRMED", settlement.get("status")); + assertEquals(settlement.get("current_version_id"), settlement.get("source_version_id")); + assertEquals(contract.publicId(), settlement.get("contract_public_id")); + assertEquals(1, projectionCount("fin_contract_settlement", documentId)); + assertEquals("20000.00", jdbcTemplate.queryForObject( + "SELECT settlement_amount FROM md_contract WHERE public_id = ?", java.math.BigDecimal.class, + contract.publicId()).toString()); + assertEquals(contractVersionBefore + 1, jdbcTemplate.queryForObject( + "SELECT version FROM md_contract WHERE public_id = ?", Long.class, contract.publicId() + )); + } + + @Test + void oa11SnapshotMismatchRollsBackFinalApprovalAndSettlement() throws Exception { + Fixtures fixtures = fixtures(); + SupplierFixture supplier = activeSupplier(fixtures); + SupplierContractFixture contract = activeSupplierContract(fixtures, supplier, "1000.00"); + ClientSession applicant = loginAndSelect("project", "PROJECT_MANAGER"); + ClientSession projectReviewer = loginAndSelect("demo", "PROJECT_MANAGER"); + ClientSession financeReviewer = loginAndSelect("finance", "FINANCE_MANAGER"); + String settlementFileId = uploadFile(applicant, "oa11-limit-settlement.pdf"); + String values = oa11Values( + contract, supplier, "999.99", "1000.01", "0.00", "0.00", settlementFileId + ); + + MvcResult created = postJson("/api/v1/source/forms", applicant, + sourcePayload("OA-11", 1, "SETTLEMENT-LIMIT-" + nextSuffix(), fixtures, values)) + .andExpect(status().isOk()).andReturn(); + String documentId = JsonPath.read(created.getResponse().getContentAsString(), "$.data.summary.publicId"); + postJson("/api/v1/source/forms/" + documentId + "/submit", applicant, "{\"version\":0}") + .andExpect(status().isOk()); + String projectTaskId = pendingTaskForDocument(projectReviewer, documentId); + postJson("/api/v1/workflow/tasks/" + projectTaskId + "/approve", projectReviewer, + "{\"version\":0,\"opinion\":\"初审通过\",\"returnPoints\":[]}") + .andExpect(status().isOk()); + String financeTaskId = pendingTaskForDocument(financeReviewer, documentId); + postJson("/api/v1/workflow/tasks/" + financeTaskId + "/approve", financeReviewer, + "{\"version\":0,\"opinion\":\"额度校验\",\"returnPoints\":[]}") + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("VALIDATION_FAILED")); + + assertEquals("FINANCE_REVIEW", jdbcTemplate.queryForObject( + "SELECT status FROM src_source_document WHERE public_id = ?", String.class, documentId)); + assertEquals("PENDING", jdbcTemplate.queryForObject( + "SELECT status FROM wf_task WHERE public_id = ?", String.class, financeTaskId)); + assertEquals(0, projectionCount("fin_contract_settlement", documentId)); + } + + @Test + void oa11BelowExistingPayableRollsBackFinalApprovalAndSettlement() throws Exception { + Fixtures fixtures = fixtures(); + SupplierFixture supplier = activeSupplier(fixtures); + SupplierContractFixture contract = activeSupplierContract(fixtures, supplier, "10000.00"); + ClientSession applicant = loginAndSelect("project", "PROJECT_MANAGER"); + ClientSession projectReviewer = loginAndSelect("demo", "PROJECT_MANAGER"); + ClientSession financeReviewer = loginAndSelect("finance", "FINANCE_MANAGER"); + approveOa04ForContract( + fixtures, supplier, contract, applicant, projectReviewer, financeReviewer, + "8000.00", "3000.25", "1999.75" + ); + String settlementFileId = uploadFile(applicant, "oa11-payable-limit-settlement.pdf"); + String values = oa11Values( + contract, supplier, "10000.00", "7999.99", "5000.00", "0.00", settlementFileId + ); + + MvcResult created = postJson("/api/v1/source/forms", applicant, + sourcePayload("OA-11", 1, "SETTLEMENT-PAYABLE-LIMIT-" + nextSuffix(), fixtures, values)) + .andExpect(status().isOk()).andReturn(); + String documentId = JsonPath.read(created.getResponse().getContentAsString(), "$.data.summary.publicId"); + postJson("/api/v1/source/forms/" + documentId + "/submit", applicant, "{\"version\":0}") + .andExpect(status().isOk()); + String projectTaskId = pendingTaskForDocument(projectReviewer, documentId); + postJson("/api/v1/workflow/tasks/" + projectTaskId + "/approve", projectReviewer, + "{\"version\":0,\"opinion\":\"初审通过\",\"returnPoints\":[]}") + .andExpect(status().isOk()); + String financeTaskId = pendingTaskForDocument(financeReviewer, documentId); + postJson("/api/v1/workflow/tasks/" + financeTaskId + "/approve", financeReviewer, + "{\"version\":0,\"opinion\":\"应付金额下限校验\",\"returnPoints\":[]}") + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("CONTRACT_LIMIT_EXCEEDED")); + + assertEquals("FINANCE_REVIEW", jdbcTemplate.queryForObject( + "SELECT status FROM src_source_document WHERE public_id = ?", String.class, documentId)); + assertEquals("PENDING", jdbcTemplate.queryForObject( + "SELECT status FROM wf_task WHERE public_id = ?", String.class, financeTaskId)); + assertEquals(0, projectionCount("fin_contract_settlement", documentId)); + } + + @Test + void approvedOa12EntersArchivePendingAndIsVisibleToArchiveSnapshot() throws Exception { + Fixtures fixtures = fixtures(); + ClientSession applicant = loginAndSelect("project", "PROJECT_MANAGER"); + ClientSession projectReviewer = loginAndSelect("demo", "PROJECT_MANAGER"); + ClientSession financeReviewer = loginAndSelect("finance", "FINANCE_MANAGER"); + String acceptanceFileId = uploadFile(applicant, "oa12-acceptance.pdf"); + String values = """ + {"acceptanceDetails":[{"stage":"竣工验收","acceptedAt":"2026-08-08", + "conclusion":"验收通过","fileId":"%s","remark":"集成测试"}], + "acceptanceSummary":"项目验收通过,资料待归档"} + """.formatted(acceptanceFileId); + + MvcResult created = postJson("/api/v1/source/forms", applicant, + sourcePayload("OA-12", 1, "ACCEPTANCE-" + nextSuffix(), fixtures, values)) + .andExpect(status().isOk()).andReturn(); + String documentId = JsonPath.read(created.getResponse().getContentAsString(), "$.data.summary.publicId"); + postJson("/api/v1/source/forms/" + documentId + "/submit", applicant, "{\"version\":0}") + .andExpect(status().isOk()); + String projectTaskId = pendingTaskForDocument(projectReviewer, documentId); + postJson("/api/v1/workflow/tasks/" + projectTaskId + "/approve", projectReviewer, + "{\"version\":0,\"opinion\":\"验收初审通过\",\"returnPoints\":[]}") + .andExpect(status().isOk()); + String financeTaskId = pendingTaskForDocument(financeReviewer, documentId); + postJson("/api/v1/workflow/tasks/" + financeTaskId + "/approve", financeReviewer, + "{\"version\":0,\"opinion\":\"验收终审通过\",\"returnPoints\":[]}") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.form.summary.status").value("ARCHIVE_PENDING")); + + assertEquals("ARCHIVE_PENDING", jdbcTemplate.queryForObject( + "SELECT status FROM src_source_document WHERE public_id = ?", String.class, documentId)); + long projectDbId = jdbcTemplate.queryForObject( + "SELECT id FROM md_project WHERE public_id = ?", Long.class, fixtures.projectId()); + assertTrue(archiveMapper.sourceDocumentSnapshots(projectDbId).stream() + .anyMatch(snapshot -> documentId.equals(snapshot.get("publicId")) + && "ARCHIVE_PENDING".equals(snapshot.get("status")))); + + MvcResult applicantCompleted = mockMvc.perform(get("/api/v1/workflow/tasks") + .cookie(applicant.cookies()).param("view", "COMPLETED").param("formType", "OA-12")) + .andExpect(status().isOk()).andReturn(); + MvcResult reviewerCompleted = mockMvc.perform(get("/api/v1/workflow/tasks") + .cookie(financeReviewer.cookies()).param("view", "COMPLETED").param("formType", "OA-12")) + .andExpect(status().isOk()).andReturn(); + assertNotNull(taskForDocument(applicantCompleted, documentId)); + assertNotNull(taskForDocument(reviewerCompleted, documentId)); + } + + @Test + void workflowTaskListSupportsProjectArrivalOverdueStatusSortAndKeyAmount() throws Exception { + Fixtures fixtures = fixtures(); + ClientSession applicant = loginAndSelect("project", "PROJECT_MANAGER"); + ClientSession reviewer = loginAndSelect("demo", "PROJECT_MANAGER"); + String evidenceFileId = uploadFile(applicant, "workflow-task-filter-" + nextSuffix() + ".pdf"); + String sourceNo = "TASK-FILTER-" + nextSuffix(); + MvcResult created = postJson("/api/v1/source/forms", applicant, sourcePayload( + "OA-05", sourceNo, fixtures, receiptValues(evidenceFileId, "审批任务筛选测试") + )).andExpect(status().isOk()).andReturn(); + String documentId = JsonPath.read(created.getResponse().getContentAsString(), "$.data.summary.publicId"); + postJson("/api/v1/source/forms/" + documentId + "/submit", applicant, "{\"version\":0}") + .andExpect(status().isOk()); + String taskId = pendingTaskForDocument(reviewer, documentId); + jdbcTemplate.update( + "UPDATE wf_task SET created_at = ?, due_at = ? WHERE public_id = ?", + java.sql.Timestamp.valueOf("2026-08-10 12:00:00"), + java.sql.Timestamp.valueOf("2026-08-10 11:00:00"), + taskId + ); + + MvcResult filtered = mockMvc.perform(get("/api/v1/workflow/tasks") + .cookie(reviewer.cookies()) + .param("view", "TODO") + .param("projectId", fixtures.projectId()) + .param("arrivedDateFrom", "2026-08-10") + .param("arrivedDateTo", "2026-08-10") + .param("overdue", "true") + .param("status", "PENDING") + .param("sort", "dueAt,desc")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[?(@.publicId == '" + taskId + "')].form.project.publicId") + .value(fixtures.projectId())) + .andExpect(jsonPath("$.data[?(@.publicId == '" + taskId + "')].form.applicantName") + .value("项目经办员")) + .andExpect(jsonPath("$.data[?(@.publicId == '" + taskId + "')].keyAmount").value("8000")) + .andReturn(); + assertNotNull(taskForDocument(filtered, documentId)); + + MvcResult outsideDate = mockMvc.perform(get("/api/v1/workflow/tasks") + .cookie(reviewer.cookies()) + .param("view", "TODO") + .param("projectId", fixtures.projectId()) + .param("arrivedDateFrom", "2026-08-11") + .param("arrivedDateTo", "2026-08-11")) + .andExpect(status().isOk()).andReturn(); + net.minidev.json.JSONArray outsideMatches = JsonPath.read( + outsideDate.getResponse().getContentAsString(), + "$.data[?(@.publicId == '" + taskId + "')].publicId" + ); + assertTrue(outsideMatches.isEmpty()); + + mockMvc.perform(get("/api/v1/workflow/tasks") + .cookie(reviewer.cookies()) + .param("arrivedDateFrom", "2026-08-11") + .param("arrivedDateTo", "2026-08-10")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("QUERY_PARAMETER_INVALID")); + mockMvc.perform(get("/api/v1/workflow/tasks") + .cookie(reviewer.cookies()).param("projectId", "invalid")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("QUERY_PARAMETER_INVALID")); + mockMvc.perform(get("/api/v1/workflow/tasks") + .cookie(reviewer.cookies()).param("sort", "document.created_at,desc")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("QUERY_PARAMETER_INVALID")); + mockMvc.perform(get("/api/v1/workflow/tasks") + .cookie(reviewer.cookies()).param("status", "PROJECT_REVIEW")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("QUERY_PARAMETER_INVALID")); + } + + @Test + void oa04ContractLimitFailureRollsBackFinalApprovalAtomically() throws Exception { + Fixtures fixtures = fixtures(); + SupplierFixture supplier = activeSupplier(fixtures); + SupplierContractFixture contract = activeSupplierContract(fixtures, supplier, "1000.00"); + ClientSession applicant = loginAndSelect("project", "PROJECT_MANAGER"); + ClientSession projectReviewer = loginAndSelect("demo", "PROJECT_MANAGER"); + ClientSession financeReviewer = loginAndSelect("finance", "FINANCE_MANAGER"); + String values = oa04Values( + supplier, contract, materialCostCategoryId(), "1000.01", "1000.01", null, + "{\"attachmentType\":\"测试资料\",\"required\":false," + + "\"notApplicableReason\":\"集成测试不上传业务附件\"}" + ); + MvcResult created = postJson("/api/v1/source/forms", applicant, + sourcePayload("OA-04", 2, "PAYABLE-LIMIT-" + nextSuffix(), fixtures, values)) + .andExpect(status().isOk()).andReturn(); + String documentId = JsonPath.read(created.getResponse().getContentAsString(), "$.data.summary.publicId"); + postJson("/api/v1/source/forms/" + documentId + "/submit", applicant, "{\"version\":0}") + .andExpect(status().isOk()); + String projectTaskId = pendingTaskForDocument(projectReviewer, documentId); + postJson("/api/v1/workflow/tasks/" + projectTaskId + "/approve", projectReviewer, + "{\"version\":0,\"opinion\":\"项目初审通过\",\"returnPoints\":[]}") + .andExpect(status().isOk()); + String financeTaskId = pendingTaskForDocument(financeReviewer, documentId); + long contractVersionBefore = jdbcTemplate.queryForObject( + "SELECT version FROM md_contract WHERE public_id = ?", Long.class, contract.publicId() + ); + + postJson("/api/v1/workflow/tasks/" + financeTaskId + "/approve", financeReviewer, + "{\"version\":0,\"opinion\":\"触发合同额度回滚\",\"returnPoints\":[]}") + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("CONTRACT_LIMIT_EXCEEDED")); + + assertEquals("FINANCE_REVIEW", jdbcTemplate.queryForObject( + "SELECT status FROM src_source_document WHERE public_id = ?", String.class, documentId + )); + assertEquals("PENDING", jdbcTemplate.queryForObject( + "SELECT status FROM wf_task WHERE public_id = ?", String.class, financeTaskId + )); + assertEquals("RUNNING", jdbcTemplate.queryForObject(""" + SELECT instance.status FROM wf_instance instance + JOIN wf_task task_record ON task_record.instance_id = instance.id + WHERE task_record.public_id = ? + """, String.class, financeTaskId)); + assertEquals(0, projectionCount("fin_payable", documentId)); + assertEquals(contractVersionBefore, jdbcTemplate.queryForObject( + "SELECT version FROM md_contract WHERE public_id = ?", Long.class, contract.publicId() + )); + assertEquals(0, jdbcTemplate.queryForObject(""" + SELECT COUNT(*) FROM wf_action action_record + JOIN wf_task task_record ON task_record.id = action_record.task_id + WHERE task_record.public_id = ? AND action_record.action = 'APPROVE' + """, Integer.class, financeTaskId)); + assertEquals(0, jdbcTemplate.queryForObject(""" + SELECT COUNT(*) FROM acc_event event_record + WHERE event_record.source_type = 'PAYABLE' + AND event_record.source_public_id IN ( + SELECT payable.public_id FROM fin_payable payable + WHERE payable.source_document_id = ( + SELECT id FROM src_source_document WHERE public_id = ? + ) + ) + """, Integer.class, documentId)); + } + + @Test + void returnedOrRejectedProjectionFormsNeverCreateMasterData() throws Exception { + Fixtures fixtures = fixtures(); + SupplierFixture supplier = activeSupplier(fixtures); + ClientSession project = loginAndSelect("project", "PROJECT_MANAGER"); + ClientSession projectReviewer = loginAndSelect("demo", "PROJECT_MANAGER"); + ClientSession financeReviewer = loginAndSelect("finance", "FINANCE_MANAGER"); + String supplierSuffix = nextSuffix(); + String qualificationFileId = uploadFile(project, "returned-qualification-" + supplierSuffix + ".pdf"); + String supplierValues = """ + {"supplierName":"退回供应商 %s","taxNo":"RETURN-TAX-%s", + "contact":[{"name":"退回联系人","phone":"13800000000"}], + "supplierType":"材料供应商", + "bankAccounts":[{"accountName":"退回供应商 %s","bankName":"退回测试银行", + "accountNo":"62228888%s","accountCategory":"GENERAL","validFrom":"2026-08-01"}], + "qualifications":[{"name":"营业执照","certificateNo":"RETURN-CERT-%s", + "validUntil":"2028-12-31","fileId":"%s"}]} + """.formatted( + supplierSuffix, supplierSuffix, supplierSuffix, supplierSuffix, supplierSuffix, + qualificationFileId + ); + MvcResult supplierCreated = postJson("/api/v1/source/forms", project, + sourcePayload("OA-02", 2, "RETURN-OA02-" + supplierSuffix, fixtures, supplierValues)) + .andExpect(status().isOk()).andReturn(); + String supplierDocumentId = JsonPath.read( + supplierCreated.getResponse().getContentAsString(), "$.data.summary.publicId" + ); + postJson("/api/v1/source/forms/" + supplierDocumentId + "/submit", project, "{\"version\":0}") + .andExpect(status().isOk()); + String supplierTaskId = pendingTaskForDocument(projectReviewer, supplierDocumentId); + postJson("/api/v1/workflow/tasks/" + supplierTaskId + "/return", projectReviewer, + "{\"version\":0,\"opinion\":\"补充供应商名称依据\",\"returnPoints\":[\"supplierName\"]}") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.form.summary.status").value("RETURNED")); + assertEquals(0, projectionCount("md_counterparty", supplierDocumentId)); + assertEquals(0, projectionCount("md_bank_account_version", supplierDocumentId)); + + String contractSuffix = nextSuffix(); + MvcResult contractCreated = postJson("/api/v1/source/forms", project, + sourcePayload("OA-03", 2, "REJECT-OA03-" + contractSuffix, fixtures, + oa03Values(supplier, "REJECT-CONTRACT-" + contractSuffix, "驳回成本合同", "5000.00"))) + .andExpect(status().isOk()).andReturn(); + String contractDocumentId = JsonPath.read( + contractCreated.getResponse().getContentAsString(), "$.data.summary.publicId" + ); + postJson("/api/v1/source/forms/" + contractDocumentId + "/submit", project, "{\"version\":0}") + .andExpect(status().isOk()); + String projectTaskId = pendingTaskForDocument(projectReviewer, contractDocumentId); + postJson("/api/v1/workflow/tasks/" + projectTaskId + "/approve", projectReviewer, + "{\"version\":0,\"opinion\":\"项目初审通过\",\"returnPoints\":[]}") + .andExpect(status().isOk()); + String financeTaskId = pendingTaskForDocument(financeReviewer, contractDocumentId); + postJson("/api/v1/workflow/tasks/" + financeTaskId + "/reject", financeReviewer, + "{\"version\":0,\"opinion\":\"合同额度不予通过\",\"returnPoints\":[]}") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.form.summary.status").value("REJECTED")); + assertEquals(0, projectionCount("md_contract", contractDocumentId)); + } + + @Test + void oa03ProjectionFailureRollsBackFinalApprovalAtomically() throws Exception { + Fixtures fixtures = fixtures(); + SupplierFixture supplier = activeSupplier(fixtures); + ClientSession project = loginAndSelect("project", "PROJECT_MANAGER"); + ClientSession projectReviewer = loginAndSelect("demo", "PROJECT_MANAGER"); + ClientSession financeReviewer = loginAndSelect("finance", "FINANCE_MANAGER"); + String suffix = nextSuffix(); + String duplicateContractNo = "OA03-DUP-" + suffix; + postJson("/api/v1/masterdata/contracts", financeReviewer, """ + {"companyId":"%s","projectId":"%s","counterpartyId":"%s", + "businessNo":"%s","name":"预占合同编号","originalAmount":"100.00","currency":"CNY"} + """.formatted(fixtures.companyId(), fixtures.projectId(), supplier.publicId(), duplicateContractNo)) + .andExpect(status().isOk()); + + MvcResult created = postJson("/api/v1/source/forms", project, + sourcePayload("OA-03", 2, "ROLLBACK-" + suffix, fixtures, + oa03Values(supplier, duplicateContractNo, "应整体回滚合同", "9000.00"))) + .andExpect(status().isOk()).andReturn(); + String documentId = JsonPath.read(created.getResponse().getContentAsString(), "$.data.summary.publicId"); + postJson("/api/v1/source/forms/" + documentId + "/submit", project, "{\"version\":0}") + .andExpect(status().isOk()); + String projectTaskId = pendingTaskForDocument(projectReviewer, documentId); + postJson("/api/v1/workflow/tasks/" + projectTaskId + "/approve", projectReviewer, + "{\"version\":0,\"opinion\":\"项目初审通过\",\"returnPoints\":[]}") + .andExpect(status().isOk()); + String financeTaskId = pendingTaskForDocument(financeReviewer, documentId); + + postJson("/api/v1/workflow/tasks/" + financeTaskId + "/approve", financeReviewer, + "{\"version\":0,\"opinion\":\"触发重复合同回滚\",\"returnPoints\":[]}") + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("DUPLICATE_RESOURCE")); + + assertEquals("FINANCE_REVIEW", jdbcTemplate.queryForObject( + "SELECT status FROM src_source_document WHERE public_id = ?", String.class, documentId + )); + Map task = jdbcTemplate.queryForMap( + "SELECT status, version FROM wf_task WHERE public_id = ?", financeTaskId + ); + assertEquals("PENDING", task.get("status")); + assertEquals(0L, ((Number) task.get("version")).longValue()); + assertEquals("RUNNING", jdbcTemplate.queryForObject(""" + SELECT instance.status + FROM wf_instance instance + JOIN wf_task task ON task.instance_id = instance.id + WHERE task.public_id = ? + """, String.class, financeTaskId)); + assertEquals(0, projectionCount("md_contract", documentId)); + assertEquals(0, jdbcTemplate.queryForObject(""" + SELECT COUNT(*) FROM wf_action action_record + JOIN wf_task task ON task.id = action_record.task_id + WHERE task.public_id = ? AND action_record.action = 'APPROVE' + """, Integer.class, financeTaskId)); + } + + @Test + void concurrentFinalApprovalsCreateExactlyOneContractProjection() throws Exception { + Fixtures fixtures = fixtures(); + SupplierFixture supplier = activeSupplier(fixtures); + ClientSession project = loginAndSelect("project", "PROJECT_MANAGER"); + ClientSession projectReviewer = loginAndSelect("demo", "PROJECT_MANAGER"); + ClientSession financeReviewer = loginAndSelect("finance", "FINANCE_MANAGER"); + String suffix = nextSuffix(); + MvcResult created = postJson("/api/v1/source/forms", project, + sourcePayload("OA-03", 2, "CONCURRENT-" + suffix, fixtures, + oa03Values(supplier, "CONCURRENT-CONTRACT-" + suffix, "并发投影合同", "18000.00"))) + .andExpect(status().isOk()).andReturn(); + String documentId = JsonPath.read(created.getResponse().getContentAsString(), "$.data.summary.publicId"); + postJson("/api/v1/source/forms/" + documentId + "/submit", project, "{\"version\":0}") + .andExpect(status().isOk()); + String projectTaskId = pendingTaskForDocument(projectReviewer, documentId); + postJson("/api/v1/workflow/tasks/" + projectTaskId + "/approve", projectReviewer, + "{\"version\":0,\"opinion\":\"项目初审通过\",\"returnPoints\":[]}") + .andExpect(status().isOk()); + String financeTaskId = pendingTaskForDocument(financeReviewer, documentId); + String approvalBody = "{\"version\":0,\"opinion\":\"并发财务终审\",\"returnPoints\":[]}"; + + ExecutorService executor = Executors.newFixedThreadPool(2); + CountDownLatch start = new CountDownLatch(1); + try { + Future first = executor.submit(() -> { + start.await(); + return workflowApprovalStatus(financeTaskId, financeReviewer, approvalBody, idempotencyKey()); + }); + Future second = executor.submit(() -> { + start.await(); + return workflowApprovalStatus(financeTaskId, financeReviewer, approvalBody, idempotencyKey()); + }); + start.countDown(); + List statuses = List.of( + first.get(30, TimeUnit.SECONDS), second.get(30, TimeUnit.SECONDS) + ); + assertEquals(1, statuses.stream().filter(code -> code == 200).count()); + assertTrue(statuses.stream().allMatch(code -> code == 200 || code == 409 || code == 422)); + } finally { + executor.shutdownNow(); + } + + assertEquals("APPROVED", jdbcTemplate.queryForObject( + "SELECT status FROM src_source_document WHERE public_id = ?", String.class, documentId + )); + assertEquals(1, projectionCount("md_contract", documentId)); + assertEquals(1, jdbcTemplate.queryForObject(""" + SELECT COUNT(*) FROM wf_action action_record + JOIN wf_task task ON task.id = action_record.task_id + WHERE task.public_id = ? AND action_record.action = 'APPROVE' + """, Integer.class, financeTaskId)); + } + + @Test + void newSourceFormsRequireAnActiveProjectAndActiveCompanyWhileExistingFormsRemainReadable() throws Exception { + Fixtures fixtures = fixtures(); + ClientSession project = loginAndSelect("project", "PROJECT_MANAGER"); + String fileId = uploadFile(project, "active-reference.pdf"); + String activePayload = sourcePayload("OA-05", "ACTIVE-" + nextSuffix(), fixtures, + receiptValues(fileId, "活动项目可用")); + + mockMvc.perform(get("/api/v1/source/forms/references").cookie(project.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.projects[?(@.publicId == '%s')]".formatted(fixtures.projectId())).exists()); + MvcResult created = postJson("/api/v1/source/forms", project, activePayload) + .andExpect(status().isOk()).andReturn(); + String documentId = JsonPath.read(created.getResponse().getContentAsString(), "$.data.summary.publicId"); + + jdbcTemplate.update("UPDATE md_project SET status = 'DRAFT' WHERE public_id = ?", fixtures.projectId()); + mockMvc.perform(get("/api/v1/source/forms/references").cookie(project.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.projects[?(@.publicId == '%s')]".formatted(fixtures.projectId())).doesNotExist()); + postJson("/api/v1/source/forms", project, sourcePayload("OA-05", "DRAFT-" + nextSuffix(), fixtures, + receiptValues(fileId, "草稿项目不可用"))) + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("VALIDATION_FAILED")); + mockMvc.perform(get("/api/v1/source/forms/{id}", documentId).cookie(project.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.summary.publicId").value(documentId)); + postJson("/api/v1/source/forms/" + documentId + "/submit", project, "{\"version\":0}") + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("VALIDATION_FAILED")); + + jdbcTemplate.update("UPDATE md_project SET status = 'ACTIVE' WHERE public_id = ?", fixtures.projectId()); + jdbcTemplate.update("UPDATE md_project SET status = 'DISABLED' WHERE public_id = ?", fixtures.projectId()); + postJson("/api/v1/source/forms", project, sourcePayload("OA-05", "PROJECT-DISABLED-" + nextSuffix(), fixtures, + receiptValues(fileId, "停用项目不可用"))) + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("VALIDATION_FAILED")); + jdbcTemplate.update("UPDATE md_project SET status = 'ACTIVE' WHERE public_id = ?", fixtures.projectId()); + jdbcTemplate.update("UPDATE md_company SET status = 'DISABLED' WHERE public_id = ?", fixtures.companyId()); + mockMvc.perform(get("/api/v1/source/forms/references").cookie(project.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.projects[?(@.publicId == '%s')]".formatted(fixtures.projectId())).doesNotExist()); + postJson("/api/v1/source/forms", project, sourcePayload("OA-05", "DISABLED-" + nextSuffix(), fixtures, + receiptValues(fileId, "停用公司不可用"))) + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("VALIDATION_FAILED")); + postJson("/api/v1/source/forms/" + documentId + "/submit", project, "{\"version\":0}") + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("VALIDATION_FAILED")); + mockMvc.perform(get("/api/v1/source/forms/{id}", documentId).cookie(project.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.summary.publicId").value(documentId)); + } + + @Test + void sourceFormValidationDuplicateProtectionAndTwoStageApprovalWorkEndToEnd() throws Exception { + Fixtures fixtures = fixtures(); + ClientSession project = loginAndSelect("project", "PROJECT_MANAGER"); + ClientSession projectReviewer = loginAndSelect("demo", "PROJECT_MANAGER"); + ClientSession financeReviewer = loginAndSelect("finance", "FINANCE_MANAGER"); + String sourceNo = "RCPT-" + nextSuffix(); + String evidenceFileId = uploadFile(project, "receipt-evidence.pdf"); + + MvcResult created = postJson("/api/v1/source/forms", project, sourcePayload( + "OA-05", sourceNo, fixtures, "{}" + )) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.summary.status").value("DRAFT")) + .andReturn(); + String documentId = JsonPath.read(created.getResponse().getContentAsString(), "$.data.summary.publicId"); + + postJson("/api/v1/source/forms", project, sourcePayload("OA-05", sourceNo, fixtures, "{}")) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("DUPLICATE_RESOURCE")); + + postJson("/api/v1/source/forms/" + documentId + "/validate", project, "{}") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.valid").value(false)) + .andExpect(jsonPath("$.data.errors[?(@.fieldCode == 'receiptAmount')]").exists()); + postJson("/api/v1/source/forms/" + documentId + "/submit", project, "{\"version\":0}") + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("VALIDATION_FAILED")); + + String completeValues = """ + {"ownerUnit":"测试业主","receiptMethod":"银行转账","receiptAmount":128000.50, + "receiptAccount":[{"accountName":"测试公司","bankName":"测试银行","accountNo":"62220001"}], + "receiptDescription":"项目工程款到账","evidenceFiles":["%s"]} + """.formatted(evidenceFileId); + patchJson("/api/v1/source/forms/" + documentId, project, updatePayload(sourceNo, fixtures, completeValues, 0)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.summary.version").value(1)) + .andExpect(jsonPath("$.data.documentVersion").value(2)); + + postJson("/api/v1/source/forms/" + documentId + "/submit", project, "{\"version\":1}") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.summary.status").value("PROJECT_REVIEW")) + .andExpect(jsonPath("$.data.summary.currentNodeCode").value("PROJECT_REVIEW")); + + MvcResult projectTaskResult = mockMvc.perform(get("/api/v1/workflow/tasks") + .cookie(projectReviewer.cookies()).param("view", "TODO")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[?(@.form.publicId == '%s')]".formatted(documentId)).exists()) + .andReturn(); + String projectTaskId = taskForDocument(projectTaskResult, documentId); + postJson("/api/v1/workflow/tasks/" + projectTaskId + "/approve", projectReviewer, + "{\"version\":0,\"opinion\":\"项目资料核对通过\",\"returnPoints\":[]}") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.form.summary.status").value("FINANCE_REVIEW")); + assertEquals(0, projectionCount("fin_receipt", documentId)); + + MvcResult financeTaskResult = mockMvc.perform(get("/api/v1/workflow/tasks") + .cookie(financeReviewer.cookies()).param("view", "TODO")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[?(@.form.publicId == '%s')]".formatted(documentId)).exists()) + .andReturn(); + String financeTaskId = taskForDocument(financeTaskResult, documentId); + String financeApproval = "{\"version\":0,\"opinion\":\"收款证据和金额核对通过\",\"returnPoints\":[]}"; + String financeApprovalKey = idempotencyKey(); + postJson("/api/v1/workflow/tasks/" + financeTaskId + "/approve", financeReviewer, + financeApproval, financeApprovalKey) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.form.summary.status").value("APPROVED")) + .andExpect(jsonPath("$.data.timeline[?(@.action == 'SUBMIT')]").exists()) + .andExpect(jsonPath("$.data.timeline[?(@.nodeCode == 'FINANCE_REVIEW' && @.action == 'APPROVE')]").exists()); + postJson("/api/v1/workflow/tasks/" + financeTaskId + "/approve", financeReviewer, + financeApproval, financeApprovalKey) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.form.summary.status").value("APPROVED")); + + Map receipt = jdbcTemplate.queryForMap(""" + SELECT receipt.public_id, receipt.business_no, receipt.amount, receipt.currency, + receipt.payer_name, receipt.status, receipt.created_by, receipt.confirmed_by, + receipt.source_version_id, document.current_version_id, + project.public_id AS project_public_id, + customer.public_id AS customer_public_id + FROM fin_receipt receipt + JOIN src_source_document document ON document.id = receipt.source_document_id + JOIN md_project project ON project.id = receipt.project_id + LEFT JOIN md_counterparty customer ON customer.id = receipt.customer_id + WHERE document.public_id = ? + """, documentId); + String receiptId = String.valueOf(receipt.get("public_id")); + assertEquals("128000.50", receipt.get("amount").toString()); + assertEquals("CNY", receipt.get("currency")); + assertEquals("测试业主", receipt.get("payer_name")); + assertEquals("CONFIRMED", receipt.get("status")); + assertEquals(fixtures.projectId(), receipt.get("project_public_id")); + assertNotNull(receipt.get("customer_public_id")); + assertFalse(receipt.get("created_by").equals(receipt.get("confirmed_by"))); + assertEquals(receipt.get("current_version_id"), receipt.get("source_version_id")); + postJson("/api/v1/receipts/" + receiptId + "/void", financeReviewer, + "{\"version\":0,\"reason\":\"来源审批收款不能直接作废\"}") + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("INVALID_STATE_TRANSITION")); + mockMvc.perform(get("/api/v1/receipts/{id}", receiptId).cookie(financeReviewer.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.receipt.status").value("CONFIRMED")) + .andExpect(jsonPath("$.data.receipt.actions[?(@ == 'VOID_RECEIPT')]").doesNotExist()); + assertEquals(1, projectionCount("fin_receipt", documentId)); + assertEquals(1, jdbcTemplate.queryForObject(""" + SELECT COUNT(*) FROM acc_event + WHERE event_type = 'RECEIPT' AND source_public_id = ? AND status = 'PENDING' + """, Integer.class, receiptId)); + assertEquals(1, jdbcTemplate.queryForObject(""" + SELECT COUNT(*) FROM fund_ledger + WHERE source_event_key = CONCAT('RECEIPT_CONFIRM:', ?) + AND entry_type = 'RECEIPT_CONFIRMED' + AND confirmed_receipt_delta = 128000.50 + """, Integer.class, receiptId)); + assertEquals("128000.50", jdbcTemplate.queryForObject(""" + SELECT control.confirmed_receipt + FROM project_fund_control control + JOIN md_project project ON project.id = control.project_id + WHERE project.public_id = ? AND control.currency = 'CNY' + """, java.math.BigDecimal.class, fixtures.projectId()).toString()); + } + + @Test + void oa05ProjectionFailureRollsBackFinalApprovalAndFinancialEffects() throws Exception { + Fixtures fixtures = fixtures(); + ClientSession project = loginAndSelect("project", "PROJECT_MANAGER"); + ClientSession projectReviewer = loginAndSelect("demo", "PROJECT_MANAGER"); + ClientSession financeReviewer = loginAndSelect("finance", "FINANCE_MANAGER"); + String sourceNo = "OA05-ROLLBACK-" + nextSuffix(); + String evidenceFileId = uploadFile(project, "oa05-rollback.pdf"); + + MvcResult created = postJson("/api/v1/source/forms", project, sourcePayload( + "OA-05", sourceNo, fixtures, receiptValues(evidenceFileId, "终审回滚测试") + )).andExpect(status().isOk()).andReturn(); + String documentId = JsonPath.read(created.getResponse().getContentAsString(), "$.data.summary.publicId"); + postJson("/api/v1/source/forms/" + documentId + "/submit", project, "{\"version\":0}") + .andExpect(status().isOk()); + String projectTaskId = pendingTaskForDocument(projectReviewer, documentId); + postJson("/api/v1/workflow/tasks/" + projectTaskId + "/approve", projectReviewer, + "{\"version\":0,\"opinion\":\"项目初审通过\",\"returnPoints\":[]}") + .andExpect(status().isOk()); + String financeTaskId = pendingTaskForDocument(financeReviewer, documentId); + + jdbcTemplate.update(""" + UPDATE md_counterparty customer + JOIN md_project project ON project.customer_id = customer.id + SET customer.status = 'DISABLED' + WHERE project.public_id = ? + """, fixtures.projectId()); + postJson("/api/v1/workflow/tasks/" + financeTaskId + "/approve", financeReviewer, + "{\"version\":0,\"opinion\":\"触发失效客户回滚\",\"returnPoints\":[]}") + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("VALIDATION_FAILED")); + + assertEquals("FINANCE_REVIEW", jdbcTemplate.queryForObject( + "SELECT status FROM src_source_document WHERE public_id = ?", String.class, documentId + )); + Map task = jdbcTemplate.queryForMap( + "SELECT status, version FROM wf_task WHERE public_id = ?", financeTaskId + ); + assertEquals("PENDING", task.get("status")); + assertEquals(0L, ((Number) task.get("version")).longValue()); + assertEquals("RUNNING", jdbcTemplate.queryForObject(""" + SELECT instance.status + FROM wf_instance instance + JOIN wf_task task_record ON task_record.instance_id = instance.id + WHERE task_record.public_id = ? + """, String.class, financeTaskId)); + assertEquals(0, projectionCount("fin_receipt", documentId)); + assertEquals(0, jdbcTemplate.queryForObject(""" + SELECT COUNT(*) FROM wf_action action_record + JOIN wf_task task_record ON task_record.id = action_record.task_id + WHERE task_record.public_id = ? AND action_record.action = 'APPROVE' + """, Integer.class, financeTaskId)); + assertEquals(0, jdbcTemplate.queryForObject(""" + SELECT COUNT(*) FROM acc_event event_record + WHERE event_record.source_type = 'RECEIPT' + AND event_record.source_public_id IN ( + SELECT receipt.public_id FROM fin_receipt receipt + WHERE receipt.source_document_id = ( + SELECT id FROM src_source_document WHERE public_id = ? + ) + ) + """, Integer.class, documentId)); + assertEquals(0, jdbcTemplate.queryForObject(""" + SELECT COUNT(*) FROM fund_ledger ledger + WHERE ledger.entry_type = 'RECEIPT_CONFIRMED' + AND ledger.description = 'OA-05 财务终审确认项目收款' + AND ledger.project_id = (SELECT id FROM md_project WHERE public_id = ?) + """, Integer.class, fixtures.projectId())); + } + + @Test + void approvedOa08CreatesInvoiceCandidateAndCompletesManualInvoiceChain() throws Exception { + Fixtures fixtures = fixtures(); + CustomerContractFixture contract = activeCustomerContract(fixtures); + ClientSession applicant = loginAndSelect("project", "PROJECT_MANAGER"); + ClientSession projectReviewer = loginAndSelect("demo", "PROJECT_MANAGER"); + ClientSession financeReviewer = loginAndSelect("finance", "FINANCE_MANAGER"); + ClientSession independentFinanceReviewer = loginAndSelect("demo", "FINANCE_MANAGER"); + String suffix = nextSuffix(); + + mockMvc.perform(get("/api/v1/source/forms/references").cookie(applicant.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.customers[?(@.publicId == '%s')]".formatted(fixtures.customerId())).exists()) + .andExpect(jsonPath("$.data.contracts[?(@.publicId == '%s')]".formatted(contract.publicId())).exists()); + + MvcResult created = postJson("/api/v1/source/forms", applicant, sourcePayload( + "OA-08", "OA08-INVOICE-" + suffix, fixtures, + oa08Values(fixtures, contract, "25000.00", fixtures.customerName()) + )).andExpect(status().isOk()) + .andExpect(jsonPath("$.data.summary.templateVersion").value(2)) + .andReturn(); + String documentId = JsonPath.read(created.getResponse().getContentAsString(), "$.data.summary.publicId"); + postJson("/api/v1/source/forms/" + documentId + "/submit", applicant, "{\"version\":0}") + .andExpect(status().isOk()); + assertEquals(0, projectionCount("fin_invoice", documentId)); + + String projectTaskId = pendingTaskForDocument(projectReviewer, documentId); + postJson("/api/v1/workflow/tasks/" + projectTaskId + "/approve", projectReviewer, + "{\"version\":0,\"opinion\":\"开票资料初审通过\",\"returnPoints\":[]}") + .andExpect(status().isOk()); + assertEquals(0, projectionCount("fin_invoice", documentId)); + + String financeTaskId = pendingTaskForDocument(financeReviewer, documentId); + String approvalBody = "{\"version\":0,\"opinion\":\"开票客户和合同复核通过\",\"returnPoints\":[]}"; + String approvalKey = idempotencyKey(); + postJson("/api/v1/workflow/tasks/" + financeTaskId + "/approve", financeReviewer, + approvalBody, approvalKey) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.form.summary.status").value("APPROVED")); + postJson("/api/v1/workflow/tasks/" + financeTaskId + "/approve", financeReviewer, + approvalBody, approvalKey) + .andExpect(status().isOk()); + + Map invoice = jdbcTemplate.queryForMap(""" + SELECT invoice.public_id, invoice.amount, invoice.currency, invoice.tax_rate, + invoice.invoice_type, invoice.status, invoice.source_version_id, + document.current_version_id, contract.public_id AS contract_public_id, + counterparty.public_id AS counterparty_public_id + FROM fin_invoice invoice + JOIN src_source_document document ON document.id = invoice.source_document_id + JOIN md_contract contract ON contract.id = invoice.contract_id + JOIN md_counterparty counterparty ON counterparty.id = invoice.counterparty_id + WHERE document.public_id = ? + """, documentId); + String invoiceId = String.valueOf(invoice.get("public_id")); + assertEquals("25000.00", invoice.get("amount").toString()); + assertEquals("CNY", invoice.get("currency")); + assertEquals("0.090000", invoice.get("tax_rate").toString()); + assertEquals("SPECIAL", invoice.get("invoice_type")); + assertEquals("DRAFT", invoice.get("status")); + assertEquals(contract.publicId(), invoice.get("contract_public_id")); + assertEquals(fixtures.customerId(), invoice.get("counterparty_public_id")); + assertEquals(invoice.get("current_version_id"), invoice.get("source_version_id")); + assertEquals(1, projectionCount("fin_invoice", documentId)); + + mockMvc.perform(patch("/api/v1/invoices/" + invoiceId) + .cookie(financeReviewer.cookies()) + .header("X-XSRF-TOKEN", financeReviewer.csrfToken()) + .header("Idempotency-Key", idempotencyKey()) + .contentType("application/json") + .content(""" + {"companyId":"%s","projectId":"%s","contractId":"%s", + "counterpartyId":"%s","invoiceType":"NORMAL","amount":1.00, + "taxRate":0.01,"requestedDate":"2026-08-21","version":0} + """.formatted(fixtures.companyId(), fixtures.projectId(), contract.publicId(), + fixtures.customerId()))) + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("INVALID_STATE_TRANSITION")); + postJson("/api/v1/invoices/" + invoiceId + "/void", financeReviewer, + "{\"version\":0,\"reason\":\"来源候选不能直接作废\"}") + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("INVALID_STATE_TRANSITION")); + + postJson("/api/v1/invoices/" + invoiceId + "/submit", financeReviewer, "{\"version\":0}") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("REVIEWING")); + postJson("/api/v1/invoices/" + invoiceId + "/review", financeReviewer, + "{\"version\":1,\"decision\":\"APPROVE\",\"opinion\":\"本人不得复核\"}") + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("SOD_VIOLATION")); + postJson("/api/v1/invoices/" + invoiceId + "/review", independentFinanceReviewer, + "{\"version\":1,\"decision\":\"APPROVE\",\"opinion\":\"额度及税率复核通过\"}") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("RESULT_PENDING")) + .andExpect(jsonPath("$.data.currency").value("CNY")) + .andExpect(jsonPath("$.data.contractAvailableAmount").value("475000.00")); + postJson("/api/v1/invoices/" + invoiceId + "/record-result", financeReviewer, + "{\"version\":2,\"invoiceNo\":\"OA08-FP-%s\",\"issuedDate\":\"2026-08-20\"}" + .formatted(suffix)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("ISSUED")); + + assertEquals(1, jdbcTemplate.queryForObject(""" + SELECT COUNT(*) FROM fin_receivable receivable + JOIN fin_invoice source_invoice ON source_invoice.id = receivable.source_invoice_id + WHERE source_invoice.public_id = ? AND receivable.status = 'OPEN' + AND receivable.amount = 25000.00 AND receivable.outstanding_amount = 25000.00 + AND receivable.currency = 'CNY' + """, Integer.class, invoiceId)); + assertEquals(1, jdbcTemplate.queryForObject(""" + SELECT COUNT(*) FROM acc_event + WHERE event_type = 'INVOICE' AND source_public_id = ? + AND amount = 25000.00 AND currency = 'CNY' AND status = 'PENDING' + """, Integer.class, invoiceId)); + } + + @Test + void oa08ProjectionFailureRollsBackFinalApprovalAtomically() throws Exception { + Fixtures fixtures = fixtures(); + CustomerContractFixture contract = activeCustomerContract(fixtures); + ClientSession applicant = loginAndSelect("project", "PROJECT_MANAGER"); + ClientSession projectReviewer = loginAndSelect("demo", "PROJECT_MANAGER"); + ClientSession financeReviewer = loginAndSelect("finance", "FINANCE_MANAGER"); + String suffix = nextSuffix(); + + MvcResult created = postJson("/api/v1/source/forms", applicant, sourcePayload( + "OA-08", "OA08-ROLLBACK-" + suffix, fixtures, + oa08Values(fixtures, contract, "10000.00", "不匹配的客户名称") + )).andExpect(status().isOk()).andReturn(); + String documentId = JsonPath.read(created.getResponse().getContentAsString(), "$.data.summary.publicId"); + postJson("/api/v1/source/forms/" + documentId + "/submit", applicant, "{\"version\":0}") + .andExpect(status().isOk()); + String projectTaskId = pendingTaskForDocument(projectReviewer, documentId); + postJson("/api/v1/workflow/tasks/" + projectTaskId + "/approve", projectReviewer, + "{\"version\":0,\"opinion\":\"项目初审通过\",\"returnPoints\":[]}") + .andExpect(status().isOk()); + String financeTaskId = pendingTaskForDocument(financeReviewer, documentId); + + postJson("/api/v1/workflow/tasks/" + financeTaskId + "/approve", financeReviewer, + "{\"version\":0,\"opinion\":\"触发客户快照不一致回滚\",\"returnPoints\":[]}") + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("VALIDATION_FAILED")); + + assertEquals("FINANCE_REVIEW", jdbcTemplate.queryForObject( + "SELECT status FROM src_source_document WHERE public_id = ?", String.class, documentId + )); + assertEquals("PENDING", jdbcTemplate.queryForObject( + "SELECT status FROM wf_task WHERE public_id = ?", String.class, financeTaskId + )); + assertEquals("RUNNING", jdbcTemplate.queryForObject(""" + SELECT instance.status FROM wf_instance instance + JOIN wf_task task_record ON task_record.instance_id = instance.id + WHERE task_record.public_id = ? + """, String.class, financeTaskId)); + assertEquals(0, projectionCount("fin_invoice", documentId)); + assertEquals(0, jdbcTemplate.queryForObject(""" + SELECT COUNT(*) FROM wf_action action_record + JOIN wf_task task_record ON task_record.id = action_record.task_id + WHERE task_record.public_id = ? AND action_record.action = 'APPROVE' + """, Integer.class, financeTaskId)); + } + + @Test + void oa08RejectsStaleContractSnapshotBeforeCreatingInvoiceCandidate() throws Exception { + Fixtures fixtures = fixtures(); + CustomerContractFixture contract = activeCustomerContract(fixtures); + ClientSession applicant = loginAndSelect("project", "PROJECT_MANAGER"); + ClientSession projectReviewer = loginAndSelect("demo", "PROJECT_MANAGER"); + ClientSession financeReviewer = loginAndSelect("finance", "FINANCE_MANAGER"); + + MvcResult created = postJson("/api/v1/source/forms", applicant, sourcePayload( + "OA-08", "OA08-STALE-CONTRACT-" + nextSuffix(), fixtures, + oa08Values(fixtures, contract, "10000.00", fixtures.customerName()) + )).andExpect(status().isOk()).andReturn(); + String documentId = JsonPath.read(created.getResponse().getContentAsString(), "$.data.summary.publicId"); + postJson("/api/v1/source/forms/" + documentId + "/submit", applicant, "{\"version\":0}") + .andExpect(status().isOk()); + String projectTaskId = pendingTaskForDocument(projectReviewer, documentId); + postJson("/api/v1/workflow/tasks/" + projectTaskId + "/approve", projectReviewer, + "{\"version\":0,\"opinion\":\"项目初审通过\",\"returnPoints\":[]}") + .andExpect(status().isOk()); + String financeTaskId = pendingTaskForDocument(financeReviewer, documentId); + + jdbcTemplate.update(""" + UPDATE md_contract + SET approved_change_amount = approved_change_amount + 1.00, + version = version + 1 + WHERE public_id = ? + """, contract.publicId()); + postJson("/api/v1/workflow/tasks/" + financeTaskId + "/approve", financeReviewer, + "{\"version\":0,\"opinion\":\"复核过期合同快照\",\"returnPoints\":[]}") + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("VALIDATION_FAILED")) + .andExpect(jsonPath("$.fieldErrors.contractAmount").exists()); + + assertEquals("FINANCE_REVIEW", jdbcTemplate.queryForObject( + "SELECT status FROM src_source_document WHERE public_id = ?", String.class, documentId + )); + assertEquals("PENDING", jdbcTemplate.queryForObject( + "SELECT status FROM wf_task WHERE public_id = ?", String.class, financeTaskId + )); + assertEquals(0, projectionCount("fin_invoice", documentId)); + } + + @Test + void oa08DraftCandidatesReserveContractLimitAcrossFinalApprovals() throws Exception { + Fixtures fixtures = fixtures(); + CustomerContractFixture contract = activeCustomerContract(fixtures); + ClientSession applicant = loginAndSelect("project", "PROJECT_MANAGER"); + ClientSession projectReviewer = loginAndSelect("demo", "PROJECT_MANAGER"); + ClientSession financeReviewer = loginAndSelect("finance", "FINANCE_MANAGER"); + + MvcResult firstCreated = postJson("/api/v1/source/forms", applicant, sourcePayload( + "OA-08", "OA08-RESERVE-FIRST-" + nextSuffix(), fixtures, + oa08Values(fixtures, contract, "300000.00", fixtures.customerName()) + )).andExpect(status().isOk()).andReturn(); + String firstDocumentId = JsonPath.read( + firstCreated.getResponse().getContentAsString(), "$.data.summary.publicId" + ); + postJson("/api/v1/source/forms/" + firstDocumentId + "/submit", applicant, "{\"version\":0}") + .andExpect(status().isOk()); + String firstProjectTaskId = pendingTaskForDocument(projectReviewer, firstDocumentId); + postJson("/api/v1/workflow/tasks/" + firstProjectTaskId + "/approve", projectReviewer, + "{\"version\":0,\"opinion\":\"第一笔项目初审通过\",\"returnPoints\":[]}") + .andExpect(status().isOk()); + String firstFinanceTaskId = pendingTaskForDocument(financeReviewer, firstDocumentId); + postJson("/api/v1/workflow/tasks/" + firstFinanceTaskId + "/approve", financeReviewer, + "{\"version\":0,\"opinion\":\"第一笔财务终审通过\",\"returnPoints\":[]}") + .andExpect(status().isOk()); + assertEquals(1, projectionCount("fin_invoice", firstDocumentId)); + + MvcResult secondCreated = postJson("/api/v1/source/forms", applicant, sourcePayload( + "OA-08", "OA08-RESERVE-SECOND-" + nextSuffix(), fixtures, + oa08Values(fixtures, contract, "250000.00", fixtures.customerName()) + )).andExpect(status().isOk()).andReturn(); + String secondDocumentId = JsonPath.read( + secondCreated.getResponse().getContentAsString(), "$.data.summary.publicId" + ); + postJson("/api/v1/source/forms/" + secondDocumentId + "/submit", applicant, "{\"version\":0}") + .andExpect(status().isOk()); + String secondProjectTaskId = pendingTaskForDocument(projectReviewer, secondDocumentId); + postJson("/api/v1/workflow/tasks/" + secondProjectTaskId + "/approve", projectReviewer, + "{\"version\":0,\"opinion\":\"第二笔项目初审通过\",\"returnPoints\":[]}") + .andExpect(status().isOk()); + String secondFinanceTaskId = pendingTaskForDocument(financeReviewer, secondDocumentId); + postJson("/api/v1/workflow/tasks/" + secondFinanceTaskId + "/approve", financeReviewer, + "{\"version\":0,\"opinion\":\"验证在途候选预占额度\",\"returnPoints\":[]}") + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("CONTRACT_LIMIT_EXCEEDED")); + + assertEquals("DRAFT", jdbcTemplate.queryForObject(""" + SELECT invoice.status FROM fin_invoice invoice + JOIN src_source_document document ON document.id = invoice.source_document_id + WHERE document.public_id = ? + """, String.class, firstDocumentId)); + assertEquals("FINANCE_REVIEW", jdbcTemplate.queryForObject( + "SELECT status FROM src_source_document WHERE public_id = ?", String.class, secondDocumentId + )); + assertEquals("PENDING", jdbcTemplate.queryForObject( + "SELECT status FROM wf_task WHERE public_id = ?", String.class, secondFinanceTaskId + )); + assertEquals(0, projectionCount("fin_invoice", secondDocumentId)); + } + + @Test + void concurrentOa08FinalApprovalsSerializeContractReservation() throws Exception { + Fixtures fixtures = fixtures(); + CustomerContractFixture contract = activeCustomerContract(fixtures); + ClientSession applicant = loginAndSelect("project", "PROJECT_MANAGER"); + ClientSession projectReviewer = loginAndSelect("demo", "PROJECT_MANAGER"); + ClientSession financeReviewer = loginAndSelect("finance", "FINANCE_MANAGER"); + + String firstDocumentId = createOa08ForFinanceReview( + fixtures, contract, applicant, projectReviewer, financeReviewer, + "OA08-CONCURRENT-FIRST-" + nextSuffix(), "300000.00" + ); + String secondDocumentId = createOa08ForFinanceReview( + fixtures, contract, applicant, projectReviewer, financeReviewer, + "OA08-CONCURRENT-SECOND-" + nextSuffix(), "300000.00" + ); + String firstTaskId = pendingTaskForDocument(financeReviewer, firstDocumentId); + String secondTaskId = pendingTaskForDocument(financeReviewer, secondDocumentId); + String approvalBody = "{\"version\":0,\"opinion\":\"并发额度终审\",\"returnPoints\":[]}"; + + ExecutorService executor = Executors.newFixedThreadPool(2); + CountDownLatch start = new CountDownLatch(1); + try { + Future first = executor.submit(() -> { + start.await(); + return workflowApprovalStatus(firstTaskId, financeReviewer, approvalBody, idempotencyKey()); + }); + Future second = executor.submit(() -> { + start.await(); + return workflowApprovalStatus(secondTaskId, financeReviewer, approvalBody, idempotencyKey()); + }); + start.countDown(); + List statuses = List.of( + first.get(30, TimeUnit.SECONDS), second.get(30, TimeUnit.SECONDS) + ); + assertEquals(1, statuses.stream().filter(code -> code == 200).count()); + assertEquals(1, statuses.stream().filter(code -> code == 422).count()); + } finally { + executor.shutdownNow(); + } + + assertEquals(1, projectionCount("fin_invoice", firstDocumentId) + + projectionCount("fin_invoice", secondDocumentId)); + assertEquals("300000.00", jdbcTemplate.queryForObject(""" + SELECT COALESCE(SUM(CASE WHEN invoice.invoice_type = 'RED_LETTER' + THEN -invoice.amount ELSE invoice.amount END), 0) + FROM fin_invoice invoice + JOIN md_contract contract ON contract.id = invoice.contract_id + WHERE contract.public_id = ? + AND invoice.status IN ( + 'DRAFT', 'REVIEWING', 'RESULT_PENDING', 'APPROVED', 'ISSUED', 'RED_LETTERED' + ) + """, java.math.BigDecimal.class, contract.publicId()).toPlainString()); + assertEquals(List.of("APPROVED", "FINANCE_REVIEW"), jdbcTemplate.queryForList(""" + SELECT document.status + FROM src_source_document document + WHERE document.public_id IN (?, ?) + ORDER BY document.status + """, String.class, firstDocumentId, secondDocumentId)); + } + + @Test + void returnedOa08CandidateReopensSourceAndReprojectsCorrectedVersion() throws Exception { + Fixtures fixtures = fixtures(); + CustomerContractFixture contract = activeCustomerContract(fixtures); + ClientSession applicant = loginAndSelect("project", "PROJECT_MANAGER"); + ClientSession projectReviewer = loginAndSelect("demo", "PROJECT_MANAGER"); + ClientSession financeReviewer = loginAndSelect("finance", "FINANCE_MANAGER"); + ClientSession invoiceReviewer = loginAndSelect("demo", "FINANCE_MANAGER"); + String sourceNo = "OA08-CORRECTION-" + nextSuffix(); + + MvcResult created = postJson("/api/v1/source/forms", applicant, sourcePayload( + "OA-08", sourceNo, fixtures, + oa08Values(fixtures, contract, "25000.00", fixtures.customerName()) + )).andExpect(status().isOk()).andReturn(); + String documentId = JsonPath.read(created.getResponse().getContentAsString(), "$.data.summary.publicId"); + postJson("/api/v1/source/forms/" + documentId + "/submit", applicant, "{\"version\":0}") + .andExpect(status().isOk()); + String projectTaskId = pendingTaskForDocument(projectReviewer, documentId); + postJson("/api/v1/workflow/tasks/" + projectTaskId + "/approve", projectReviewer, + "{\"version\":0,\"opinion\":\"首次项目初审通过\",\"returnPoints\":[]}") + .andExpect(status().isOk()); + String financeTaskId = pendingTaskForDocument(financeReviewer, documentId); + postJson("/api/v1/workflow/tasks/" + financeTaskId + "/approve", financeReviewer, + "{\"version\":0,\"opinion\":\"首次财务终审通过\",\"returnPoints\":[]}") + .andExpect(status().isOk()); + + Map firstProjection = jdbcTemplate.queryForMap(""" + SELECT invoice.public_id, invoice.source_version_id, invoice.amount, invoice.status + FROM fin_invoice invoice + JOIN src_source_document document ON document.id = invoice.source_document_id + WHERE document.public_id = ? + """, documentId); + String invoiceId = String.valueOf(firstProjection.get("public_id")); + long firstSourceVersionId = ((Number) firstProjection.get("source_version_id")).longValue(); + assertEquals("DRAFT", firstProjection.get("status")); + + postJson("/api/v1/invoices/" + invoiceId + "/submit", financeReviewer, "{\"version\":0}") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("REVIEWING")); + postJson("/api/v1/invoices/" + invoiceId + "/review", invoiceReviewer, """ + {"version":1,"decision":"REJECT","opinion":"来源候选不允许直接驳回","returnPoints":[]} + """) + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("INVALID_STATE_TRANSITION")); + postJson("/api/v1/invoices/" + invoiceId + "/review", invoiceReviewer, """ + {"version":1,"decision":"RETURN","opinion":"请更正开票金额","returnPoints":[]} + """) + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("VALIDATION_FAILED")); + postJson("/api/v1/invoices/" + invoiceId + "/review", invoiceReviewer, """ + {"version":1,"decision":"RETURN","opinion":"请更正开票金额和开票要求", + "returnPoints":["invoiceAmount","invoiceRequirements"]} + """) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("RETURNED")) + .andExpect(jsonPath("$.data.sourceDocumentId").value(documentId)); + + assertEquals("RETURNED", jdbcTemplate.queryForObject( + "SELECT status FROM src_source_document WHERE public_id = ?", String.class, documentId + )); + assertEquals(1, jdbcTemplate.queryForObject(""" + SELECT COUNT(*) FROM src_approval_snapshot approval + JOIN src_document_version version ON version.id = approval.document_version_id + JOIN src_source_document document ON document.id = version.source_document_id + WHERE document.public_id = ? AND approval.action = 'RETURN' + AND approval.opinion = '请更正开票金额和开票要求' + """, Integer.class, documentId)); + postJson("/api/v1/invoices/" + invoiceId + "/submit", financeReviewer, "{\"version\":2}") + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("INVALID_STATE_TRANSITION")); + + MvcResult returned = mockMvc.perform(get("/api/v1/source/forms/" + documentId).cookie(applicant.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.summary.status").value("RETURNED")) + .andReturn(); + Number returnedVersion = JsonPath.read( + returned.getResponse().getContentAsString(), "$.data.summary.version" + ); + MvcResult corrected = patchJson("/api/v1/source/forms/" + documentId, applicant, + updatePayload( + sourceNo, + fixtures, + oa08Values(fixtures, contract, "15000.00", fixtures.customerName()), + returnedVersion.longValue() + )) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.summary.status").value("DRAFT")) + .andReturn(); + Number correctedVersion = JsonPath.read( + corrected.getResponse().getContentAsString(), "$.data.summary.version" + ); + postJson("/api/v1/source/forms/" + documentId + "/submit", applicant, + "{\"version\":" + correctedVersion.longValue() + "}") + .andExpect(status().isOk()); + String correctedProjectTaskId = pendingTaskForDocument(projectReviewer, documentId); + postJson("/api/v1/workflow/tasks/" + correctedProjectTaskId + "/approve", projectReviewer, + "{\"version\":0,\"opinion\":\"更正版本项目初审通过\",\"returnPoints\":[]}") + .andExpect(status().isOk()); + String correctedFinanceTaskId = pendingTaskForDocument(financeReviewer, documentId); + postJson("/api/v1/workflow/tasks/" + correctedFinanceTaskId + "/approve", financeReviewer, + "{\"version\":0,\"opinion\":\"更正版本财务终审通过\",\"returnPoints\":[]}") + .andExpect(status().isOk()); + + Map correctedProjection = jdbcTemplate.queryForMap(""" + SELECT invoice.public_id, invoice.source_version_id, invoice.amount, invoice.status, + document.current_version_id + FROM fin_invoice invoice + JOIN src_source_document document ON document.id = invoice.source_document_id + WHERE document.public_id = ? + """, documentId); + assertEquals(invoiceId, correctedProjection.get("public_id")); + assertEquals("15000.00", correctedProjection.get("amount").toString()); + assertEquals("DRAFT", correctedProjection.get("status")); + assertEquals(correctedProjection.get("current_version_id"), correctedProjection.get("source_version_id")); + assertNotEquals(firstSourceVersionId, ((Number) correctedProjection.get("source_version_id")).longValue()); + assertEquals(1, projectionCount("fin_invoice", documentId)); + assertEquals(2, jdbcTemplate.queryForObject(""" + SELECT COUNT(*) FROM src_document_version version + JOIN src_source_document document ON document.id = version.source_document_id + WHERE document.public_id = ? + """, Integer.class, documentId)); + + mockMvc.perform(get("/api/v1/invoices/" + invoiceId).cookie(financeReviewer.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.sourceDocumentId").value(documentId)) + .andExpect(jsonPath("$.data.sourceVersionId").isNotEmpty()) + .andExpect(jsonPath("$.data.allowedActions").value(org.hamcrest.Matchers.hasItem("SUBMIT"))) + .andExpect(jsonPath("$.data.allowedActions").value(org.hamcrest.Matchers.not( + org.hamcrest.Matchers.hasItem("EDIT") + ))) + .andExpect(jsonPath("$.data.allowedActions").value(org.hamcrest.Matchers.not( + org.hamcrest.Matchers.hasItem("VOID") + ))); + } + + @Test + void returnedFormsCanBeVersionedAndResubmittedWhileWithdrawAndVoidRemainTraceable() throws Exception { + Fixtures fixtures = fixtures(); + ClientSession project = loginAndSelect("project", "PROJECT_MANAGER"); + ClientSession reviewer = loginAndSelect("demo", "PROJECT_MANAGER"); + String sourceNo = "RETURN-" + nextSuffix(); + String evidenceFileId = uploadFile(project, "return-evidence.pdf"); + String values = """ + {"ownerUnit":"测试业主","receiptMethod":"银行转账","receiptAmount":8000, + "receiptAccount":[{"accountName":"测试公司","bankName":"测试银行","accountNo":"10001"}], + "receiptDescription":"测试退回重提","evidenceFiles":["%s"]} + """.formatted(evidenceFileId); + MvcResult created = postJson("/api/v1/source/forms", project, sourcePayload( + "OA-05", sourceNo, fixtures, values + )).andExpect(status().isOk()).andReturn(); + String documentId = JsonPath.read(created.getResponse().getContentAsString(), "$.data.summary.publicId"); + postJson("/api/v1/source/forms/" + documentId + "/submit", project, "{\"version\":0}") + .andExpect(status().isOk()); + + MvcResult taskResult = mockMvc.perform(get("/api/v1/workflow/tasks") + .cookie(reviewer.cookies()).param("view", "TODO")) + .andExpect(status().isOk()).andReturn(); + String taskId = taskForDocument(taskResult, documentId); + postJson("/api/v1/workflow/tasks/" + taskId + "/return", reviewer, """ + {"version":0,"opinion":"请补充收款说明","returnPoints":["receiptDescription"]} + """) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.form.summary.status").value("RETURNED")); + + patchJson("/api/v1/source/forms/" + documentId, project, updatePayload(sourceNo, fixtures, values, 2)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.summary.status").value("DRAFT")) + .andExpect(jsonPath("$.data.summary.version").value(3)); + postJson("/api/v1/source/forms/" + documentId + "/submit", project, "{\"version\":3}") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.summary.version").value(4)); + postJson("/api/v1/source/forms/" + documentId + "/withdraw", project, + "{\"version\":4,\"opinion\":\"发起人撤回调整\"}") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.summary.status").value("DRAFT")) + .andExpect(jsonPath("$.data.approvals[?(@.action == 'WITHDRAW')]").exists()); + postJson("/api/v1/source/forms/" + documentId + "/void", project, + "{\"version\":5,\"opinion\":\"该笔来源不再使用\"}") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.summary.status").value("VOID")) + .andExpect(jsonPath("$.data.approvals[?(@.action == 'VOID')]").exists()); + } + + @Test + void restrictedOa09ValuesAreEncryptedAtRestAndUnauthorizedRolesAreRejected() throws Exception { + Fixtures fixtures = fixtures(); + ClientSession project = loginAndSelect("project", "PROJECT_MANAGER"); + ClientSession archive = loginAndSelect("archive", "ARCHIVE_MANAGER"); + String sourceNo = "PEOPLE-" + nextSuffix(); + String laborContractFileId = uploadFile(project, "oa09-labor-contract.pdf"); + String idCardFileId = uploadFile(project, "oa09-id-card.pdf"); + String values = """ + {"teamName":"钢筋班组","teamLeader":[{"name":"班组长","phone":"13800000000"}], + "workers":[{"name":"张三","idCardNo":"430101199001011234","phone":"13900000000", + "monthlySalary":8000,"workType":"钢筋工","entryDate":"2026-08-01", + "emergencyContact":"13700000000","bankName":"测试银行","bankAccountNo":"6222333344445555"}], + "workerFiles":[{"workerName":"张三","laborContractFileId":"%s","idCardFileId":"%s"}]} + """.formatted(laborContractFileId, idCardFileId); + MvcResult created = postJson("/api/v1/source/forms", project, sourcePayload( + "OA-09", sourceNo, fixtures, values + )).andExpect(status().isOk()).andReturn(); + String documentId = JsonPath.read(created.getResponse().getContentAsString(), "$.data.summary.publicId"); + + String storedPayload = jdbcTemplate.queryForObject(""" + SELECT CAST(version.payload_json AS CHAR) + FROM src_document_version version + JOIN src_source_document document ON document.current_version_id = version.id + WHERE document.public_id = ? + """, String.class, documentId); + assertNotNull(storedPayload); + assertFalse(storedPayload.contains("430101199001011234")); + assertFalse(storedPayload.contains("6222333344445555")); + assertTrue(storedPayload.contains("$encrypted")); + + mockMvc.perform(get("/api/v1/source/forms/{id}", documentId).cookie(archive.cookies())) + .andExpect(status().isForbidden()); + } + + @Test + void sourceAndWorkflowCommandsReplayWithoutDuplicatingBusinessEffects() throws Exception { + Fixtures fixtures = fixtures(); + ClientSession project = loginAndSelect("project", "PROJECT_MANAGER"); + ClientSession reviewer = loginAndSelect("demo", "PROJECT_MANAGER"); + String fileId = uploadFile(project, "idempotent-source.pdf"); + String sourceNo = "IDEMPOTENT-" + nextSuffix(); + String payload = sourcePayload("OA-05", sourceNo, fixtures, + receiptValues(fileId, "幂等来源表单")); + + mockMvc.perform(post("/api/v1/source/forms") + .cookie(project.cookies()) + .header("X-XSRF-TOKEN", project.csrfToken()) + .contentType("application/json") + .content(payload)) + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("VALIDATION_FAILED")); + + String createKey = idempotencyKey(); + MvcResult created = postJson("/api/v1/source/forms", project, payload, createKey) + .andExpect(status().isOk()) + .andReturn(); + String documentId = JsonPath.read(created.getResponse().getContentAsString(), "$.data.summary.publicId"); + postJson("/api/v1/source/forms", project, payload, createKey) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.summary.publicId").value(documentId)); + postJson("/api/v1/source/forms", project, + sourcePayload("OA-05", sourceNo + "-DIFFERENT", fixtures, + receiptValues(fileId, "同键不同请求")), createKey) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("IDEMPOTENCY_KEY_REUSED")); + assertEquals(1, jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM src_source_document WHERE source_no = ?", Integer.class, sourceNo + )); + + String validateKey = idempotencyKey(); + postJson("/api/v1/source/forms/" + documentId + "/validate", project, "{}", validateKey) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.valid").value(true)); + postJson("/api/v1/source/forms/" + documentId + "/validate", project, "{}", validateKey) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.valid").value(true)); + + String submitKey = idempotencyKey(); + postJson("/api/v1/source/forms/" + documentId + "/submit", project, "{\"version\":0}", submitKey) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.summary.status").value("PROJECT_REVIEW")); + postJson("/api/v1/source/forms/" + documentId + "/submit", project, "{\"version\":0}", submitKey) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.summary.status").value("PROJECT_REVIEW")); + postJson("/api/v1/source/forms/" + documentId + "/submit", project, "{\"version\":1}", submitKey) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("IDEMPOTENCY_KEY_REUSED")); + + MvcResult tasks = mockMvc.perform(get("/api/v1/workflow/tasks") + .cookie(reviewer.cookies()).param("view", "TODO")) + .andExpect(status().isOk()).andReturn(); + String taskId = taskForDocument(tasks, documentId); + String approvalBody = "{\"version\":0,\"opinion\":\"幂等审批通过\",\"returnPoints\":[]}"; + String approvalKey = idempotencyKey(); + postJson("/api/v1/workflow/tasks/" + taskId + "/approve", reviewer, approvalBody, approvalKey) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.form.summary.status").value("FINANCE_REVIEW")); + postJson("/api/v1/workflow/tasks/" + taskId + "/approve", reviewer, approvalBody, approvalKey) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.form.summary.status").value("FINANCE_REVIEW")); + postJson("/api/v1/workflow/tasks/" + taskId + "/approve", reviewer, + "{\"version\":0,\"opinion\":\"同键不同审批意见\",\"returnPoints\":[]}", approvalKey) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("IDEMPOTENCY_KEY_REUSED")); + assertEquals(1, jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM wf_action action_record + JOIN wf_task task ON task.id = action_record.task_id + WHERE task.public_id = ? AND action_record.action = 'APPROVE' + """, Integer.class, taskId)); + } + + @Test + void importBatchValidatesBeforeAtomicConfirmation() throws Exception { + Fixtures fixtures = fixtures(); + ClientSession project = loginAndSelect("project", "PROJECT_MANAGER"); + ClientSession admin = loginAndSelect("admin", "SYSTEM_ADMIN"); + MockMultipartFile file = new MockMultipartFile( + "file", "source-import.csv", "text/csv", "sourceNo,amount\nIMPORT-1,18800\n".getBytes() + ); + MvcResult uploaded = mockMvc.perform(multipart("/api/v1/files") + .file(file).cookie(project.cookies()).header("X-XSRF-TOKEN", project.csrfToken()) + .header("Idempotency-Key", idempotencyKey())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.scanStatus").value("AVAILABLE")) + .andReturn(); + String fileId = JsonPath.read(uploaded.getResponse().getContentAsString(), "$.data.publicId"); + String sha256 = JsonPath.read(uploaded.getResponse().getContentAsString(), "$.data.sha256"); + String sourceNo = "IMPORT-" + nextSuffix(); + String request = """ + {"formType":"OA-05","templateVersion":2,"sourceSystem":"EXCEL_IMPORT", + "sourceFileId":"%s","fileSha256":"%s","rows":[ + {"rowNo":2,"sourceNo":"%s","sourceVersion":"1","companyId":"%s", + "projectId":"%s","applicantName":"导入经办员","businessDate":"2026-08-08", + "values":{"ownerUnit":"测试业主","receiptMethod":"银行转账","receiptAmount":18800, + "receiptAccount":[{"accountName":"测试公司","bankName":"测试银行","accountNo":"10001"}], + "receiptDescription":"批量导入收款","evidenceFiles":["%s"]}} + ]} + """.formatted(fileId, sha256, sourceNo, fixtures.companyId(), fixtures.projectId(), fileId); + String createImportKey = idempotencyKey(); + MvcResult batch = postJson("/api/v1/source/imports", project, request, createImportKey) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("READY")) + .andExpect(jsonPath("$.data.validRows").value(1)) + .andExpect(jsonPath("$.data.errorRows").value(0)) + .andReturn(); + String batchId = JsonPath.read(batch.getResponse().getContentAsString(), "$.data.publicId"); + mockMvc.perform(get("/api/v1/source/imports/{id}", batchId).cookie(admin.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("READY")); + postJson("/api/v1/source/imports", project, request, createImportKey) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.publicId").value(batchId)) + .andExpect(jsonPath("$.data.status").value("READY")); + postJson("/api/v1/source/imports", project, + request.replace(sourceNo, sourceNo + "-DIFFERENT"), createImportKey) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("IDEMPOTENCY_KEY_REUSED")); + + String confirmImportKey = idempotencyKey(); + MvcResult confirmed = postJson("/api/v1/source/imports/" + batchId + "/confirm", admin, + "{\"version\":0}", confirmImportKey) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.length()").value(1)) + .andExpect(jsonPath("$.data[0].summary.sourceSystem").value("EXCEL_IMPORT")) + .andExpect(jsonPath("$.data[0].summary.status").value("DRAFT")) + .andReturn(); + String importedDocumentId = JsonPath.read( + confirmed.getResponse().getContentAsString(), "$.data[0].summary.publicId" + ); + postJson("/api/v1/source/imports/" + batchId + "/confirm", admin, + "{\"version\":0}", confirmImportKey) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.length()").value(1)) + .andExpect(jsonPath("$.data[0].summary.publicId").value(importedDocumentId)); + postJson("/api/v1/source/imports/" + batchId + "/confirm", admin, + "{\"version\":1}", confirmImportKey) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("IDEMPOTENCY_KEY_REUSED")); + assertEquals(1, jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM src_source_document WHERE source_no = ?", Integer.class, sourceNo + )); + mockMvc.perform(get("/api/v1/source/imports/{id}", batchId).cookie(project.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("CONFIRMED")) + .andExpect(jsonPath("$.data.version").value(1)); + } + + @Test + void fileReferencesAndReturnPointsAreValidatedAgainstTheCurrentUserAndTemplate() throws Exception { + Fixtures fixtures = fixtures(); + SupplierFixture supplier = activeSupplier(fixtures); + SupplierContractFixture contract = activeSupplierContract(fixtures, supplier, "10000.00"); + ClientSession project = loginAndSelect("project", "PROJECT_MANAGER"); + ClientSession reviewer = loginAndSelect("demo", "PROJECT_MANAGER"); + ClientSession finance = loginAndSelect("finance", "FINANCE_MANAGER"); + String ownFileId = uploadFile(project, "own-evidence.pdf"); + String foreignFileId = uploadFile(reviewer, "foreign-evidence.pdf"); + String sourceNo = "BOUNDARY-" + nextSuffix(); + String validValues = receiptValues(ownFileId, "边界校验收款"); + + MvcResult created = postJson("/api/v1/source/forms", project, sourcePayload( + "OA-05", sourceNo, fixtures, validValues + )).andExpect(status().isOk()).andReturn(); + String documentId = JsonPath.read(created.getResponse().getContentAsString(), "$.data.summary.publicId"); + + patchJson("/api/v1/source/forms/" + documentId, project, + updatePayload(sourceNo, fixtures, receiptValues("00000000000000000000000999", "不存在文件"), 0)) + .andExpect(status().isOk()); + postJson("/api/v1/source/forms/" + documentId + "/validate", project, "{}") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.errors[0].fieldCode").value("evidenceFiles")) + .andExpect(jsonPath("$.data.errors[0].message").value("收款证据附件引用的文件不存在")); + + patchJson("/api/v1/source/forms/" + documentId, project, + updatePayload(sourceNo, fixtures, receiptValues(foreignFileId, "他人文件"), 1)) + .andExpect(status().isOk()); + postJson("/api/v1/source/forms/" + documentId + "/validate", project, "{}") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.errors[0].fieldCode").value("evidenceFiles")) + .andExpect(jsonPath("$.data.errors[0].message").value("收款证据附件不属于当前账号或表单范围")); + + jdbcTemplate.update("UPDATE file_object SET scan_status = 'QUARANTINED' WHERE public_id = ?", ownFileId); + patchJson("/api/v1/source/forms/" + documentId, project, + updatePayload(sourceNo, fixtures, validValues, 2)) + .andExpect(status().isOk()); + postJson("/api/v1/source/forms/" + documentId + "/validate", project, "{}") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.errors[0].fieldCode").value("evidenceFiles")) + .andExpect(jsonPath("$.data.errors[0].message").value("收款证据附件尚未通过安全检查")); + + String matrixSourceNo = "MATRIX-" + nextSuffix(); + MvcResult matrix = postJson("/api/v1/source/forms", project, sourcePayload("OA-04", 2, + matrixSourceNo, fixtures, invalidOa04NestedValues( + supplier, contract, materialCostCategoryId(), "not-a-public-id" + ))) + .andExpect(status().isOk()).andReturn(); + String matrixDocumentId = JsonPath.read(matrix.getResponse().getContentAsString(), "$.data.summary.publicId"); + postJson("/api/v1/source/forms/" + matrixDocumentId + "/validate", project, "{}") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.valid").value(false)) + .andExpect(jsonPath("$.data.errors[?(@.fieldCode == 'invoiceDetails')].message") + .value(org.hamcrest.Matchers.hasItems( + "发票号码为必填项", "发票日期日期格式无效", "含税金额必须为有效非负数" + ))) + .andExpect(jsonPath("$.data.errors[?(@.fieldCode == 'attachmentMatrix')].message") + .value(org.hamcrest.Matchers.hasItem("条件附件矩阵文件标识无效"))); + + String missingFileId = "00000000000000000000000999"; + patchJson("/api/v1/source/forms/" + matrixDocumentId, project, updatePayload( + matrixSourceNo, fixtures, + invalidOa04NestedValues(supplier, contract, materialCostCategoryId(), missingFileId), 0 + )).andExpect(status().isOk()); + postJson("/api/v1/source/forms/" + matrixDocumentId + "/validate", project, "{}") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.errors[?(@.fieldCode == 'attachmentMatrix')].message") + .value(org.hamcrest.Matchers.hasItem("条件附件矩阵文件引用的文件不存在"))); + + patchJson("/api/v1/source/forms/" + matrixDocumentId, project, updatePayload( + matrixSourceNo, fixtures, + invalidOa04NestedValues(supplier, contract, materialCostCategoryId(), foreignFileId), 1 + )).andExpect(status().isOk()); + postJson("/api/v1/source/forms/" + matrixDocumentId + "/validate", project, "{}") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.errors[?(@.fieldCode == 'attachmentMatrix')].message") + .value(org.hamcrest.Matchers.hasItem("条件附件矩阵文件不属于当前账号或表单范围"))); + + patchJson("/api/v1/source/forms/" + matrixDocumentId, project, updatePayload( + matrixSourceNo, fixtures, + invalidOa04NestedValues(supplier, contract, materialCostCategoryId(), ownFileId), 2 + )).andExpect(status().isOk()); + postJson("/api/v1/source/forms/" + matrixDocumentId + "/validate", project, "{}") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.errors[?(@.fieldCode == 'attachmentMatrix')].message") + .value(org.hamcrest.Matchers.hasItem("条件附件矩阵文件尚未通过安全检查"))); + postJson("/api/v1/source/forms/" + matrixDocumentId + "/submit", project, "{\"version\":3}") + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("VALIDATION_FAILED")); + assertEquals(0, workflowTaskCount(matrixDocumentId)); + assertEquals(0, projectionCount("fin_payable", matrixDocumentId)); + + jdbcTemplate.update("UPDATE file_object SET scan_status = 'AVAILABLE' WHERE public_id = ?", ownFileId); + patchJson("/api/v1/source/forms/" + documentId, project, + updatePayload(sourceNo, fixtures, validValues, 3)) + .andExpect(status().isOk()); + postJson("/api/v1/source/forms/" + documentId + "/submit", project, "{\"version\":4}") + .andExpect(status().isOk()); + MvcResult tasks = mockMvc.perform(get("/api/v1/workflow/tasks").cookie(reviewer.cookies()).param("view", "TODO")) + .andExpect(status().isOk()).andReturn(); + String taskId = taskForDocument(tasks, documentId); + postJson("/api/v1/workflow/tasks/" + taskId + "/return", reviewer, """ + {"version":0,"opinion":"退回点越界","returnPoints":["not-a-template-field"]} + """) + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("VALIDATION_FAILED")); + } + + @Test + void workflowRejectsFallbackAndAdjacentReviewerAssignments() throws Exception { + Fixtures fixtures = fixtures(); + ClientSession project = loginAndSelect("project", "PROJECT_MANAGER"); + String fileId = uploadFile(project, "reviewer-separation.pdf"); + String sourceNo = "REVIEWER-" + nextSuffix(); + MvcResult created = postJson("/api/v1/source/forms", project, sourcePayload( + "OA-05", sourceNo, fixtures, receiptValues(fileId, "审批人员分离") + )).andExpect(status().isOk()).andReturn(); + String documentId = JsonPath.read(created.getResponse().getContentAsString(), "$.data.summary.publicId"); + + jdbcTemplate.update("UPDATE iam_user SET enabled = FALSE WHERE username = 'demo'"); + try { + postJson("/api/v1/source/forms/" + documentId + "/submit", project, "{\"version\":0}") + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("INVALID_STATE_TRANSITION")); + } finally { + jdbcTemplate.update("UPDATE iam_user SET enabled = TRUE WHERE username = 'demo'"); + } + + postJson("/api/v1/source/forms/" + documentId + "/submit", project, "{\"version\":0}") + .andExpect(status().isOk()); + ClientSession reviewer = loginAndSelect("demo", "PROJECT_MANAGER"); + MvcResult tasks = mockMvc.perform(get("/api/v1/workflow/tasks").cookie(reviewer.cookies()).param("view", "TODO")) + .andExpect(status().isOk()).andReturn(); + String taskId = taskForDocument(tasks, documentId); + + jdbcTemplate.update("UPDATE iam_user SET enabled = FALSE WHERE username = 'finance'"); + try { + postJson("/api/v1/workflow/tasks/" + taskId + "/approve", reviewer, + "{\"version\":0,\"opinion\":\"项目复核通过\",\"returnPoints\":[]}") + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("INVALID_STATE_TRANSITION")); + } finally { + jdbcTemplate.update("UPDATE iam_user SET enabled = TRUE WHERE username = 'finance'"); + } + } + + @Test + void oa06RejectsFundingOverrunAndCompletesTheRealApprovalChain() throws Exception { + Fixtures fixtures = fixtures(); + SupplierFixture supplier = activeSupplier(fixtures); + SupplierContractFixture contract = activeSupplierContract(fixtures, supplier, "50000.00"); + String payableId = confirmedPayable(fixtures, supplier, contract, "12000.00"); + setConfirmedProjectFunds(fixtures.projectId(), "20000.00"); + ClientSession applicant = loginAndSelect("project", "PROJECT_MANAGER"); + ClientSession projectReviewer = loginAndSelect("demo", "PROJECT_MANAGER"); + ClientSession financeReviewer = loginAndSelect("finance", "FINANCE_MANAGER"); + String attachmentFileId = uploadFile(applicant, "oa06-payment-support.pdf"); + String sourceNo = "OA06-" + nextSuffix(); + String overrunValues = """ + {"contractAmount":50000,"availableFund":10000,"requestedAmount":12000, + "contractId":"%s","supplierId":"%s","payableId":"%s", + "payeeAccount":[{"payee":"%s","accountName":"%s", + "bankName":"%s","accountNo":"%s"}], + "paymentDetails":[{"costContractNo":"%s","subject":"材料款", + "supplier":"%s","payableAmount":12000,"paidAmount":0,"requestAmount":12000}], + "attachmentMatrix":[{"attachmentType":"付款资料","required":true,"fileId":"%s"}]} + """.formatted( + contract.publicId(), supplier.publicId(), payableId, supplier.name(), supplier.accountName(), + supplier.bankName(), supplier.accountNo(), contract.businessNo(), supplier.name(), attachmentFileId + ); + MvcResult created = postJson("/api/v1/source/forms", applicant, + sourcePayload("OA-06", sourceNo, fixtures, overrunValues)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.summary.status").value("DRAFT")) + .andReturn(); + String documentId = JsonPath.read(created.getResponse().getContentAsString(), "$.data.summary.publicId"); + + postJson("/api/v1/source/forms", applicant, sourcePayload("OA-06", sourceNo, fixtures, overrunValues)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("DUPLICATE_RESOURCE")); + postJson("/api/v1/source/forms/" + documentId + "/validate", applicant, "{}") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.valid").value(false)) + .andExpect(jsonPath("$.data.errors[?(@.fieldCode == 'requestedAmount')]").exists()); + postJson("/api/v1/source/forms/" + documentId + "/submit", applicant, "{\"version\":0}") + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("VALIDATION_FAILED")); + + String validValues = overrunValues.replace("\"requestedAmount\":12000", "\"requestedAmount\":8000") + .replace("\"requestAmount\":12000", "\"requestAmount\":8000"); + patchJson("/api/v1/source/forms/" + documentId, applicant, + updatePayload(sourceNo, fixtures, validValues, 0)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.summary.version").value(1)); + + approveSourceDocument(documentId, 1, applicant, projectReviewer, financeReviewer); + mockMvc.perform(get("/api/v1/projects/{projectId}/forms", fixtures.projectId()) + .cookie(applicant.cookies()).param("formType", "OA-06").param("status", "APPROVED")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[?(@.publicId == '%s' && @.formType == 'OA-06')]" + .formatted(documentId)).exists()); + + Map payment = jdbcTemplate.queryForMap(""" + SELECT payment.public_id, payment.status, payment.requested_amount, payment.frozen_amount, + payment.source_version_id, submitter.username AS submitted_by, + approver.username AS approved_by + FROM fin_payment_request payment + JOIN src_source_document document ON document.id = payment.source_document_id + JOIN iam_user submitter ON submitter.id = payment.submitted_by + JOIN iam_user approver ON approver.id = payment.approved_by + WHERE document.public_id = ? + """, documentId); + assertEquals("OFFLINE_PENDING", payment.get("status")); + assertEquals("8000.00", payment.get("requested_amount").toString()); + assertEquals("8000.00", payment.get("frozen_amount").toString()); + assertEquals("project", payment.get("submitted_by")); + assertEquals("finance", payment.get("approved_by")); + assertNotNull(payment.get("source_version_id")); + assertEquals("8000.00", jdbcTemplate.queryForObject(""" + SELECT control.frozen_amount + FROM project_fund_control control + JOIN md_project project ON project.id = control.project_id + WHERE project.public_id = ? AND control.currency = 'CNY' + """, String.class, fixtures.projectId())); + assertEquals(1, jdbcTemplate.queryForObject(""" + SELECT COUNT(*) FROM fund_ledger ledger + JOIN md_project project ON project.id = ledger.project_id + WHERE project.public_id = ? AND ledger.entry_type = 'PAYMENT_FUNDS_RESERVED' + """, Integer.class, fixtures.projectId())); + + mockMvc.perform(get("/api/v1/payments").cookie(financeReviewer.cookies()) + .param("tab", "offline").param("projectId", fixtures.projectId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[?(@.publicId == '%s' && @.status == 'OFFLINE_PENDING')]" + .formatted(payment.get("public_id"))).exists()); + } + + @Test + void oa06ProjectionFailureRollsBackWorkflowPaymentAndFunds() throws Exception { + Fixtures fixtures = fixtures(); + SupplierFixture supplier = activeSupplier(fixtures); + SupplierContractFixture contract = activeSupplierContract(fixtures, supplier, "50000.00"); + String payableId = confirmedPayable(fixtures, supplier, contract, "12000.00"); + setConfirmedProjectFunds(fixtures.projectId(), "1000.00"); + ClientSession applicant = loginAndSelect("project", "PROJECT_MANAGER"); + ClientSession projectReviewer = loginAndSelect("demo", "PROJECT_MANAGER"); + ClientSession financeReviewer = loginAndSelect("finance", "FINANCE_MANAGER"); + String attachmentFileId = uploadFile(applicant, "oa06-insufficient-funds.pdf"); + String sourceNo = "OA06-ROLLBACK-" + nextSuffix(); + String values = """ + {"contractAmount":50000,"availableFund":10000,"requestedAmount":8000, + "contractId":"%s","supplierId":"%s","payableId":"%s", + "payeeAccount":[{"payee":"%s","accountName":"%s", + "bankName":"%s","accountNo":"%s"}], + "paymentDetails":[{"costContractNo":"%s","subject":"材料款", + "supplier":"%s","payableAmount":12000,"paidAmount":0,"requestAmount":8000}], + "attachmentMatrix":[{"attachmentType":"付款资料","required":true,"fileId":"%s"}]} + """.formatted( + contract.publicId(), supplier.publicId(), payableId, supplier.name(), supplier.accountName(), + supplier.bankName(), supplier.accountNo(), contract.businessNo(), supplier.name(), attachmentFileId + ); + MvcResult created = postJson("/api/v1/source/forms", applicant, + sourcePayload("OA-06", sourceNo, fixtures, values)) + .andExpect(status().isOk()).andReturn(); + String documentId = JsonPath.read(created.getResponse().getContentAsString(), "$.data.summary.publicId"); + + postJson("/api/v1/source/forms/" + documentId + "/submit", applicant, "{\"version\":0}") + .andExpect(status().isOk()); + String projectTaskId = pendingTaskForDocument(projectReviewer, documentId); + postJson("/api/v1/workflow/tasks/" + projectTaskId + "/approve", projectReviewer, + "{\"version\":0,\"opinion\":\"项目资料核对通过\",\"returnPoints\":[]}") + .andExpect(status().isOk()); + String financeTaskId = pendingTaskForDocument(financeReviewer, documentId); + postJson("/api/v1/workflow/tasks/" + financeTaskId + "/approve", financeReviewer, + "{\"version\":0,\"opinion\":\"财务资料核对通过\",\"returnPoints\":[]}") + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("FUNDS_INSUFFICIENT")); + + assertEquals("FINANCE_REVIEW", jdbcTemplate.queryForObject( + "SELECT status FROM src_source_document WHERE public_id = ?", String.class, documentId)); + assertEquals("PENDING", jdbcTemplate.queryForObject( + "SELECT status FROM wf_task WHERE public_id = ?", String.class, financeTaskId)); + assertEquals(0, jdbcTemplate.queryForObject(""" + SELECT COUNT(*) FROM fin_payment_request payment + JOIN src_source_document document ON document.id = payment.source_document_id + WHERE document.public_id = ? + """, Integer.class, documentId)); + assertEquals("0.00", jdbcTemplate.queryForObject(""" + SELECT control.frozen_amount + FROM project_fund_control control + JOIN md_project project ON project.id = control.project_id + WHERE project.public_id = ? AND control.currency = 'CNY' + """, String.class, fixtures.projectId())); + assertEquals(0, jdbcTemplate.queryForObject(""" + SELECT COUNT(*) FROM fund_ledger ledger + JOIN md_project project ON project.id = ledger.project_id + WHERE project.public_id = ? AND ledger.entry_type = 'PAYMENT_FUNDS_RESERVED' + """, Integer.class, fixtures.projectId())); + } + + @Test + void oa10Oa13AndOa14ValidateApproveAndAppearInProjectViews() throws Exception { + Fixtures fixtures = fixtures(); + ClientSession applicant = loginAndSelect("project", "PROJECT_MANAGER"); + ClientSession projectReviewer = loginAndSelect("demo", "PROJECT_MANAGER"); + ClientSession financeReviewer = loginAndSelect("finance", "FINANCE_MANAGER"); + String[] photoIds = { + uploadFile(applicant, "oa10-photo-1.jpg"), uploadFile(applicant, "oa10-photo-2.jpg"), + uploadFile(applicant, "oa10-photo-3.jpg"), uploadFile(applicant, "oa10-photo-4.jpg") + }; + String oa10Values = """ + {"contractAmount":500000,"reportPeriod":"2026-Q3", + "progressMetrics":[{"currentOutput":80000,"cumulativeOutput":260000, + "currentCost":60000,"cumulativeCost":210000,"vatRevenue":7339.45, + "completionRate":52,"remainingDays":120}], + "progressSummary":"主体工程按计划推进", + "taskDetails":[{"name":"主体施工","startDate":"2026-07-01", + "endDate":"2026-09-30","progress":52,"owner":"项目负责人"}], + "sitePhotos":["%s","%s","%s","%s"]} + """.formatted(photoIds[0], photoIds[1], photoIds[2], photoIds[3]); + String oa10Id = createValidateAndApproveSource( + "OA-10", "OA10-" + nextSuffix(), "contractAmount", oa10Values, + fixtures, applicant, projectReviewer, financeReviewer + ); + mockMvc.perform(get("/api/v1/projects/{projectId}/progress", fixtures.projectId()) + .cookie(applicant.cookies()).param("status", "APPROVED")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[?(@.publicId == '%s' && @.formType == 'OA-10')]" + .formatted(oa10Id)).exists()); + + String oa13Values = """ + {"chargeDate":"2026-08-15", + "feeDetails":[{"position":"项目经理","name":"张三","personOwner":"测试公司", + "certificateStartDate":"2026-08-01","feeStandard":1500,"monthlyAmount":1500}], + "monthlyReceivable":1500} + """; + String oa13Id = createValidateAndApproveSource( + "OA-13", "OA13-" + nextSuffix(), "chargeDate", oa13Values, + fixtures, applicant, projectReviewer, financeReviewer + ); + mockMvc.perform(get("/api/v1/projects/{projectId}/people", fixtures.projectId()) + .cookie(applicant.cookies()).param("view", "certificate-fees").param("status", "APPROVED")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[?(@.publicId == '%s' && @.formType == 'OA-13')]" + .formatted(oa13Id)).exists()); + + String oa14Values = """ + {"constructionPermitRequired":true, + "projectLeaders":[{"businessLeaderName":"李经理","businessLeaderPhone":"13800000001", + "projectLeaderName":"王经理","projectLeaderPhone":"13800000002"}], + "projectDates":[{"awardAmount":500000,"contractDuration":365, + "awardPublicDate":"2026-01-10","contractCompletionDate":"2026-12-31"}], + "certificatePeople":[{"position":"安全员","name":"赵工","category":"新增", + "phone":"13800000003","certificateType":"安全员证","online":true}], + "residentPeople":[{"name":"钱工","phone":"13800000004", + "appointmentDate":"2026-02-01","salary":12000,"remark":"项目驻派"}]} + """; + String oa14Id = createValidateAndApproveSource( + "OA-14", "OA14-" + nextSuffix(), "constructionPermitRequired", oa14Values, + fixtures, applicant, projectReviewer, financeReviewer + ); + mockMvc.perform(get("/api/v1/projects/{projectId}/people", fixtures.projectId()) + .cookie(applicant.cookies()).param("status", "APPROVED")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[?(@.publicId == '%s' && @.formType == 'OA-14')]" + .formatted(oa14Id)).exists()); + } + + private String createValidateAndApproveSource( + String formType, + String sourceNo, + String missingField, + String completeValues, + Fixtures fixtures, + ClientSession applicant, + ClientSession projectReviewer, + ClientSession financeReviewer + ) throws Exception { + MvcResult created = postJson("/api/v1/source/forms", applicant, + sourcePayload(formType, sourceNo, fixtures, "{}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.summary.status").value("DRAFT")) + .andReturn(); + String documentId = JsonPath.read(created.getResponse().getContentAsString(), "$.data.summary.publicId"); + postJson("/api/v1/source/forms", applicant, sourcePayload(formType, sourceNo, fixtures, "{}")) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("DUPLICATE_RESOURCE")); + postJson("/api/v1/source/forms/" + documentId + "/validate", applicant, "{}") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.valid").value(false)) + .andExpect(jsonPath("$.data.errors[?(@.fieldCode == '%s')]".formatted(missingField)).exists()); + patchJson("/api/v1/source/forms/" + documentId, applicant, + updatePayload(sourceNo, fixtures, completeValues, 0)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.summary.version").value(1)); + approveSourceDocument(documentId, 1, applicant, projectReviewer, financeReviewer); + return documentId; + } + + private void approveSourceDocument( + String documentId, + long version, + ClientSession applicant, + ClientSession projectReviewer, + ClientSession financeReviewer + ) throws Exception { + postJson("/api/v1/source/forms/" + documentId + "/submit", applicant, + "{\"version\":%d}".formatted(version)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.summary.status").value("PROJECT_REVIEW")); + String projectTaskId = pendingTaskForDocument(projectReviewer, documentId); + postJson("/api/v1/workflow/tasks/" + projectTaskId + "/approve", applicant, + "{\"version\":0,\"opinion\":\"非处理人尝试审批\",\"returnPoints\":[]}") + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("PERMISSION_DENIED")); + postJson("/api/v1/workflow/tasks/" + projectTaskId + "/approve", projectReviewer, + "{\"version\":0,\"opinion\":\"项目资料核对通过\",\"returnPoints\":[]}") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.form.summary.status").value("FINANCE_REVIEW")); + String financeTaskId = pendingTaskForDocument(financeReviewer, documentId); + postJson("/api/v1/workflow/tasks/" + financeTaskId + "/approve", financeReviewer, + "{\"version\":0,\"opinion\":\"财务资料核对通过\",\"returnPoints\":[]}") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.form.summary.status").value("APPROVED")); + mockMvc.perform(get("/api/v1/source/forms/{id}", documentId).cookie(applicant.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.summary.status").value("APPROVED")); + } + + private Fixtures fixtures() throws Exception { + ClientSession finance = loginAndSelect("finance", "FINANCE_MANAGER"); + ClientSession reviewer = loginAndSelect("demo", "FINANCE_MANAGER"); + ClientSession project = loginAndSelect("project", "PROJECT_MANAGER"); + String suffix = nextSuffix(); + + MvcResult company = postJson("/api/v1/masterdata/companies", finance, """ + {"businessNo":"SC-%s","name":"来源测试公司 %s","taxNo":"SC-TAX-%s"} + """.formatted(suffix, suffix, suffix)).andExpect(status().isOk()).andReturn(); + String companyId = JsonPath.read(company.getResponse().getContentAsString(), "$.data.publicId"); + postJson("/api/v1/masterdata/companies/" + companyId + "/submit", finance, "{\"version\":0}") + .andExpect(status().isOk()); + postJson("/api/v1/masterdata/companies/" + companyId + "/review", reviewer, + "{\"version\":1,\"decision\":\"APPROVE\",\"opinion\":\"通过\"}") + .andExpect(status().isOk()); + + String customerName = "来源测试客户 " + suffix; + String customerTaxNo = "SRC-CU-TAX-" + suffix; + MvcResult customer = postJson("/api/v1/masterdata/counterparties", finance, """ + {"businessNo":"SRC-CU-%s","type":"CUSTOMER","name":"%s","taxNo":"%s"} + """.formatted(suffix, customerName, customerTaxNo)).andExpect(status().isOk()).andReturn(); + String customerId = JsonPath.read(customer.getResponse().getContentAsString(), "$.data.publicId"); + postJson("/api/v1/masterdata/counterparties/" + customerId + "/submit", finance, "{\"version\":0}") + .andExpect(status().isOk()); + postJson("/api/v1/masterdata/counterparties/" + customerId + "/review", reviewer, + "{\"version\":1,\"decision\":\"APPROVE\",\"opinion\":\"通过\"}") + .andExpect(status().isOk()); + + String ownerId = jdbcTemplate.queryForObject( + "SELECT public_id FROM iam_user WHERE username = 'project'", String.class + ); + MvcResult projectCreated = postJson("/api/v1/projects", project, """ + {"businessNo":"SRC-P-%s","name":"来源测试项目 %s","companyId":"%s", + "customerId":"%s","ownerId":"%s","currency":"CNY"} + """.formatted(suffix, suffix, companyId, customerId, ownerId)) + .andExpect(status().isOk()).andReturn(); + String projectId = JsonPath.read(projectCreated.getResponse().getContentAsString(), "$.data.publicId"); + jdbcTemplate.update("UPDATE md_project SET status = 'ACTIVE' WHERE public_id = ?", projectId); + return new Fixtures(companyId, projectId, customerId, customerName, customerTaxNo); + } + + private SupplierFixture activeSupplier(Fixtures fixtures) throws Exception { + ClientSession finance = loginAndSelect("finance", "FINANCE_MANAGER"); + ClientSession reviewer = loginAndSelect("demo", "FINANCE_MANAGER"); + String suffix = nextSuffix(); + String supplierName = "合同测试供应商 " + suffix; + MvcResult created = postJson("/api/v1/masterdata/counterparties", finance, """ + {"businessNo":"OA03-SUP-%s","type":"SUPPLIER","name":"%s","taxNo":"OA03-TAX-%s"} + """.formatted(suffix, supplierName, suffix)).andExpect(status().isOk()).andReturn(); + String supplierId = JsonPath.read(created.getResponse().getContentAsString(), "$.data.publicId"); + postJson("/api/v1/masterdata/counterparties/" + supplierId + "/submit", finance, "{\"version\":0}") + .andExpect(status().isOk()); + postJson("/api/v1/masterdata/counterparties/" + supplierId + "/review", reviewer, + "{\"version\":1,\"decision\":\"APPROVE\",\"opinion\":\"供应商复核通过\"}") + .andExpect(status().isOk()); + + String accountNo = "62223344" + suffix; + String accountName = supplierName; + String bankName = "合同测试银行"; + MvcResult account = postJson("/api/v1/masterdata/bank-accounts", finance, """ + {"ownerType":"COUNTERPARTY","ownerId":"%s","accountCategory":"GENERAL", + "accountName":"%s","bankName":"%s","accountNo":"%s", + "validFrom":"2026-01-01","validTo":"2028-12-31"} + """.formatted(supplierId, accountName, bankName, accountNo)) + .andExpect(status().isOk()).andReturn(); + String accountId = JsonPath.read(account.getResponse().getContentAsString(), "$.data.publicId"); + postJson("/api/v1/masterdata/bank-accounts/" + accountId + "/submit", finance, "{\"version\":0}") + .andExpect(status().isOk()); + postJson("/api/v1/masterdata/bank-accounts/" + accountId + "/review", reviewer, + "{\"version\":1,\"decision\":\"APPROVE\",\"opinion\":\"账户复核通过\"}") + .andExpect(status().isOk()); + return new SupplierFixture(supplierId, supplierName, accountName, bankName, accountNo); + } + + private CustomerContractFixture activeCustomerContract(Fixtures fixtures) throws Exception { + ClientSession finance = loginAndSelect("finance", "FINANCE_MANAGER"); + ClientSession reviewer = loginAndSelect("demo", "FINANCE_MANAGER"); + String suffix = nextSuffix(); + String contractName = "总承包收入合同 " + suffix; + String amount = "500000.00"; + MvcResult created = postJson("/api/v1/masterdata/contracts", finance, """ + {"companyId":"%s","projectId":"%s","counterpartyId":"%s", + "businessNo":"OA08-CONTRACT-%s","name":"%s", + "originalAmount":"%s","currency":"CNY"} + """.formatted( + fixtures.companyId(), fixtures.projectId(), fixtures.customerId(), suffix, contractName, amount + )).andExpect(status().isOk()).andReturn(); + String contractId = JsonPath.read(created.getResponse().getContentAsString(), "$.data.publicId"); + postJson("/api/v1/masterdata/contracts/" + contractId + "/submit", finance, "{\"version\":0}") + .andExpect(status().isOk()); + postJson("/api/v1/masterdata/contracts/" + contractId + "/review", reviewer, + "{\"version\":1,\"decision\":\"APPROVE\",\"opinion\":\"收入合同复核通过\"}") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("ACTIVE")); + return new CustomerContractFixture(contractId, contractName, amount); + } + + private SupplierContractFixture activeSupplierContract( + Fixtures fixtures, + SupplierFixture supplier, + String amount + ) throws Exception { + ClientSession finance = loginAndSelect("finance", "FINANCE_MANAGER"); + ClientSession reviewer = loginAndSelect("demo", "FINANCE_MANAGER"); + String suffix = nextSuffix(); + String businessNo = "OA04-CONTRACT-" + suffix; + String name = "应付测试成本合同 " + suffix; + MvcResult created = postJson("/api/v1/masterdata/contracts", finance, """ + {"companyId":"%s","projectId":"%s","counterpartyId":"%s", + "businessNo":"%s","name":"%s","originalAmount":"%s","currency":"CNY"} + """.formatted( + fixtures.companyId(), fixtures.projectId(), supplier.publicId(), businessNo, name, amount + )).andExpect(status().isOk()).andReturn(); + String contractId = JsonPath.read(created.getResponse().getContentAsString(), "$.data.publicId"); + postJson("/api/v1/masterdata/contracts/" + contractId + "/submit", finance, "{\"version\":0}") + .andExpect(status().isOk()); + postJson("/api/v1/masterdata/contracts/" + contractId + "/review", reviewer, + "{\"version\":1,\"decision\":\"APPROVE\",\"opinion\":\"成本合同复核通过\"}") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("ACTIVE")); + return new SupplierContractFixture(contractId, businessNo, name, amount); + } + + private String confirmedPayable( + Fixtures fixtures, + SupplierFixture supplier, + SupplierContractFixture contract, + String amount + ) { + String publicId = "00000000000000000000" + nextSuffix(); + String businessNo = "OA06-PBL-" + nextSuffix(); + assertEquals(1, jdbcTemplate.update(""" + INSERT INTO fin_payable ( + public_id, business_no, company_id, project_id, contract_id, counterparty_id, + source_document_id, source_version_id, cost_category_id, business_date, + amount, invoiced_amount, paid_amount, status, confirmed_by, confirmed_at, + created_by, updated_by + ) + SELECT ?, ?, company.id, project.id, contract.id, supplier.id, + NULL, NULL, NULL, '2026-08-16', ?, 0, 0, 'CONFIRMED', actor.id, + UTC_TIMESTAMP(3), actor.id, actor.id + FROM md_company company + JOIN md_project project ON project.public_id = ? AND project.company_id = company.id + JOIN md_contract contract ON contract.public_id = ? AND contract.project_id = project.id + JOIN md_counterparty supplier ON supplier.public_id = ? + AND supplier.id = contract.counterparty_id + JOIN iam_user actor ON actor.username = 'finance' + WHERE company.public_id = ? + """, publicId, businessNo, amount, fixtures.projectId(), contract.publicId(), + supplier.publicId(), fixtures.companyId())); + return publicId; + } + + private void setConfirmedProjectFunds(String projectPublicId, String amount) { + jdbcTemplate.update(""" + INSERT IGNORE INTO project_fund_control (project_id, currency) + SELECT id, currency FROM md_project WHERE public_id = ? + """, projectPublicId); + assertEquals(1, jdbcTemplate.update(""" + UPDATE project_fund_control control + JOIN md_project project ON project.id = control.project_id + SET control.confirmed_receipt = ?, control.confirmed_deduction = 0, + control.approved_adjustment = 0, control.frozen_amount = 0, + control.approved_unpaid = 0, control.paid_amount = 0, + control.version = control.version + 1 + WHERE project.public_id = ? AND control.currency = project.currency + """, amount, projectPublicId)); + } + + private String materialCostCategoryId() { + return jdbcTemplate.queryForObject( + "SELECT public_id FROM md_cost_category WHERE code = 'MATERIAL' AND status = 'ACTIVE'", + String.class + ); + } + + private String oa04Values( + SupplierFixture supplier, + SupplierContractFixture contract, + String costCategoryId, + String payableAmount, + String firstInvoiceAmount, + String secondInvoiceAmount, + String attachmentRow + ) { + String secondInvoice = secondInvoiceAmount == null ? "" : """ + ,{"invoiceNo":"OA04-INV-2-%s","invoiceType":"NORMAL","invoiceDate":"2026-08-02", + "taxInclusiveAmount":%s,"taxExclusiveAmount":%s,"taxRate":0, + "taxAmount":0} + """.formatted(nextSuffix(), secondInvoiceAmount, secondInvoiceAmount); + return """ + {"contractId":"%s","costContractNo":"%s", + "supplierId":"%s","supplierName":"%s", + "costCategoryId":"%s","costType":"材料", + "payableAmount":%s, + "invoiceDetails":[ + {"invoiceNo":"OA04-INV-1-%s","invoiceType":"SPECIAL","invoiceDate":"2026-08-01", + "taxInclusiveAmount":%s,"taxExclusiveAmount":%s,"taxRate":0, + "taxAmount":0}%s], + "attachmentMatrix":[%s]} + """.formatted( + contract.publicId(), contract.businessNo(), supplier.publicId(), supplier.name(), + costCategoryId, payableAmount, nextSuffix(), firstInvoiceAmount, firstInvoiceAmount, + secondInvoice, attachmentRow + ); + } + + private String invalidOa04NestedValues( + SupplierFixture supplier, + SupplierContractFixture contract, + String costCategoryId, + String attachmentFileId + ) { + return """ + {"contractId":"%s","costContractNo":"%s", + "supplierId":"%s","supplierName":"%s", + "costCategoryId":"%s","costType":"材料","payableAmount":8000, + "invoiceDetails":[{"invoiceType":"SPECIAL","invoiceDate":"not-a-date", + "taxInclusiveAmount":"not-a-number","taxExclusiveAmount":8000, + "taxRate":0,"taxAmount":0}], + "attachmentMatrix":[{"attachmentType":"发票","required":true,"fileId":"%s"}]} + """.formatted( + contract.publicId(), contract.businessNo(), supplier.publicId(), supplier.name(), + costCategoryId, attachmentFileId + ); + } + + private String oa11Values( + SupplierContractFixture contract, + SupplierFixture supplier, + String contractAmount, + String settlementAmount, + String invoicedAmount, + String paidAmount, + String settlementFileId + ) { + return """ + {"costContractNo":"%s","supplierName":"%s", + "amounts":[{"contractAmount":%s,"settlementAmount":%s, + "invoicedAmount":%s,"paidAmount":%s}], + "settlementFiles":[{"fileType":"结算确认单","fileId":"%s","remark":"集成测试留档"}]} + """.formatted( + contract.businessNo(), supplier.name(), contractAmount, settlementAmount, + invoicedAmount, paidAmount, settlementFileId + ); + } + + private void approveOa04ForContract( + Fixtures fixtures, + SupplierFixture supplier, + SupplierContractFixture contract, + ClientSession applicant, + ClientSession projectReviewer, + ClientSession financeReviewer, + String payableAmount, + String firstInvoiceAmount, + String secondInvoiceAmount + ) throws Exception { + String values = oa04Values( + supplier, contract, materialCostCategoryId(), payableAmount, + firstInvoiceAmount, secondInvoiceAmount, + "{\"attachmentType\":\"发票及验收资料\",\"required\":false," + + "\"notApplicableReason\":\"集成测试不上传业务附件\"}" + ); + MvcResult created = postJson("/api/v1/source/forms", applicant, + sourcePayload("OA-04", 2, "PAYABLE-BEFORE-SETTLEMENT-" + nextSuffix(), fixtures, values)) + .andExpect(status().isOk()).andReturn(); + String documentId = JsonPath.read(created.getResponse().getContentAsString(), "$.data.summary.publicId"); + postJson("/api/v1/source/forms/" + documentId + "/submit", applicant, "{\"version\":0}") + .andExpect(status().isOk()); + String projectTaskId = pendingTaskForDocument(projectReviewer, documentId); + postJson("/api/v1/workflow/tasks/" + projectTaskId + "/approve", projectReviewer, + "{\"version\":0,\"opinion\":\"应付初审通过\",\"returnPoints\":[]}") + .andExpect(status().isOk()); + String financeTaskId = pendingTaskForDocument(financeReviewer, documentId); + postJson("/api/v1/workflow/tasks/" + financeTaskId + "/approve", financeReviewer, + "{\"version\":0,\"opinion\":\"应付终审通过\",\"returnPoints\":[]}") + .andExpect(status().isOk()); + } + + private String oa03Values(SupplierFixture supplier, String contractNo, String contractName, String amount) { + return """ + {"contractName":"%s","contractNo":"%s","contractAmount":%s, + "supplierId":"%s","currency":"CNY", + "supplierSnapshot":[{"supplier":"%s","accountName":"%s","bankName":"%s", + "accountNo":"%s"}], + "realtimeCost":[{"targetAmount":%s,"signedAmount":0,"paidAmount":0, + "receivedAmount":0,"deductionAmount":0,"balance":%s}], + "quantityPriceReview":[{"quantity":1,"unitPrice":%s,"costPlan":%s, + "overrunAmount":0,"opinion":"量价符合成本计划"}]} + """.formatted( + contractName, contractNo, amount, supplier.publicId(), supplier.name(), + supplier.accountName(), supplier.bankName(), supplier.accountNo(), + amount, amount, amount, amount + ); + } + + private String oa08Values(Fixtures fixtures, CustomerContractFixture contract, + String invoiceAmount, String customerName) { + return """ + {"contractAmount":%s,"invoicedAmount":0,"invoiceAmount":%s, + "contractId":"%s","counterpartyId":"%s", + "invoiceType":"SPECIAL","taxRate":0.09,"customerName":"%s", + "invoiceHeader":[{"title":"%s","taxNo":"%s", + "addressPhone":"测试注册地址 010-88888888","bankAccount":"测试银行 6222000012345678"}], + "invoiceRequirements":[{"invoiceType":"SPECIAL","taxRate":0.09, + "content":"工程服务","settlementMethod":"银行转账", + "expectedReceiptDate":"2026-09-30"}]} + """.formatted( + contract.amount(), invoiceAmount, contract.publicId(), fixtures.customerId(), customerName, + fixtures.customerName(), fixtures.customerTaxNo() + ); + } + + private String createOa08ForFinanceReview( + Fixtures fixtures, + CustomerContractFixture contract, + ClientSession applicant, + ClientSession projectReviewer, + ClientSession financeReviewer, + String sourceNo, + String invoiceAmount + ) throws Exception { + MvcResult created = postJson("/api/v1/source/forms", applicant, sourcePayload( + "OA-08", sourceNo, fixtures, + oa08Values(fixtures, contract, invoiceAmount, fixtures.customerName()) + )).andExpect(status().isOk()).andReturn(); + String documentId = JsonPath.read( + created.getResponse().getContentAsString(), "$.data.summary.publicId" + ); + postJson("/api/v1/source/forms/" + documentId + "/submit", applicant, "{\"version\":0}") + .andExpect(status().isOk()); + String projectTaskId = pendingTaskForDocument(projectReviewer, documentId); + postJson("/api/v1/workflow/tasks/" + projectTaskId + "/approve", projectReviewer, + "{\"version\":0,\"opinion\":\"项目初审通过\",\"returnPoints\":[]}") + .andExpect(status().isOk()); + pendingTaskForDocument(financeReviewer, documentId); + return documentId; + } + + private String pendingTaskForDocument(ClientSession reviewer, String documentId) throws Exception { + MvcResult tasks = mockMvc.perform(get("/api/v1/workflow/tasks") + .cookie(reviewer.cookies()).param("view", "TODO")) + .andExpect(status().isOk()).andReturn(); + return taskForDocument(tasks, documentId); + } + + private int projectionCount(String table, String documentId) { + String projectionTable = switch (table) { + case "md_counterparty" -> "md_counterparty"; + case "md_bank_account_version" -> "md_bank_account_version"; + case "md_contract" -> "md_contract"; + case "fin_receipt" -> "fin_receipt"; + case "fin_invoice" -> "fin_invoice"; + case "fin_payable" -> "fin_payable"; + case "fin_contract_settlement" -> "fin_contract_settlement"; + default -> throw new IllegalArgumentException("Unsupported projection table"); + }; + return jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM %s projection + JOIN src_source_document document ON document.id = projection.source_document_id + WHERE document.public_id = ? + """.formatted(projectionTable), Integer.class, documentId); + } + + private int workflowTaskCount(String documentId) { + return jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM wf_task task_record + JOIN wf_instance instance ON instance.id = task_record.instance_id + JOIN src_document_version source_version ON source_version.id = instance.source_version_id + JOIN src_source_document document ON document.id = source_version.source_document_id + WHERE document.public_id = ? + """, Integer.class, documentId); + } + + private String sourcePayload(String formType, String sourceNo, Fixtures fixtures, String values) { + return sourcePayload(formType, Set.of("OA-04", "OA-05", "OA-06", "OA-08").contains(formType) ? 2 : 1, + sourceNo, fixtures, values); + } + + private String sourcePayload( + String formType, + int templateVersion, + String sourceNo, + Fixtures fixtures, + String values + ) { + return """ + {"formType":"%s","templateVersion":%d,"sourceSystem":"MANUAL","sourceNo":"%s", + "sourceVersion":"1","companyId":"%s","projectId":"%s","applicantName":"项目经办员", + "businessDate":"2026-08-08","values":%s} + """.formatted( + formType, templateVersion, sourceNo, fixtures.companyId(), fixtures.projectId(), values + ); + } + + private String receiptValues(String evidenceFileId, String description) { + return """ + {"ownerUnit":"测试业主","receiptMethod":"银行转账","receiptAmount":8000, + "receiptAccount":[{"accountName":"测试公司","bankName":"测试银行","accountNo":"10001"}], + "receiptDescription":"%s","evidenceFiles":["%s"]} + """.formatted(description, evidenceFileId); + } + + private String uploadFile(ClientSession session, String filename) throws Exception { + MockMultipartFile file = new MockMultipartFile("file", filename, "application/pdf", "test-file".getBytes()); + MvcResult uploaded = mockMvc.perform(multipart("/api/v1/files") + .file(file).cookie(session.cookies()).header("X-XSRF-TOKEN", session.csrfToken()) + .header("Idempotency-Key", idempotencyKey())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.scanStatus").value("AVAILABLE")) + .andReturn(); + return JsonPath.read(uploaded.getResponse().getContentAsString(), "$.data.publicId"); + } + + private String updatePayload(String sourceNo, Fixtures fixtures, String values, long version) { + return """ + {"sourceNo":"%s","sourceVersion":"1","companyId":"%s","projectId":"%s", + "applicantName":"项目经办员","businessDate":"2026-08-08","values":%s,"version":%d} + """.formatted(sourceNo, fixtures.companyId(), fixtures.projectId(), values, version); + } + + private String taskForDocument(MvcResult result, String documentId) throws Exception { + net.minidev.json.JSONArray matches = JsonPath.read( + result.getResponse().getContentAsString(), "$.data[?(@.form.publicId == '" + documentId + "')].publicId" + ); + return String.valueOf(matches.get(0)); + } + + private org.springframework.test.web.servlet.ResultActions postJson( + String path, + ClientSession session, + String json + ) throws Exception { + return postJson(path, session, json, idempotencyKey()); + } + + private org.springframework.test.web.servlet.ResultActions postJson( + String path, + ClientSession session, + String json, + String key + ) throws Exception { + return mockMvc.perform(post(path) + .cookie(session.cookies()) + .header("X-XSRF-TOKEN", session.csrfToken()) + .header("Idempotency-Key", key) + .contentType("application/json") + .content(json)); + } + + private int workflowApprovalStatus( + String taskId, + ClientSession session, + String json, + String key + ) throws Exception { + return mockMvc.perform(post("/api/v1/workflow/tasks/" + taskId + "/approve") + .cookie(session.cookies()) + .header("X-XSRF-TOKEN", session.csrfToken()) + .header("Idempotency-Key", key) + .contentType("application/json") + .content(json)) + .andReturn() + .getResponse() + .getStatus(); + } + + private org.springframework.test.web.servlet.ResultActions patchJson( + String path, + ClientSession session, + String json + ) throws Exception { + return mockMvc.perform(patch(path) + .cookie(session.cookies()) + .header("X-XSRF-TOKEN", session.csrfToken()) + .header("Idempotency-Key", idempotencyKey()) + .contentType("application/json") + .content(json)); + } + + private ClientSession loginAndSelect(String username, String role) throws Exception { + MvcResult csrf = mockMvc.perform(get("/api/v1/auth/csrf")).andExpect(status().isOk()).andReturn(); + Cookie csrfCookie = csrf.getResponse().getCookie("XSRF-TOKEN"); + assertNotNull(csrfCookie); + String csrfToken = JsonPath.read(csrf.getResponse().getContentAsString(), "$.data.token"); + MvcResult login = mockMvc.perform(post("/api/v1/auth/login") + .cookie(csrfCookie) + .header("X-XSRF-TOKEN", csrfToken) + .contentType("application/json") + .content("{\"username\":\"%s\",\"password\":\"LocalOnly@123\"}".formatted(username))) + .andExpect(status().isOk()).andReturn(); + Cookie sessionCookie = login.getResponse().getCookie("KAIDI_SESSION"); + assertNotNull(sessionCookie); + MvcResult selected = mockMvc.perform(post("/api/v1/auth/select-role") + .cookie(sessionCookie, csrfCookie) + .header("X-XSRF-TOKEN", csrfToken) + .contentType("application/json") + .content("{\"roleCode\":\"%s\"}".formatted(role))) + .andExpect(status().isOk()).andReturn(); + Cookie rotated = selected.getResponse().getCookie("KAIDI_SESSION"); + return new ClientSession(rotated == null ? sessionCookie : rotated, csrfCookie, csrfToken); + } + + private String nextSuffix() { + return "%06d".formatted(SEQUENCE.incrementAndGet()); + } + + private record Fixtures( + String companyId, + String projectId, + String customerId, + String customerName, + String customerTaxNo + ) { + } + + private record SupplierFixture( + String publicId, + String name, + String accountName, + String bankName, + String accountNo + ) { + } + + private record CustomerContractFixture( + String publicId, + String name, + String amount + ) { + } + + private record SupplierContractFixture( + String publicId, + String businessNo, + String name, + String amount + ) { + } + + private record ClientSession(Cookie sessionCookie, Cookie csrfCookie, String csrfToken) { + Cookie[] cookies() { + return new Cookie[]{sessionCookie, csrfCookie}; + } + } + + @TestConfiguration(proxyBeanMethods = false) + static class ScannerTestConfiguration { + @Bean + @Primary + FileScanner testFileScanner() { + return path -> FileScanner.ScanResult.clean("source workflow test scanner: clean"); + } + } +} diff --git a/backend/src/test/java/com/kaidi/finance/update/application/SystemUpdateApplicationServiceTest.java b/backend/src/test/java/com/kaidi/finance/update/application/SystemUpdateApplicationServiceTest.java new file mode 100644 index 0000000..0980446 --- /dev/null +++ b/backend/src/test/java/com/kaidi/finance/update/application/SystemUpdateApplicationServiceTest.java @@ -0,0 +1,269 @@ +package com.kaidi.finance.update.application; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.kaidi.finance.iam.domain.FinancePrincipal; +import com.kaidi.finance.iam.domain.RoleAssignment; +import com.kaidi.finance.shared.api.BusinessException; +import com.kaidi.finance.shared.audit.AuditService; +import com.kaidi.finance.shared.security.AuthorizationService; +import com.kaidi.finance.shared.security.IdentityContext; +import com.kaidi.finance.update.api.SystemUpdateContracts.DownloadUpdateRequest; +import com.kaidi.finance.update.api.SystemUpdateContracts.InstallUpdateRequest; +import com.sun.net.httpserver.HttpServer; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class SystemUpdateApplicationServiceTest { + + @TempDir + Path tempDir; + + @Test + void comparesStableAndPreviewVersionsWithoutAllowingDowngrades() { + assertTrue(SystemUpdateApplicationService.compareVersions("1.0.1", "1.0.0") > 0); + assertTrue(SystemUpdateApplicationService.compareVersions("1.0.0", "1.0.0-preview.9") > 0); + assertTrue(SystemUpdateApplicationService.compareVersions("1.0.0-preview.10", "1.0.0-preview.9") > 0); + assertTrue(SystemUpdateApplicationService.compareVersions("1.0.0-preview.1", "1.0.0-preview.2") < 0); + assertEquals(0, SystemUpdateApplicationService.compareVersions("2.4.6", "2.4.6")); + } + + @Test + void resolvesPrivateGiteaLatestReleaseWithServerSideToken() throws Exception { + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/api/v1/repos/ERP-Team/kaidi/releases/latest", exchange -> { + assertEquals("token read-only-token", exchange.getRequestHeaders().getFirst("Authorization")); + String assetUrl = "http://127.0.0.1:" + server.getAddress().getPort() + + "/assets/release-manifest.json"; + byte[] body = (""" + {"tag_name":"v1.0.0-preview.2","assets":[ + {"name":"release-manifest.json","browser_download_url":"%s"} + ]} + """).formatted(assetUrl).getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(200, body.length); + exchange.getResponseBody().write(body); + exchange.close(); + }); + server.createContext("/assets/release-manifest.json", exchange -> { + assertEquals("token read-only-token", exchange.getRequestHeaders().getFirst("Authorization")); + byte[] body = """ + {"version":"1.0.0-preview.2","artifact":"kaidi-finance-1.0.0-preview.2.tar.gz", + "sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "publishedAt":"2026-08-16T00:00:00Z","releaseNotes":"Gitea Preview update"} + """.getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(200, body.length); + exchange.getResponseBody().write(body); + exchange.close(); + }); + server.start(); + try { + Path inbox = Files.createDirectory(tempDir.resolve("gitea-inbox")); + SystemUpdateProperties properties = new SystemUpdateProperties(true, "1.0.0-preview.1", null, null, + "http://127.0.0.1:" + server.getAddress().getPort() + + "/api/v1/repos/ERP-Team/kaidi/releases/latest", + "read-only-token", inbox.resolve("request.json"), tempDir.resolve("gitea-status.json"), + Duration.ofSeconds(2), Duration.ofSeconds(2), true); + AuthorizationService authorization = mock(AuthorizationService.class); + when(authorization.hasPermission(any())).thenReturn(true); + IdentityContext identity = mock(IdentityContext.class); + when(identity.requireActiveRole()).thenReturn("SYSTEM_ADMIN"); + when(identity.requirePrincipal()).thenReturn(new FinancePrincipal(1, "01M00000000000000000000001", + "admin", "系统管理员", "信息中心", false, + List.of(new RoleAssignment(1, "SYSTEM_ADMIN", "系统管理员", "系统治理")))); + SystemUpdateApplicationService service = new SystemUpdateApplicationService(properties, authorization, + identity, mock(AuditService.class), new ObjectMapper()); + + var checked = service.check(); + + assertTrue(checked.enabled()); + assertEquals("1.0.0-preview.2", checked.latestVersion()); + assertTrue(checked.allowedActions().contains("DOWNLOAD")); + } finally { + server.stop(0); + } + } + + @Test + void rejectsPrivateGiteaAssetOnAnotherOriginWithoutSendingToken() throws Exception { + AtomicInteger assetRequests = new AtomicInteger(); + HttpServer assetServer = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + assetServer.createContext("/release-manifest.json", exchange -> { + assetRequests.incrementAndGet(); + exchange.sendResponseHeaders(204, -1); + exchange.close(); + }); + HttpServer apiServer = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + apiServer.createContext("/api/v1/repos/ERP-Team/kaidi/releases/latest", exchange -> { + assertEquals("token read-only-token", exchange.getRequestHeaders().getFirst("Authorization")); + String assetUrl = "http://127.0.0.1:" + assetServer.getAddress().getPort() + + "/release-manifest.json"; + byte[] body = (""" + {"assets":[{"name":"release-manifest.json","browser_download_url":"%s"}]} + """).formatted(assetUrl).getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(200, body.length); + exchange.getResponseBody().write(body); + exchange.close(); + }); + assetServer.start(); + apiServer.start(); + try { + Path inbox = Files.createDirectory(tempDir.resolve("cross-origin-inbox")); + SystemUpdateApplicationService service = serviceForGitea(apiServer, inbox, + tempDir.resolve("cross-origin-status.json")); + + assertThrows(BusinessException.class, service::check); + assertEquals(0, assetRequests.get()); + } finally { + apiServer.stop(0); + assetServer.stop(0); + } + } + + private SystemUpdateApplicationService serviceForGitea(HttpServer server, Path inbox, Path statusFile) { + SystemUpdateProperties properties = new SystemUpdateProperties(true, "1.0.0-preview.1", null, null, + "http://127.0.0.1:" + server.getAddress().getPort() + + "/api/v1/repos/ERP-Team/kaidi/releases/latest", + "read-only-token", inbox.resolve("request.json"), statusFile, + Duration.ofSeconds(2), Duration.ofSeconds(2), true); + AuthorizationService authorization = mock(AuthorizationService.class); + when(authorization.hasPermission(any())).thenReturn(true); + IdentityContext identity = mock(IdentityContext.class); + when(identity.requireActiveRole()).thenReturn("SYSTEM_ADMIN"); + when(identity.requirePrincipal()).thenReturn(new FinancePrincipal(1, "01M00000000000000000000001", + "admin", "系统管理员", "信息中心", false, + List.of(new RoleAssignment(1, "SYSTEM_ADMIN", "系统管理员", "系统治理")))); + return new SystemUpdateApplicationService(properties, authorization, identity, mock(AuditService.class), + new ObjectMapper()); + } + + @Test + void preservesRollbackFailureWhileAProcessingRequestAwaitsRecovery() throws Exception { + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/release-manifest.json", exchange -> { + byte[] body = """ + {"version":"1.0.0-preview.2","artifact":"kaidi-finance-1.0.0-preview.2.tar.gz", + "sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "publishedAt":"2026-08-16T00:00:00Z","releaseNotes":"Preview update"} + """.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, body.length); + exchange.getResponseBody().write(body); + exchange.close(); + }); + server.start(); + try { + Path inbox = Files.createDirectory(tempDir.resolve("inbox")); + Path processing = Files.createDirectory(tempDir.resolve("processing")); + Files.writeString(processing.resolve("request.json"), """ + {"version":"1.0.0-preview.2","requestedAt":"2026-08-16T00:00:00Z"} + """); + Path statusFile = tempDir.resolve("status.json"); + Files.writeString(statusFile, """ + {"state":"FAILED","message":"回滚未完成,更新服务将重试","targetVersion":"1.0.0-preview.2", + "updatedAt":"2026-08-16T00:01:00Z"} + """); + SystemUpdateProperties properties = new SystemUpdateProperties(true, "1.0.0-preview.1", null, + "http://127.0.0.1:" + server.getAddress().getPort() + "/", null, null, + inbox.resolve("request.json"), statusFile, Duration.ofSeconds(2), Duration.ofSeconds(2), true); + AuthorizationService authorization = mock(AuthorizationService.class); + when(authorization.hasPermission(any())).thenReturn(true); + IdentityContext identity = mock(IdentityContext.class); + when(identity.requireActiveRole()).thenReturn("SYSTEM_ADMIN"); + when(identity.requirePrincipal()).thenReturn(new FinancePrincipal(1, "01M00000000000000000000001", + "admin", "系统管理员", "信息中心", false, + List.of(new RoleAssignment(1, "SYSTEM_ADMIN", "系统管理员", "系统治理")))); + SystemUpdateApplicationService service = new SystemUpdateApplicationService(properties, authorization, + identity, mock(AuditService.class), new ObjectMapper()); + + var status = service.check(); + + assertEquals("FAILED", status.state()); + assertEquals("回滚未完成,更新服务将重试", status.message()); + assertFalse(status.allowedActions().contains("INSTALL")); + } finally { + server.stop(0); + } + } + + @Test + void checksConfiguredManifestAndQueuesOnlyItsLatestVersion() throws Exception { + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/release-manifest.json", exchange -> { + byte[] body = """ + {"version":"1.0.0-preview.2+build.7","artifact":"kaidi-finance-1.0.0-preview.2+build.7.tar.gz", + "sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "publishedAt":"2026-08-16T00:00:00Z","releaseNotes":"Preview update"} + """.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, body.length); + exchange.getResponseBody().write(body); + exchange.close(); + }); + server.start(); + try { + Path inbox = Files.createDirectory(tempDir.resolve("inbox")); + Path versionFile = tempDir.resolve("VERSION"); + Files.writeString(versionFile, "1.0.0-preview.1\n"); + SystemUpdateProperties properties = new SystemUpdateProperties(true, "1.0.0-preview.1", versionFile, + "http://127.0.0.1:" + server.getAddress().getPort() + "/", null, null, + inbox.resolve("request.json"), tempDir.resolve("status.json"), Duration.ofSeconds(2), + Duration.ofSeconds(2), true); + AuthorizationService authorization = mock(AuthorizationService.class); + when(authorization.hasPermission(any())).thenReturn(true); + IdentityContext identity = mock(IdentityContext.class); + when(identity.requireActiveRole()).thenReturn("SYSTEM_ADMIN"); + when(identity.requirePrincipal()).thenReturn(new FinancePrincipal(1, "01M00000000000000000000001", + "admin", "系统管理员", "信息中心", false, + List.of(new RoleAssignment(1, "SYSTEM_ADMIN", "系统管理员", "系统治理")))); + SystemUpdateApplicationService service = new SystemUpdateApplicationService(properties, authorization, + identity, mock(AuditService.class), new ObjectMapper()); + + var checked = service.check(); + assertTrue(checked.updateAvailable()); + assertEquals("1.0.0-preview.2+build.7", checked.latestVersion()); + assertTrue(checked.allowedActions().contains("DOWNLOAD")); + assertFalse(checked.allowedActions().contains("INSTALL")); + + var downloadQueued = service.download(new DownloadUpdateRequest("1.0.0-preview.2+build.7")); + assertEquals("DOWNLOAD_QUEUED", downloadQueued.state()); + String downloadRequest = Files.readString(inbox.resolve("request.json")); + assertTrue(downloadRequest.contains("1.0.0-preview.2+build.7")); + assertTrue(downloadRequest.contains("\"action\":\"DOWNLOAD\"")); + assertFalse(Files.isSymbolicLink(inbox.resolve("request.json"))); + assertEquals("DOWNLOAD_QUEUED", service.status().state()); + assertThrows(BusinessException.class, + () -> service.download(new DownloadUpdateRequest("1.0.0-preview.2+build.7"))); + + Files.delete(inbox.resolve("request.json")); + Files.writeString(tempDir.resolve("status.json"), """ + {"state":"READY","message":"下载及校验完成","targetVersion":"1.0.0-preview.2+build.7", + "updatedAt":"2026-08-16T00:02:00Z"} + """); + var ready = service.status(); + assertTrue(ready.allowedActions().contains("INSTALL")); + assertFalse(ready.allowedActions().contains("DOWNLOAD")); + + var installQueued = service.install(new InstallUpdateRequest("1.0.0-preview.2+build.7", "上线 Preview")); + assertEquals("INSTALL_QUEUED", installQueued.state()); + String installRequest = Files.readString(inbox.resolve("request.json")); + assertTrue(installRequest.contains("\"action\":\"INSTALL\"")); + assertTrue(installRequest.contains("上线 Preview")); + } finally { + server.stop(0); + } + } +} diff --git a/backend/src/test/java/com/kaidi/finance/workbench/WorkbenchIntegrationTest.java b/backend/src/test/java/com/kaidi/finance/workbench/WorkbenchIntegrationTest.java new file mode 100644 index 0000000..fdc5e85 --- /dev/null +++ b/backend/src/test/java/com/kaidi/finance/workbench/WorkbenchIntegrationTest.java @@ -0,0 +1,329 @@ +package com.kaidi.finance.workbench; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.jayway.jsonpath.JsonPath; +import jakarta.servlet.http.Cookie; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +@Testcontainers(disabledWithoutDocker = true) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class WorkbenchIntegrationTest { + + @Container + static final MySQLContainer MYSQL = new MySQLContainer<>("mysql:8.4") + .withDatabaseName("kaidi_finance_workbench_test") + .withUsername("kaidi") + .withPassword("kaidi_test_password"); + + @DynamicPropertySource + static void configureDatabase(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", MYSQL::getJdbcUrl); + registry.add("spring.datasource.username", MYSQL::getUsername); + registry.add("spring.datasource.password", MYSQL::getPassword); + registry.add("finance.bootstrap.enabled", () -> true); + registry.add("finance.bootstrap.password", () -> "LocalOnly@123"); + } + + @Autowired + MockMvc mockMvc; + + @Autowired + JdbcTemplate jdbcTemplate; + + @Test + void eachRoleOnlyReadsItsOwnRealWorkbench() throws Exception { + String deniedRequestId = "01J0000000000000000000000H"; + removeLegacyWorkbenchPermissions(); + ClientSession project = loginAndSelect("project", "PROJECT_MANAGER"); + mockMvc.perform(get("/api/v1/workbenches/project").cookie(project.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.roleCode").value("PROJECT_MANAGER")) + .andExpect(jsonPath("$.data.metrics.length()").value(4)) + .andExpect(jsonPath("$.data.metrics[0].value").value(0)) + .andExpect(jsonPath("$.data.metrics[?(@.code == 'RETURNED_FORMS')].targetRoute") + .value(org.hamcrest.Matchers.contains("/forms?status=RETURNED&createdByMe=true"))) + .andExpect(jsonPath("$.data.metrics[?(@.code == 'DUE_SOON_TASKS')].targetRoute") + .value(org.hamcrest.Matchers.contains("/tasks?view=TODO"))); + mockMvc.perform(get("/api/v1/workbenches/finance") + .header("X-Request-Id", deniedRequestId) + .cookie(project.cookies())) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("PERMISSION_DENIED")) + .andExpect(jsonPath("$.requestId").value(deniedRequestId)); + Integer deniedAuditCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM audit_log + WHERE request_id = ? + AND user_public_id = ? + AND username = 'project' + AND active_role = 'PROJECT_MANAGER' + AND action_code = 'HTTP_ACCESS_DENIED' + AND object_type = 'HTTP_ENDPOINT' + AND result_code = 'DENIED' + AND reason = 'PERMISSION_DENIED' + AND JSON_UNQUOTE(JSON_EXTRACT(after_json, '$.method')) = 'GET' + AND JSON_UNQUOTE(JSON_EXTRACT(after_json, '$.uri')) = '/api/v1/workbenches/finance' + AND JSON_UNQUOTE(JSON_EXTRACT(after_json, '$.status')) = '403' + """, Integer.class, deniedRequestId, userPublicId("project")); + assertEquals(1, deniedAuditCount); + + ClientSession finance = loginAndSelect("finance", "FINANCE_MANAGER"); + mockMvc.perform(get("/api/v1/workbenches/finance").cookie(finance.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.roleCode").value("FINANCE_MANAGER")) + .andExpect(jsonPath("$.data.metrics.length()").value(4)); + + ClientSession archive = loginAndSelect("archive", "ARCHIVE_MANAGER"); + mockMvc.perform(get("/api/v1/workbenches/archive").cookie(archive.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.roleCode").value("ARCHIVE_MANAGER")) + .andExpect(jsonPath("$.data.metrics.length()").value(5)) + .andExpect(jsonPath("$.data.metrics[?(@.code == 'MISSING_ARCHIVE_ITEMS')].targetRoute") + .value(org.hamcrest.Matchers.contains("/archives/projects?completeness=INCOMPLETE"))) + .andExpect(jsonPath("$.data.metrics[?(@.code == 'BORROWS_DUE')].targetRoute") + .value(org.hamcrest.Matchers.contains("/archives/files?resource=borrows"))) + .andExpect(jsonPath("$.data.metrics[?(@.code == 'QUARANTINED_FILES')].targetRoute") + .value(org.hamcrest.Matchers.contains("/archives/files?scanStatus=QUARANTINED"))); + + ClientSession administrator = loginAndSelect("admin", "SYSTEM_ADMIN"); + mockMvc.perform(get("/api/v1/workbenches/project").cookie(administrator.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.roleCode").value("PROJECT_MANAGER")); + mockMvc.perform(get("/api/v1/workbenches/finance").cookie(administrator.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.roleCode").value("FINANCE_MANAGER")); + mockMvc.perform(get("/api/v1/workbenches/archive").cookie(administrator.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.roleCode").value("ARCHIVE_MANAGER")); + } + + @Test + void workbenchesKeepDatabaseMetricsDetailsAndDrilldownsConsistent() throws Exception { + long projectUserId = userId("project"); + long financeUserId = userId("finance"); + String projectUserPublicId = userPublicId("project"); + String companyId = "00000000000000000000004001"; + String projectId = "00000000000000000000004002"; + String packageId = "00000000000000000000004003"; + String itemId = "00000000000000000000004004"; + String fileId = "00000000000000000000004005"; + String linkSeriesId = "00000000000000000000004006"; + String auditId = "00000000000000000000004007"; + String otherCompanyId = "00000000000000000000004009"; + String otherProjectId = "00000000000000000000004010"; + String otherPackageId = "00000000000000000000004011"; + String otherItemId = "00000000000000000000004012"; + + jdbcTemplate.update(""" + INSERT INTO md_company (public_id, business_no, name, status, created_by, updated_by) + VALUES (?, 'WB-C-001', '工作台测试公司', 'ACTIVE', ?, ?) + """, companyId, financeUserId, financeUserId); + jdbcTemplate.update(""" + INSERT INTO md_project (public_id, company_id, business_no, name, owner_user_id, currency, + status, created_by, updated_by) + SELECT ?, id, 'WB-P-001', '工作台测试项目', ?, 'CNY', 'ACTIVE', ?, ? + FROM md_company WHERE public_id = ? + """, projectId, projectUserId, financeUserId, financeUserId, companyId); + jdbcTemplate.update(""" + INSERT INTO md_company (public_id, business_no, name, status, created_by, updated_by) + VALUES (?, 'WB-C-002', '工作台范围外公司', 'ACTIVE', ?, ?) + """, otherCompanyId, financeUserId, financeUserId); + jdbcTemplate.update(""" + INSERT INTO md_project (public_id, company_id, business_no, name, owner_user_id, currency, + status, created_by, updated_by) + SELECT ?, id, 'WB-P-002', '工作台范围外项目', ?, 'CNY', 'ACTIVE', ?, ? + FROM md_company WHERE public_id = ? + """, otherProjectId, projectUserId, financeUserId, financeUserId, otherCompanyId); + long projectDatabaseId = jdbcTemplate.queryForObject( + "SELECT id FROM md_project WHERE public_id = ?", Long.class, projectId); + long otherProjectDatabaseId = jdbcTemplate.queryForObject( + "SELECT id FROM md_project WHERE public_id = ?", Long.class, otherProjectId); + jdbcTemplate.update(""" + INSERT INTO project_fund_control (project_id, currency, confirmed_receipt, confirmed_deduction, + approved_adjustment, frozen_amount, approved_unpaid, paid_amount) + VALUES (?, 'CNY', 1000.00, 100.00, 50.00, 200.00, 300.00, 150.00) + """, projectDatabaseId); + jdbcTemplate.update(""" + INSERT INTO arc_package (public_id, business_no, company_id, project_id, package_version, + completeness, missing_count, retention_years, status, created_by, updated_by) + VALUES (?, 'WB-ARC-001', (SELECT id FROM md_company WHERE public_id = ?), ?, 1, + 50.00, 1, 10, 'CHECKED', ?, ?) + """, packageId, companyId, projectDatabaseId, financeUserId, financeUserId); + long packageDatabaseId = jdbcTemplate.queryForObject( + "SELECT id FROM arc_package WHERE public_id = ?", Long.class, packageId); + jdbcTemplate.update(""" + INSERT INTO arc_package_item (public_id, package_id, item_type, item_name, required_flag, status) + VALUES (?, ?, 'CONTRACT', '缺失合同', TRUE, 'MISSING') + """, itemId, packageDatabaseId); + jdbcTemplate.update(""" + INSERT INTO arc_package (public_id, business_no, company_id, project_id, package_version, + completeness, missing_count, retention_years, status, created_by, updated_by) + VALUES (?, 'WB-ARC-002', (SELECT id FROM md_company WHERE public_id = ?), ?, 1, + 50.00, 1, 10, 'CHECKED', ?, ?) + """, otherPackageId, otherCompanyId, otherProjectDatabaseId, financeUserId, financeUserId); + long otherPackageDatabaseId = jdbcTemplate.queryForObject( + "SELECT id FROM arc_package WHERE public_id = ?", Long.class, otherPackageId); + jdbcTemplate.update(""" + INSERT INTO arc_package_item (public_id, package_id, item_type, item_name, required_flag, status) + VALUES (?, ?, 'CONTRACT', '范围外缺失合同', TRUE, 'MISSING') + """, otherItemId, otherPackageDatabaseId); + jdbcTemplate.update(""" + INSERT INTO file_object (public_id, original_name, storage_key, media_type, extension, size_bytes, + sha256, scan_status, uploaded_by) + VALUES (?, 'recent-download.pdf', 'workbench/recent-download.pdf', 'application/pdf', 'pdf', 1, + RPAD('a', 64, 'a'), 'AVAILABLE', ?) + """, fileId, projectUserPublicId); + long fileDatabaseId = jdbcTemplate.queryForObject( + "SELECT id FROM file_object WHERE public_id = ?", Long.class, fileId); + jdbcTemplate.update(""" + INSERT INTO file_link (file_id, object_type, object_public_id, company_public_id, project_public_id, + document_type, version_no, active, linked_by, archive_status, series_public_id) + VALUES (?, 'PROJECT', ?, ?, ?, 'PROJECT_BASE', 1, TRUE, ?, 'ACTIVE', ?) + """, fileDatabaseId, projectId, companyId, projectId, projectUserPublicId, linkSeriesId); + jdbcTemplate.update(""" + INSERT INTO audit_log (public_id, request_id, user_public_id, username, active_role, + company_public_id, project_public_id, action_code, object_type, + object_public_id, result_code) + VALUES (?, '00000000000000000000004008', ?, 'archive', 'ARCHIVE_MANAGER', ?, ?, + 'ARCHIVE_FILE_DOWNLOAD', 'FILE', ?, 'SUCCESS') + """, auditId, userPublicId("archive"), companyId, projectId, fileId); + + restrictScope(projectUserId, "PROJECT_MANAGER", "project:project:view", projectId); + restrictScope(projectUserId, "PROJECT_MANAGER", "archive:package:view", projectId); + ClientSession project = loginAndSelect("project", "PROJECT_MANAGER"); + mockMvc.perform(get("/api/v1/workbenches/project").cookie(project.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.metrics[?(@.code == 'ACTIVE_PROJECTS')].value") + .value(org.hamcrest.Matchers.contains(1))) + .andExpect(jsonPath("$.data.issues[?(@.code == 'MISSING_ARCHIVE_ITEMS')].count") + .value(org.hamcrest.Matchers.contains(1))) + .andExpect(jsonPath("$.data.issues[?(@.code == 'MISSING_ARCHIVE_ITEMS')].targetRoute") + .value(org.hamcrest.Matchers.contains("/archives/projects?completeness=INCOMPLETE"))); + + ClientSession finance = loginAndSelect("finance", "FINANCE_MANAGER"); + mockMvc.perform(get("/api/v1/workbenches/finance").cookie(finance.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.funds[?(@.projectPublicId == '%s')].availableAmount".formatted(projectId)) + .value(org.hamcrest.Matchers.contains("300.00"))) + .andExpect(jsonPath("$.data.funds[?(@.projectPublicId == '%s')].targetRoute".formatted(projectId)) + .value(org.hamcrest.Matchers.contains( + "/finance/payments?projectId=" + projectId + "&view=fund-ledger"))); + + ClientSession archive = loginAndSelect("archive", "ARCHIVE_MANAGER"); + mockMvc.perform(get("/api/v1/workbenches/archive").cookie(archive.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.metrics[?(@.code == 'MISSING_ARCHIVE_ITEMS')].value") + .value(org.hamcrest.Matchers.contains(2))) + .andExpect(jsonPath("$.data.archiveItems[?(@.publicId == '%s')].kind".formatted(itemId)) + .value(org.hamcrest.Matchers.contains("MISSING"))) + .andExpect(jsonPath("$.data.archiveItems[?(@.publicId == '%s')].kind".formatted(auditId)) + .value(org.hamcrest.Matchers.contains("DOWNLOAD"))); + + ClientSession administrator = loginAndSelect("admin", "SYSTEM_ADMIN"); + mockMvc.perform(get("/api/v1/workbenches/project").cookie(administrator.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.metrics[?(@.code == 'ACTIVE_PROJECTS')].value") + .value(org.hamcrest.Matchers.contains(2))) + .andExpect(jsonPath("$.data.projects[?(@.publicId == '%s')]".formatted(projectId)).exists()) + .andExpect(jsonPath("$.data.projects[?(@.publicId == '%s')]".formatted(otherProjectId)).exists()); + mockMvc.perform(get("/api/v1/workbenches/finance").cookie(administrator.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.funds[?(@.projectPublicId == '%s')]".formatted(projectId)).exists()); + mockMvc.perform(get("/api/v1/workbenches/archive").cookie(administrator.cookies())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.metrics[?(@.code == 'MISSING_ARCHIVE_ITEMS')].value") + .value(org.hamcrest.Matchers.contains(2))) + .andExpect(jsonPath("$.data.archiveItems[?(@.publicId == '%s')]".formatted(itemId)).exists()) + .andExpect(jsonPath("$.data.archiveItems[?(@.publicId == '%s')]".formatted(otherItemId)).exists()); + } + + private void removeLegacyWorkbenchPermissions() { + jdbcTemplate.update(""" + DELETE scope FROM iam_scope scope + JOIN iam_user account ON account.id = scope.user_id + JOIN iam_role role ON role.id = scope.role_id + JOIN iam_permission permission ON permission.id = scope.permission_id + WHERE (account.username = 'project' AND role.code = 'PROJECT_MANAGER' + AND permission.code = 'workflow:task:read') + OR (account.username = 'finance' AND role.code = 'FINANCE_MANAGER' + AND permission.code = 'workflow:task:read') + OR (account.username = 'archive' AND role.code = 'ARCHIVE_MANAGER' + AND permission.code = 'archive:read') + """); + } + + private long userId(String username) { + return jdbcTemplate.queryForObject("SELECT id FROM iam_user WHERE username = ?", Long.class, username); + } + + private String userPublicId(String username) { + return jdbcTemplate.queryForObject("SELECT public_id FROM iam_user WHERE username = ?", String.class, + username); + } + + private void restrictScope(long userId, String roleCode, String permissionCode, String projectId) { + jdbcTemplate.update(""" + DELETE scope FROM iam_scope scope + JOIN iam_role role ON role.id = scope.role_id + JOIN iam_permission permission ON permission.id = scope.permission_id + WHERE scope.user_id = ? AND role.code = ? AND permission.code = ? + """, userId, roleCode, permissionCode); + jdbcTemplate.update(""" + INSERT INTO iam_scope (user_id, role_id, permission_id, scope_type, project_public_id, status) + SELECT ?, role.id, permission.id, 'PROJECT', ?, 'ACTIVE' + FROM iam_role role JOIN iam_permission permission + WHERE role.code = ? AND permission.code = ? + """, userId, projectId, roleCode, permissionCode); + } + + private ClientSession loginAndSelect(String username, String role) throws Exception { + MvcResult csrf = mockMvc.perform(get("/api/v1/auth/csrf")).andExpect(status().isOk()).andReturn(); + Cookie csrfCookie = csrf.getResponse().getCookie("XSRF-TOKEN"); + assertNotNull(csrfCookie); + String csrfToken = JsonPath.read(csrf.getResponse().getContentAsString(), "$.data.token"); + MvcResult login = mockMvc.perform(post("/api/v1/auth/login") + .cookie(csrfCookie) + .header("X-XSRF-TOKEN", csrfToken) + .contentType("application/json") + .content("{\"username\":\"%s\",\"password\":\"LocalOnly@123\"}".formatted(username))) + .andExpect(status().isOk()).andReturn(); + Cookie sessionCookie = login.getResponse().getCookie("KAIDI_SESSION"); + assertNotNull(sessionCookie); + MvcResult selected = mockMvc.perform(post("/api/v1/auth/select-role") + .cookie(sessionCookie, csrfCookie) + .header("X-XSRF-TOKEN", csrfToken) + .contentType("application/json") + .content("{\"roleCode\":\"%s\"}".formatted(role))) + .andExpect(status().isOk()).andReturn(); + Cookie rotated = selected.getResponse().getCookie("KAIDI_SESSION"); + return new ClientSession(rotated == null ? sessionCookie : rotated, csrfCookie); + } + + private record ClientSession(Cookie sessionCookie, Cookie csrfCookie) { + Cookie[] cookies() { + return new Cookie[]{sessionCookie, csrfCookie}; + } + } +} diff --git a/backend/src/test/resources/application-test.yml b/backend/src/test/resources/application-test.yml new file mode 100644 index 0000000..437d000 --- /dev/null +++ b/backend/src/test/resources/application-test.yml @@ -0,0 +1,4 @@ +finance: + crypto: + field-key: ${FIELD_ENCRYPTION_KEY:MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=} + reject-default: false diff --git a/backend/src/test/resources/reporting-fixture.sql b/backend/src/test/resources/reporting-fixture.sql new file mode 100644 index 0000000..a4f085e --- /dev/null +++ b/backend/src/test/resources/reporting-fixture.sql @@ -0,0 +1,422 @@ +SET @finance_id = (SELECT id FROM iam_user WHERE username = 'finance'); +SET @project_user_id = (SELECT id FROM iam_user WHERE username = 'project'); +SET @finance_public_id = (SELECT public_id FROM iam_user WHERE username = 'finance'); +SET @template_id = (SELECT id FROM src_template WHERE form_type = 'OA-06' AND status = 'ACTIVE' ORDER BY template_version DESC LIMIT 1); +SET @payable_template_id = (SELECT id FROM src_template WHERE form_type = 'OA-04' AND status = 'ACTIVE' ORDER BY template_version DESC LIMIT 1); +SET @definition_version_id = (SELECT version.id FROM wf_definition_version version JOIN wf_definition definition ON definition.id = version.definition_id WHERE definition.code = 'SOURCE_STANDARD_REVIEW' AND version.status = 'ACTIVE' LIMIT 1); + +INSERT INTO md_company (public_id, business_no, name, status, created_by, updated_by) +VALUES ('80000000000000000000000001', 'GS-REPORT-001', '报表集成测试公司', 'ACTIVE', @finance_id, @finance_id); + +INSERT INTO md_company (public_id, business_no, name, status, created_by, updated_by) +VALUES ('80000000000000000000000030', 'GS-REPORT-002', '报表范围错配公司', 'ACTIVE', @finance_id, @finance_id); + +INSERT INTO md_counterparty + (public_id, business_no, counterparty_type, name, status, created_by, updated_by) +VALUES ('80000000000000000000000002', 'GY-REPORT-001', 'BOTH', '报表集成测试往来单位', 'ACTIVE', @finance_id, @finance_id); + +INSERT INTO md_project + (public_id, company_id, business_no, name, customer_id, owner_user_id, start_date, + currency, stage, finance_status, archive_status, status, created_by, updated_by) +SELECT '80000000000000000000000003', company.id, 'XM-REPORT-001', '报表集成测试项目', counterparty.id, + @project_user_id, CURRENT_DATE(), 'CNY', 'EXECUTION', 'IN_PROGRESS', 'RETURNED', 'ACTIVE', + @finance_id, @finance_id +FROM md_company company +JOIN md_counterparty counterparty ON counterparty.public_id = '80000000000000000000000002' +WHERE company.public_id = '80000000000000000000000001'; + +INSERT INTO md_contract + (public_id, company_id, project_id, counterparty_id, business_no, name, original_amount, + approved_change_amount, settlement_amount, currency, status, created_by, updated_by) +SELECT '80000000000000000000000004', company.id, project.id, counterparty.id, + 'HT-REPORT-001', '报表集成测试合同', 1000.00, 100.00, 900.00, 'CNY', 'ACTIVE', + @finance_id, @finance_id +FROM md_company company +JOIN md_project project ON project.public_id = '80000000000000000000000003' +JOIN md_counterparty counterparty ON counterparty.public_id = '80000000000000000000000002' +WHERE company.public_id = '80000000000000000000000001'; + +INSERT INTO src_source_document + (public_id, business_no, form_type, source_system, source_no, normalized_source_no, + source_version, normalized_source_version, template_id, company_id, project_id, + status, created_by, updated_by) +SELECT '80000000000000000000000005', 'OA06-REPORT-001', 'OA-06', 'MANUAL', 'REPORT-001', + 'REPORT-001', '1', '1', @template_id, company.id, project.id, 'APPROVED', @finance_id, @finance_id +FROM md_company company +JOIN md_project project ON project.public_id = '80000000000000000000000003' +WHERE company.public_id = '80000000000000000000000001'; + +INSERT INTO src_document_version + (public_id, source_document_id, version_no, template_id, snapshot_sha256, submitted_at, created_by) +SELECT '80000000000000000000000006', document.id, 1, @template_id, + REPEAT('a', 64), UTC_TIMESTAMP(3), @finance_id +FROM src_source_document document +WHERE document.public_id = '80000000000000000000000005'; + +UPDATE src_source_document document +JOIN src_document_version version ON version.public_id = '80000000000000000000000006' +SET document.current_version_id = version.id +WHERE document.public_id = '80000000000000000000000005'; + +INSERT INTO src_source_document + (public_id, business_no, form_type, source_system, source_no, normalized_source_no, + source_version, normalized_source_version, template_id, company_id, project_id, + status, created_by, updated_by) +SELECT source.public_id, source.business_no, 'OA-04', 'MANUAL', source.source_no, + source.source_no, '1', '1', @payable_template_id, company.id, project.id, + 'APPROVED', @finance_id, @finance_id +FROM md_company company +JOIN md_project project ON project.public_id = '80000000000000000000000003' +JOIN ( + SELECT '80000000000000000000000038' AS public_id, + 'OA04-REPORT-001' AS business_no, 'REPORT-PAYABLE-001' AS source_no + UNION ALL + SELECT '80000000000000000000000039', + 'OA04-REPORT-002', 'REPORT-PAYABLE-002' +) source +WHERE company.public_id = '80000000000000000000000001'; + +INSERT INTO src_document_version + (public_id, source_document_id, version_no, template_id, snapshot_sha256, submitted_at, created_by) +SELECT CASE document.public_id + WHEN '80000000000000000000000038' THEN '80000000000000000000000040' + ELSE '80000000000000000000000041' + END, + document.id, 1, @payable_template_id, REPEAT('f', 64), UTC_TIMESTAMP(3), @finance_id +FROM src_source_document document +WHERE document.public_id IN ( + '80000000000000000000000038', '80000000000000000000000039' +); + +UPDATE src_source_document document +JOIN src_document_version version ON version.source_document_id = document.id +SET document.current_version_id = version.id +WHERE document.public_id IN ( + '80000000000000000000000038', '80000000000000000000000039' +); + +INSERT INTO wf_instance + (public_id, definition_version_id, source_version_id, current_node_code, status, + created_by, updated_by) +SELECT CASE document.public_id + WHEN '80000000000000000000000038' THEN '80000000000000000000000042' + ELSE '80000000000000000000000043' + END, + @definition_version_id, version.id, NULL, 'APPROVED', @finance_id, @finance_id +FROM src_source_document document +JOIN src_document_version version ON version.id = document.current_version_id +WHERE document.public_id IN ( + '80000000000000000000000038', '80000000000000000000000039' +); + +INSERT INTO src_approval_snapshot + (public_id, document_version_id, node_code, actor_name, actor_id, action, opinion, acted_at) +SELECT '80000000000000000000000007', version.id, 'FINANCE_REVIEW', '财务测试用户', + @finance_id, 'APPROVE', '报表测试审批通过', UTC_TIMESTAMP(3) +FROM src_document_version version +WHERE version.public_id = '80000000000000000000000006'; + +INSERT INTO wf_instance + (public_id, definition_version_id, source_version_id, current_node_code, status, + created_by, updated_by, created_at, updated_at) +SELECT '80000000000000000000000008', @definition_version_id, version.id, + 'FINANCE_REVIEW', 'RUNNING', @finance_id, @finance_id, + DATE_SUB(UTC_TIMESTAMP(3), INTERVAL 2 MINUTE), DATE_SUB(UTC_TIMESTAMP(3), INTERVAL 2 MINUTE) +FROM src_document_version version +WHERE version.public_id = '80000000000000000000000006'; + +INSERT INTO wf_task + (public_id, instance_id, node_code, sequence_no, assignee_id, status, created_at, updated_at) +SELECT '80000000000000000000000009', instance.id, 'FINANCE_REVIEW', 1, + @finance_id, 'PENDING', DATE_SUB(UTC_TIMESTAMP(3), INTERVAL 2 MINUTE), + DATE_SUB(UTC_TIMESTAMP(3), INTERVAL 2 MINUTE) +FROM wf_instance instance +WHERE instance.public_id = '80000000000000000000000008'; + +INSERT INTO wf_action + (public_id, task_id, sequence_no, action, opinion, actor_id, acted_at, request_id) +SELECT '80000000000000000000000010', task.id, 1, 'RETURN', '报表测试退回', + @finance_id, DATE_SUB(UTC_TIMESTAMP(3), INTERVAL 1 MINUTE), '80000000000000000000000011' +FROM wf_task task +WHERE task.public_id = '80000000000000000000000009'; + +INSERT INTO md_bank_account_version + (public_id, owner_type, counterparty_id, account_category, account_name, bank_name, + account_no_ciphertext, account_no_hash, masked_account_no, version_no, valid_from, + status, created_by, updated_by) +SELECT '80000000000000000000000012', 'COUNTERPARTY', counterparty.id, 'BASIC', + '报表测试账户', '报表测试银行', X'01', REPEAT('b', 64), '****0001', 1, + DATE_SUB(UTC_TIMESTAMP(3), INTERVAL 1 DAY), 'ACTIVE', @finance_id, @finance_id +FROM md_counterparty counterparty +WHERE counterparty.public_id = '80000000000000000000000002'; + +INSERT INTO fin_payable + (public_id, business_no, company_id, project_id, contract_id, counterparty_id, + source_document_id, cost_category_id, business_date, amount, invoiced_amount, + paid_amount, status, confirmed_by, confirmed_at, created_by, updated_by) +SELECT '80000000000000000000000013', 'YF-REPORT-001', company.id, project.id, contract.id, + counterparty.id, document.id, category.id, CURRENT_DATE(), 600.00, 200.00, 150.00, + 'PART_PAID', @finance_id, UTC_TIMESTAMP(3), @finance_id, @finance_id +FROM md_company company +JOIN md_project project ON project.public_id = '80000000000000000000000003' +JOIN md_contract contract ON contract.public_id = '80000000000000000000000004' +JOIN md_counterparty counterparty ON counterparty.public_id = '80000000000000000000000002' +JOIN src_source_document document ON document.public_id = '80000000000000000000000038' +JOIN md_cost_category category ON category.code = 'MATERIAL' +WHERE company.public_id = '80000000000000000000000001'; + +INSERT INTO fin_payable + (public_id, business_no, company_id, project_id, contract_id, counterparty_id, + source_document_id, cost_category_id, business_date, amount, invoiced_amount, + paid_amount, status, created_by, updated_by) +SELECT '80000000000000000000000037', 'YF-REPORT-PENDING-001', company.id, project.id, contract.id, + counterparty.id, document.id, category.id, CURRENT_DATE(), 75.00, 0.00, 0.00, + 'PENDING', @finance_id, @finance_id +FROM md_company company +JOIN md_project project ON project.public_id = '80000000000000000000000003' +JOIN md_contract contract ON contract.public_id = '80000000000000000000000004' +JOIN md_counterparty counterparty ON counterparty.public_id = '80000000000000000000000002' +JOIN src_source_document document ON document.public_id = '80000000000000000000000039' +JOIN md_cost_category category ON category.code = 'MATERIAL' +WHERE company.public_id = '80000000000000000000000001'; + +INSERT INTO fin_receipt + (public_id, business_no, company_id, project_id, customer_id, source_document_id, source_version_id, + receipt_date, amount, allocated_amount, currency, payer_name, reference_no, + status, confirmed_by, confirmed_at, created_by, updated_by) +SELECT '80000000000000000000000014', '=SUM(A1:A2)', company.id, project.id, + counterparty.id, document.id, document.current_version_id, CURRENT_DATE(), 300.00, 100.00, 'CNY', + '报表测试付款方', 'BANK-REPORT-001', 'PARTIALLY_ALLOCATED', @finance_id, UTC_TIMESTAMP(3), + @finance_id, @finance_id +FROM md_company company +JOIN md_project project ON project.public_id = '80000000000000000000000003' +JOIN md_counterparty counterparty ON counterparty.public_id = '80000000000000000000000002' +JOIN src_source_document document ON document.public_id = '80000000000000000000000005' +WHERE company.public_id = '80000000000000000000000001'; + +INSERT INTO fin_invoice + (public_id, business_no, company_id, project_id, contract_id, counterparty_id, + source_document_id, source_version_id, invoice_type, amount, currency, tax_rate, requested_date, invoice_no, + issued_date, status, submitted_by, reviewed_by, result_recorded_by, created_by, updated_by) +SELECT '80000000000000000000000015', 'FP-REPORT-001', company.id, project.id, contract.id, + counterparty.id, document.id, document.current_version_id, 'SPECIAL', 200.00, 'CNY', 0.13, CURRENT_DATE(), + 'INV-REPORT-001', CURRENT_DATE(), 'ISSUED', @project_user_id, @finance_id, + @finance_id, @finance_id, @finance_id +FROM md_company company +JOIN md_project project ON project.public_id = '80000000000000000000000003' +JOIN md_contract contract ON contract.public_id = '80000000000000000000000004' +JOIN md_counterparty counterparty ON counterparty.public_id = '80000000000000000000000002' +JOIN src_source_document document ON document.public_id = '80000000000000000000000005' +WHERE company.public_id = '80000000000000000000000001'; + +INSERT INTO fin_receivable + (public_id, business_no, source_document_id, source_invoice_id, company_id, project_id, + contract_id, counterparty_id, business_date, amount, outstanding_amount, currency, + status, created_by, updated_by) +SELECT '80000000000000000000000016', 'YS-REPORT-001', document.id, invoice.id, + company.id, project.id, contract.id, counterparty.id, CURRENT_DATE(), 1000.00, + 700.00, 'CNY', 'PARTIALLY_SETTLED', @finance_id, @finance_id +FROM md_company company +JOIN md_project project ON project.public_id = '80000000000000000000000003' +JOIN md_contract contract ON contract.public_id = '80000000000000000000000004' +JOIN md_counterparty counterparty ON counterparty.public_id = '80000000000000000000000002' +JOIN src_source_document document ON document.public_id = '80000000000000000000000005' +JOIN fin_invoice invoice ON invoice.public_id = '80000000000000000000000015' +WHERE company.public_id = '80000000000000000000000001'; + +INSERT INTO fin_payment_request + (public_id, business_no, company_id, project_id, contract_id, payable_id, supplier_id, + bank_account_version_id, source_document_id, source_version_id, requested_amount, approved_amount, + requested_date, purpose, status, submitted_by, checked_by, approved_by, + submitted_at, checked_at, approved_at, created_by, updated_by) +SELECT '80000000000000000000000017', 'FK-REPORT-001', company.id, project.id, contract.id, + payable.id, counterparty.id, bank.id, document.id, document.current_version_id, + 400.00, 400.00, CURRENT_DATE(), + '报表集成测试付款', 'PART_PAID', @project_user_id, @finance_id, @finance_id, + DATE_SUB(UTC_TIMESTAMP(3), INTERVAL 3 HOUR), DATE_SUB(UTC_TIMESTAMP(3), INTERVAL 2 HOUR), + DATE_SUB(UTC_TIMESTAMP(3), INTERVAL 1 HOUR), @finance_id, @finance_id +FROM md_company company +JOIN md_project project ON project.public_id = '80000000000000000000000003' +JOIN md_contract contract ON contract.public_id = '80000000000000000000000004' +JOIN fin_payable payable ON payable.public_id = '80000000000000000000000013' +JOIN md_counterparty counterparty ON counterparty.public_id = '80000000000000000000000002' +JOIN md_bank_account_version bank ON bank.public_id = '80000000000000000000000012' +JOIN src_source_document document ON document.public_id = '80000000000000000000000005' +WHERE company.public_id = '80000000000000000000000001'; + +INSERT INTO fin_payment_result + (public_id, result_no, payment_id, result_type, paid_date, amount, transaction_no, + recorded_by, recorded_at, verified_by, verified_at, status) +SELECT '80000000000000000000000018', 'PAY-RESULT-REPORT-001', payment.id, + 'SUCCESS', CURRENT_DATE(), 200.00, 'TX-REPORT-001', @project_user_id, + DATE_SUB(UTC_TIMESTAMP(3), INTERVAL 30 MINUTE), @finance_id, + DATE_SUB(UTC_TIMESTAMP(3), INTERVAL 25 MINUTE), 'VERIFIED' +FROM fin_payment_request payment +WHERE payment.public_id = '80000000000000000000000017'; + +INSERT INTO fin_payment_result + (public_id, result_no, payment_id, result_type, paid_date, amount, transaction_no, + recorded_by, recorded_at, verified_by, verified_at, status) +SELECT '80000000000000000000000031', 'PAY-RESULT-REPORT-002', payment.id, + 'SUCCESS', CURRENT_DATE(), 200.00, 'TX-REPORT-002', @project_user_id, + DATE_SUB(UTC_TIMESTAMP(3), INTERVAL 20 MINUTE), @finance_id, + DATE_SUB(UTC_TIMESTAMP(3), INTERVAL 15 MINUTE), 'VERIFIED' +FROM fin_payment_request payment +WHERE payment.public_id = '80000000000000000000000017'; + +INSERT INTO fin_payment_result + (public_id, result_no, payment_id, result_type, paid_date, amount, transaction_no, + recorded_by, recorded_at, verified_by, verified_at, status) +SELECT '80000000000000000000000032', 'PAY-RESULT-REPORT-003', payment.id, + 'REFUND', CURRENT_DATE(), 50.00, 'TX-REPORT-003', @project_user_id, + DATE_SUB(UTC_TIMESTAMP(3), INTERVAL 10 MINUTE), @finance_id, + DATE_SUB(UTC_TIMESTAMP(3), INTERVAL 5 MINUTE), 'VERIFIED' +FROM fin_payment_request payment +WHERE payment.public_id = '80000000000000000000000017'; + +INSERT INTO fin_payment_result + (public_id, result_no, payment_id, result_type, paid_date, amount, transaction_no, + recorded_by, recorded_at, status) +SELECT '80000000000000000000000033', 'PAY-RESULT-REPORT-004', payment.id, + 'SUCCESS', CURRENT_DATE(), 999.00, 'TX-REPORT-PENDING', @project_user_id, + UTC_TIMESTAMP(3), 'PENDING_VERIFY' +FROM fin_payment_request payment +WHERE payment.public_id = '80000000000000000000000017'; + +UPDATE fin_payment_request +SET paid_amount = 350.00 +WHERE public_id = '80000000000000000000000017'; + +INSERT INTO acc_event + (public_id, business_no, event_type, source_type, source_public_id, company_id, + project_id, counterparty_id, business_date, period, amount, currency, status, created_by) +SELECT '80000000000000000000000019', 'SJ-REPORT-001', 'PAYMENT', 'PAYMENT', result.public_id, + company.id, project.id, counterparty.id, CURRENT_DATE(), DATE_FORMAT(CURRENT_DATE(), '%Y-%m'), + 350.00, 'CNY', 'COMPLETED', @finance_id +FROM fin_payment_result result +JOIN md_company company ON company.public_id = '80000000000000000000000001' +JOIN md_project project ON project.public_id = '80000000000000000000000003' +JOIN md_counterparty counterparty ON counterparty.public_id = '80000000000000000000000002' +WHERE result.public_id = '80000000000000000000000018'; + +INSERT INTO acc_voucher + (public_id, business_no, event_id, company_id, project_id, period, business_date, + summary, debit_total, credit_total, status, external_voucher_no, result_at, + result_recorded_by, result_recorded_at, result_verified_by, result_verified_at, + created_by, updated_by) +SELECT '80000000000000000000000020', 'PZ-REPORT-001', event.id, company.id, project.id, + DATE_FORMAT(CURRENT_DATE(), '%Y-%m'), CURRENT_DATE(), '报表集成测试凭证', + 350.00, 350.00, 'COMPLETED', 'EXT-REPORT-001', CURRENT_DATE(), + @project_user_id, DATE_SUB(UTC_TIMESTAMP(3), INTERVAL 15 MINUTE), @finance_id, + DATE_SUB(UTC_TIMESTAMP(3), INTERVAL 10 MINUTE), @finance_id, @finance_id +FROM acc_event event +JOIN md_company company ON company.public_id = '80000000000000000000000001' +JOIN md_project project ON project.public_id = '80000000000000000000000003' +WHERE event.public_id = '80000000000000000000000019'; + +UPDATE acc_event event +JOIN acc_voucher voucher ON voucher.public_id = '80000000000000000000000020' +SET event.voucher_id = voucher.id +WHERE event.public_id = '80000000000000000000000019'; + +INSERT INTO arc_package + (public_id, business_no, company_id, project_id, package_version, completeness, + missing_count, status, created_by, updated_by) +SELECT '80000000000000000000000021', 'ARC-REPORT-001-V01', company.id, project.id, + 1, 90.00, 0, 'ARCHIVED', @finance_id, @finance_id +FROM md_company company JOIN md_project project ON project.public_id = '80000000000000000000000003' +WHERE company.public_id = '80000000000000000000000001'; + +INSERT INTO arc_package + (public_id, business_no, company_id, project_id, package_version, completeness, + missing_count, status, created_by, updated_by) +SELECT '80000000000000000000000022', 'ARC-REPORT-001-V02', company.id, project.id, + 2, 75.00, 1, 'RETURNED', @finance_id, @finance_id +FROM md_company company JOIN md_project project ON project.public_id = '80000000000000000000000003' +WHERE company.public_id = '80000000000000000000000001'; + +INSERT INTO file_object + (public_id, original_name, storage_key, media_type, extension, size_bytes, + sha256, scan_status, uploaded_by) +VALUES ('80000000000000000000000023', 'report-proof.pdf', 'reporting/report-proof.pdf', + 'application/pdf', 'pdf', 128, REPEAT('c', 64), 'AVAILABLE', @finance_public_id); + +INSERT INTO file_object + (public_id, original_name, storage_key, media_type, extension, size_bytes, + sha256, scan_status, uploaded_by) +VALUES ('80000000000000000000000035', 'wrong-receipt-proof.pdf', 'reporting/wrong-receipt-proof.pdf', + 'application/pdf', 'pdf', 128, REPEAT('d', 64), 'AVAILABLE', @finance_public_id), + ('80000000000000000000000036', 'wrong-workflow-proof.pdf', 'reporting/wrong-workflow-proof.pdf', + 'application/pdf', 'pdf', 128, REPEAT('e', 64), 'AVAILABLE', @finance_public_id); + +INSERT INTO file_link + (file_id, object_type, object_public_id, series_public_id, company_public_id, project_public_id, + form_type, document_type, original_type, archive_status, version_no, active, linked_by) +SELECT file.id, 'SOURCE_DOCUMENT', document.public_id, file.public_id, company.public_id, project.public_id, + document.form_type, 'PAYMENT_SUPPORT', 'PDF', 'ACTIVE', 1, TRUE, @finance_public_id +FROM file_object file +JOIN src_source_document document ON document.public_id = '80000000000000000000000005' +JOIN md_company company ON company.public_id = '80000000000000000000000001' +JOIN md_project project ON project.public_id = '80000000000000000000000003' +WHERE file.public_id = '80000000000000000000000023'; + +INSERT INTO file_link + (file_id, object_type, object_public_id, series_public_id, company_public_id, project_public_id, + document_type, original_type, archive_status, version_no, active, linked_by) +SELECT file.id, 'RECEIPT', '80000000000000000000000014', file.public_id, + '80000000000000000000000030', '80000000000000000000000003', + 'RECEIPT_PROOF', 'PDF', 'ACTIVE', 1, TRUE, @finance_public_id +FROM file_object file WHERE file.public_id = '80000000000000000000000035'; + +INSERT INTO file_link + (file_id, object_type, object_public_id, series_public_id, company_public_id, project_public_id, + document_type, original_type, archive_status, version_no, active, linked_by) +SELECT file.id, 'WORKFLOW_TASK', '80000000000000000000000009', file.public_id, + '80000000000000000000000030', '80000000000000000000000003', + 'WORKFLOW_PROOF', 'PDF', 'ACTIVE', 1, TRUE, @finance_public_id +FROM file_object file WHERE file.public_id = '80000000000000000000000036'; + +INSERT INTO fin_payment_attachment (public_id, payment_id, file_id, document_type, uploaded_by) +SELECT '80000000000000000000000024', payment.id, file.id, 'PAYMENT_SUPPORT', @finance_id +FROM fin_payment_request payment JOIN file_object file ON file.public_id = '80000000000000000000000023' +WHERE payment.public_id = '80000000000000000000000017'; + +UPDATE fin_payment_result result +JOIN file_object file ON file.public_id = '80000000000000000000000023' +SET result.receipt_file_id = file.id +WHERE result.public_id = '80000000000000000000000018'; + +INSERT INTO fin_payment_result_file (public_id, payment_result_id, file_id, created_by) +SELECT '80000000000000000000000025', result.id, file.id, @finance_id +FROM fin_payment_result result JOIN file_object file ON file.public_id = '80000000000000000000000023' +WHERE result.public_id = '80000000000000000000000018'; + +INSERT INTO fin_payment_result_file (public_id, payment_result_id, file_id, created_by) +SELECT '80000000000000000000000034', result.id, file.id, @finance_id +FROM fin_payment_result result JOIN file_object file ON file.public_id = '80000000000000000000000023' +WHERE result.public_id = '80000000000000000000000033'; + +INSERT INTO arc_package_item + (public_id, package_id, item_type, item_name, object_type, object_public_id, + file_id, file_link_id, file_sha256, required_flag, status) +SELECT '80000000000000000000000026', package.id, 'PAYMENT_PROOF', '付款回单', + 'PAYMENT', '80000000000000000000000017', file.id, link.id, file.sha256, TRUE, 'PRESENT' +FROM arc_package package +JOIN file_object file ON file.public_id = '80000000000000000000000023' +JOIN file_link link ON link.file_id = file.id +WHERE package.public_id = '80000000000000000000000022'; + +INSERT INTO arc_package_item + (public_id, package_id, item_type, item_name, required_flag, status, missing_reason) +SELECT '80000000000000000000000027', package.id, 'INVOICE', '发票资料', TRUE, 'MISSING', '待补发票附件' +FROM arc_package package WHERE package.public_id = '80000000000000000000000022'; + +INSERT INTO arc_package_item + (public_id, package_id, item_type, item_name, required_flag, status, not_applicable_reason) +SELECT '80000000000000000000000028', package.id, 'TAX', '税务资料', FALSE, 'NOT_APPLICABLE', '本项目不涉及' +FROM arc_package package WHERE package.public_id = '80000000000000000000000022'; + +INSERT INTO arc_package_item + (public_id, package_id, item_type, item_name, required_flag, status) +SELECT '80000000000000000000000029', package.id, 'OLD_VERSION', '旧版资料', FALSE, 'SUPERSEDED' +FROM arc_package package WHERE package.public_id = '80000000000000000000000022'; diff --git a/deploy/compose.yaml b/deploy/compose.yaml new file mode 100644 index 0000000..0713023 --- /dev/null +++ b/deploy/compose.yaml @@ -0,0 +1,27 @@ +services: + mysql: + image: mysql:8.4 + container_name: kaidi-finance-mysql + restart: unless-stopped + environment: + MYSQL_DATABASE: kaidi_finance + MYSQL_USER: kaidi + MYSQL_PASSWORD: kaidi_local_2026 + MYSQL_ROOT_PASSWORD: root_local_2026 + TZ: Asia/Shanghai + command: + - --character-set-server=utf8mb4 + - --collation-server=utf8mb4_0900_ai_ci + - --default-time-zone=+00:00 + ports: + - "3307:3306" + volumes: + - kaidi_mysql_data:/var/lib/mysql + healthcheck: + test: ["CMD-SHELL", "mysqladmin ping -h localhost -uroot -p$$MYSQL_ROOT_PASSWORD --silent"] + interval: 5s + timeout: 5s + retries: 30 + +volumes: + kaidi_mysql_data: diff --git a/deploy/env.production.example b/deploy/env.production.example new file mode 100644 index 0000000..f11da98 --- /dev/null +++ b/deploy/env.production.example @@ -0,0 +1,25 @@ +SPRING_PROFILES_ACTIVE=production +SERVER_PORT=18080 +SESSION_COOKIE_SECURE=false + +DB_URL=jdbc:mysql://127.0.0.1:3307/kaidi_finance?useUnicode=true&characterEncoding=utf8&connectionTimeZone=UTC&serverTimezone=UTC +DB_USERNAME=kaidi +DB_PASSWORD=CHANGE_ME +FIELD_ENCRYPTION_KEY=BASE64_32_BYTE_KEY + +FILE_STORAGE_ROOT=/var/lib/kaidi/files +FILE_STORAGE_TEMP=/var/lib/kaidi/tmp +FILE_SCANNER_ENABLED=false + +FINANCE_BOOTSTRAP_ENABLED=false +FINANCE_BOOTSTRAP_PASSWORD= + +APP_VERSION=1.0.0-preview.8 +UPDATE_CURRENT_VERSION_FILE=/opt/kaidi/current/VERSION +FINANCE_UPDATE_ENABLED=true +# Use either a stable direct asset base URL or the private Gitea latest-release API. +UPDATE_RELEASE_BASE_URL= +UPDATE_RELEASE_API_URL=https://git.awaioi.com/api/v1/repos/ERP-Team/kaidi/releases/latest +UPDATE_RELEASE_TOKEN=READ_ONLY_GITEA_TOKEN +UPDATE_REQUEST_FILE=/var/lib/kaidi-update/inbox/request.json +UPDATE_STATUS_FILE=/var/lib/kaidi-update/status.json diff --git a/deploy/install-from-git.sh b/deploy/install-from-git.sh new file mode 100755 index 0000000..6784be8 --- /dev/null +++ b/deploy/install-from-git.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +umask 077 + +ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +INSTALLER="$ROOT/deploy/install.sh" +INSTALLER_SHA256=${KAIDI_INSTALLER_SHA256:-33d4921bcad7ef2be12f20bc0512a5f6df4227ea6dd3b7e6225164077d9cd9e7} +RELEASE_API_URL=${KAIDI_RELEASE_API_URL:-https://git.awaioi.com/api/v1/repos/ERP-Team/kaidi/releases/latest} +PUBLIC_KEY_SHA256=${KAIDI_RELEASE_PUBLIC_KEY_SHA256:-807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9} +TOKEN_FILE=${KAIDI_RELEASE_TOKEN_FILE:-} +TEMP_TOKEN_FILE= + +log() { printf '[kaidi-git-install] %s\n' "$*"; } +die() { printf '[kaidi-git-install] ERROR: %s\n' "$*" >&2; exit 1; } + +cleanup() { + [ -z "$TEMP_TOKEN_FILE" ] || rm -f "$TEMP_TOKEN_FILE" +} +trap cleanup EXIT + +[ -f "$INSTALLER" ] || die "deploy/install.sh is missing; run this command from the Kaidi Git checkout" +command -v sha256sum >/dev/null 2>&1 || die "sha256sum is required" +[[ "$INSTALLER_SHA256" =~ ^[0-9A-Fa-f]{64}$ ]] || die "The installer SHA-256 is invalid" +[[ "$PUBLIC_KEY_SHA256" =~ ^[0-9A-Fa-f]{64}$ ]] || die "The release public-key SHA-256 is invalid" +actual_installer_sha256=$(sha256sum "$INSTALLER" | awk '{print $1}') +normalized_actual_installer_sha256=$(printf '%s' "$actual_installer_sha256" | tr '[:upper:]' '[:lower:]') +normalized_installer_sha256=$(printf '%s' "$INSTALLER_SHA256" | tr '[:upper:]' '[:lower:]') +[ "$normalized_actual_installer_sha256" = "$normalized_installer_sha256" ] \ + || die "deploy/install.sh does not match the trusted SHA-256 for this Git tag" + +if [ -z "$TOKEN_FILE" ]; then + TOKEN_FILE=$(mktemp) + TEMP_TOKEN_FILE=$TOKEN_FILE + chmod 0600 "$TOKEN_FILE" + if [ -t 0 ]; then + read -rsp 'Gitea read-only token: ' token + printf '\n' + printf '%s' "$token" > "$TOKEN_FILE" + unset token + else + die "Set KAIDI_RELEASE_TOKEN_FILE or run interactively to enter the Gitea read-only token" + fi +fi + +[ -f "$TOKEN_FILE" ] && [ ! -L "$TOKEN_FILE" ] || die "The Gitea token file must be a regular file" +token_size=$(wc -c < "$TOKEN_FILE" | tr -d '[:space:]') +[ "$token_size" -gt 0 ] && [ "$token_size" -le 512 ] || die "The Gitea token file must contain 1 to 512 bytes" +LC_ALL=C grep -q '[[:cntrl:]]' "$TOKEN_FILE" && die "The Gitea token file contains invalid control characters" + +root_command=(env) +if [ "$(id -u)" -ne 0 ]; then + command -v sudo >/dev/null 2>&1 || die "sudo is required when not running as root" + root_command=(sudo env) +fi + +install_env=( + "KAIDI_RELEASE_API_URL=$RELEASE_API_URL" + "KAIDI_RELEASE_TOKEN_FILE=$TOKEN_FILE" + "KAIDI_RELEASE_PUBLIC_KEY_SHA256=$PUBLIC_KEY_SHA256" +) + +KAIDI_REINSTALL=${KAIDI_REINSTALL:-} +KAIDI_SETUP_WIZARD=${KAIDI_SETUP_WIZARD:-} +KAIDI_DB_URL=${KAIDI_DB_URL:-} +KAIDI_DB_USERNAME=${KAIDI_DB_USERNAME:-} +KAIDI_DB_PASSWORD=${KAIDI_DB_PASSWORD:-} +KAIDI_DB_HOST=${KAIDI_DB_HOST:-} +KAIDI_DB_PORT=${KAIDI_DB_PORT:-} +KAIDI_DB_NAME=${KAIDI_DB_NAME:-} +KAIDI_DB_CONTAINER=${KAIDI_DB_CONTAINER:-} +KAIDI_SESSION_COOKIE_SECURE=${KAIDI_SESSION_COOKIE_SECURE:-} +KAIDI_FILE_SCANNER_ENABLED=${KAIDI_FILE_SCANNER_ENABLED:-} + +for name in KAIDI_REINSTALL KAIDI_SETUP_WIZARD KAIDI_DB_URL KAIDI_DB_USERNAME KAIDI_DB_PASSWORD \ + KAIDI_DB_HOST KAIDI_DB_PORT KAIDI_DB_NAME KAIDI_DB_CONTAINER \ + KAIDI_SESSION_COOKIE_SECURE KAIDI_FILE_SCANNER_ENABLED; do + value=${!name} + if [ -n "$value" ]; then + install_env+=("$name=$value") + fi +done + +log "Installing the latest signed release from $RELEASE_API_URL" +"${root_command[@]}" "${install_env[@]}" bash "$INSTALLER" diff --git a/deploy/install.sh b/deploy/install.sh new file mode 100755 index 0000000..844253c --- /dev/null +++ b/deploy/install.sh @@ -0,0 +1,820 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +umask 077 + +APP_ROOT=${KAIDI_APP_ROOT:-/opt/kaidi} +STATE_ROOT=${KAIDI_STATE_ROOT:-/var/lib/kaidi} +UPDATE_STATE_ROOT=${KAIDI_UPDATE_STATE_ROOT:-/var/lib/kaidi-update} +CONFIG_ROOT=${KAIDI_CONFIG_ROOT:-/etc/kaidi} +RELEASE_BASE_URL=${KAIDI_RELEASE_BASE_URL:-} +RELEASE_API_URL=${KAIDI_RELEASE_API_URL:-https://git.awaioi.com/api/v1/repos/ERP-Team/kaidi/releases/latest} +RELEASE_TOKEN=${KAIDI_RELEASE_TOKEN:-} +RELEASE_TOKEN_FILE=${KAIDI_RELEASE_TOKEN_FILE:-} +HEALTH_URL=${KAIDI_HEALTH_URL:-http://127.0.0.1:18080/actuator/health} +TRUSTED_PUBLIC_KEY_SHA256=${KAIDI_RELEASE_PUBLIC_KEY_SHA256:-} +REINSTALL=${KAIDI_REINSTALL:-false} +SETUP_WIZARD=${KAIDI_SETUP_WIZARD:-false} +WORK_DIR= +RELEASE_AUTH_HEADER_FILE= +INSTALL_TRANSACTION_ARMED=false +INSTALL_COMMITTED=false +RELEASE_DIR= +BACKUP_DIR= +JAVA_STAGED_DIR= +JAVA_PREVIOUS_DIR= +JAVA_ACTIVATED=false +CREATED_DB_CONTAINER=false +CREATED_DB_VOLUME=false + +log() { printf '[kaidi-install] %s\n' "$*"; } +die() { printf '[kaidi-install] ERROR: %s\n' "$*" >&2; exit 1; } + +sha256_file() { + sha256sum "$1" | awk '{print $1}' +} + +preflight_host() { + [ "$(uname -s)" = Linux ] || die "The installer only supports Linux" + [ "$APP_ROOT" = /opt/kaidi ] && [ "$STATE_ROOT" = /var/lib/kaidi ] \ + && [ "$UPDATE_STATE_ROOT" = /var/lib/kaidi-update ] && [ "$CONFIG_ROOT" = /etc/kaidi ] \ + || die "Custom installation roots are not supported by the packaged systemd and Nginx configuration" + command -v systemctl >/dev/null 2>&1 || die "systemd is required" + [ -d /run/systemd/system ] || die "systemd is not running as PID 1" + [ -d /etc/systemd/system ] || die "/etc/systemd/system is missing" + [[ "$TRUSTED_PUBLIC_KEY_SHA256" =~ ^[0-9A-Fa-f]{64}$ ]] \ + || die "Set KAIDI_RELEASE_PUBLIC_KEY_SHA256 to the trusted release public-key SHA-256" + case "$REINSTALL" in + true|false) ;; + *) die "KAIDI_REINSTALL must be true or false" ;; + esac + case "$SETUP_WIZARD" in + true|false) ;; + *) die "KAIDI_SETUP_WIZARD must be true or false" ;; + esac + if [ "$SETUP_WIZARD" = true ] && [ "$REINSTALL" = true ]; then + [ -s "$CONFIG_ROOT/kaidi.env" ] \ + || die "A setup-wizard repair needs the existing $CONFIG_ROOT/kaidi.env" + [ "$(read_existing_env FINANCE_SETUP_ENABLED)" = true ] \ + || die "The existing installation is already in production mode; use a normal repair reinstall" + [ ! -e "$STATE_ROOT/setup/locked" ] \ + || die "The setup wizard is already locked; use a normal repair reinstall" + fi +} + +[ "$(id -u)" -eq 0 ] || die "Run with sudo or as root" +[ -z "$RELEASE_TOKEN" ] || [ -z "$RELEASE_TOKEN_FILE" ] \ + || die "Set only one of KAIDI_RELEASE_TOKEN or KAIDI_RELEASE_TOKEN_FILE" +if [ -n "$RELEASE_TOKEN_FILE" ]; then + [ -f "$RELEASE_TOKEN_FILE" ] && [ ! -L "$RELEASE_TOKEN_FILE" ] \ + || die "KAIDI_RELEASE_TOKEN_FILE must be a regular file" + [ "$(wc -c < "$RELEASE_TOKEN_FILE" | tr -d '[:space:]')" -le 512 ] \ + || die "KAIDI_RELEASE_TOKEN_FILE is too large" + RELEASE_TOKEN=$(cat "$RELEASE_TOKEN_FILE") +fi +[ "$REINSTALL" = "true" ] || [ ! -e "$APP_ROOT/current" ] \ + || die "Kaidi Finance is already installed; use the system update page" +if [ -z "$RELEASE_API_URL" ]; then + case "$RELEASE_BASE_URL" in + *OWNER/REPO*) die "Set KAIDI_RELEASE_BASE_URL or KAIDI_RELEASE_API_URL to the Git release source" ;; + esac +fi +[ -z "$RELEASE_TOKEN" ] || { [ "${#RELEASE_TOKEN}" -le 512 ] \ + && ! printf '%s' "$RELEASE_TOKEN" | grep -q '[[:cntrl:]]'; } \ + || die "KAIDI_RELEASE_TOKEN is invalid" + +download() { + local url=$1 output=$2 + case "$url" in + https://*) + curl --fail --silent --show-error --location --proto '=https' --proto-redir '=https' \ + --tlsv1.2 "$url" -o "$output" + ;; + *) + [ "${KAIDI_ALLOW_INSECURE_UPDATE:-false}" = "true" ] || die "Release URLs must use HTTPS" + curl --fail --silent --show-error --location --proto '=http,https' \ + --proto-redir '=http,https' "$url" -o "$output" + ;; + esac +} + +download_release_url() { + local url=$1 output=$2 + case "$url" in + https://*) + if [ -n "$RELEASE_TOKEN" ]; then + [ -s "$RELEASE_AUTH_HEADER_FILE" ] || die "Release authentication header is unavailable" + curl --fail --silent --show-error --proto '=https' --tlsv1.2 \ + --header "@$RELEASE_AUTH_HEADER_FILE" "$url" -o "$output" + else + download "$url" "$output" + fi + ;; + *) + [ "${KAIDI_ALLOW_INSECURE_UPDATE:-false}" = "true" ] || die "Release URLs must use HTTPS" + if [ -n "$RELEASE_TOKEN" ]; then + [ -s "$RELEASE_AUTH_HEADER_FILE" ] || die "Release authentication header is unavailable" + curl --fail --silent --show-error --proto '=http,https' \ + --header "@$RELEASE_AUTH_HEADER_FILE" "$url" -o "$output" + else + download "$url" "$output" + fi + ;; + esac +} + +release_asset_url() { + local asset_name=$1 asset_url api_origin + if [ -n "$RELEASE_API_URL" ]; then + asset_url=$(jq -er --arg name "$asset_name" \ + '.assets[] | select(.name == $name) | .browser_download_url + | strings | select(startswith("https://") or startswith("http://"))' \ + "$WORK_DIR/release-api.json" | head -n 1) || die "Release asset $asset_name is missing" + api_origin=$(printf '%s' "$RELEASE_API_URL" | sed -E 's#^(https?://[^/]+).*$#\1#') + case "$asset_url" in + "$api_origin"/*) printf '%s\n' "$asset_url" ;; + *) die "Release asset $asset_name must use the Gitea API host" ;; + esac + else + printf '%s/%s\n' "${RELEASE_BASE_URL%/}" "$asset_name" + fi +} + +download_release_asset() { + local asset_name=$1 output=$2 url + url=$(release_asset_url "$asset_name") + download_release_url "$url" "$output" +} + +install_packages() { + if command -v apt-get >/dev/null 2>&1; then + export DEBIAN_FRONTEND=noninteractive + apt-get update -qq + apt-get install -y -qq ca-certificates coreutils curl findutils gzip jq openssl tar nginx util-linux default-mysql-client + elif command -v dnf >/dev/null 2>&1; then + dnf install -y ca-certificates coreutils curl findutils gzip jq openssl tar nginx util-linux mysql + elif command -v yum >/dev/null 2>&1; then + yum install -y ca-certificates coreutils curl findutils gzip jq openssl tar nginx util-linux mysql + else + die "Supported package managers are apt, dnf, and yum" + fi +} + +install_docker() { + command -v docker >/dev/null 2>&1 && return + if command -v apt-get >/dev/null 2>&1; then + apt-get install -y -qq docker.io + elif command -v dnf >/dev/null 2>&1; then + dnf install -y docker + else + yum install -y docker + fi + systemctl enable --now docker +} + +azul_arch() { + case "$(uname -m)" in + x86_64|amd64) printf x86 ;; + i386|i486|i586|i686) printf i686 ;; + aarch64|arm64) printf arm_64 ;; + armv7l|armv6l) printf arm ;; + *) die "Unsupported CPU architecture: $(uname -m)" ;; + esac +} + +prepare_java() { + local api java_metadata package_metadata package_uuid java_url java_sha256 actual_sha256 + api="https://api.azul.com/metadata/v1/zulu/packages/?java_version=17&os=linux&arch=$(azul_arch)&archive_type=tar.gz&java_package_type=jre&release_status=ga&availability_types=CA&latest=true" + java_metadata="$WORK_DIR/zulu-metadata.json" + download "$api" "$java_metadata" + package_uuid=$(jq -er '[.[] | select(.name | contains("-fx-") | not)][0].package_uuid' \ + "$java_metadata") \ + || die "No Java 17 runtime is published for this Linux architecture" + package_metadata="$WORK_DIR/zulu-package.json" + download "https://api.azul.com/metadata/v1/zulu/packages/$package_uuid" "$package_metadata" + java_url=$(jq -er '.download_url | strings | select(startswith("https://cdn.azul.com/"))' \ + "$package_metadata") || die "Java 17 runtime download URL is invalid" + java_sha256=$(jq -er '.sha256_hash | strings | ascii_downcase | select(test("^[0-9a-f]{64}$"))' \ + "$package_metadata") || die "Java 17 runtime SHA-256 is unavailable" + JAVA_STAGED_DIR="$WORK_DIR/java.next" + download "$java_url" "$WORK_DIR/java.tar.gz" + actual_sha256=$(sha256_file "$WORK_DIR/java.tar.gz") + [ "$actual_sha256" = "$java_sha256" ] || die "Java 17 runtime SHA-256 verification failed" + mkdir -p "$JAVA_STAGED_DIR" + tar -xzf "$WORK_DIR/java.tar.gz" --strip-components=1 -C "$JAVA_STAGED_DIR" + "$JAVA_STAGED_DIR/bin/java" -version +} + +activate_java() { + mkdir -p "$APP_ROOT/runtime" + JAVA_PREVIOUS_DIR="$WORK_DIR/java.previous" + if [ -e "$APP_ROOT/runtime/java" ]; then + mv "$APP_ROOT/runtime/java" "$JAVA_PREVIOUS_DIR" + fi + mv "$JAVA_STAGED_DIR" "$APP_ROOT/runtime/java" + JAVA_ACTIVATED=true + "$APP_ROOT/runtime/java/bin/java" -version +} + +random_secret() { openssl rand -base64 36 | tr -d '\n/+=' | cut -c1-36; } + +decode_env_value() { + local raw=$1 result='' char next index=0 length + if [[ "$raw" == \"*\" && ${#raw} -ge 2 ]]; then + raw=${raw:1:${#raw}-2} + length=${#raw} + while [ "$index" -lt "$length" ]; do + char=${raw:index:1} + if [ "$char" = "\\" ] && [ $((index + 1)) -lt "$length" ]; then + next=${raw:index+1:1} + if [ "$next" = "\\" ] || [ "$next" = '"' ]; then + result+="$next" + index=$((index + 2)) + continue + fi + fi + result+="$char" + index=$((index + 1)) + done + printf '%s' "$result" + elif [[ "$raw" == \'*\' && ${#raw} -ge 2 ]]; then + printf '%s' "${raw:1:${#raw}-2}" + else + printf '%s' "$raw" + fi +} + +read_existing_env() { + read_env_file "$CONFIG_ROOT/kaidi.env" "$1" +} + +read_env_file() { + local file=$1 name=$2 line value='' found=false + [ -s "$file" ] || return 0 + while IFS= read -r line || [ -n "$line" ]; do + case "$line" in + "$name="*) + value=${line#*=} + found=true + ;; + esac + done < "$file" + if [ "$found" = true ]; then + decode_env_value "$value" + fi +} + +read_setup_env() { + read_env_file "$STATE_ROOT/setup/application.env" "$1" +} + +read_reinstall_env() { + local value + value=$(read_setup_env "$1") + if [ -n "$value" ]; then + printf '%s' "$value" + else + read_existing_env "$1" + fi +} + +is_semver() { + [ "${#1}" -le 128 ] \ + && LC_ALL=C grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$' \ + </dev/null 2>&1 \ + || die "Docker container kaidi-finance-mysql already exists; provide explicit database parameters" + ! docker volume inspect kaidi_mysql_data >/dev/null 2>&1 \ + || die "Docker volume kaidi_mysql_data already exists; provide explicit database parameters" + docker volume create kaidi_mysql_data >/dev/null + CREATED_DB_VOLUME=true + docker run -d \ + --name kaidi-finance-mysql \ + --restart unless-stopped \ + -e MYSQL_DATABASE=kaidi_finance \ + -e MYSQL_USER="$DB_USERNAME" \ + -e MYSQL_PASSWORD="$DB_PASSWORD" \ + -e MYSQL_ROOT_PASSWORD="$DB_ROOT_PASSWORD" \ + -e TZ=Asia/Shanghai \ + -p 127.0.0.1:3307:3306 \ + -v kaidi_mysql_data:/var/lib/mysql \ + --health-cmd='mysqladmin ping -h localhost -uroot -p$MYSQL_ROOT_PASSWORD --silent' \ + --health-interval=5s --health-timeout=5s --health-retries=30 \ + mysql:8.4 \ + --character-set-server=utf8mb4 \ + --collation-server=utf8mb4_0900_ai_ci \ + --default-time-zone=+00:00 >/dev/null + CREATED_DB_CONTAINER=true + local attempts=0 health + while [ "$attempts" -lt 60 ]; do + health=$(docker inspect --format '{{.State.Health.Status}}' kaidi-finance-mysql 2>/dev/null || true) + [ "$health" = healthy ] && break + [ "$health" = unhealthy ] && die "MySQL container failed its health check" + attempts=$((attempts + 1)) + sleep 2 + done + [ "$health" = healthy ] || die "MySQL did not become ready" + DB_URL='jdbc:mysql://127.0.0.1:3307/kaidi_finance?useUnicode=true&characterEncoding=utf8&connectionTimeZone=UTC&serverTimezone=UTC' +} + +database_host() { + local endpoint host_port + endpoint=${DB_URL#jdbc:mysql://} + host_port=${endpoint%%/*} + printf '%s' "${KAIDI_DB_HOST:-${host_port%%:*}}" +} + +database_port() { + local endpoint host_port candidate + endpoint=${DB_URL#jdbc:mysql://} + host_port=${endpoint%%/*} + candidate=${host_port##*:} + [ "$candidate" != "$host_port" ] || candidate=3306 + printf '%s' "${KAIDI_DB_PORT:-$candidate}" +} + +database_name() { + local endpoint name + endpoint=${DB_URL#jdbc:mysql://} + name=${endpoint#*/} + name=${name%%\?*} + printf '%s' "${KAIDI_DB_NAME:-$name}" +} + +preflight_database() { + local client host port name version table + [ "$SETUP_WIZARD" != true ] || return + case "$DB_URL" in + jdbc:mysql://*) ;; + *) die "KAIDI_DB_URL must start with jdbc:mysql://" ;; + esac + client=$(command -v mysql || command -v mariadb || true) + [ -n "$client" ] || die "A MySQL command-line client is required" + host=$(database_host) + port=$(database_port) + name=$(database_name) + [ -n "$host" ] || die "The database host is empty" + [[ "$port" =~ ^[0-9]{1,5}$ ]] && [ "$port" -ge 1 ] && [ "$port" -le 65535 ] \ + || die "The database port is invalid" + [[ "$name" =~ ^[A-Za-z0-9_$]+$ ]] || die "The database name is invalid" + + version=$(MYSQL_PWD="$DB_PASSWORD" "$client" --protocol=TCP --connect-timeout=10 \ + -h "$host" -P "$port" -u "$DB_USERNAME" "$name" --batch --skip-column-names \ + -e 'SELECT VERSION()' 2>/dev/null) || die "Cannot connect to the configured MySQL database" + case "$version" in + 8.4.*) ;; + *) die "MySQL 8.4.x is required; server reported $version" ;; + esac + + table="kaidi_install_preflight_$$" + if ! MYSQL_PWD="$DB_PASSWORD" "$client" --protocol=TCP --connect-timeout=10 \ + -h "$host" -P "$port" -u "$DB_USERNAME" "$name" >/dev/null </dev/null 2>&1 || true + die "The database account needs DDL and DML permissions on $name" + fi + log "MySQL $version connectivity and DDL/DML permissions verified" +} + +write_env_file() { + local output=$1 temporary name value escaped + shift + temporary="${output}.next.$$" + : > "$temporary" + while [ "$#" -gt 0 ]; do + name=$1 + value=$2 + shift 2 + case "$value" in + *$'\n'*|*$'\r'*) die "$name contains a line break" ;; + esac + escaped=${value//\\/\\\\} + escaped=${escaped//\"/\\\"} + printf '%s="%s"\n' "$name" "$escaped" >> "$temporary" + done + mv -f "$temporary" "$output" +} + +write_env_file_preserving_unknown() { + local output=$1 existing='' temporary name value escaped line key managed_keys='|' + shift + if [ "$REINSTALL" = true ] && [ -f "$output" ]; then + existing="$WORK_DIR/existing-kaidi.env" + cp -p "$output" "$existing" + fi + local arguments=("$@") + while [ "$#" -gt 0 ]; do + managed_keys+="$1|" + shift 2 + done + temporary="${output}.next.$$" + : > "$temporary" + if [ -n "$existing" ]; then + while IFS= read -r line || [ -n "$line" ]; do + if [[ "$line" =~ ^([A-Za-z_][A-Za-z0-9_]*)= ]]; then + key=${BASH_REMATCH[1]} + case "$managed_keys" in + *"|$key|"*) continue ;; + esac + fi + printf '%s\n' "$line" >> "$temporary" + done < "$existing" + fi + set -- "${arguments[@]}" + while [ "$#" -gt 0 ]; do + name=$1 + value=$2 + shift 2 + case "$value" in + *$'\n'*|*$'\r'*) die "$name contains a line break" ;; + esac + escaped=${value//\\/\\\\} + escaped=${escaped//\"/\\\"} + printf '%s="%s"\n' "$name" "$escaped" >> "$temporary" + done + mv -f "$temporary" "$output" +} + +wait_for_health() { + local attempts=0 + while [ "$attempts" -lt 60 ]; do + if curl -fsS "$HEALTH_URL" | jq -e '.status == "UP"' >/dev/null 2>&1; then + return + fi + attempts=$((attempts + 1)) + sleep 2 + done + journalctl -u kaidi-finance.service -n 80 --no-pager >&2 || true + die "Application health check did not become UP" +} + +backup_managed_state() { + local index=0 path + MANAGED_PATHS=( + "$APP_ROOT/current" + "$APP_ROOT/bin/update.sh" + "$CONFIG_ROOT/kaidi.env" + "$CONFIG_ROOT/update.env" + "$CONFIG_ROOT/release-public.pem" + "$UPDATE_STATE_ROOT/status.json" + /root/kaidi-first-login.txt + /etc/systemd/system/kaidi-finance.service + /etc/systemd/system/kaidi-update.service + /etc/systemd/system/kaidi-update.path + /etc/nginx/conf.d/kaidi-finance.conf + /etc/nginx/sites-enabled/default + ) + BACKUP_DIR="$WORK_DIR/rollback" + mkdir -p "$BACKUP_DIR/files" + for path in "${MANAGED_PATHS[@]}"; do + if [ -e "$path" ] || [ -L "$path" ]; then + cp -a -- "$path" "$BACKUP_DIR/files/$index" + : > "$BACKUP_DIR/files/$index.exists" + fi + index=$((index + 1)) + done + systemctl is-active --quiet kaidi-finance.service && PREVIOUS_APP_ACTIVE=true || PREVIOUS_APP_ACTIVE=false + systemctl is-enabled --quiet kaidi-finance.service && PREVIOUS_APP_ENABLED=true || PREVIOUS_APP_ENABLED=false + systemctl is-active --quiet kaidi-update.path && PREVIOUS_UPDATE_ACTIVE=true || PREVIOUS_UPDATE_ACTIVE=false + systemctl is-enabled --quiet kaidi-update.path && PREVIOUS_UPDATE_ENABLED=true || PREVIOUS_UPDATE_ENABLED=false + systemctl is-active --quiet nginx && PREVIOUS_NGINX_ACTIVE=true || PREVIOUS_NGINX_ACTIVE=false + systemctl is-enabled --quiet nginx && PREVIOUS_NGINX_ENABLED=true || PREVIOUS_NGINX_ENABLED=false + INSTALL_TRANSACTION_ARMED=true +} + +restore_unit_state() { + local unit=$1 was_enabled=$2 was_active=$3 + if [ "$was_enabled" = true ]; then + systemctl enable "$unit" >/dev/null 2>&1 + else + systemctl disable "$unit" >/dev/null 2>&1 + fi + if [ "$was_active" = true ]; then + systemctl start "$unit" >/dev/null 2>&1 + else + systemctl stop "$unit" >/dev/null 2>&1 + fi +} + +restore_managed_path() { + local path=$1 index=$2 temporary + if [ -e "$BACKUP_DIR/files/$index.exists" ]; then + mkdir -p "$(dirname "$path")" + temporary="${path}.rollback.$$" + rm -rf -- "$temporary" + cp -a -- "$BACKUP_DIR/files/$index" "$temporary" || return 1 + mv -Tf -- "$temporary" "$path" || { + rm -rf -- "$temporary" + return 1 + } + else + rm -rf -- "$path" + fi +} + +rollback_install() { + local index=0 path rollback_failed=false + set +e + log "Installation failed; restoring the previous managed state" + systemctl stop kaidi-update.path kaidi-finance.service >/dev/null 2>&1 || rollback_failed=true + for path in "${MANAGED_PATHS[@]}"; do + restore_managed_path "$path" "$index" || rollback_failed=true + index=$((index + 1)) + done + if [ -n "$RELEASE_DIR" ]; then + rm -rf -- "$RELEASE_DIR" || rollback_failed=true + fi + if [ -d "$JAVA_PREVIOUS_DIR" ]; then + rm -rf -- "$APP_ROOT/runtime/java" || rollback_failed=true + mkdir -p "$APP_ROOT/runtime" || rollback_failed=true + mv "$JAVA_PREVIOUS_DIR" "$APP_ROOT/runtime/java" || rollback_failed=true + elif [ "$JAVA_ACTIVATED" = true ]; then + rm -rf -- "$APP_ROOT/runtime/java" || rollback_failed=true + fi + systemctl daemon-reload >/dev/null 2>&1 || rollback_failed=true + restore_unit_state kaidi-finance.service "$PREVIOUS_APP_ENABLED" "$PREVIOUS_APP_ACTIVE" \ + || rollback_failed=true + restore_unit_state kaidi-update.path "$PREVIOUS_UPDATE_ENABLED" "$PREVIOUS_UPDATE_ACTIVE" \ + || rollback_failed=true + restore_unit_state nginx "$PREVIOUS_NGINX_ENABLED" "$PREVIOUS_NGINX_ACTIVE" || rollback_failed=true + if [ "$PREVIOUS_NGINX_ACTIVE" = true ]; then + nginx -t >/dev/null 2>&1 && systemctl reload nginx >/dev/null 2>&1 || rollback_failed=true + fi + set -e + [ "$rollback_failed" = false ] +} + +cleanup() { + local result=$? + trap - EXIT + if [ "$result" -ne 0 ] && [ "$INSTALL_TRANSACTION_ARMED" = true ] \ + && [ "$INSTALL_COMMITTED" != true ]; then + rollback_install || log "ERROR: rollback was incomplete; inspect systemd and Nginx state" + fi + if [ "$result" -ne 0 ] && [ "$INSTALL_COMMITTED" != true ] \ + && [ "$CREATED_DB_CONTAINER" = true ]; then + docker rm -f kaidi-finance-mysql >/dev/null 2>&1 || true + fi + if [ "$result" -ne 0 ] && [ "$INSTALL_COMMITTED" != true ] \ + && [ "$CREATED_DB_VOLUME" = true ]; then + docker volume rm kaidi_mysql_data >/dev/null 2>&1 || true + fi + [ -z "$WORK_DIR" ] || rm -rf "$WORK_DIR" + exit "$result" +} + +trap cleanup EXIT + +preflight_host +install_packages + +BASE=${RELEASE_BASE_URL%/} +WORK_DIR=$(mktemp -d) +if [ -n "$RELEASE_TOKEN" ]; then + RELEASE_AUTH_HEADER_FILE="$WORK_DIR/release-auth-header" + printf 'Authorization: token %s\n' "$RELEASE_TOKEN" > "$RELEASE_AUTH_HEADER_FILE" + chmod 0600 "$RELEASE_AUTH_HEADER_FILE" +fi +if [ -n "$RELEASE_API_URL" ]; then + download_release_url "$RELEASE_API_URL" "$WORK_DIR/release-api.json" +fi +download_release_asset release-public.pem "$WORK_DIR/release-public.pem" +ACTUAL_PUBLIC_KEY_SHA256=$(sha256_file "$WORK_DIR/release-public.pem" | tr '[:upper:]' '[:lower:]') +EXPECTED_PUBLIC_KEY_SHA256=$(printf '%s' "$TRUSTED_PUBLIC_KEY_SHA256" | tr '[:upper:]' '[:lower:]') +[ "$ACTUAL_PUBLIC_KEY_SHA256" = "$EXPECTED_PUBLIC_KEY_SHA256" ] \ + || die "Release public-key SHA-256 does not match the trusted fingerprint" +download_release_asset release-manifest.json "$WORK_DIR/release-manifest.json" +download_release_asset release-manifest.sig "$WORK_DIR/release-manifest.sig" +openssl dgst -sha256 -verify "$WORK_DIR/release-public.pem" -signature "$WORK_DIR/release-manifest.sig" \ + "$WORK_DIR/release-manifest.json" >/dev/null 2>&1 || die "Release manifest signature verification failed" + +VERSION=$(jq -er '.version | strings | select(length > 0 and length <= 128)' \ + "$WORK_DIR/release-manifest.json") || die "Release version is invalid" +is_semver "$VERSION" || die "Release version is invalid" +ARTIFACT=$(jq -er '.artifact | strings | select(test("^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}\\.tar\\.gz$"))' \ + "$WORK_DIR/release-manifest.json") || die "Release artifact name is invalid" +EXPECTED_SHA=$(jq -er '.sha256 | strings | ascii_downcase | select(test("^[0-9a-f]{64}$"))' \ + "$WORK_DIR/release-manifest.json") || die "Release SHA-256 is invalid" +download_release_asset "$ARTIFACT" "$WORK_DIR/release.tar.gz" +ACTUAL_SHA=$(sha256sum "$WORK_DIR/release.tar.gz" | awk '{print $1}') +[ "$ACTUAL_SHA" = "$EXPECTED_SHA" ] || die "Release artifact SHA-256 verification failed" +tar -tzf "$WORK_DIR/release.tar.gz" > "$WORK_DIR/archive.list" +grep -Eq '(^/|(^|/)\.\.(/|$))' "$WORK_DIR/archive.list" \ + && die "Release archive contains an unsafe path" +mkdir "$WORK_DIR/extracted" +tar -xzf "$WORK_DIR/release.tar.gz" -C "$WORK_DIR/extracted" +find "$WORK_DIR/extracted" -type l -print -quit | grep -q . \ + && die "Release archive must not contain symbolic links" +[ -s "$WORK_DIR/extracted/app.jar" ] || die "Release app.jar is missing" +[ -s "$WORK_DIR/extracted/public/index.html" ] || die "Release frontend is missing" +[ "$(cat "$WORK_DIR/extracted/VERSION")" = "$VERSION" ] || die "Release version mismatch" +[ -x "$WORK_DIR/extracted/ops/update.sh" ] || die "Release updater is missing" + +prepare_java +configure_database +preflight_database + +id kaidi >/dev/null 2>&1 || useradd --system --home "$STATE_ROOT" --shell /usr/sbin/nologin kaidi +mkdir -p "$APP_ROOT/releases" "$APP_ROOT/bin" "$STATE_ROOT/files" "$STATE_ROOT/tmp" \ + "$STATE_ROOT/setup" "$UPDATE_STATE_ROOT/inbox" "$CONFIG_ROOT" /var/log/kaidi +chown -R kaidi:kaidi "$STATE_ROOT" "$UPDATE_STATE_ROOT/inbox" /var/log/kaidi +chown root:kaidi "$UPDATE_STATE_ROOT" +chmod 0750 "$STATE_ROOT" "$STATE_ROOT/files" "$STATE_ROOT/tmp" "$UPDATE_STATE_ROOT" "$UPDATE_STATE_ROOT/inbox" +chmod 0700 "$STATE_ROOT/setup" + +RELEASE_DIR="$APP_ROOT/releases/$VERSION" +[ ! -e "$RELEASE_DIR" ] || RELEASE_DIR="$APP_ROOT/releases/${VERSION}-reinstall-$(date -u +%Y%m%dT%H%M%SZ)" +backup_managed_state +activate_java +mv "$WORK_DIR/extracted" "$RELEASE_DIR" +chown -R root:root "$RELEASE_DIR" +chmod -R go-w "$RELEASE_DIR" +ln -sfn "$RELEASE_DIR" "$APP_ROOT/current.next" +mv -Tf "$APP_ROOT/current.next" "$APP_ROOT/current" +install -m 0755 "$RELEASE_DIR/ops/update.sh" "$APP_ROOT/bin/update.sh" +install -m 0644 "$WORK_DIR/release-public.pem" "$CONFIG_ROOT/release-public.pem" + +if [ "$REINSTALL" = true ]; then + FIELD_KEY=$(read_existing_env FIELD_ENCRYPTION_KEY) + [ -n "$FIELD_KEY" ] || die "The existing FIELD_ENCRYPTION_KEY is missing" + ADMIN_PASSWORD= + SESSION_COOKIE_SECURE=${KAIDI_SESSION_COOKIE_SECURE:-$(read_existing_env SESSION_COOKIE_SECURE)} + FILE_STORAGE_ROOT_VALUE=$(read_existing_env FILE_STORAGE_ROOT) + FILE_STORAGE_TEMP_VALUE=$(read_existing_env FILE_STORAGE_TEMP) + FILE_SCANNER_ENABLED_VALUE=$(read_existing_env FILE_SCANNER_ENABLED) + [ -n "$SESSION_COOKIE_SECURE" ] || SESSION_COOKIE_SECURE=false + [ -n "$FILE_STORAGE_ROOT_VALUE" ] || FILE_STORAGE_ROOT_VALUE="$STATE_ROOT/files" + [ -n "$FILE_STORAGE_TEMP_VALUE" ] || FILE_STORAGE_TEMP_VALUE="$STATE_ROOT/tmp" + [ -n "$FILE_SCANNER_ENABLED_VALUE" ] || FILE_SCANNER_ENABLED_VALUE=false +else + FIELD_KEY=$(openssl rand -base64 32 | tr -d '\n') + if [ "$SETUP_WIZARD" = true ]; then + ADMIN_PASSWORD= + else + ADMIN_PASSWORD="Kaidi!$(random_secret)Aa1" + fi + SESSION_COOKIE_SECURE=${KAIDI_SESSION_COOKIE_SECURE:-false} + FILE_STORAGE_ROOT_VALUE="$STATE_ROOT/files" + FILE_STORAGE_TEMP_VALUE="$STATE_ROOT/tmp" + FILE_SCANNER_ENABLED_VALUE=${KAIDI_FILE_SCANNER_ENABLED:-false} +fi +BOOTSTRAP_ENABLED=true +SETUP_CODE= +SETUP_TOKEN_SHA256= +if [ "$REINSTALL" = true ] || [ "$SETUP_WIZARD" = true ]; then + BOOTSTRAP_ENABLED=false +fi +if [ "$SETUP_WIZARD" = true ]; then + SETUP_CODE="KD-$(random_secret)" + SETUP_TOKEN_SHA256=$(printf '%s' "$SETUP_CODE" | sha256sum | awk '{print $1}') +fi +write_env_file_preserving_unknown "$CONFIG_ROOT/kaidi.env" \ + SPRING_PROFILES_ACTIVE production \ + SERVER_PORT 18080 \ + SESSION_COOKIE_SECURE "$SESSION_COOKIE_SECURE" \ + DB_URL "$DB_URL" \ + DB_USERNAME "$DB_USERNAME" \ + DB_PASSWORD "$DB_PASSWORD" \ + FIELD_ENCRYPTION_KEY "$FIELD_KEY" \ + FILE_STORAGE_ROOT "$FILE_STORAGE_ROOT_VALUE" \ + FILE_STORAGE_TEMP "$FILE_STORAGE_TEMP_VALUE" \ + FILE_SCANNER_ENABLED "$FILE_SCANNER_ENABLED_VALUE" \ + FINANCE_BOOTSTRAP_ENABLED "$BOOTSTRAP_ENABLED" \ + FINANCE_BOOTSTRAP_PASSWORD "$ADMIN_PASSWORD" \ + FINANCE_SETUP_ENABLED "$SETUP_WIZARD" \ + FINANCE_SETUP_TOKEN_SHA256 "$SETUP_TOKEN_SHA256" \ + FINANCE_SETUP_ENV_FILE "$STATE_ROOT/setup/application.env" \ + FINANCE_SETUP_MARKER_FILE "$STATE_ROOT/setup/locked" \ + FINANCE_SETUP_RESTART_AFTER_COMPLETE true \ + APP_VERSION "$VERSION" \ + UPDATE_CURRENT_VERSION_FILE "$APP_ROOT/current/VERSION" \ + FINANCE_UPDATE_ENABLED true \ + UPDATE_RELEASE_BASE_URL "$([ -n "$RELEASE_API_URL" ] && printf '' || printf '%s/' "$BASE")" \ + UPDATE_RELEASE_API_URL "$RELEASE_API_URL" \ + UPDATE_RELEASE_TOKEN "$RELEASE_TOKEN" \ + UPDATE_REQUEST_FILE "$UPDATE_STATE_ROOT/inbox/request.json" \ + UPDATE_STATUS_FILE "$UPDATE_STATE_ROOT/status.json" +chmod 0600 "$CONFIG_ROOT/kaidi.env" + +write_env_file "$CONFIG_ROOT/update.env" \ + UPDATE_RELEASE_BASE_URL "$([ -n "$RELEASE_API_URL" ] && printf '' || printf '%s/' "$BASE")" \ + UPDATE_RELEASE_API_URL "$RELEASE_API_URL" \ + UPDATE_RELEASE_TOKEN "$RELEASE_TOKEN" \ + UPDATE_REQUEST_FILE "$UPDATE_STATE_ROOT/inbox/request.json" \ + UPDATE_STATUS_FILE "$UPDATE_STATE_ROOT/status.json" \ + UPDATE_PUBLIC_KEY "$CONFIG_ROOT/release-public.pem" \ + KAIDI_APP_ROOT "$APP_ROOT" \ + KAIDI_UPDATE_STATE_ROOT "$UPDATE_STATE_ROOT" \ + KAIDI_SERVICE_NAME kaidi-finance.service \ + KAIDI_HEALTH_URL "$HEALTH_URL" \ + KAIDI_DB_CONTAINER "${KAIDI_DB_CONTAINER:-kaidi-finance-mysql}" \ + KAIDI_DB_HOST "$(database_host)" \ + KAIDI_DB_PORT "$(database_port)" \ + KAIDI_DB_NAME "$(database_name)" \ + KAIDI_DB_USERNAME "$DB_USERNAME" \ + KAIDI_DB_PASSWORD "$DB_PASSWORD" +chmod 0600 "$CONFIG_ROOT/update.env" + +install -m 0644 "$RELEASE_DIR/ops/kaidi-finance.service" /etc/systemd/system/kaidi-finance.service +install -m 0644 "$RELEASE_DIR/ops/kaidi-update.service" /etc/systemd/system/kaidi-update.service +install -m 0644 "$RELEASE_DIR/ops/kaidi-update.path" /etc/systemd/system/kaidi-update.path +install -m 0644 "$RELEASE_DIR/ops/kaidi-finance.conf" /etc/nginx/conf.d/kaidi-finance.conf +rm -f /etc/nginx/sites-enabled/default +nginx -t +systemctl daemon-reload +systemctl enable kaidi-finance.service +systemctl restart kaidi-finance.service +wait_for_health +systemctl enable --now nginx +systemctl reload nginx +curl -fsS http://127.0.0.1/actuator/health | jq -e '.status == "UP"' >/dev/null \ + || die "Nginx proxy health check failed" + +if [ "$SETUP_WIZARD" != true ]; then + sed -i 's/^FINANCE_BOOTSTRAP_ENABLED="true"$/FINANCE_BOOTSTRAP_ENABLED="false"/' "$CONFIG_ROOT/kaidi.env" + sed -i 's/^FINANCE_BOOTSTRAP_PASSWORD=.*$/FINANCE_BOOTSTRAP_PASSWORD=""/' "$CONFIG_ROOT/kaidi.env" +fi +jq -n --arg version "$VERSION" --arg updatedAt "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + '{state:"CURRENT",message:"Initial release is running",targetVersion:$version,updatedAt:$updatedAt}' \ + > "$UPDATE_STATE_ROOT/status.json" +chmod 0644 "$UPDATE_STATE_ROOT/status.json" +systemctl enable --now kaidi-update.path + +if [ "$SETUP_WIZARD" = true ]; then + cat > /root/kaidi-first-login.txt < /root/kaidi-first-login.txt <&2; exit 1 ;; +esac +[ "$HEALTH_ATTEMPTS" -ge 1 ] || { printf '%s\n' "Update health-check attempts must be at least 1" >&2; exit 1; } + +mkdir -p "$STATE_ROOT/inbox" "$PROCESSING_DIR" "$FAILED_REQUEST_ROOT" "$TRANSACTION_ROOT" \ + "$STATE_ROOT/work" "$APP_ROOT/releases" "$LOG_ROOT" "$BACKUP_ROOT" "$CACHE_ROOT" +chmod 0700 "$PROCESSING_DIR" "$FAILED_REQUEST_ROOT" "$TRANSACTION_ROOT" "$BACKUP_ROOT" "$CACHE_ROOT" + +status() { + state=$1 + message=$2 + version=${3:-} + tmp="$STATUS_FILE.tmp.$$" + jq -n \ + --arg state "$state" \ + --arg message "$message" \ + --arg version "$version" \ + --arg updatedAt "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + '{state:$state,message:$message,updatedAt:$updatedAt} + + (if ($version | length) > 0 then {targetVersion:$version} else {} end)' > "$tmp" + chmod 0644 "$tmp" + mv -f "$tmp" "$STATUS_FILE" +} + +archive_processing_request() { + [ -f "$PROCESSING_FILE" ] || return 0 + archived="$FAILED_REQUEST_ROOT/request-$(date -u +%Y%m%dT%H%M%SZ)-$$.json" + mv "$PROCESSING_FILE" "$archived" + chmod 0600 "$archived" +} + +complete_request() { + rm -f "$PROCESSING_FILE" +} + +fail() { + TERMINAL_STATUS_WRITTEN=true + status FAILED "$1" "${TARGET_VERSION:-}" + archive_processing_request || true + printf '%s\n' "$1" >&2 + exit 1 +} + +# shellcheck disable=SC2329 # Invoked by the EXIT trap below. +cleanup() { + rc=$? + trap - EXIT HUP INT TERM + [ -z "$WORK_DIR" ] || rm -rf "$WORK_DIR" + [ -z "$CACHE_TEMP" ] || rm -rf "$CACHE_TEMP" + if [ "$rc" -ne 0 ] && [ "$TERMINAL_STATUS_WRITTEN" != true ]; then + status FAILED "Update process was interrupted; automatic recovery will run" "${TARGET_VERSION:-}" || true + fi + exit "$rc" +} +trap cleanup EXIT +trap 'exit 129' HUP +trap 'exit 130' INT +trap 'exit 143' TERM + +download() { + url=$1 + output=$2 + case "$url" in + https://*) + if [ -n "$RELEASE_TOKEN" ]; then + [ -s "$RELEASE_AUTH_HEADER_FILE" ] || return 2 + curl --fail --silent --show-error --proto '=https' --tlsv1.2 \ + --header "@$RELEASE_AUTH_HEADER_FILE" "$url" -o "$output" + else + curl --fail --silent --show-error --location --proto '=https' --proto-redir '=https' \ + --tlsv1.2 "$url" -o "$output" + fi + ;; + *) + [ "${KAIDI_ALLOW_INSECURE_UPDATE:-false}" = "true" ] || return 2 + if [ -n "$RELEASE_TOKEN" ]; then + [ -s "$RELEASE_AUTH_HEADER_FILE" ] || return 2 + curl --fail --silent --show-error --proto '=http,https' \ + --header "@$RELEASE_AUTH_HEADER_FILE" "$url" -o "$output" + else + curl --fail --silent --show-error --location --proto '=http,https' \ + --proto-redir '=http,https' "$url" -o "$output" + fi + ;; + esac +} + +release_asset_url() { + asset_name=$1 + if [ -n "$RELEASE_API_URL" ]; then + asset_url=$(jq -er --arg name "$asset_name" \ + '.assets[] | select(.name == $name) | .browser_download_url + | strings | select(startswith("https://") or startswith("http://"))' \ + "$WORK_DIR/release-api.json" | head -n 1) || return 1 + api_origin=$(printf '%s' "$RELEASE_API_URL" | sed -E 's#^(https?://[^/]+).*$#\1#') + case "$asset_url" in + "$api_origin"/*) printf '%s\n' "$asset_url" ;; + *) return 1 ;; + esac + else + printf '%s/%s\n' "${RELEASE_BASE_URL%/}" "$asset_name" + fi +} + +is_semver() { + [ "${#1}" -le 128 ] \ + && LC_ALL=C grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$' \ + </dev/null 2>&1; then + return 0 + fi + attempts=$((attempts + 1)) + sleep "$HEALTH_INTERVAL_SECONDS" + done + return 1 +} + +verify_public_surface() { + wait_for_health "$PUBLIC_HEALTH_URL" || return 1 + index_file=$(mktemp "$STATE_ROOT/work/public-index.XXXXXX") + if curl -fsS "$PUBLIC_INDEX_URL" -o "$index_file" \ + && grep -Eiq '/dev/null 2>&1 \ + && docker inspect "$KAIDI_DB_CONTAINER" >/dev/null 2>&1; then + if ! docker exec -e MYSQL_PWD="${KAIDI_DB_PASSWORD:-}" "$KAIDI_DB_CONTAINER" \ + mysqldump --single-transaction --routines --triggers \ + -u "${KAIDI_DB_USERNAME:-kaidi}" "${KAIDI_DB_NAME:-kaidi_finance}" > "$dump_file"; then + rm -f "$dump_file" "$backup_tmp" + fail "Database backup failed" + fi + else + command -v mysqldump >/dev/null 2>&1 || { + rm -f "$dump_file" "$backup_tmp" + fail "mysqldump is required before installing an update" + } + if ! MYSQL_PWD=${KAIDI_DB_PASSWORD:-} mysqldump --single-transaction --routines --triggers \ + -h "${KAIDI_DB_HOST:-127.0.0.1}" -P "${KAIDI_DB_PORT:-3306}" \ + -u "${KAIDI_DB_USERNAME:-kaidi}" "${KAIDI_DB_NAME:-kaidi_finance}" > "$dump_file"; then + rm -f "$dump_file" "$backup_tmp" + fail "Database backup failed" + fi + fi + + [ -s "$dump_file" ] || { + rm -f "$dump_file" "$backup_tmp" + fail "Database backup is empty" + } + if ! gzip -9 -c "$dump_file" > "$backup_tmp" || ! gzip -t "$backup_tmp"; then + rm -f "$dump_file" "$backup_tmp" + fail "Database backup compression failed" + fi + chmod 0600 "$backup_tmp" + mv -f "$backup_tmp" "$backup" + rm -f "$dump_file" + find "$BACKUP_ROOT" -type f -name 'mysql-*.sql.gz' -printf '%T@ %p\n' \ + | sort -nr | awk 'NR > 5 {sub(/^[^ ]+ /, ""); print}' | xargs -r rm -f +} + +atomic_install() { + install_source=$1 + install_destination=$2 + install_mode=$3 + install_temporary="${install_destination}.next.$$" + rm -f "$install_temporary" + install -m "$install_mode" "$install_source" "$install_temporary" || { + rm -f "$install_temporary" + return 1 + } + mv -f "$install_temporary" "$install_destination" +} + +backup_managed_file() { + backup_source=$1 + backup_name=$2 + if [ -f "$backup_source" ] && [ ! -L "$backup_source" ]; then + cp -p "$backup_source" "$OPS_BACKUP/$backup_name" + elif [ ! -e "$backup_source" ]; then + : > "$OPS_BACKUP/$backup_name.absent" + else + return 1 + fi +} + +restore_managed_file() { + restore_destination=$1 + restore_name=$2 + if [ -f "$OPS_BACKUP/$restore_name.absent" ]; then + rm -f "$restore_destination" + return + fi + [ -f "$OPS_BACKUP/$restore_name" ] || return 1 + restore_temporary="${restore_destination}.rollback.$$" + rm -f "$restore_temporary" + cp -p "$OPS_BACKUP/$restore_name" "$restore_temporary" || { + rm -f "$restore_temporary" + return 1 + } + mv -f "$restore_temporary" "$restore_destination" +} + +backup_operations() { + OPS_BACKUP="$ACTIVE_TRANSACTION/operations-backup" + mkdir -p "$OPS_BACKUP" + backup_managed_file "$UPDATER_PATH" update.sh \ + && backup_managed_file "$SYSTEMD_ROOT/kaidi-finance.service" kaidi-finance.service \ + && backup_managed_file "$SYSTEMD_ROOT/kaidi-update.service" kaidi-update.service \ + && backup_managed_file "$SYSTEMD_ROOT/kaidi-update.path" kaidi-update.path \ + && backup_managed_file "$NGINX_CONFIG" kaidi-finance.conf +} + +apply_operations() { + atomic_install "$RELEASE_DIR/ops/update.sh" "$UPDATER_PATH" 0755 \ + && atomic_install "$RELEASE_DIR/ops/kaidi-finance.service" \ + "$SYSTEMD_ROOT/kaidi-finance.service" 0644 \ + && atomic_install "$RELEASE_DIR/ops/kaidi-update.service" \ + "$SYSTEMD_ROOT/kaidi-update.service" 0644 \ + && atomic_install "$RELEASE_DIR/ops/kaidi-update.path" \ + "$SYSTEMD_ROOT/kaidi-update.path" 0644 \ + && atomic_install "$RELEASE_DIR/ops/kaidi-finance.conf" "$NGINX_CONFIG" 0644 \ + && systemctl daemon-reload \ + && nginx -t +} + +restore_operations() { + restore_failed=0 + restore_managed_file "$UPDATER_PATH" update.sh || restore_failed=1 + restore_managed_file "$SYSTEMD_ROOT/kaidi-finance.service" kaidi-finance.service || restore_failed=1 + restore_managed_file "$SYSTEMD_ROOT/kaidi-update.service" kaidi-update.service || restore_failed=1 + restore_managed_file "$SYSTEMD_ROOT/kaidi-update.path" kaidi-update.path || restore_failed=1 + restore_managed_file "$NGINX_CONFIG" kaidi-finance.conf || restore_failed=1 + systemctl daemon-reload || restore_failed=1 + if nginx -t; then + systemctl reload nginx || restore_failed=1 + else + restore_failed=1 + fi + [ "$restore_failed" -eq 0 ] +} + +write_transaction_value() { + name=$1 + value=$2 + tmp="$ACTIVE_TRANSACTION/$name.tmp.$$" + printf '%s\n' "$value" > "$tmp" + mv -f "$tmp" "$ACTIVE_TRANSACTION/$name" +} + +transaction_value() { + cat "$ACTIVE_TRANSACTION/$1" 2>/dev/null || true +} + +remove_failed_release() { + failed_release=$1 + case "$failed_release" in + "$APP_ROOT"/releases/*) + [ "$(readlink "$APP_ROOT/current" 2>/dev/null || true)" = "$failed_release" ] || rm -rf "$failed_release" + ;; + esac +} + +rollback_active_transaction() { + reason=$1 + previous_target=$(transaction_value previous-target) + failed_release=$(transaction_value release-dir) + OPS_BACKUP="$ACTIVE_TRANSACTION/operations-backup" + rollback_ok=true + + systemctl stop "$SERVICE_NAME" || true + if [ -n "$previous_target" ] && [ -d "$previous_target" ]; then + if ! ln -sfn "$previous_target" "$APP_ROOT/current.next" \ + || ! mv -Tf "$APP_ROOT/current.next" "$APP_ROOT/current"; then + rollback_ok=false + fi + else + rollback_ok=false + fi + restore_operations || rollback_ok=false + systemctl start "$SERVICE_NAME" || rollback_ok=false + if [ "$rollback_ok" = true ] && wait_for_health "$HEALTH_URL" && verify_public_surface; then + remove_failed_release "$failed_release" + rm -rf "$ACTIVE_TRANSACTION" + fail "$reason; previous release was restored and verified" + fi + TERMINAL_STATUS_WRITTEN=true + status FAILED "$reason; rollback is incomplete and will be retried" "${TARGET_VERSION:-}" + printf '%s\n' "$reason; rollback is incomplete and will be retried" >&2 + exit 1 +} + +recover_interrupted_transaction() { + [ -d "$ACTIVE_TRANSACTION" ] || return 0 + TARGET_VERSION=$(transaction_value target-version) + phase=$(transaction_value phase) + case "$phase" in + COMMITTED) + complete_request + rm -rf "$ACTIVE_TRANSACTION" + status SUCCEEDED "Release $TARGET_VERSION is running" "$TARGET_VERSION" + return 0 + ;; + PREPARED|'') + remove_failed_release "$(transaction_value release-dir)" + rm -rf "$ACTIVE_TRANSACTION" + return 0 + ;; + *) rollback_active_transaction "Interrupted update transaction detected" ;; + esac +} + +validate_release_operations() { + sh -n "$WORK_DIR/extracted/ops/update.sh" || return 1 + command -v systemd-analyze >/dev/null 2>&1 || return 1 + systemd-analyze verify \ + "$WORK_DIR/extracted/ops/kaidi-finance.service" \ + "$WORK_DIR/extracted/ops/kaidi-update.service" \ + "$WORK_DIR/extracted/ops/kaidi-update.path" >/dev/null || return 1 +} + +exec 9>"$LOCK_FILE" +flock -n 9 || exit 0 +recover_interrupted_transaction + +if [ -e "$PROCESSING_FILE" ] || [ -L "$PROCESSING_FILE" ]; then + if [ -L "$PROCESSING_FILE" ] || [ ! -f "$PROCESSING_FILE" ]; then + rm -f "$PROCESSING_FILE" 2>/dev/null || true + fail "Processing update request must be a regular file" + fi +else + if [ ! -e "$REQUEST_FILE" ] && [ ! -L "$REQUEST_FILE" ]; then + exit 0 + fi + if [ -L "$REQUEST_FILE" ] || [ ! -f "$REQUEST_FILE" ]; then + rm -f "$REQUEST_FILE" 2>/dev/null || true + fail "Update request must be a regular file" + fi + mv "$REQUEST_FILE" "$PROCESSING_FILE" || exit 1 + if [ -L "$PROCESSING_FILE" ] || [ ! -f "$PROCESSING_FILE" ]; then + rm -f "$PROCESSING_FILE" 2>/dev/null || true + fail "Claimed update request must be a regular file" + fi +fi +chmod 0600 "$PROCESSING_FILE" + +if ! REQUESTED_VERSION=$(jq -er '.version | strings | select(length > 0 and length <= 128)' \ + "$PROCESSING_FILE"); then + fail "Update request version is invalid" +fi +is_semver "$REQUESTED_VERSION" || fail "Update request version is invalid" +TARGET_VERSION=$REQUESTED_VERSION +REQUEST_ACTION=$(jq -er '(.action // "INSTALL") | strings | ascii_upcase + | select(. == "DOWNLOAD" or . == "INSTALL")' "$PROCESSING_FILE") \ + || fail "Update request action is invalid" +[ -z "$RELEASE_TOKEN" ] || { [ "${#RELEASE_TOKEN}" -le 512 ] \ + && ! printf '%s' "$RELEASE_TOKEN" | grep -q '[[:cntrl:]]'; } \ + || fail "UPDATE_RELEASE_TOKEN is invalid" +[ -s "$PUBLIC_KEY" ] || fail "Release verification public key is missing" + +status VERIFYING "Validating signed release $TARGET_VERSION" "$TARGET_VERSION" +WORK_DIR=$(mktemp -d "$STATE_ROOT/work/update.XXXXXX") +if [ -n "$RELEASE_TOKEN" ]; then + RELEASE_AUTH_HEADER_FILE="$WORK_DIR/release-auth-header" + printf 'Authorization: token %s\n' "$RELEASE_TOKEN" > "$RELEASE_AUTH_HEADER_FILE" + chmod 0600 "$RELEASE_AUTH_HEADER_FILE" +fi +if [ "$REQUEST_ACTION" = DOWNLOAD ]; then + [ -n "$RELEASE_BASE_URL" ] || [ -n "$RELEASE_API_URL" ] \ + || fail "UPDATE_RELEASE_BASE_URL or UPDATE_RELEASE_API_URL is not configured" + if [ -n "$RELEASE_API_URL" ]; then + download "$RELEASE_API_URL" "$WORK_DIR/release-api.json" \ + || fail "Gitea latest Release lookup failed" + fi + MANIFEST_URL=$(release_asset_url release-manifest.json) \ + || fail "Release manifest asset is missing" + SIGNATURE_URL=$(release_asset_url release-manifest.sig) \ + || fail "Release manifest signature asset is missing" + download "$MANIFEST_URL" "$WORK_DIR/release-manifest.json" \ + || fail "Release manifest download failed" + download "$SIGNATURE_URL" "$WORK_DIR/release-manifest.sig" \ + || fail "Release manifest signature download failed" +else + CACHE_DIR="$CACHE_ROOT/$TARGET_VERSION" + [ -d "$CACHE_DIR" ] && [ ! -L "$CACHE_DIR" ] \ + || fail "Verified release cache is missing" + [ -f "$CACHE_DIR/release-manifest.json" ] && [ ! -L "$CACHE_DIR/release-manifest.json" ] \ + || fail "Cached release manifest is missing" + [ -f "$CACHE_DIR/release-manifest.sig" ] && [ ! -L "$CACHE_DIR/release-manifest.sig" ] \ + || fail "Cached release signature is missing" + [ -f "$CACHE_DIR/release.tar.gz" ] && [ ! -L "$CACHE_DIR/release.tar.gz" ] \ + || fail "Cached release artifact is missing" + cp "$CACHE_DIR/release-manifest.json" "$WORK_DIR/release-manifest.json" + cp "$CACHE_DIR/release-manifest.sig" "$WORK_DIR/release-manifest.sig" + cp "$CACHE_DIR/release.tar.gz" "$WORK_DIR/release.tar.gz" +fi +openssl dgst -sha256 -verify "$PUBLIC_KEY" -signature "$WORK_DIR/release-manifest.sig" \ + "$WORK_DIR/release-manifest.json" >/dev/null 2>&1 || fail "Release manifest signature verification failed" + +TARGET_VERSION=$(jq -er '.version | strings | select(length > 0 and length <= 128)' \ + "$WORK_DIR/release-manifest.json") || fail "Release manifest version is invalid" +is_semver "$TARGET_VERSION" || fail "Release manifest version is invalid" +[ "$TARGET_VERSION" = "$REQUESTED_VERSION" ] || fail "Requested version is no longer the latest signed release" +ARTIFACT=$(jq -er '.artifact | strings | select(test("^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}\\.tar\\.gz$"))' \ + "$WORK_DIR/release-manifest.json") || fail "Release artifact name is invalid" +EXPECTED_SHA=$(jq -er '.sha256 | strings | ascii_downcase | select(test("^[0-9a-f]{64}$"))' \ + "$WORK_DIR/release-manifest.json") || fail "Release SHA-256 is invalid" + +CURRENT_VERSION=$(cat "$APP_ROOT/current/VERSION" 2>/dev/null || true) +if [ "$CURRENT_VERSION" = "$TARGET_VERSION" ]; then + status CURRENT "Version $TARGET_VERSION is already installed" "$TARGET_VERSION" + TERMINAL_STATUS_WRITTEN=true + complete_request + exit 0 +fi + +if [ "$REQUEST_ACTION" = DOWNLOAD ]; then + status DOWNLOADING "Downloading signed release $TARGET_VERSION" "$TARGET_VERSION" + ARTIFACT_URL=$(release_asset_url "$ARTIFACT") || fail "Release artifact asset is missing" + download "$ARTIFACT_URL" "$WORK_DIR/release.tar.gz" || fail "Release artifact download failed" +else + status VERIFYING "Revalidating cached release $TARGET_VERSION" "$TARGET_VERSION" +fi +ACTUAL_SHA=$(sha256sum "$WORK_DIR/release.tar.gz" | awk '{print $1}') +[ "$ACTUAL_SHA" = "$EXPECTED_SHA" ] || fail "Release artifact SHA-256 verification failed" + +tar -tzf "$WORK_DIR/release.tar.gz" > "$WORK_DIR/archive.list" || fail "Release archive could not be listed" +if grep -Eq '(^/|(^|/)\.\.(/|$))' "$WORK_DIR/archive.list"; then + fail "Release archive contains an unsafe path" +fi +mkdir "$WORK_DIR/extracted" +tar -xzf "$WORK_DIR/release.tar.gz" -C "$WORK_DIR/extracted" || fail "Release archive could not be extracted" +if find "$WORK_DIR/extracted" -type l -print -quit | grep -q .; then + fail "Release archive must not contain symbolic links" +fi +[ -s "$WORK_DIR/extracted/app.jar" ] || fail "Release app.jar is missing" +[ -s "$WORK_DIR/extracted/public/index.html" ] || fail "Release frontend is missing" +[ "$(cat "$WORK_DIR/extracted/VERSION" 2>/dev/null)" = "$TARGET_VERSION" ] || fail "Release version file mismatch" +[ -x "$WORK_DIR/extracted/ops/update.sh" ] || fail "Release updater is missing" +[ -s "$WORK_DIR/extracted/ops/kaidi-finance.service" ] || fail "Release application unit is missing" +[ -s "$WORK_DIR/extracted/ops/kaidi-update.service" ] || fail "Release updater unit is missing" +[ -s "$WORK_DIR/extracted/ops/kaidi-update.path" ] || fail "Release updater path unit is missing" +[ -s "$WORK_DIR/extracted/ops/kaidi-finance.conf" ] || fail "Release Nginx configuration is missing" +validate_release_operations || fail "Release operations validation failed" + +if [ "$REQUEST_ACTION" = DOWNLOAD ]; then + CACHE_DIR="$CACHE_ROOT/$TARGET_VERSION" + CACHE_TEMP=$(mktemp -d "$CACHE_ROOT/.release.XXXXXX") + cp "$WORK_DIR/release-manifest.json" "$CACHE_TEMP/release-manifest.json" + cp "$WORK_DIR/release-manifest.sig" "$CACHE_TEMP/release-manifest.sig" + cp "$WORK_DIR/release.tar.gz" "$CACHE_TEMP/release.tar.gz" + chmod 0600 "$CACHE_TEMP/release-manifest.json" "$CACHE_TEMP/release-manifest.sig" \ + "$CACHE_TEMP/release.tar.gz" + if [ -e "$CACHE_DIR" ] || [ -L "$CACHE_DIR" ]; then + [ -d "$CACHE_DIR" ] && [ ! -L "$CACHE_DIR" ] || fail "Verified release cache path is unsafe" + rm -rf "$CACHE_DIR" + fi + mv "$CACHE_TEMP" "$CACHE_DIR" || fail "Verified release cache could not be activated" + CACHE_TEMP= + status READY "Release $TARGET_VERSION is downloaded and verified; confirm installation" "$TARGET_VERSION" + TERMINAL_STATUS_WRITTEN=true + complete_request + exit 0 +fi + +RELEASE_DIR="$APP_ROOT/releases/$TARGET_VERSION" +if [ -e "$RELEASE_DIR" ]; then + [ "$(readlink "$APP_ROOT/current" 2>/dev/null || true)" != "$RELEASE_DIR" ] \ + || fail "Release directory is already active" + rm -rf "$RELEASE_DIR" || fail "Failed release staging directory could not be cleaned" +fi +mv "$WORK_DIR/extracted" "$RELEASE_DIR" || fail "Release directory could not be activated" +chown -R root:root "$RELEASE_DIR" || fail "Release ownership could not be secured" +chmod -R go-w "$RELEASE_DIR" || fail "Release permissions could not be secured" + +PREVIOUS_TARGET=$(readlink "$APP_ROOT/current" 2>/dev/null || true) +mkdir "$ACTIVE_TRANSACTION" +write_transaction_value target-version "$TARGET_VERSION" +write_transaction_value previous-target "$PREVIOUS_TARGET" +write_transaction_value release-dir "$RELEASE_DIR" +write_transaction_value phase PREPARED + +status BACKING_UP "Backing up database before installing $TARGET_VERSION" "$TARGET_VERSION" +backup_database +if ! backup_operations; then + remove_failed_release "$RELEASE_DIR" + rm -rf "$ACTIVE_TRANSACTION" + fail "Existing operations files could not be backed up" +fi +status INSTALLING "Installing release $TARGET_VERSION" "$TARGET_VERSION" +write_transaction_value phase OPS_APPLYING +apply_operations || rollback_active_transaction "Release operations files could not be activated" +write_transaction_value phase OPS_APPLIED + +write_transaction_value phase APP_SWITCHING +systemctl stop "$SERVICE_NAME" || rollback_active_transaction "Application service could not be stopped" +if ! ln -sfn "$RELEASE_DIR" "$APP_ROOT/current.next" \ + || ! mv -Tf "$APP_ROOT/current.next" "$APP_ROOT/current"; then + rollback_active_transaction "Release application link could not be activated" +fi +write_transaction_value phase APP_SWITCHED + +status RUNNING "Starting and verifying release $TARGET_VERSION" "$TARGET_VERSION" +write_transaction_value phase HEALTH_CHECKING +if systemctl start "$SERVICE_NAME" \ + && wait_for_health "$HEALTH_URL" \ + && systemctl reload nginx \ + && systemctl is-active --quiet kaidi-update.path \ + && verify_public_surface; then + write_transaction_value phase COMMITTED + status SUCCEEDED "Release $TARGET_VERSION is running" "$TARGET_VERSION" + TERMINAL_STATUS_WRITTEN=true + complete_request + rm -rf "$ACTIVE_TRANSACTION" + exit 0 +fi + +rollback_active_transaction "Release health or public-surface verification failed" diff --git a/docs/evidence/r1/archive-borrow-approved-1366x768.jpg b/docs/evidence/r1/archive-borrow-approved-1366x768.jpg new file mode 100644 index 0000000000000000000000000000000000000000..0fd51bf785e987d497e0b7d7f6fc16d3652a9f54 GIT binary patch literal 65141 zcmeFY2Ut^EmoOYJii)Bl1T=tpX+mgH0)%2E^kPUtC<$CC0)ZeTfKrqzSCAS63`(RZ zfsjIilmG!iMd<=kLPtb;6{LvR{@gqB&il@MGxI$EGygZ={Ll04^PIES-g}+3_FjAK zb#__j=itxxfFo9BmS%umy8wV)f(!8T!!OE~moB+qcCa_Iw1NFq@XIlPAnZN^0N?{d z2oB~KPdPceoZ9>TpWgVh?20Eb=+F2+2m-oW{eMyi0D9H`1DyXXd%u@A(Nlo1D!4-k z0_K9sdI?|&pMQdH{0V#f6P)}fd_5#6M1b@7PndvoxFmop1hBHtKfoUU0DA@z{=^>@ z;27frLjUCTXZW+m2fYJP2tn*5xMcw5fNO%w2M_@82515h0DpiV;EDh`1Aqab00V%I zAif~DAc9*9pe@LO0`vr_Gk{BgivWGWQy*X^$T1Ld*0t=9CU?n;eew`ZN65(!YX`ptgH|*|kqlia7$oR7D6e zq+<1w>8lTG0_xHNroRZ7|BbOA79{`VE+{k*6O6Q7dv@>tWzX(^#`%-A*wJorEqLi3 ziC-@`TnSEl0$kPBx&3lNPM44_EV(9M_H!6;P)I;lOh^oH4)BBVH`>By@B1m1n5nnA zhBGWYh}>iGiL)kS@dy@^$oMUWhEtvL?MML8Rsed69>{ck#R zsV?O|0d|G$<_-0USdBB?mz;GS-b{H)`jS)7nhITyHZpQk>k!8}vcJkpC8;cR99jkV zasI~oe|CK1!S@2cA~v(z#l_6fvM}_L)9WAFKUDW-ynNzv!KgdUmET6^+qAug^_wD1 zQP+W3uZejR>O`Gl7};0a?`g(arlS@L6Q9V56^kO9hkt8Jnm?6p`G~-+DdghO{XLf` zcXHT~TP4@;4du>yq8Qm4&=X~ok(`EkA6qf$qMv|1p5qoA%SN2V5!`CeA~Wmz=BgI_P7Ry2Ix(|>C;EstwkE=fmwL_~Qs+E= zchddB!E06gi_>gaWKe3#0-Lyg$F$vGQiVOQw2i2C=$lJE^9p6H#xPNG21fKTafp_I zRn){?uu|1pIoU8Lf>AYuIM#g9v9dw?YSwZzESs(0AWZ5TPwxVvtoqr=`<4`y9!M5t z_>CoylE|QcJoj)Rb;c^Q@{fr_(})Nv8Q&-64EC2#2S%tJzS#;Ne)x$p(+UdHs6}+% z?HZfiz$$)7=WGUZFP6B}2*VUw*bQ z4zi7kZ4GXXl)C*g>bO%;+?OK0dS&-pH!z*SRAGEJ1H%j0Ij$mFXYv$_&6N-uu-4f% z-;GU|3y>%HVdMhMq0*%$Lc|K}OKqSvyJeNBU4wlpuORT?;*?;a9ft{;Bv@2B@jE0^ z7MGF8TozSBFZ$+~Y1yJRYsse$Eg zCCoyvmRd(SDki<^z@0EqcJ-4^-wsiQ>x-~PRn5askQK;FW?%9@Vbw!A{4rB0+D$yv zpbdBt?2{=p`Z0Q6gqBzJ*|=Fk2m~KtUT)=_%bd2MB2Y(!=(Gc>Oy7G4PxKj`88bQ;TK zve8g+l~)PfzJIWyyHg-fkt5xe!&j}#NsS0DBX%J%wFnJOAr`jz%kx>qqz|mwLv0hQ zPx2kVs-X>QE3_`@NLqMAEOe+Ig$v*IZv;24Eb{<2^m(1o1=Kh|HSL``E- zhFelP597CGXteydiJ%DAWHN3ppXo$POU=wuN-0wabj#^bm&ZXnB+Cg;H=9<(Ql z$vou4h)IXlJ(Z_+x-ZAss#TaDn0CgYk_Y;c1rOqi+PgAl*0*G4;=2Q`9rWS?b(f3o`W*z% zKV_Q|WfD_U*nHbCu$ z_2R4cX=t_W;EO)wl}CD|bn`e38bF!&XW2PAFxYmBxQ&G8a~DrqRZGO8kA8H*MQp3^*kt*2O%$ar?_+ z)Ku10DzV);ujjm8pp;7lDE>Dx_b|ngx9oN1ER2uK2XmMj20RlVe&ytzm#4>i9BZVO zrUFV0^&!Lb6+2TmJJ|QrTC^YqK3SU&USiC9jvap*JK{GL6<=&_Dg8i|b#nL4s#~|R zTp_z8F6IeWZh|X7j*mlr4(v zh1_7S+L?cRC9Q~+BKc0HE!|#kl`Cb|Flvwx;H@esW0QG0`%prSb-=CEKDR8j_6znU z73de+@Aw`pOk%Sf6bJ;Kp<5R`SPUb%%DP{tTljS^rx>q9W>=fy!Uw*VJ-fFy27Vkf zkw4%X(S1n~rbdSFA^zehXW^lh*p%pV>Vsk8+wn`^3c74p3vol0Dg}j+YPuC9OW8{o z%*xKc#_*21B#0eLikmq0Ccnsda@UB<2B$HMW2K7kPp7e+$`rJoYdF%htCv2+8j-T+ zHh<|H3KUJZP#kQPQ6r8H$a68-;%|6eS0&cRgkwJXRS#}DM^)P1=(D<0d&%CZ^~7MN zxt5ZpLx`=T5!zijX~yP#`!8aSPGTsOrlJz$g_ zMg2``Bib}o;Drhe)O@0N+5_J?E~d!IA|O+uAT2fnSb zy1_bbE30V3f3)&ZCWOIK%_?!=1j-Z>QExm6tjuX~)QP=ZSV5TIGUS1wYoRP^kF>19 zdaqT6p00mrPL9o*=_JWp(l6&p&x_{D#Y68LFGg&7BTBGv*a&oFP1Wb<}Sojy4{I?sLLQ zQ;F<8a4PxO&>6~8x%?{UY&em;zH906@d#U3P)S+W9?JX5#W? zzC65a#4bzxXtrRTqXt2CB2gqqYKBVdt>>0qKMqxxe-TctsXv`R_am#U)(J)~6xTVn zI+?mMJ2JXh!SSbEe}4Z_WKHFh^2%wK4DJe9!E&rPHB79tlSppxj@mw5{M`MTR#{@@ zd@v{Fqpj0bdQ|)@!A|7ac3$83A8fH77pN;G3AmvrF8Tq*Bu-3+xu}hQrbTiaHxgm| zpa;+I{fuApF=$$D%b)P8!G)aGw|xRSXYbfn`Br{W+G%Hso^D=`BDb(Hb!hOI8;Ds` zZ94H{e%DkE`dnbQH2(SIT}1u!Yhe?J%R8-D4KW}nC8Ac%Y*7OeoP6)n<1H6gh0I!i z@bap)oV8j-D524~WcooHX&~$-xX2Fh(pIdz%Ivr9?f1E=m}7k;5X0jZ97QODv}~eK zC^uu`>&Ddg<))JqXKEo&&pEgv@)|IHcH&GHwnRqug;4HuU+=h|0Ms@5v&(u+{|Jw) zuCyG zLc1_WqTAIjv8Q8C=6*RZCO@LxI0G8$l7jO$K?U{a%biv*)&Cc@I|74QAJvkh#l|-m zr|>+YxO#@~8Eu#+$Kr$=e@{5f+6pk)M z|6$&gm^|HHo*t3PToAF^Y8qZ}!nsOwDML=FrWj8H_lpd#T~?&t!2F!6)F*{SWS3%e zqqq|@kzV~i_s%*0Wyh~c4;N$iC?Xy{#C08TW80e{ZU^nMO7F%~9oe$=q#T#!kfDqH zR|cDIoHhqv$YsA zc&|2l)21kas~JcwG?Rn24zfBdU;Ink{~G|H#@4WEV`ct$NMRx;@d+jUm1-zuV?(ew ztTuRfOH>wk0G~G>cfY2oKcMICjf8qLv7;tTv-AaAzLMC`Y;)r5+w(wm$zdZ1Cvz;I z$QuGw^OMlh$p9tXvEL~Hp4F&i8gZ7$Yc-CDH$y`wYSR+8zLAOWpd}5U7$KgX)N$a& zK~UY%ioOxY@iGcX%4U9iw|Kpt38tnKm#^81nq;-Vb*Xm3f0n)vj{35N3Q&|)SWkeyf@FPjx>lHZ}#m6y&I-%gA9BX}7 zta?veeme79Bhu54F~u81A}4naJU;x|6TT7wp?|&tY{^E;BrEG$bxHK4*JSmo%V%#N z*X2Y7*L4y3bB$PR6fdgSTYP`6@Tw5VR=SF9Vsk_ttAGNkqQDL)OJO(0-78L0j`{1z zKy98Ij(PsO=u^W)k*g!rgi~lkmHbF*4(YlSR1rtrB;Q~zdPjXR1ng}zwmc~IniScc zVkWTi#0UG6nI%AX%dLOu`;*eXHN_uKjllRm;fJ5@Z5q~K8MWwjhdRHzw6trjGKtyw zNwfm%c>Ug2KomZIq5Rn&cqvrQ)Y%vRlV8+%QTq=yF97_XOp#KjTDP9__P^NvOUA-~ z3HavkXaAD%g}UcBcR_3+2QXXT^+(2FVn zjpe`h>HkgrO}qcEB>w*+iQfwV|7I5XFShM3K&L~<7_b+(XZXY7g4r<7jn7Kgz1=G9 z>9}aqU!~mP^<%%mboeJB?p{q?U7zjsdUX%PE0}4MT%8Zf69w5M16{JU6BAjjuCvhD zdG%#}sCLhD{K4uFe^G_>SK^3$^$z*a!A_LZ0CAll)N`Q)d#y9WTxF_iRN8n1Ny?|? z>e0GTq_j46WZJQZK5Gxsw;tN|81l~?t%la?dns3zfzSVN|=Q$$fH)qhVTZY-F_O$pC!G@!T2rF3= z*xHM*m$fxUG%WiT;!Ie{-azf&E%vF7!+{DV`LP*&5beS2@21f>p98T?!SmW|^wcv) zNao;)iq&Z@;Z0&=oK5uZgxLrUof!eo%c3s;lDM|MsvCn zz32Lnb|%?C+e8iifZ2^#?~JNg%pR3&#dm*nR3uqxkX_&ieD^dtqvzt_u_RD2q0ed~ zu=c@$?dstHPVlER1nEpINSsjb$ba_;E2=sXH>qnlGLk*_w27(3D7U>j9nQ>ObCWUX zWaWf(=Uz3Kc=y^B0r9@;arjh0Z&D*lDXqt$G#yn|<5*}Ksz&rXWh0+GMx$cy`17-G zOte*-t&LZ?Ut8)PNfDm|=Ut}`HSg|nDu_x+o9j0;EA8YHAfegWWO3K7J2^K``k8-t z*j)ZRHM$6=`%Z;Le7kF+0t7E4fBxKPS>VV(0V|R{A($aAR2vJFKsIfbnnd?@$#PdV zZxp=+8S*A6lsRtjC7nu5EHYp_lAmIWH-{uXgHo%BI)2^#nV|@qH(?X&I}-0ym`XvT zA9u~_8v41yg9>P_WQHtU)x*PZ$oWKBNtVo#oMu0=*W^q!?H-1qb}c7ugr1!aeJVfv zC6`WVe^4cQRg0Z_h_H$|Mw{@5Oyzd|_HFkJ|}xrCgxW+0f_uzv&EbKb6|QwXaibfLc%(eU-fqB3UhqYCq4K> zVqov`^&3_9J?xo}OjA&HYXH0hIqs~mE#{*CLbnJE*h+h92%z1&jK+!hSo9=h1X ztXmvu-j~CC?z|_-B9B1xEUfHr)T?QhhbP1j7?V*^EsS(-Lwd|}%cV`}ebabT?K;d% zaD^)>qG1POCQi!*-pV)?z@L|uL7R2M;8rZq zPr$o^bT7{Q&b z{IH|Bkv=bL+nGLEY6$Uk%#?!eyfIfLWB8~KOy9ZeG?ZK5A@6|eyJ?9AlXU-Mji$Fy z9avLSH+9%#`^Ihl@Bg4fCsT+Rj+tQVznUQT3FC8M2Y9$Qw{{ShhuNuBj2M|_yaIpA z@rn%h=Nk9O1;I9Nzs=95!QqGp03_Fy!A5HJz|9%rH`Xw`wr8W$>&!2Gm4GhYgR6T% zl`fx0l^8_{eaq=j!U7|Q=jA5B%fYR1NAwk5rxek3*!$C+VfAzjgT4n)SEJ?g>aY-LhzEn}ODJ z5WryCSU6bjlbd-yKxSd`F36l2b%0SWaE;GmuRwKuEy z8p(Y<1$-&!=$7W>Q9WMA0FD)PALqG`wk>q0W{C1NYMI)NetDTOWijN@jyB#lIvoZ8 zu1I`jQPcM^6=}rPBsHf2oDT^Y4yGZ_222&gJg8OtS0+4`MM|o-0z4{#lFjW7=Sh*O z&W58P=K=rDC$jZ98l#eKpdR*Mpi5gb^NXvDR?+VEQC8kL1srq2&g$iF&wP>TeJ|}r zXGHGXK0_{)w%f!r=`HXTWC*_fDIe)}_LljRKRi0ubm6+DX5!Dgegcf2qEWei9ZC19 z=e}KMpJCkWOh=F8;cv&+n(M5kWTN=(C@E!<{kiNn-LgWD3mDIAA&RzY^X?k6syMIy z0IMb{Cf6aqL@F4GGTC=DX}TDpi!dfG-UQ}PqC%<=&<8^dKMhf4CTCtvq|ah-180_u_3kG=>VW>bw*f+$CX_rg zj3HT$Kxq5q9rMHOt=OSBpSaK)YbK|T(Y|6Kjlp6=9EHKxy}BRPMUOni`fhZ_ahM|} z5FM2w&w{9sWE-CIoo^SF!Zq8zpCF{1h#z0jjUc~mh6l0=s?2TrVeSf=OoGYDyHu1I zy)T4qvxd5LL+;nb$JrYxG-$qj?c~XGdWLldSCZ~-Lm%@TMm2gy@zIivWwgQ=g$!&> znnHYh5*cOc|B%w&LAVY8RrZ(TF#}q6-yQI!DqJ~em;$1*ZesXdnGw94Or#HLV=gIE z!75K4op;EejF}aFb1)>Rsa3(*tv?^S5T7{R3^AQ(etR>fAV#DsC>l*zi53g zS-GtG4_54dD=1hrKo>fWaQW!Ieki}&{_<2*tw^h;BUC`mu8d^P75f{gsrt?HX27ejK)=Rq|L9{anDE^x(F$qhur z#;)xY9JJFcUzNu^eMSIcQcMZ!m|(=l|I#iU?#TM1MO~YFiUZd=uNC`O&Fc4?0v=$W zL+eX92-d8=%47Is{U4q17kF~jndSLTo0vYe z#eyxR$t2T)tRQ-|oAMi#J{e)s$cogCYYL_YcD;zqu3Mz{nQo6Z`aj88)0C3a zlw|tEl9jn0MQ>O7u>b5FDo7@x+W0F~RSnhO*30kX`f*YfU@RRwTTr^y4r+yBU^zS#I*K zx%e4Yb{|3YQHNN#Fth{*C7DCQ!5VOTmFE}W)&-~pbyMVKnLiS*&Fc#(3Gjbl70)qy zot<_j=@09R^b=bHY7!Oe@JjvOrS47-f2y1#mopH7V?u|Jb|Q&;^Z#gC#P_!5-`^yR z$dp#5Q#AvNw}UX#m}Z||yp0-U0M6bu6ctGjE~5MeRk@I_{SNFp>WzON5QYzXhRd$`MP7Ei4) zZQ&eISmh3n=z|w{(FNJrxv_1>&&~DshtFg_xo3oOwi`BZ&}+yRlcDqa!&olwjJ57J z0}W3Ji^3>n<}U7g0(8r|1u9s2p2F!jD@y0P?VrlCsX*5(UO=IVR`K)Mp9A`Z%fU0M z;YIy{0BCI|$v&vw(jAD@g7mGuq;F3Vra5UtpS*m!)@ReyGaF4r&}I2tx3>E@eNRqf z>OLN_Kavb2c1XRs_PfE?raK5!cVLKGn!Y4p$TC>3fEUX)I{E-q48tZA9li`Y)tULxS|%m1-j%~9 zertK8f6le%A#QC|d~E)`MZe=4ew3dE1S+?+r3^aNR#1=V3lR6wkp<_=sS>i>tw4`= zNgx{4(wk*ch##T>g*0Y|jqjYFcZx?kx;WDI5u{seF?j-^j?s|# z!s?lOk|u5MU+t*XUE^54_dKO#)PJHRjnj^Tb8OuXyg2j_$mXVvs`qi9%BO=KX6ZmO z4lt)+HakIu_x?}V4an~TGXZ*JeJyXFRAK~k0m#gEO4g7Joq-pD^#l>et;Ml z^}>CRH5WHBmv{Nz*$@w!OEW}D_8IEP^!&zzH;3`Z2j1F=eEp-%d7w4=yyW@6_t`nn3=fk+0CR(;U{lqdQXAl1JHLRz-asHU2yigN zsTBYadQjWDz_;5(rX;B91vttTl!R%s^2n*ZckH#U#!1dh>Xk1z&CrVPFEY4o-oZ?I zR@WMD$M(kDJe3}@6ryUibEg=GNCd{(jV3g%SS4+&arS`OG0HfUx%kYj%NqVm=n|0HWAsv@K-lE>?YprY;e zd(h$(an0hOnNoQi^#&D@?(_5z!n}~o4d%4j3YL+dV^c8nZ0$xEtRZ|gX?EG@s01xm z-pfxMa;&BUSEZbkJ#7M25;|nz&FImywUnM8v7>Osq09tr&p*VBw`bWi>(amPdNn`Y z*#C&V=Vn#{HriA6aAPpX9VI0AHY6`BFy+%0Urp<8H|B`bs<0Jjac1Zm#-?4`zc> zva&c@=Q*T)QR^toXAd7uM85w$Tx4Vg`Fu|T3SU&Y!k4V^or>zQO|= zRY~^T4>86^?y#7%eWu`or_cHoq_D(C zH?K)u6HSwnb)1HR#`j5XFK1Fj4yd^r<#@_HzWNa^Ym_4mL>=p|HP-$NUXas(^28JR zQFDRGj>vWdnwsgnph&2<^nQ}i8To+1WD!{Nn%W%!Bt??+O*I>rn*(C6`#mH&v4UkmfmPijDh`wp zOM6SZL}uCrkXavUrrPef$~m(Dg2H|XEe;uDa^F%-^cIzvtppFs66(_RFpO{&CbbOv z`gT9~Ld|g0GmRe{y8Id~k72{ixH_@OGaJ``%%gCbj9+P46`u;Y?KkLj*L0}DUA2T| z(^u2{3~1AtqB58V7iI}mbKG%sXq2aUsi%mgL~^-g>2^|i9nPA-y^MK3II29q;K_Gf zXa^bXd)93PD0-5&a5L#A;OQS<^&Xvm4KF*EC1K+)?+Iu7Blh*#StJbJ6qxz{_OPoM z03d{=Ze04Oeo@Q);eJ(TOA^!a$!$X43o(yH+{{?!Qnzr z5vvOlm><>IwpUriSFM7rI@UsBJe|(C?|x>w<B#!RfFM<+Qq0e!&H`JwNSy;+@N~y>5KA*iD~{00yoK z;X?N;$WL`pTKm8wlEZoJv#51O06^HX*z5QIc5VC582+h=yBtaNxn=ofpzExHf;2fY z1O>sc%{_Zxw(NNf=hTkS!SnMhf4}(!u#}j&LOefWM`vMYTk3bfEt3vUR2Kol=s*R< z@0~k$Vy2Wi%LDbzFsn_^ofle@;S(t&7%@Cw^Heir*<7n-rbH3erhM}8zp2qbi$z%0 z?SbzY(58Y<2TdHm>N)j&GE@~ zYA)+ew8X^bmiMK;U3V=DhxJ_%K*))ELknQVu;O5ttN6RkB~x>6WaGA7uVsamw65f^ z!Q#q*Rlh@TPu;?pSJ!=>ol`sFEZ$3~a&ON3;RbJrdaaFPucaD)SZ}b$J4@`>$amEO z|Lf`CyFM`lBiUos^&hEWkM0)-5F8y~muK`dP)p|E)6{s-ZxbN{wt2txd>5}kRHj=5 z_O=79r=rOskmIp7nuqT{`?~+u(T(n5ReU~az@|Or)3nZSkE#7XfGC6pCK@OfzQw%x zTiD~NdeM-V6i@0%-@QV`PKr5V0AA7-bCm-;^DO9DFTX?Lu1&>SjaCRLA%tkbN!)h8 zb8AxZAa4`|MaV^GWQTzTlg8K24*^fk-);N}m`nLe7z?WvZM{AgFzJeLl|Eo@+_Nkoo`m)iPpu7W=Yf3T0>Y?`>b++kUk9uqe*!9mNzsK#Hf28n ztD*x~RlTElz2R|rgiL9(f$rJEKR|ep{VfI;$-Dqp_k2>N5L_3wrWEQnjeLcILzPcd z9Fm@pw#DgsfFhEx3Q`vD8G?zemNnbN*dA$0Fv}+JCUX0h+sq4x{iDhU%8cypI%4PC zrB9IY3=4#FdkW0|(S6K>!IuHR^~dW6q#E!&EL*&xQRLelmhONAx!W4zaPE&|v)~0P z;@oMOZ?i?e{~JC2?@9hSWP>I2Zv+g8o~B&2R7)R|k2&UHI*@68=>#YXX~+NYEX{6o zy_UQO-6S;}!4hrbP-vBgH4%wWs^`O*(s&Y%l?I2qJUgF0Z|T!>8im!gV{_Z_eR1_! z3ylI7ZQPp)+54Z$68Rqz>DNQZXZjr)5!UkFF^<&1Ee^cp+5HK1+tB)Pv_*|#c(J!9 zc~g^>x!>K0ET%n;-hmP>m6x@Yxt>BjzR3eoQfLtnNxh=ore;imE8&@VVW|c=Exc~K zs(czd>mxrcev4VTACm=p{|qjbdZiPK+8CBcm|rk@9FoyTTb4eS9AKN)_SqAyoc-t} zTze*GY-_Lz=R2RhrM>6Y)i)8H#4~g@?k7M>nVDGaCQ`@K0axJ6yHS@!ZLA?K#YCj_ zQP!3-%8UjScTHSdF9F)zCKr8lmflkqQ9#nyI>}{n;wbt&*_W7y=vtBNGj|7(`7BJy~&oMLJs2JeA=3Au0JkY}$4m1g% z@TF}yp|YwYpy{4`)wI&n3N~`@d)_kl)^XeSs2lg;Q#QWp5+73LTF7jF?mG&T6owLy z$>15cPa6f-?Y9Rb14BquJeYL;Yw+c>#x(|ne(Ik_J@pI!`58cYUota_L=K1JvVqY z=kNmohDmnJr9#JZx0{Hc>OY<^kEWdzsnMkBSX(N342(H3Uq8+d?1x3Gqnd>|QEj}9 z6DCT(`^931c8G^aZ!b0$<0E`Wro-Ite8FXlyOV-n5?HUcnI~dbbylz7%I6w$XK!#{ zFaJvTn55bbf2&%}rRN4H8H7zu7SVga<6q52a3#p(ID?)_Bdl@fD_|N7tY?1_+RA1p z|G0ChH$-#7)x07z?6R%uvVNUc%GS=*P~FLO7uL)6T+6Pvagr3X*5}K69snJrCxk>4(XN_M1h)9pGSt*!VD9ykaQ@p=!duI#j1wqFWustejL`ku8Ja8Y*wsN^T}YPFy^8h z0nH5mHWwHr8ztpS3;=0GTI<}>wQ_NJop{$1pUCmNPpD(@_4b|X4;Jw&6{z;Y%k!@ofgwj;rxc|RRi(#~yl{we z_&3lp>Q!mGD`grLv8>(AG&!;D@K(QvHI+FX!RF+A(m-LpXG-V3w%h0ZO}?Efm111S z3&|a@w8Y|E@g5c>=w^freWn&AP68&|`L&*}^6d66ESiW(``D{zUw}%+dwT_6RtQG+ zD7nyUa_!1k;mp&s;6_75*-%kq8nb`?Xti6XXXdBqp(Ds$D(zCm=d> zp6%rBSC&76!hrps9}6_zZkq_-H8l*C6&(39^DCcQE^g#rYjkj7f3h0-aFxhX`{*OF z&Jo|AytW>(Lh?01=%&InYjh%bs9~3RSwbRbfxNF-J}5b`D1F-8UvACKB52siGrgDA zPTkRrs;%GaA{&&edJ|@ZlY9bZgsdFL0)I5)8 zuT#=|V5pGdG%?VWW08U>%1*WQjP&@SOzY`)C8o48=d;kbaTji=?xO0k0o($PJKWy- z&VhhXz1idbO{y|_VgC7Vb0z2Y*1+7uidnSni*@h|#+_;RyUSY>m^`!7z%BaQS+nNQ(7JPuhsNKZ zxH`5%Ar^!zhfNb}m&$Hh|0AV%}a{KvBupT9qgl8xSA{sfchKqs?Vz-U5{a3(@fRJ~wjyh$-$t}8o}1u|tTG}Cegg6=46F^K za3aM(EOxTomp63H9_NkEcFrd#J#)jBVPr=r`_XX6U86(|=E7vA=ube5QO3&iLlDSO zzk`P7pC9GbPU$`>zhTuSkJ#T{)g#;i;cuP0Gafrq4A=HkW^b%l>T^KT0sE@m&4prF zrG9_Zi}d50Qgj_Gdv>;ET{wgWMoFpNakI592xhIdZ*-+U$-LooLj?sUYg4(9h_gaB z+cXlV24clgTJ6SV%!CyK%Gkl}KVTt&9}CZnso*z1pH}o%RZ1J-g7O0<9oJ#+StE_& zc>oio$RxpEt|HHOUqAi21?!BXJn`^p^{CJ4@~pYkc;{KPWKw7H2wnVzKW z!H!6o&nCzE1)nx=3_Z{3q|xymjm@gp0X}oJ{o_ZyYA>t5+)!~A-fGe_hjFs_C?sr? zd-+R#oy#KYq~)bxDubC)^D*%_rL10wxW}8CXyEw zUq8N$6p2;kcV>3@)2lHGL%rw6d}@!o-7vMcV&Q^^B~Ejq$UIuRHd6T~;Kn&oL!&R`ks+^Y`#WbUs_q~aFrlf-(?wRLR(nV5JXx^Sf!(j>U2Ku{2bWBU4_)} z-6}B~DD{T%(uy(XAB(b;=+rskXZz`${PC&J(XZ@CKfJ@X;6GMxfkLjYt5%hze!jat zp&PPpy}Q?3I!D4_7%Kh;q1xt0dh48Y_O*o1<|VEspI3XS&$F;~_oJVkjLVVz*0#Jv z{XX$tw{IO~QQ8YrC^6$L^g}Q;^eoiGj48}vO47OzW>ge2hE`CHUk+=Sw$W6yld-YC zFv=KDt<}0&nKPO@z3taM{)Y3e?cBS=f3MZ58N9C!C-&5WWygRvCO)A94wTx+3*CB@Qm@zQevMV=`2fYCNNE8h#ZU)t9-Su1)(fbPe>EBS zd-L@w>?h<}3kFQ9y305s41VN z3@-C^U)nb@gW5a=-QR`u${KNOUe73Qxvw1E!>IrsPLQ-15XqM9>b%uAgy-v1tSdHx z&rVwmXp6+!gY|eop!g!p-8mn?2&wA9;@2;a`b{zlvLIfGrtO%lPKits1q8NG@$(Eo zu4W4%dT##DmSo)Tj7s%GiK8L zHX^zd%Ajta>k}1;*(@>&bq%P0{@DGR)71N9u|9`E^&|G;X>ZaE4Tfg~hn~nlxUGRN z;|epE2J-b}1ww>FmnQh#c>@R`umv0z0Dx`adI0i zd;(2^f*iVgt$iq(4BZe$`_3%E9YriRs_oxSP=IA}yl@Lg_uzp$7R$45IY`P-W) z5KIPblu!uOJEoU{cF7uxA<$GOjq7;+#s4jg=rgkt;yJBV8M_356fMgwe6sj^C z7D_c&l1*ico)R_&>3GJ_IQgo!NA1ya?Z!z5P+k%11m>6Rbcea``RPlGIf|9(BA;QU z&XXyiX~9I~Rmc5Hmv=p~^{hl-S%E?Di)}k&wjaFg!L~NI*@Xts`CVCiV!G`kazBbz zraiFiK5A8wpvSS1^K{Kqu!qzB6ZQD76#X(*HD$f^6VMQQ@o&)Yt_DdxuHah|YOC)w zBsW7%qzkJ?3bXV$bmD-H0`48SzllEJjne)mM|2yVtaxyKUem>*<5c2=>$SR<_A(M& zR)V?VV(XUZ}grXY0t)=Aw{i?nTjEW?$nw<_b4ODNn zHbLssoWmMRP-L$Z6Oqqzz~6xKnk6*!({h28-r=#$B4f8wzcxR)*anmTb|wO*x1w>Z?LwdQTKNUjwvjIu+?%0N%gw((mBqK@c&w3V$N zLIr!ysTAw&(0VJ$$;gmaRTQwbF*Ho2xr^LddhTUM*^>c@Tqb*om9lID6S6cm!O9H@7#tmoEoNj_u)9Z?)w;0rY|~^``FEzJXo;2JE?% zxb6KDaCIPc``pNxWjoc02j3sLoqPWi5bz2&`Mo3Wi#f#cC*T3P;Q7N8tT@idDxiZP z$MwGggROt@^_EiOCtRz2+s{kf>a}Yn)2sXW1PznsZoJ&pg!uRC0#Cz3A8iA*hcqKh zf~a2B>I!5+Q*-`;H@9~JD2BK@uM^TXyVi8qw*x_+XJBQxAsqprg#_uNK!3t#=pZce zX;yS2|{*|taetY;(M z4|9f_vx6?S^(Z$>`J;fk?)%k>(o>d*^9)pCdNJXG(y}ypD0LsF3(_P|`%C$q9)tL| zCKVkwoAjm?`iN!eJRa63pM&AMS$FLVhjC^Rc-~8QA<9Dht&44xxrc$`G3yNkJB;YC zEwuIm>RQ&irK%r%xVyN-?9FD{C;xLIN!ia+14Sbv3k#8rx?2-sYvSKd!`NCn+QU!z zy|Dj>z4s1lDqY)0nX!z01~DR4r4t0CgkDC6(2Iergp$B0J)wr8G*QL^fq)4FBtp>8 zQXoh#0*)ZPNeKuDN^c@vitVh~{o8wg=evHV{H}AIYp*|8i#Kmu&wA_g-p~EOLw%*y zh8;hp9HUs3zNWFaB^`%gh}PKYL7)fHa&^EP9@6Aw9I@N61Xp??N1 zDU#CgRmHB`VG$pM{0R5e+OS^C+aIo%Y(&^YHqc`i#`x z>*d5r*4Q^bnxsZbm)0-)|6yX*$}ZmlA@1H9(K~Nn*VcUbQl7ulBXz-0HOoHQ`qqz+ z>VD$e2hzo@L&wf~P$MrcNJ~fcZP-|t{9-1BK%fl{E*WA)*(7I$=_gD1bg#KjzWryn zLw@+iH@&Y@oyGgc_rvo;$vQ2e&)XpS$LxNwL6r`^Ky5`-ko6Iw1pp3)Xlt|UPkyUz z{^?+{S69WBnaJe5iqoBghBIn~-#7M{#_|KB4i;1X~ z3=Tp|BJ*s6I_m@x**a1sx5(f5dcZ>M{BSk;NMQtzpUWGXqW5<}&sW+Gb1yuu#>f{c z=nDA;uWP4-C|bd(-9cmt9uP!UWfij7>9ap%FKDEixziF28X4jEz3e9$IiKg(vYbznFUb<>?;r>EKA7u!3| z-BVw5@?{8n*g#4D5$pNuOu;Ww&_emB@W+XW)HY_sm z60(9aA%v*mvI_hiz}muX#oS|1f8AtV&=nzN=?dJmdqb{nk6XXtIdlHy;OoMH5vyG4 z&_bc@Ks^*~`sq=SnbNaqRPlS~BP9G8(gvegu)F%c(R78aHwp9c`utsu?iS>BhX?6g z9FcOFxj=5lR$T~^W-**Jai<_fJX zY85V4n3uY>Q9J*h=IJ2^^)S z>18#ZvHbN!mC#3F^L7yoa!7clh%Oyu`=pZ>G)(0Uwj2oMsx6BahIO!OnDLEgj4nqBrk^X| z3?;rRwzJbX1KCtFDHMTpPZW$R+%K=1t;zW5kVl`w5`HSK>=bj*xWp!1ONC80uMV1? z>l$r&IuP<9dooUj>b(Gc!g5zQ&dppk z-gWaoJp!e+HK}-}3w~15H5=dS&@WV&&xv|*8XEV+y~h0EPI*@l`cNSjdtC`KJhAZe z1;m0FUJ20{n1`}$EhJHoTJH!KT~9|@x}HHAO%)c5V=TIB4dB%UuoQl{Wg)ik7x9x}>9y@oh zj+aKddyLXwFDF_e3n3J@vj;UFr6qmiV;|^{{%F2**Gd<+TzR_bd-KG{E;{Ty_RM1X z#K`TT#lo_!!&yiXbf8Yad`q%I?ZwqCts?af@cqUt7vS9v{`<0~3yU>@g@oeJ|) zVUtW?koMXziFK6^>LEksGu$T(ijFjITs}Xvjn~TSh@{%cp6zR{Y(pAN9?Ts4x@aMZ zLuGFALZGi%SDDRN$u&+2j?1q`-Amy>zSfv88mkh`ymRUH!&2dK6?mcbWQVpSd9gi} zgyeCYP*BMQvYmBXHuE;S?Fp(bzWiFz;KL0DJKTIoK`K71TtP`t5gy5A4^{H-9_?tG zc>RNIi^9Vp1%Sw+;-=*1F#!g|lUCAoa+CIRth*g0k-LKtm?~oqS%3*|bzuF$mK zCs$~8SQ|y8y2_PVSc8FgrtoU0fyG1Gcyw~gyIQNGN@O$py*mX&y3{>Q{p(?3s!A#u z+9K>)L5k9i{QWAyaURL0IH>#+cEo9!%T@VI|CAO(C^2PSGP(U7v%cg*Y>Pqr(us07 z46vvmw=Q}I3QG!iE*Z#;a8kNemL?Bg-}7{l_Gpkw0$Ayo6l+3pWE-gn24XC}XbPBO z#b`9nN%I-D+sWDV*7wkFd=L_VnG!7tTrRV+a0(#GLEUl4=W8V<3S+X$@|Xwu5cJZP z*nygR;bWr3w$|;bdApJ`L71H}5yuj?QoxX`7}EN1Pv`!?1uQ<5ek85pUVd<*x07@Q z!*j{CW8H~_gfB1ul0#pv8Fl3Z3-B&I_2KBrHD{O5J*r!)oqNn>)qXSA&1@#>o8z`o zoHK3G;ZTLguNT{9bcG0<%sbtL0iYpJSEt>)+u|gzNskurszO(ePH6t4dtuGm1dlCbwJ!)^IEU-UaxY{Gp>1rG>O|NNM?#PU4p48CQZ-c& z)Vq-TLQyGlbF=Nr4yL%ZDLk)caY_*-AtCb&Ak6Lj&2I5( zwpA&PLN1^f83mPZiB?;#!h4u6Sba+=B+_PKc?Aq@%=NV%RUQbU(anRfAk1!8P!S1S z8Bt7uoT8XHSva!Pcz`RRT6C_9E(AAW=ezO*I~&D0@F$RDWlA|{k`R{ z2ZKgt7hz$6$n|R7TVlS0TZ;8do;j)Fu<#|BY#Ots06_Wykbo*@j9r@e0jQN!WaYuj zdu&flhnh}b$Yp(DHZ!CDaE3Bu;sx0A%(Xv;rJEtthL2R}gkfil3*y=b&=RYJ=R%=j z4A9D8be`kj%)N_~W#+h_BD78io%GC}HG9f!(B>~;e!g2LUnJdruT`S9XC=<)Ma zHfC1|S1gkX$Rd*cp7$p;^%2aXiA)zmsbzJ)AyW5swQxq9ZTg0Rkf53gn-^N5A~utM z0B(ew>1;}dPi138^UdSqKN2|7LC~4TKpWYVjK~bGz&-7$Zkyay>pc*3w!mH#Ij&c; zzIKy;%|K(#MSh9e&TtQ)r?jFfysaXKQtbdU__LfsQn#CdU3tARSNb~6^Zt)EYJR$t zzR=#1-a!rRQ2`V^G@Hf+j9hy=UJOt4BwZ8DIK7h|etXrRxVO;qq=%!~8D>H=A?`@n zPLPo`&Tqg&<+I<4XWNx8+{;Xz0UQErE+Nc! zvh&jmu5oBJ%wl7ZUWZUk;6@x)5|qxXAMpyI#i5d+jC{xdPWeX%YW9#ip38$>L6$ho zjGsJh*EwTcA)_YT?Mj##=+>8xU8bT>BZpAYWB6R-Tm`}QF&b~sFQ5voQ1uKP+Be*e z*EF$A1qr4opS+U*(my{<;3U0%S3e4?@M}djs~nb5VtBM5ePTLIqNZ~Mi+dE}yx#JD zI`K4KCNR^Z=(tBwdh}(6(v>bomDTad8QV`lho?b@-A{Ruschwp<45b(6|h#)`j?F< zNaOA7EH&3_I0b$?CFImhp1`X-=Ok}eg|Esn!FPEOf`MU^b&Em>;zN76pxi?bwGq~c zOBHwoA5yWm7}l5UzP}*Ih4u)})w zH@>syFa>==l8(H*$de`V!p(WjMiBslz{5@Hl_bJj9*Kfo;qG3={FlrZB+?!ia8Of=G^8|it!Bp#cE6F3~-T7*Ge;z!^xG!Oo$faRLa&E zi}~cjf|=O_9CS+Taj3D!1GJ6b6Z2T1ljTK5)IR)GQo~v!0zm7q?$~Hw8%6C^VM^`l z>0aZ-Z6Z|+XxPZglmvsp_?p`;9VN}6O}Vji^LQXPb=6E!GL??U#ky{?IM~=5BU7jJJ`d~IksfpDv>B1nOAbZ)V$yoP zkv|%BxRJ%#RqFvmOJVjpHx|b$h2-7yBltzih{(y7${6Ldlhu$gW%Y*qEWR4vhtoCf zFFks_Cn9Uon=3d#VAd=5CYK;qL6N(N7w>`PpoHa|=AmNaZ+xmsH>$^{P;;l$MsbrY zlY*0%Kei^o(Krqucqk(%vGHL(BW`mYi7mV7xIJjScn>b6V)|4QD8Qq!c1PLGY{qvX z{-rIyAf(+YMjRFvw<9f7E3_k1LWO6VA&3aUG>ObnQ#@1Ii8A0srX^BW?A?%eXLQJF=w7%#a{#dcYMJAf- zw6aDazu+V(L{Eh*iDI+@oB4k6nF7`3)j{gWnu}kAa0-8quu)UwUSBq58}D9-mm~+! zs@p$L%Z;etTQX>nFI=;d&?eDLw_|q{$m~u01TLT(fK^Lm3DUHn-d^*|XGK(VdyQK& z+7{2dsd&a`%}9U@vVuxL!>@N!)~-0?CL{cVTB#R5B#g5&g^L}#+|hUifPZ9>`Jt1r z&(CQz)5QU9VVy+FTsTWr6Xp_%i!4D}xQo+U@)jokTaZ%`O+!~jMN(kIO@c%**B?Ug zq;N2-J-bc>Gml!{Q(Zr@oz1n-c;?zUQgF>^u)L~Uv=H9T22Fp=)D0-%LTovO>o@7mY6)vS z`_&6I7i&fjNam3qU=|@yWgmP&wH9+qrh}Xs<*JQ(2J;P{A+=NUY7PS=#$;k14QYZe zN>B6?6w9L)CpUSN>zH~Hnu5~z;sknIUyiySC$JfW2I$A{bb~Xu%Hxh#)Q^_gkAHcT z?QpSRmewZWsRvxlNKX7Qo=df|IWFlSt*&NuxxLA+t>1LUWwJPyMYm`|ACStf8ku4MMEuCYt^7CS#Q8bm< zJt_}-y#@)lnOvN_FoW)nb)7GRt&SCpz6>b{9YnR4Xygi@JtY{>I^bb}_9(&ma4j-M$QeIe6mvP*J4oFl)dkt0(JkKfL~Y)LU?_A`373p=bW0L0!a@ zcnhK=+TiAaviOZroATss*T3gy`1tp|t(AlbG2^z+yE~`*lzV&)25rWYeyh zyx}`x;l=j{ATeMFJUHIQl+n4n{(Tl*bT<3UM0y!s7`7lDa?=ioPN)y6<_i$C`HiKd zV`yVymji5R>X@eiwr><%6R)dax=f6BR!NaK9ul?M9rh^vFyp~|COh1NXXXR|x*)VX z$JM^1b=v6NAVbnIHa2$PUF^eOa`+ z3LvUHPv;rfJi32%Zx+^#&lo_%2t4F;ZPnSo5(4sfH$bO|Q{glDfkSwoQCPirwZcpfnZpInS=BI0Ehvh$t6}zI%;igE}(1xT6 zp5TU5dQ7_P$wmW4xYdY9Ejn+s&Wmv@vWuSey{epSJ00bxo_2abyQUEB?nts0Re7NE z;Me-Ey9yHb?vXY)m8l$H*_HaLwOL8Es$mJb_Fg)-wtaEi7B!lm8~*d+9jr%3!5~|L zA*ouu{^0lnLi7Tm<^`--M;nts%$=WcrFD5xLmb(x&u-R{CB%%?U?jwkGY+B}^~j_DME;L7{{PT645agho4G^XdasrFfv?uz_QJs(|Qy*4ce6if;gu3<-A zSs8(t>W({8_vS>&5gTiT#(BcAaT%-o?sC)Rh8cKB^C3hTK?N!z+}(1OoWI}FT~M@5 zekl{3Gl;5L67!RyhKQIxUHmHlhQMiA)J2kF3z4n}$A|tWTo8MOI5w04t)#FC`(jPK z`Znx~Xn7V<64q_*x1~&c=h90Qgo}5b%~kjVJDiTr8G;Pr38^>#OI&mvKu^dYI#l8R>HLO!Y`E$6Lt1o_OARlz7^3-J4ccwj9;MmCm2|{awc)6eL|c0Po^t> zo&i9aeqR_BrGHFb(PGOLT>&G=AlNhD>QV;K+Krc+Wv>#H2l6FzA2=28u8g}q&dcO> z3iRh#ImirRvLD8;XT)dZyJpm18%%OM=X5kLCrTX_GQZ#gm=1FDYUY5%_qeOf=e_WK zmqsr6naZU3v0qoDH=>~l;VSK!%tVY^oeLUrT#5=i!aC)h29-f>XwL(*)9rW53UP}2 zFSxCn3Fnw*0K)MtP&S)0H}M+35T%|xpE1DlaIfY_=mRVfa_mEZ;2Hy6^&jB8{=v!V zdN!jdH=9Xy(!acWhy)l--5GSY0+;fhwAEieOawD36;4(>dB9mO9Nc*K&MzfpQx$FE zU~!=*5n=`c)K!wJB}`rto2!Kr&{74)>@Zp^&O7Qzuj9MO<1Bh{uZcO&KJ|%kET;+5 z@uHpET*6+rKJwUq1k`>9**`Kt4b@|^?U_tY!jAA9-76yiX^?nWZcwV@tmwegYEoQp zH5$?Wz78?M?MORRg?v|we)T)ZF0!?4S&*tjd#D5?5bY)1if|jqa5hY!{0$_qM>I_A z@PshnpPE`^I>7y_n3f5OT6#xLZ4|y*kIjsQd)l!j?qtCcW=SSCwEhj0a3y(~hl9 ze9DPv3cBW-C(=?2;U3m+patGdOqD${7(ViYDI zJjW66O6^Aw5*gTTIyRvvM2}>j*{IV6y3zo>1^{-4?g$=NFCLvq)nU`_y-#-NaTo3J zP|3(1X~N}c&77K4!j;U^AHHEKL9W!$7o=9(%&rvp#1=OuOwGU$R#jpe53!pRB8Xnr z3$=fAioYc8fp!vpa{Y6@N=J_#!aO`T#fXQ}GX6UD_I~?~{Bm}LWc1hFU7e9KST@IGEY6A*P=mz|D!UC{x7OTE&y(;BqOc1r0v z@dJOZe_m?uRc2lh#aArIHl3L=uwm#mfGS>2wCD8@)cT?D@r< z9Ja4gd4w$ubPzQQ)%cV~ z#EvCYB2VDn;pviI)A=k(smP}-B}515ptr(->!7~3vFbDTX0p+DX*O3Ye;%>QI73O> z(Jtpn%~9b5B>)n}Saoi^0)F!!41;=)T6KrB@RI2tSafJQ%p*vzYMA*3giC3K%D?(P z*b(s5>eW&KRM2kKBfiH=`&B1fC75{pLMp{KJWdKoYM(9wO}5R=zwj5E`BLs#1S{vK z8qtg}OZp3K?eFId{35FenwPX|9XqFJQN6?cFMhkkK!xi<;F&4il$a-<%v@7Vbg?&r zFzZHmJJF{x*+vEZ$PV3K>6z=ztvTlA)vS?^Di;S(%^w*I+5Ho(;|Dc9R&~zO->P|9 zzdz#-()#B()v5Md9apuzq^E?W9O~CQ!^s3DtC5rSkwo89rFMOe?|enDYuB<_?m=l@ zP4J!$YtShG!9_>pmhZBrM^-4o?sq;2ctrlc_xkHvsLd1v|I0?tfBDt(j|vRRr4gm( zul@YpuY7#0pgcO8yw*2pVWJ_3vLE3!Oa!KYbcUWu^IFf$yATdG2aY#X9L-r@Rw|y9 zHU&t=)d1`wamH8@z0-yD)6%s_0ZJMMoffaD~^eCAay9(Se+ZvD~<>oWOnq_`}P<^j$8iuqi}LtA!E9V0151^J)4Qg5Gb zao|G#FM8dRr3~03w4~h`oN1M1AmEHSLemVWuw6KSQE^#3E8v<(RFANKhD8!^?Dre5 z(=C<7JzcA86x&Cg5Em>I)larasIX(_4DE>S=)=>>dK*$XPl5%*_nN{La8BTEc~>Wv z^<{?aZ{#rj@WYocvOKYj*=B=JqQbm4vVCIb!IOe~M*6G%isIh#`Oaq^4?(lOFuw*} zLupY?%Ms^ZE%szgUg{9qJb@N}BQaebdn$M()V`abj{rJDO9CzP>=J{jU9QqRF}4(( zeL9M)X=@L%bQHkf#SRrC2uvok_F|+%!F7=kpdqBFvKsg3g~XF!Z;@oJW_L`bLpLEG zhE_gxfju6_xF#cc-9uDDj~uVVC~$uP*x5zrs9q`x2uHwLliYuSuxE@qGNyq6Keamz z)njz`_b!)rgC%B6ESzi*2Ymc}MT^ped+XxXGgv*)ug*Sa3tv^)R{T(7iWL!&%psq1 zl@3nwD@ta^4wefLWdxJFi{RJt-RtB&sMw;7+r*?I%u$*`lWZp4x&k0D<1*Gy57D5+ zfXvrXU)HY0%O?bqKHwORi;mZAE~x38RzQ4G7#*L66Ew93Hj@{E@KW`!Ue34$t8Z@D zh`iZe(m@sTV`_+rmaz7?yWN<&Sg)RJxjm}8nqms_1VythWz)3H4KJF=LuVJ?MXjxe zg?9{bEIz<~Y8`fb=_b$sVnvpPW8?QOURGoh8LmA^`%8d3E!jQl;V~ujC0BAEPbEDP zq2n=7PQkOy=F$!~z$OxuGX-zwhg89!&sUVnbEs-SlT?~~-x)We5JE_JSD}DeBAHCt zRcIm7LhFva!GrGOC~xA8zg)kdE-&zQr&3dRq+_GBqtjc>m;z5P53z|nks6jRARJ2y z2K$ygn#P7L#(`eAt}R(~Wem|g&sKkxKH{?&+jz`iBJOcvz7uKxz-nLfuAHn}Q66s~ zy|;T1&nV}I2p(ZIQZg5MhJ65}Ai!oJqk;W5;LSd$J-;`2A7p?{=4L1kt5&blvr+8P8?Yu z2%;hkg&KKKS=LXR4_vN>l|7p(&HRecn*@eDt@0c?KL5|Z z)PCdp@O5YNcb_Qhjb_8a%%2tqBMm&!>0f^ge)Ns+rxn9b&+E_rHYW3p@1<_f*Oiqd z%YBR2=_+5#=0orXXVo~JAS$oM>Kk9|^{g*%-~RaGLvr+_7zkXrzTJ}d-Ii|8nQwfK z!8+wxKc@3$qyHhF`~OgtseZn_%(;Z{1wJX9`2Pg2jf5Mt>Uf z2IbgLCBgI?C-Vvp{N#Kvs5mB8`MZo4MuNAxe`Or~-SZ;iz&o{vaxtqyou@9{I5vCa zTiH@Y^c2qrI=1IK80HJ+n*wW}0EBn`zmJ&Ek zx2BdtAlj@ZtSLz8q@fvQWF$^x?WM>-a5pb~)hBxhFDQR^X3kn@@)mC4?&3E-zxpdL zJRdtO&=b%~A9hcowHJp}oX7bWE8E+@@r4SOXpuzkZGTM~ck&}L?uto#Mnl@#7bN{S zL4_G?nTmjTsUMwo z>s~jM62It-5qxfLS#KG~#3~vzJ08jyo(!+^$oAkMmeGN}7HJe^sVn*t98>v)OzXJ< zrMs&IeT&^gW+z?cYU0)(b*b{4wHlPwJ4+XGfLE8dd(F~1ME!#yr`9W^LEj{JNjJ2t zdQb9*%?-+F%;rr-OjejrVubyoM>g5Mm@TnHFwR2+WrT_r)})ri`3|I=VkJIt^1Dq& z)*m1g*x~ihkhwr?EGI2+^Y*-AwFaI$XWms}9A9gmxPBEA;Gf$uJZr5m+Zno43Kg*v zvp6k00$|H^3EL9x0kaGl%#iFUq>YI>Qu|YrVqV_XF@)ju7#f^X@3km)%(7ic5~D4` zovRkDkCYYnvdJzZcUj$_@gyVJ_nY9z?F;SAK2!K|%nX29D(^^`lKX*Ykfs`L?k$-Q zkK7Wt%%gT3^GXOJ3p%v5$c>w2K;Mm*0|wm89;!EFe#X+ysvIXf(h&IN2LT3(D2q9t zpSYhw&&187Y71aVgaI9`p`&xC;@wYj!8J$sUzw+}uEvo$3)w;Cj&TJbj6DJ*S^d*& z`^TSsYg|0Fbx4APK4Ii+kuVW``N>|;P{eV&r7W~tkyB*3mb*)GL6W~>Xg?L3P`ly?!C#f!d&7C#&!$X>(C+$=Nn_B%zjyMWx{ZP*S!=4L~9d(oMJPp-rId7?s{|cn9L{D zuXvZNY5ivVm&wI>5W)-`69AukeJ~c<7MM+hI=C@O#XRkTyY02&b25%`TUnJNcg0Fb zTi2mA;X1~y&bT-c5fRW!XFD^Fiv0P)Z>Qhp)@Tz75-DRjr-?NzHP$f*$f0o+fJj<#s->Fb*`*Xf+yXO?oFZMQAXhkp@+LI zKfg5<{xzngf<}}vL#v%9t*gpZFWo^`BMNN_0fazbUhp0wdlfLCMJ%&*QVl)kv(Uo& z-0q7^PKwLVB7FC7%yV?QP_C;qlTaeVUJK6#+T-^4(VP5sn0Xv-CO3wA7QSJh=e^_`dKmNNKJdViCsk=s=2iN!OQs1*V)i)yZ$@Cm;LYledYhJhaO2&CJT;& zv=6E6e|@_BhrSXt@cym`<#m_KuFos(<`Tp2OU}GTSqN(*_|@)J3sBC#x}}}92_4>T z8r^I5AJJ4yQE~E0x3FHw6j5cCnlFeo1+8bWb033QeID?uF$7>_m5jg_dwjc*J`s>2 zR}60wWEQkrV3Kek>z+W2MA)r;Yal=4ahycZyW52zMc_3_#|(auJnH5xFpqz@{&M`5 zZL%wM_UdzH3NdNj)SxjMAtw%7CBZMGElaWLkVbo^%Yhg8HO|n}Dc^xG@ts*HvmX{7Y1Rjlog?C-so~?E>a7j-hYQuD1}bUjOMw zrPKx6QQzXyF^}?GY@)Pdh2x@v6o=dsCAdE{>2U2b!{lyN5=%_2ArZM zj}-bdbMtb8#&<=N27mJaSx@Jb7<15API49Fm6O})!ftQq9CkQ`MD1hi$iN(*#~Z!t ztS%(5Wg$l00kK&Xg-3EL8wn&OLG6>(o#;B~X>H-b>TaHy(EUJk9T1eI(D}o?x9kP) zm5_DkjJ=Vni8yIm$q`ffc8)H3f~vj}WY^xH0t+Axycnx4ZCr)#D=16ho!&E#%MZfZ zvLw17t{J?zSH>jNvIwXd=T~X`!AA21m1de7kGD-Amy`p5=t{G%z>GH7Fq}kBVGTLJe}HLybOd)R9B((~Su*>KHmad`piTQ(sZCi?c~kl%egIwvq(RU)DlyP>Wd?^|vMLQ#>4;B06b``enRB)j8$fGR zMSUrbzP@;lFNt!&FA2&V^SmnlUZM!LdH%rMFFr$Yi%=!?ZWi5RLNi@V55c`ieC^B; zcBR7tI6d0??(M+JdNnOL-V46LPjhLwK`X>lqlg?@F6c`;RF(FW0cC^;JN7N&Lleff z<7=-&mqI-9n>yd-6Iw6>NFTGbU0tJQPPIh#Ix9uT!Y}5ElV9*wFqJx=K*qV>Gvi-o zvIc@QRshST#kw%4=5IH`E@ z**qBUBI{02+L?DQRl^K!ZsiN9BD~k!y(jE9?j6E2{82}K5XsZj#e|EuaZlGlX>X6& zbxG@`1+?-R5K-CwI6^T}&XATC^Oyb})-2mQKw_PlK1zDh-1Zq0x0 zBIW8q^fkkQgwhU@&ao^`lj^{}!haYM{`K*@u1G1>daEC5D|oge4%^<4eyDrrH5f4R zyblRCa?uEyeKAQhb1O3oX64YFB9)wiFY5Y~of;`_6Nch^g$mdM?~-O8TFY+2M&c?J z0N-OtK|*#%CqSPuAl9rsygMWqs{a+o^xKAdTAV^uKkz9>dvqZ|3KPt`6B)Bn z+sYk=a@h=5yZipvb7~YVqTG%L_aoju9yL#ArS(gfj%lbT(ojab%L6P6$+zspm%ZgRHLXLi5Ly-1 z?@+H&jGgh*NHl?4sj8#pg%$OQ@#qk>4tmHnbB#_(j7XDDV6VY8ubasEC=z;?v-HTS z@_E$V;1TwWiMGJ;!HzMik&PbLirTPJUy?5xI`(l_DFc4vAP5arRc>wBY<^3gHZ zN25Qto9}l*fAv=`te3=n{_4n19)2x3<1whs62Dr?v>QNC-^fDsaoY+v8Ji-cRUWp2pk9I7b# zJnrHYY5bR%aZt95Dl`4v{i%$>JPb3ggLqr~pEKcu|E1Cze@<)mGr#ZuSh19Ut<1mV zBRcH|qk>1&?OBii-zwt&sjB|WUxM|-RVTU@r`r~EaL0iu@#zwzx* zWfrb)_F9IzCKU-KF8osA5n=Bl_QsI5orO31w6-?ZxLXwVu`KI3Y=)D6k0B>b)38!* zbEw`(<5r066I&6G1;XZM;BWEl#OL+OKO7u-_q{3Y;?Tlp>Z;upeg3giN~QTrbH@Cu?V|HWCIbk8KkGtd9sOKAT5_X3pvZ2^yc zcz zZFti_w<;R8VlbGzJv%RA9?8GKO1-h(d-w0+JNvilYy=7v1T4Wmo@Ra?9VVGq%s$CdME{wMX3XV*TGrp89)r{BHkU5dZdT{znjhY1aQJ z2=Z*jij^2l4mkh{OBP$E=NbSU2Bn}9>fVdm~N`}SsM{Z z?DV_B4v>f+bY`+?x6^N9IFu;?a-|J?(ZL!_rTe*3r8rEJ58<53ANwT7AJa_LXr`~1|p6`t+H z=3$u3Tzck;+iIK0!x=oe3)zr=OlS|%BdQzKUFY*P{2*oe^+=VOVznqc5`SDxfCsqwhC9XZ-WAzGyr@D|#MZP8xq^ zuF@tC!6F=c&bRlHtXAM+uJ; zI;B$XyekfZodzt~>>?@aR|cW~G?~TL_%eohe&k`$B8Ar8`ewye$qG8!R1rfFW`DXX zt*G7}CtBM1dSnX;7{Lj7=tG>OpHKgDCdmg==f{kCn{0AU$GKA-2UV!W)jen>;n>&@ zP|gcotEUp9hCTb4Sx%8=!qY*BE)3`n)TZw*x}cxU|NZ*lul#-$e|E9IaLF8c;g}ME<8oHT=2+t&@$)=B1{CU z7;jQ^yh{T8MzZh;VfOeleyPMvEpDxqTVjx7ZEsv^-o>=w*|A@;L86-M&8Js>adNNL zDM_WqiTdFs{0fN8t0s~eq`_uDEU~cyd$PDNT`}S}TawJU0I*_q5;KRRNGxTqKUdZn zPhL+l^4WZfZhl~+%X#7$p3Y|H(!C{-$bk|4_Gy2}J7R2l&qclQQGcf>=liW=rz+>) z3m_Yksz;}<)uVlQP68v%qv>9YRRtrr{=6an5y3|C7!h4P=~dJOhvIP9nOU~Q!MPn z;J`Q%9>P`hs1_dP=SPGf5ajY`Qd$52WJl!XFng`^pYwiiaarDid7If6z-gJiu+s;d zKls`_sIrt;QVOE>_|^RA{+7+2+!;K~&UZ|w&{xn$&q_=wp;VFn>3VO-;F6kw!YyTU zA?hVM*Xt)n9%-az4?Q z0rSh_olST-K+v9#t?9lQ_(Ct^QCH1GX#Ik#bav?*Cy&HKQ&Gc-5n1BzTi8a01Ro67 z0pd1U&D*WjT^u`7io&u#*z9x*x0he*-g7DhbW96zuxcLytd^f&`y{qZrzF%DO)}!DeH0*eFM)Z1%l!jYo%O-9Q?r zXUROLyU~W{wN|wsMJNf|oJjO3931`PhB`D$S6KWiT}Z}|&3Qb+@>%Axzbr^UZ#K83 z+f8&>`7v%Qr4QEIP_~=Lq+~fNJ*x2YD>Yqx>NYL%ePtVIL-YB6;m zI@WC826MT?3{A%yO13so3uBPmL_J*dcqaN7h_g1vJtGFSuWs&ahVmdaZ!bYa9Lz2$ z$mEl_1W9ooU=q~M1%RLo_F9{H{x26oGzC#sW)gK9xt0%dEem7J?9Px^vn%|r@5gXJ zw+at`9O;4*Q@LKJd!wkXgi2?H$RyX|Ib6Q_COsPP3-NpCU5*jv$Mqc^b~xCLYm-bw zDa}|jU!qk2-LQO!{vDQyUv4&s%gT$l4PS7A{ZdX18Bg%>ssb87hid^XN^fpYh3?6H zppHP}J#zY9T8Wu_<6E;ranQIo&rV9NvepBsF6NY1SM1(+zYi9S2v&>>EJ7Ubdf}c@Czuviw)1^nE0YtleCyL&_}MpL8aagJW`)8gh}KyIt0y6KeRr8 zr^h;QZ7rmYAjeY8D~5E;!`mK*TfeT5+YI){m51ImpE|_qIY+2ubNp_5U$80blh&^r ztchD>$_;ab>q>ra#U$8B6FHKj88UXzT9L+wz9TUasp;%7*TSkHukl*0M@3%mvp$X_ z-1;~^1fd&N1hE@@A8zOac9GGAH^ceOBVl=weQk-R3*uY?X!#FnUg~nFso?f@s~_3F z)aGQGqQ-re?0gChdZt|c%KzE!XiL`qeO;TBe6zQ!(U;qo0Gjaeh?`|k-{ew|R<&Bl zYrhWwGmTF7bMkZd9DJ(BmbZZG`(utq&C+$k0TT&sK^5hCp8gS%jT)hL?A~6vJL6u( zU`L3h@TTQ3$90I{>M>iLfe3ox$eWv;v$i~<6XE8M5)4yvTZ+HTu0rEs`VaZoV8@}LBF6;>w2l3&ls}zVfMD2GZosQIGI)@en@ z#D|@eS218b4;Ar$5?X#iMW2hu3QyExik~~R`Jb{D4_UE%CVg+rZCuR%_HybIM8DOA z9-0FnaJW#Chj|~^jJ{{}^FPmKf7>RJb=RxDHh$xCGW)sVzwUtsI)lmj3FO{tyF(vA z1Cze=ch?rc-H}#r^fu@g=(Bk;!I<-Kw!FOwi8Rp^vN89(?VCT5-M^jU{njw*)fCi4 z6y{WlmyQIKLV10?`YRa1B#Zh*wDz?!R=$5yPPnv&l}IHvvGX0U^p}3Hs)?|f0Jc|~ zSE?C#-BDI@rbb$4_6IaX0B3t~X-!RMJr8Vz(8gfF8w&s3TODivOPt2Wt` zgRC3gy&urpQkXeK*RcH8mw8t8LagCa(XqzbiMZwgxD~v^?#;pD^_lAn5L+uicfzFn zRrN?bcYZaoCwUg_dQUK2tot5~QM|!x!Q-z*WJy_(fF7KyLl0R%+b9Bk=;3mY`uxcu z>k&sU*CCG&(~Taz(7iqCOjma@<6CFLBBXX}sujYiCCPpStw3g^2AP7M4jFHiX zj8?!_+O2>7*8lbYCm}3Wwmn-S$=AGd`SrkhuU44Va*Mb)8P8%DI6$RI=W{|eE)bM@ z?dEh#_3y6oHM}z15;2J$8+bdcK6bI}$1ldcZ?{7CI|7Tl9X+kfgrx zC9F?*n`HBmYgp1VWu*r$HiP58Oxb8tYK*y$So z1enf4j}Dh4Ma<&d;Y|O+uzi<%!m5TnBL~^T3(g-GpSyjQ>@Nlh^Op3j3I$LC);k%P zc4%yi_{U|vkP|ycN=|R|9aLMnv9bq0dqo3LA4JiK4IW%jr&`suE|rT} zMreC4icROK7NT6I6#&5QELt*6@~TyEm%~)#)NnOzR`!@U^)cGyQnsA|G~KJxF?I^A zQRzm(FIC@k!rXkMFK0mq=`Ny|?`H?F9NaQ5MF}2)2DK0dY zHwf^ok5mEg?rAn)O6uunW|{OSPq7M&xPHrBpup(2<0ksR>$#q?x#xYH{&P9^QYZAK zf}g&ZH1{lUHfKX;?1*f(tij9o67_Foo>d$)6OeQ>-(HOvUyurC)O`q|0zd$rfkK-z zE8@*IR@NI=Rzv~3fKgwozRTCH=SyAJKh@YQU(bbO+yfZ|Oqh{)J=Wb>@D`-N~< z+d%@jXFKOg{U3FILX-`^U#ohBo6_FZtyf!^^?jo*p)+~_@?t3U;ZEMy?0ns+1@YC zjE>!L5D=wUXi8|J1PDz>6M8X_O(X;u>4YANbVp}E2m~b%kgn7e2vUNCI)d~jC7}t5 zbP$j(Ao$zP%sDgnyl2jHp7+jke($|^_8&=})%VJGt-aT`d_Gzed8Y66ML}j??#n-H zDU6!DnN^@?(%c?e3_-;z98-w7Z|2cTr257m;ubIjA{;-qiujfKBU74j6EPhp>=- zj6mKUPI@kXiPeBO7b5?Pp`2q)5j1zEex83FR4_Ul^+XQJy(2npc?KeONH_LkdRE`A zM{bK^LQ*Wd?%0Cr#gZN6s*K&HzZ&@$a{_Ou>dqpGf*JyxC|Yf4+OYNpS8L6UUSI*r zt9P7zqt~6g$U(|XB+s3QnG%8I#)_6@$nx>rBR|WR*wrq+gVEc?4u2KnH1OjK!_ze- znF{M;$7@GQk~c-#cbw!lJ*%9QmJ!KY-B!z!)0cnKdzo`$?}(YzjW>-)r!GOa5O5@;fEqZ{r&a=yo5ER9efbnfPDy zh`%K_tYy7!KH)y6bEh@=cdEz}za`H6@*3E{aX>8JH_yASQI@#+M zT>sLVu5WW7N^~)TcT~B_PxH~oBbKrO2A*Y@x%q7-c9BwlJ*|=1iD!ZS4>{cEd*d4( zR^HWv!;qxB+<+6M3WE)NSCVVKz!+?OGXrH(|H+U31<-=K;84@9x(|_kmt^en56Cf4&3zq4bubHv9<}1UN5qSGjIpFecyXx-R(WsKH;8(fr_7Q z4a0(yE4jY-CH3TOLo545F0Nlve=WlGOX{yh{;K%b^~&kxW;-Zl5!!W#pOtINp7G_}XYp1_Z=2?-+~5 zo_yz!#=n2Kd-SU!BAZWl9`%MJPn)V5v1d~2fxvIFgTr)W758fij`Y*X%TgFxl-uGZ zyFeaDL>l{=@I~j;Zx35j(qQQjXiQx}{w11R>^O|x8toIqbNJyE=>P*h4#v-13n^4o z&-4{)KKWvm#lU*Doc|gU*`Gs1ct7k^(0@!%Y_nF66i_<81V?GXnm|xZLsSdCljr-= z8?(Lbm(S;f#YmMN2Cg>Wj2tfv%DrR>LqnX^&xB4^KIC*mbq|LwNlH!;*(WA-H>rd9 znlu#v4Qp=p{Bg8#RfM)*&#k0ivLx$K7 zhoUuUij33_1kl*5!-rlyx2cEx&Si~zh?ahX)C243tuq&ERTG@%z(qgtnYSs!HStJs#eG^Ehlsk(Nf@!z>K(z_+B!I1EgQrdnvK2GmKHp z^vmHxBDptr4h8qTp2@z+*A!DPHG8TguuEV#l1-phMMkB(TV*P!@MvjniasXy?PH1t zxpGiGrkE(W1qut1<>tHi=y|%HIw?9wpuDuZs>>96M&W^tPgab0KyfrT-xj6J=*H>0 zw;8UbJ!aVTxuPWgwtPfGDGf5Fk#eF_%Fgcp- zapRR;Ry-lURgekHd*8IG&r5`(=WGD_`M_VbYTemgTfUY6W)*Bxx4ZV04QE7hptLIh~Nvo;@wtE-himi{!o zz}p;@WY<^9I@v-@%f!&?$%K5ICk~pjnNeV!@iD7?Z{9Mc+1})=t@z6Kr|+u|o|HLK5Dyc003WD}XDxbF>zRk50LzoLo6W{=Ua+!{sYQhNuo>A&S|r`eVcmUQd2yD+EJ4<*puf@!oonqwnCx2kMFX)kCS*} z^>u+>Er=brMVz6|bN>Zb0%Mj_Z81gF;M(gAE^% z@QOambs#+AM>u@yr;jMa&-u+;Tpu-=mw$w}On9Vut#X~zw-Msvy7;JpNBAx`4@rb; z|63?!0uSYGZTGCdp44Y!#_d^u>HahqC9<9SDp!ao5+-tf`lyXrs%9phg_St=@(XSr zWw`zl_6*V4Jk>PA4qs-73;F)5KY!BycP8uKtcyU=2F=Jc3I%jJ0G;{iSH#lasjhKl z13uCqnE+r7q3yu4P`~>6U;X1o|4aiK$W?`(^QOL@wSLm)>@@neLILIVs`3L%S?Oel ze?j{G`sgu_wDAU%Y^4+rH&O(5t2v+CqFpvQqKsWXXTPn5M2g!toWEk38098k-z-AQ zJuOtBo$n2mx4f04ZD;FB(4?pJn-J)p%iOB`kGNZXQeQg5XLEHFbc{LQo+z)6tA(Ng z6Oa+itNmhSv&KbgG>O$^Bx5WluXC79MBvo1=__%`PvHvc!oE(mO!#CVVCABdnfMe(VWFbyIXJMB2@b_8#5= zc+n^46~F(7zn=f$$!-8$U1hD=^V4jQ?zneLemoRmlw5ZxJ@eq;ncGQHq6?RMM3H6% zye@cFM~7$H!RV7#*K7%+*MR_2*itsH=M>tiB`9&SPehhGvQCl=tbWJ5F=c6Xc|Dajpu86s?Ia106U3Pta|eDrK%e{NqK} z1eKNsjHhs-wt8I>$bZS6%HFP1tRiHUSC`Ik#rQnfwy(?yrTVeZkL~q4f*T=L@ z&^#t9(aKXzhe|OOp?Z&900T+{!=jR8GCM|?po|A(6eNcsd3vkPwe}A2*uKbl`h0JM zG>#jqw089Blm*~PQi8IPr3N!j{ZAXB3=hx~q?jK( zR?cm@=F>lv7Maa*{1deY23Iv_Ws3nM5-L#brWb`;}|ekk8=k@MUU#Dh7++ zHhW&>N?sYF+Qr~EML={{G90*gd{|Av0cE6_0`H0oe2@xuxU$Tj4i?_^FXuFVbdgA)3!#r*nJmCBD5)iS5>e* zmgR3h%-Wjf+)qYeTn8vWaV#dwwk#AtPlJO=C#Ez2z6qyFslQy#m+PM+=rlYiR zD-wY~rPXASXv0vz=Nb+cQ~rdM)lR-SnW4#HFbenpgx4M2heH&)wOnmS zwnejU=C{xj)PhpZKeIVZTu-x&}og2Dw-5N>@KBjbS0%q#|-rlYmSz& zbqj6NguN`%5sc6H_^1nMvR!eYyOxCPR=J5~%2IT4;bf0t2dR{lr8*7k0Eij&2}>i5 zpy`^zspl;cDQE@*$3ZIai9nB(?{mGXnwrnT^RpuOPUiQtHH)woQZr%^m!6g?^g}2fXI984*sINl6)+*YY?6bRo9pWq>;|0OAP%Ee;PJk1#Wb zuJhRGfJg;ajW$*XTa^JrOAncU{13TJLv*h6-<}m0$@@sU^K`NI%m3nb4co5j2v7}$ z<39A)=i(3_UUXEXI$b2?^Zm% z%<4-Tuuh|;o)JQ}mU!7(XDBK%XiCq_TFI6#^aa;xjizFRBKZ2VgX8k;jeeQx!q@Yj zoy0VX6?KX}iijGuibL(}%NJIADoVn!XkN{B&OGQ@HtsWk;y=57#q^-M{3E!>KP&u8 z+8^T~9PhoxMtOH-faFg2qmy4(TC9F~15x?BFS7av{~rqYgSq0k-7osQU3@(9-CX_a ztKxgj+l2iprj;J4EZu&2@Y@qB`z>X+{;K%b zDp&m9_6^hcj1%3p(Mbv@Ymp3=>cQLpr^@yjWIJzU__$3%n!xxCjlTm={8tEyKi(+5 zAUU5p%f;o`_3P0|+IImASL-6#$$Kxhdk!}FJZdh|8a+Z(=q=! zjtD}~R=qQN!Ij1+t31Ufik*#USkZYEGl8dtvx}qVQvtdA3glo<3<6lgN{2AYXP@LJ zMUa`YRGTbqWNR4nB!LZp5eTRTn4IX$1$Wf#z}^z?`0^z4AKxX?)y>DeJdb$Nq>i5# zS+Xu@Ss~|x2rq?oZfQd@G6q=Qg&ay4;0;2{1XNKPMPTM2Nl*&^g=(8jRq4X8lcOiy zUf*}SQgHecmo(-RSKot#l?~R4f43AlDZV-_zgL!V?}7s%Tg2;@*BOZBgcsi?!p&>> zDa#NWd4fK#|EvY~RRPl?QR?)j*kj_&lIe)&mdRQR)l$^?o@x!TC6KRBjHgX6j%d-88qLn?CLXDT1PTv`ONp-kS7#tw!wfo z5g6v3h+R;ep>n4iS%Qd}f!L%Suy-kBVCW`NG5oXWTl$elOLz2q;!CFxI4Ug-p#$kI zM|gY8*_{$RAtWMV6INvBM z*{)>J*#@@E^zrxT@FQ6)Y$SU~+~!=0Kg4HU?$p&S>eCGm;RY`4fG45ze1^)Q z<*ImH`vJ0(hPbQSTIrywOWu$x3H+ae3eKCGk8c)*Ja?_RtW9%+Dwxvxq#1V7B(yd= z_Xm=wSs_Xb;)$Okyzk&X{Isa?WL?crxw_xRlO4-Dov)|wJ=04|(0%kK?{RKeTBh$n zuSv^}4+0JZhTo2CWkt)VVqsQ zFyS4mCrZKu+e1k>rU2z*CO*;2gH{PbqP~AucdYNeL&qmB4dKSM^|6gA6YY{(%%Pe7 z!MWhWf`-jA*3`Sp8qdsN@|g9p2eDBwuf~KuxQuyE!jg3^DLzdAHw7b!R9T5)AF@6e z9*78ayWFU8FVrG>!T^0+&qt<+5kKCb1jVzWLj?tG*eFd^i!{R`2@^tro8G&^PX4Wn z4IKmKP6(GGojC9GaBqM3Jb<#48q~~>zFBv)r`7UGu8n;9sqMusVrDsD;+2;<^on8~ zouifI@zG4xr;9HrtfR<<{lQS#aAZFo?_Tm+%EC2^rE#}6&PSi>gLOm2yK31*6tgu8 z0Z+1)T>8Pru!Jo+r=j^$9#<%HrLwgXHj#g?A{gYiRSvkNf+L@Ly1k2CEeCFP677Qr z>-Wu=D9Cr|9WLz_6{7TV20!L{G@PvbAVO-+ViuI6K+2tVd)tfOLh7xKvo-^P4g*&@ zPndku%O4Qb@zUWL#y7^gTZ4oMbBUZHW)Xz$N3-L*JUKCGvRt28)QChPgQBBpW;4_b z{Fu{3A)&7zuJ;YCTD-?1j7MIuO6^(D;&qu|LP_6HeT$x$LIeUK?!srHJu~zA5(FXU zYF)_v4y7f!RyK9-2^HZNpGF(i3iMIHBY$eiMZpREuX)HbsQ_R~lM;?=*(_VCwKcve zcY-=^d_&8f>U!AuI~OlhwU~A`IXd!COhqK-1umBzD#M6s_af0~o5p$c)R(iR5iP`s z7^#$o43=7f-BJ)j#GmnP%|A2HCkDnF9pjZR%wGTnWhPC>Fns?N>9`*V;SOcZ!DflV|) zW_KP1f8x>)`4CgGy@vb#=vSjF(nP}fW|7&#a0Y%|kF{Nr^4o>{8O5NZV)Kjl7FwhM5>~F=pZBASIn(EsEste33qSOiQe+ z*&H9V4ROqmCJGC=#cQ@Sk0nMAWcV8(9uPz({Lcz1220-?4RTF$_Gx(S=_5ryE>PB= zPSApu54L&55^Cb{kFaB50hikq3`c=GA9VF1X|C)W#)D!xF`TLLn>H~kn&V(h*rhd1 z7*Mv1F=0Jue;zvF>(?p_EI|wp2 zlSN5TxQ`HllE=o*F}%{h9r!40>A)JAJxkB`X~;;NW#qZ|5$6L(@~mN}1Yq;lO6Eu4 zZ&PysUxJQ%8LDlwgizX3=$z>)vrz13n*>6k;9BozWX##|$%FdX%7d#CjaHc|$F7x> ztgR^}N?7Zjp8#SNq>Boo6)5wXj- zx$0!eb|0v%evSv7j6M57r=Xy8XnNLhLH3z(5jr0=PfCL}G;b?{h=tb9I>qjCS02S% z9LG;j&bCaL*WolSx;}3ktC~{TqJGr(7hr9kHVm5dP42OhOFC@WFl2XcfgHCOtj6PP zV{A`jJ#dL(FnG-PEh0n5XpcSP4P+}{Eqv2s?DUl$Emc}i=y>0ty-p9VJrio}R`cFj zvxV8PS2K2P&ovNBcWmR`_Ph3mwX%(PbrpyqVMmAEI&_membM#q<$}8 zRxf_e$ec;;L20iu6^$hTVJj6c)b9E8B>%P5DJ?lPZhNhP7)>m^v7pd9Z(0%ToKz6w zzbVBi6oD*#G_UNq3UQo_vCQc%4{+dEX6LxJ;0}rwL=y!i@}(x;JS`OJ6HQ7Bx@E#d zDy&-R$@UQlw!wCZQrNtackW@j0p*&s(Or{~@~uk0cU_FeudYLSGWoR?l3G=UoT)d9 z4P<=uXy*bn(ASY4-a+xvXoF~>i$tThPx67xas!_Xrg^~`o~9DMZw!`NZ3B91?AKX9 z;jD=Kg61g50NY}Dxalwmr@m#bzAIG>MvC>IkZk>^feC>v`&KcR7?7n(UORHe&Cg+< z`U9hd+qi(7MuhHQd2+e=JkhsE$$Hklx75sU0uV*Rkn)X3N83=2N88tv1aJX^juJiy zTj&C=;`qy6lnT0#9yJfrF3_J}+Ep)yVuUeF(6lEx^IhJDL8&K0&fgYJ#)Pg?Fk#UDx6UZ_70*C1LVqy98w?Fk6l@^ci-K6t@6n&0->Dxu$+a z5OXWI{N{|(rdzzqVHeNKrZ|d~DBS@C3=~pDePAAM*Rac@jmv%4vrTcW4O1?K>>f^^ zM&X0_8EIJ@EZ+dYnXr^`E6wU7vAXHv_H)7+rQnG~d059xb+ARpgR+Lp(k&{)r|=0> zstvs1dtB)x!`W>)q+~9m+lmkmS^}+r#_O$@C!I4}?TFlhMey;;-XsHQTK3b|sc3^k zBd%VhL=hs|0jU;6yv6S1)ym7$_d>MTd6F|-y4ufmwUVXd!^~wpidwatgUC#hNpr!T zY#>CCrweHeU&$5!`X#1ii*VbLZZ3L0Ke*#fiI<+?lwnNig;N4dWK#q5a;hz03iYh_ z5S9(Mz`2zs$TUA}^vX`s)or*&h|;W;1DM zZ(E=Zi({lp%1^|>I(>_oLhW!%m_f2fieWnR^(hg^k*zmHg*0Wq(o`X(V(Df)%1)OcljHIWBV>MqDNXjAelG(T=|b zfq)NI-qg6%J*3{^H40+{E%@YJCxA4&CGv9bR zYWMcJhpTW{`~}~|tYWsY?uWkSL1}p=C9x5Tdox*b8?lJ$bLEW>boKV^HhhrU-FTH# zsN^r0QrjEj$KoLrXq1A=$(8LU+Jm@505zUBCk8XqpR9B~jzMHN$DTHZ z$bHo=7!o)9xD@pYqyog^yK&ZSRtNPH) z(i*GE7k#lCFmVvtM55T_tR9d>dhT7hIB4!#SSM^F8Y-1k?;PVKIg)Q*Jk;EYFSrsH z)aCSJ(R5YIo9$JvMg-nl9_#2RY_hkoR8ifR_`v+gatdJ_Z*#D*tvvpafk^RJb@ z;Ut^f32({3L2e0>0eJ2CZ!WFI>bR8zm~LSN=WCs9ME9LjaO{W zTfl`sdiyTJyDxC{J=&Z)<3Ojns;lu8q>j(sK5Ht6Rb#c96Shbbj2(DVu~yBw7u5P`uF8bi~Ho3nHz!ff>DP&U3O4 z+-umka7>`E;?QeJR;`1O2SsS@z8-^LrJ-HNyHX=fs_ufAoy8G-5N5WEVPG3U1MT_- z*oliy#xs1b_AxW+)tTDE2pt!%KS2HCb|97dKa8yyMHL!3rZ01}2xf+I5ax`c}|NMC<(fAdB#n$mf`7j=qP8 zFUiXG8FtzU=Pbo$=124$u+nap937}FaE8V!!sbLrdI7@&thZ%|nLLbaK@p7oO;P+T z30>Q4_rNjY2-_$`s4YXC4+?M(G=vmKTktZ1eEgqUbj7x!13iPFzZZ@@-kwgq29Uf0R`VN-=`*x-mVRGo?$aG&E18) z%dmwQyEkk^OqpggovgnpS=Vu^dz!pEYTeb(i57qmPit0-<%bmYsq+;KR3L-GmpKfy z6r+K`5S-Yb*E8?Sy{HnQNOOL-LQmJY+_*P1(0iGE!=w{wATHJ|FZ4mol%5F~%7C7H zklZJKdksDCtNs0W%&%6OFa9BZ6?tJ#Otq5?Aim<2;cOe4L{%VW2h9l3AWNpI7Zzj3 zXP5D^eyN`IGj^ppW5GG(c-rQzFr^nW*1AfWF+Knv^%8AGUvA%rcf-~Z1&20njWGI7 zM!$#?z;5OPE9lz6Lp1w2TrV(wAbg6@lYCL-rhQ&zz1)PH7(kF$?8Tb^W zWdda+&Tn>^+`*4t{boRh9QATLzi@qvfJ@9wH7oT#&{uBYBI1&8F#6Q;jkH8L-_5y^ zIr}xFi4d+?GTR#7DC*;5!5H`DxzCH}R68$f?NmgVI-+_$%Kr{zso+2(sor8Mcg)F= zYMtZOa=W%5b(ZLRj-WpQyK{g#H8CUN`>^v^gA1M1cbIxE%)b2@5HYwA1I3mZ03WS1 zu1NA9j*=UXPfB=|ODDZHP|3m+M4Me~p<4FGEZcm|)-07^@(7{L?|EVJnCOPNIqJLK zxa0{T-a;*-%RYRAJ)&t7Y<*l75qQIbkCNYLN*1*WR#c3I$UnHV&)&Iy%_@uSy>(rf zRczk%fM{!G3KQxtnuO+i#dwkjm5NUxb}tD>)R%@iPUX5J!Hl< z=<}y1eetUvI>Q*!*?fNc>z%6?{;7M>A9VaT45>c@OiJ{o2&lJNvQ+E+IQ(`qZ4C^@ zuBcy8pH=d!cBldavE7z}Is=F!a*2z^Ao^sb)#Z`Q+mVxjy}6^|PP|{wzQa za^?$50W1s8(@N?F`*KCNr_Bc^!HPDhaa57zxfr8%6G-Y3IPHmlbJORgx5Wc|d(-2bh) zSgv!w%Oh1F3F9k#rHiWp-zWT5D8}{m^xDyiozsuSe^=Q2_Lll}w@+MJl!Lb&L)@!0 z=a02-PxYx}p>avPXv zgQ{_XKd5khlz9yAkdTNpA>CE{+z;jFsv<)*U9*DVIIimbJe5kH$D#ds_+i7l4{jV^ z_&j6j$l9jic%{RwNe@5Y$MNS~OeFF8*$8fQG=LU*A5nkj8b5%ffP{(gx3ruSsh_y0eGhgAK5f<8D-LOt)qUjWCdadKj zyjZO(!K3E?Sb%rS386|wN+sV=#z{Fx^5)(=2 zV$PRluD_qaHmB6L`gl>}wmSa+z3~s8!~ZH70m5K@;#$7rF9~8yL@bfs5VcS?b6G;&M3WLdMSIo@d{jqW}f@IKqL4;f4D$68$Kc1P@ zU|S*gK5aKJ@uX71ZrZv;1-Zun3i)5HtAI~jcXuismLqm7-jD9K zE?T|6@+@i>shkxhPa_k4=Ph2LDE89s%pSU1r zRes{y1FL+!{`wQwr6dV2|C;%lUAr%t4a^cGe8ReE)6Q1f#-GOWT+pX&aT}EnmInyj zHbP$(iMSFqF*R+-UOktxxV;_=tsH%(l~9)3yy)aIO&nvGQjs)-IeuG39~o z>n4Wte)iTnf`vqTnjQb)Y1m-9d`@O!A#ej|luC=q6qeCI_IL6QzMq&63VQX^lBiFug{4?J&5PS;q z1`34;LGeVSZ6GGR0HrU1?tFexJU7hjR{YJ(pr^EQB^HHNkE*4~CEueR|9*d4VUc6H zP@eNxfE2b*0@+mAWzuo~<(G8VURQ>zwF#VTVBcPZTXGKMDSj9~jF4q;NU2*zuCW*e zE4iuQ5Yo#p_aTiNkz((1LW`Ro1%TCXd|p{ z#%}QoN#4Kj$911mA8@V_17O$<2%dv|bMTj0@~HXx%)9EfPh5Mp{W>Rjw((1@S(WAx znT?ai0Xjo;Q9jSOsdkvG2TS?NPYvO#&R5nB1)L6_F&9jY8W@C+MXQ6WB0aFY&lL+8 z?O*q$&fM^$}@;Qgt)V%UawT(t zJjYh_H(dB3FtF(JE_=<-h7!7DBq$y+b6uqqENGPTZzy&CuKlUxMR8`N(I8i3fy!?t&QNI$}97Io#8Vd_N+Wv#OM&VP3 zT+Y7zTdKCZTF>sxn7whVM!km9Bj;z&dmlY(zsR%3Q?3-T z6*0U=`&7k?9ryzAuS$PS#9tEe-&z_Xzp@Ciw11vJQLmH@XYC36veDJgOZeZwtoTejoh9m@7Y!Ixgr~p0{}R%y<8@D%vImejeAkNB`<>|MPkO4w%e;epG+lBS=hC#;A}2NO^+KNP7SqeE8u+D=~HIllND|BqwooaO%_fiDJdeKgz9X@Ey~swxbe@b zbFFNv(?*YSNIDwFN-&|s7}_kzR9LPZdqt93p#(`yG|F4$f(={0uQ@fIG1ISCyfl4EV@+g~_W&x>Kvv5#d#umG`xcbB*$Qu>j(0-!4kfCGs zi2ORrl!l{uE&Ig+7FkKJOYhE{&RZZ_9FEy)>VLIxXmYGA&AKFhYuuW-)W*i&YBi#) z7sMAB#Zf2}Mx&g(>a+vpagZz_(^Yx_Uu~c{J137}pb(Y^2$iZH``ayAiqe{;!^h(d zGT+)pXWAT#st z9myP)p3MvN0hzkWTc=Q#*o!yH2Xlhfj^$>?tf+@rwj9Mes9k!On+;rgY*E5)7e9(! z1Ijd|(_g?%Mf^YPW?xHaSi5pu<%vX^L-~ICtGEt>*!6(+@e!>5vYgp`KEWwfTq;8= zH;$xrTr@$l6$zLm@J>u!ep`wqMYQ=`=3dD5(V*$3Tox1{)MSIe(l-<{sZ>e^jU=Jl z2%U`)si#drxz#!w)79SF-Qb`BSVcVoF7FBz7Bv>LN8f`o;J!suVIcoSqR)3UN^U{4 zuBJ$@VVXXp6*HX7W9HcziN5E*W2b3`*#L0J7roZ6^T#rK6XT78O|?&VF`2UO&p|vxAIqUS0r?gx3X$ zU~2pfN1f0uCg%kJgC819UheNTVb_`Mg);8WsABVPKFaUKOL*3!C8I#x`tFcUb$#Zt z39|g;IO`@P&(Bm}n4@C}R|#X8eA1^2_1!+@K7Ck6T8STF#U*>J4@7+H=cc o-8k=yJ#)@^uC*0Ya;d>~--Cp_I(fF>*Z-jF|Fj49nNI`%AK&|X1poj5 literal 0 HcmV?d00001 diff --git a/docs/evidence/r1/archive-borrow-returned-1366x768.jpg b/docs/evidence/r1/archive-borrow-returned-1366x768.jpg new file mode 100644 index 0000000000000000000000000000000000000000..7a13c3cb5c4b2c25684747733a4da2c6ec9fc1c7 GIT binary patch literal 63261 zcmeFZ2{@bEw=kZLS~{GRlo~piMQIH&R>#B`dO`*wI5kU9VyJ4hrvoLLXb>tjoW__p z#4!Y+v?z+ALZaxTW=c_1wc0QJ-Fv>@x!?cZ?|J^u|GD?O|L6JE`@HX7d#%0K+S9w< zoxRu2kC#8*fqt>Iw6O$j+5`e^5?`Pn?>B4OoIB_7tK)e~8zkZ%jLmW&F>E~!0tE$! zlO3(j9(H!YAC`FcFFAhlE_j87{0#pC5X)T^{ge&@_38WvY5sTQo!&lSUg87`;yauy zRxU2Aw-}f4`xiXwC+_(#_}!oQrSOn&ahgAV;$*brIWb--#x?!^hI{@E_X;8ZO#e!p z=0s3n#7|v6;b)0=`2=H}#G$kJRs>mrE{ZQdP$0+$bPVJKB7p)x7sS|c5CUWhG65Ng z!!zOwCcgDR`eKSH$VePJ4mt-q3o;fzjX{=Tiiwz~2ZH|t7pM5yo?`i8xb+4EvS|46 z<44^;X|@d@P?-@3B+36LP176%0`CHWo^}0`_RlsBxj??~4{=+?&&@t(L7-0%aa8~i zNO~LulDZ|P%Kqg42X#FxX8qf`9tPQffh z+a)Bn?~wdOQgY``Ntr!+cl{zOBPS;-vwy$5lG-781r^2p`wt&ItfH==1=TumNawhY z=5aMmEzO@oHc3cGNbZo7mXws%l;1C}`TrVzJOP1sYyoY;b1-$9UQ*i}0 zfwpY=2|sK457;6ut;FU{JH%YKUqG9-YynAZ-MVAvj%||Lwr&A!+AQXSx9^jdIilx; zq9^w%pGkQr0ntZa@FKHh4IMF|H_8NkDh6kOrpr}*s>Vx}or{Hkr*wr%^?t=l(m`A3SMOz^&5=_7im zhudV%IMT}?7eW{GZ#0f58yJ$47q{FgM5U@`FE*IUpn(Q3@h{no(AUgt+H4j*28 z1W73T_LuJe)}chg^$tsB^iT;TKhG?+Ew#Oc&AzuV^mOR{f} zek;6M*{<8--eZTE$$+CBv{J{jqX=ZDnQ>w%&rD~XX>CL=$3hFYS@9I#Sk$-^UMwfw zN+cB--x+vuPGN54S3U{l{z_#unnU2>V%Yt8*`=fJ>S6ym`!7cqG3XMa&<38t z%2IL3C@FqQcPRpxw3T&Q(RXh)$^Yap^B7~m6#M#2L~=p7=@A_3$>&!LC3cbxH;`WF zIm@%~zjp0&PH%WR#kNk^DwM1zP92a*QLf&Rplptln*Aaf20J7ftzdVl#+CRn^-na) z;sEqn4Uz8zrCw5NQ~Tg9v2DS}Gh1OI2VFI6btVd66%-W|zLsNKCRarSYCdhcnpUQm zf#l0-6@0DH&zDMni)fN@vqVEi!Q4%fpS_y*P?eNPI4H5 zFsyqNMDJ9W<2M;)aM47+lgXufcFg~F{rs!SuaLqq!85?PtzM<-()dr`X;CK7wYd3~^w zuw=td`ElHZCYFv_394= zsznjC+P6IK97qP@h>?#?zi6wtmfQ_()0K$-X41e7GALr-WOUIwksTQmdG!rYM(j(Z zUP4+kBN703gfV2{9t{L*PstCErJ<7wOuK!$F&ImAI!APYcGW)UV}-RWAzNmy>V zIiDN_T!QO|GQ!OKiJLxJ0o^fhecs~GA&Qyy_rVO=zM%aDslk>e^(#>`=!4O zoIY1}T_Ktet&ZcDS`zd2V%!hlH1^sQWnj=9N7R(&-?}Kg-8C=tEK@AET>Fk6$kL3Ght3TyP@is$QE##2b@0)AC;iA!fZ5 zU<;TR*))D7S_RdOy?WXH{T|8yKW%$kS++Mo9!lR${KrT4!~J z=?~4Lt7g^CS;8krASh|GqM8n_G>;sTdJo&@uN-uk!^Szbs|fGLdS1b*!pY4%JkH6yfciCI9TyN7d`*y#qG9V~QhXp_OcZgQE^BcI#zJdD=685A z3${1!>)ZvM?p36st<(=GC~&5$??AeDeT|h7?kA-V357wl%M)IJ=1_4Mp-sKk%daOp zigm$c)0C(#3)ZdWVO{R2@!?G}d`c*b;X3SryE_oSm*b;r3X6a^i=b$~+(W4&45Im+ z8M2}%A+T5k1(^N#NIPvGp|g=q)&seC(X1(f17tq}O1G+39~A-Zy2dhBe|=3z$~gE) z%RT$9|3^okcoDE9B1Yceh15E|u-|21bg9#TFrhE!p$r%wAi!h~Y1K~(Ysa@W`UdVa z@Q|XH29}u_D+4|zrqcakc#@P4n~ASB)tY9*-UTD#;8A(O#T{c{{^9BHHoUm8g>fdJ9I>w`HNh9 zuB-D*OTTAV^yIC1@X9~TK+4~gzfAK%cxxH4lefPuVbP# zR9r_6U@fHCS>PULLR+3I&a$067F~Ns&G@1#6-*?Iwk+3nOG=qikJ{LN zWqH&p_Ynp^Uel)JX24S=(IdicW17qEs+TL5vZJ(~qTM3RBD|g7JtFY9`YPVZqN#&V zUr&%Scx1hDJ#JX${W0cC%)7AlOTjm9A+>cF5Qsse+Q}cFX>`3b3BJxaGNdLe^$jH;NhTU4L)Ov#3)nul%tEwcI<3ca1zsFIhm(o92!FoFq zhJ^1!jE(d?ZsGAgce6sm4PoJHG2F|}Pp{-gDyZ69s>8yH$I;8_6~ABE&`rkA1g;EN z+G}f0MSM=TKd*W|5mnpWsn~yMJ8(=n*RcD=^lx6n5qBeJ|@U# zVBqf?Dw6WOrW*DfqvbP7A|kHHFGo~a%e8h)d4@Sc;{ZMk69qtZaesF42dm{{jtsA%-9(WxCtYdEk1p0k2I z8za##^Fm|W6rneY_Q+Zhmm7Y7q6*XItZOYURs-d%?Hr$ot1p{o_^8m$ewvT9a1|2b zpPzpRiTEF&4mZC$8wY?J-kwjtENVkg&L78wHR!Ph3UKXKvtY!T=9%wy)--u;s1DKT zJu+RmTBC9B>M!5>YKJws%fWekK{Ez9j&L3)n(Ak!FK=j^S~KfC^xW_oy$c|gCA6~GAFffcdRd* zce&Q^#>)p1Bp7v5c*Fd&>(?*f?y*eB<;7F%#}aszFL6mt4L%&-SpV1MI8vKL2xzi&=)i@?Lf4sN zGP?ohoBIV}W7oWv?|&$e&Gx!@@M>Q&m#ne|eX#~*69ak)>F=M_e?9rjSCRtq2< z_dCdKB@XX4b-Xs*Vl{7wF-&&s+->3JD8f`_Uuvtj<}hq9vd4n2vmQzByel6u1oP)| z#nUyG7eLb5AIGxfcez~fn5vV^SeIpnHa+BmCQ6t)bN2(&&^Zy&HXz52uiIW*G6)3# z6{nlfNy;aJI=X_z)DGyyyZlk!sETCZOI`eu{d75~5_>;@E8h=?kHyEm`)kx~F2>Gi z65rw=s=I(|5e3%02OsCZ>H&yQyo+xdlPTw&f$|OywB!gaNHo&ZKS8mTsdKrn_w83R zD(LY^vck!=n(w7_#wZ1%T3qaQ!%=l^cNy^$tPv*@kB7KN<)C~U|7ui!(A1Pxz=RM> zYLRxX1&V-7=_!ZNnTCNgp;(=!xHOKF*@Gzj2H{hy(;Zcv-!%m;bE(8co8a6V__^@G z)1nJ6(LGxyW6?c={C2r^l`vJ3dz&(^i|hlDWkL^~mJH;*&}TDrSOvCmF!{mAfZ~35 zlkzb`5r~buSjZ`U$j$&NP}b8I(1Z)!UibNP%Y=f69z;pCcEvnKn=n+^sAQgX z-VJ7^EjP(8Vkh)!KQpnb1rp`_-TTjZ8LK&WYg`}vMe7dwhHh_^*miQEN#y5 z3k0Qin=a6%n=B#VCl5WWl0cn74u^-TN=_mS&xePVb-$lfEkR!^FNL@TmX>Oq2CWq) z9Jy&i?}^qc>JNLxC6DMaFz-3bIlukY`quvOY_vb$Rcg|RGLaOecC6_$?e=@dX>N_%r z^nHiJ|LNwtSuxhO`uN=+Hh=5zJW$>Ljs0Wj|KFDQ%Lx4cttB?!+j9CZYxeJjJ%+m$ zK#FdwA934|{fk(8sVuXj_Vh9phC&yZ9RE@nI&DrV2uXat=Y?Wy_8j@D@m4n52folp z(tFWYTy~V52^_uLaW&KU7JuO#wsIPVfWuOPc*;JutpjMs4iOA%rOwGLffFytzgk3N z-iudHMoII$-l<0h`T?#-87XC-%1SUalegN87&Q_K=GBV19HXusPFp$J-~NcB+UXoG zYEEz;Cqgf{-(ShSym;vsi{pwun5Bo>fq_0(%Z#eB)WR?3c2?X*cd($!+>l36aw0bs zJ{tFHm0h`QvW{}CMUHMxncI4m6NMoNJ3c!4y)m$QpSK! zmMo_`^RP2SN+Q+$v%-PmyOal*w-3P(GN%(1aH~gIU=B1|h2V&e#g99K?$_1|zQq}1 z^vAqH87rKIyBao&ujVRFmP7pr-Y!O=ZFbVbZM>mZC}~lR$BREM-eS-eBMSfUtQsH} z7g^0dFHNBH@SJoNush|x0|A1QbJtEOD9AQrMis^m_1gAd!O3+%eL(kq!8Nd;HXaBm zjUu8?ndy4`_x^5Ksk@`o`X#urM_5?e%Y&5*<2>?nN=qE_x?#d>w64Jne1-e#?kAxA z{{L!S&sZ&afAv^68v<$;d@X71Br-?NoZIGY_`yYkG!Y(bS)ecOqRE{McS4Q#WC<=v zHMf{m$xo}x;{`FfS_0k=P7^BOn%|t{<`*YNm*12aQ;6@DK~X{5Qv-ZrHWnQNAOHpt zq$1aaGjf5DfmW6eO0Q*2xPE!Gs{T!>%=#89+=(zJxBXmA-^i9(fSR*6x{O^?G*O1T zV|Zzg_fTX+eipjVKw)9_i_9A>Kt1H$$4!%lW&y6Kkh}s{23rZG?dfSY=yI^Ul&$zd z`PeixAp1VS$yyhkX59U3uHV5%@g;cfY7g4pr*k;2LQd2pVH%~oqhN#t8_Rlg(o8*Q zI=*G*@KV-h!LYE+dYaAgj)3pYr%dUf94+$IiKI{{F2d4?Ha6iUg-wA0*`v_FoPmGq4#c71l(Qge#GYfqZH-gvtB-!oYM3ACRUvZ;4yhQg`3*MfpJm=Rms_Fa%h)v4>63vnwC z3Nvx~BPz=oIweK)$BJj_t&nprIomrdBFo74-Q6okvNgAJ@4J_-&W`WeAH6L*W2%Kb z|B9HCzXrjY5QLR^T`aR&kpE zkey1as@PTQzOOu_XC005x)UIBrEU3l*UKg50xQlrlok|5AmiEby;DLD(W71d7kQsB z{eAjvW}W9bmGpNmuQYxd<=C+M*)ir9=Icu*lh!TJx1Re$Q81blXs! zQVSnosF>5lHy2+FL$!{i`zG7cnCP&@@>nY7%0M*@Y>v5Zvn|m&hg{&5UnOcWs%=w2 zCBzS$U|?db*~z@7&axMQvgOGC0CndjdwX3#l6y*KYL;Vc7=(~64r2c1 zM>Qu+s7l6iPd{C5weG-JQ4v5%oY@HIg6zS*Uq7~-JgsEko&2iI4CaMRlQms`Zl%p2 z2r=&g|EWwm#yxnCPvE7k1r(FfB9dGS{2Hdq4GwqLL5y~;+z|f$x6yNm8b;t+@*(&d zvhotaPimd9w=cWyB{7GvUZ)m4JkD;0ZYX(QxlH0&I4XxAK5lu4KpSnY%_ z^2%c+IjS$d=Z7&a^E+oTE9}5c+5QLK?XevVVwconKWGyAi;{JS87uO4z4TH&SsVr- zfo7eM(;xB29`b+{dMGjX63*3!O^Wc?pF2uq5yvSM3TJkinNxhTD>t~Xfa48;VCV9Y z6t-kpBz?9H6JtpBK{+MC;0`DD6m5Fe?8`2E;o7zDq5-BKfu(wvlb{^Ndd-SDIk8z0 z-OlWbq?RO`Ne-$ee4tYq&%+{^V`z+h?GhR>_9zJs@_eJS!jqMcehCHB1$r|tSyZK? z0K0`x&UG?@BUeA2%i;~sSAPBi9m{_d=ur;G8Qr1*b!J1HMw1^PtE5wZd;}>@+`Ag& zamLANTgm-smJxz|QAWI&E4RMeR-XB#K**`iKM~bk>yR`$c>CGX7xA1@5Irtm#%ASTyhb>9Z9*yX|u*)T-+C5yywEXx7Y+BwWc78Q9x<}D~B-POS3 zVpe4|bPWoz*r{;Tg*cfb;XVIkbA40RN;vrp1YX&F4eXOBKZjgwDpf;w_z5#pi;B^a z>otDWumEt`hdjzLx`9(#?J;BJMbTsrw4g}r2+?(@JwjW^cxUyuiOKJb51Xcn|`FQrJQ!7i2S=h^kM!IK5a~I!KHsyadv+)RysoJhQr`R*D zaFoMI19VY}*^gZ^SY{GuY@DK)Vl(R^XmIPs_O}n7HSB0-S#Qmm>R(jNsnT`Kni&Pw$vPMpRX+*6C1t3eBF3rO3E}?{ z+XB1LM&D@9=rqH}FcCS#{{#PpPl6Zm4vYpPHiGMl?#3vy~Ob#YOvTJAkt4e&jKa+qs zrOdLH6J|Z+5S#^yi@>y^oD^k~k8LU3JoEz;K#J2-pd8i8z1732BoMi2Gk*GCVLED! zJV)VcwHx>Zg@aV<-o)bC?y$>+s)yGs%U*FYpYQyNYB%?-Y}`uhKW{UQ;g%p5x=Rmg z_hZC~xmLB7Z zbFEm$>(qn!;d=e9z_dJ`mzv2;vKpBZ!ig{?ZKo3$kL(@s(No7Pasz1o@e9*Q zJrlH#4sxC&-}m_IqN}AUs}($2$^zN!-x5>@g7a*Ym6gXaDESHX@=t`UClWfm;$a-@ z`GwE7@S5#%=l0}U7R|iH!NSI~xqd#31Hu?H`B;tew0m3TzO^mrg$N;&;=WW$56i#Olv?-#!X<)%2|D)lbXnGc)o*`7#9N zl*w^ZjNNGvXm1M%^K6``osv9C+0HBJ=AiWX1P$1!(}&_kxtU``(pq<2tA6bC&WPbJ_8;Zvz7>3?gVY8tjvGHB zRWChw0|%x5Ouoo{+i~&oFaKY&Nc9t(R~&3Vt{wTU9Yorqx~X{Az3qSMpO$~}|BU{E zy&4xIp2D2obo>Yix#P6-=3llxx&7ZdBA#TCSL9=IQ&;vi!ou0A!C2FM$v#c2k*M6` zg};W#n_sN)m=1=Kry*~$v7-L7t!LK+M!D`qZzNkhqLdifvxv(NgWSp=R`da!2s#7; zF=eqvP}gG5PETN>b)tc+KJ3e448{R&L)^#puj+=k*7AztDK+iY3xT!wUQfGSycvhb z*WbN+6ZdQY3X?S@Pl_bGcPSYqdRR@+)rt}p`ILck=I`Bg#b0rB&o*~Sa@rjrbzbZ78I)ZfCP4>|UMVM`gY^0R;iZA3blZ5Y<5bYpIkzn0woek=1XQi%?hZW1Jl5>v*7q3P0??=y$ zxHfVf6r*A^9!_CoQqhpRF$Qes^}>@TuN73#L>_y|wb6vNwB_O7UXICmGdm?#154ql z!%c{Nw9S3Wa?VYkoQtMS(S4wBP@#Vj06g>-_OorYm)Lt6%6SbX!HTkqeF-6(l1T>+ z|4q_=1C%6oGTfcKTf3~hDx2AJ&S@(z46XOExSwFrQAA+^m!9l7KdN*5d#URW(4$kU zDgKz+QOnsD`=K4|N%0nq7Ojnu$GP;uWpBUn^-s^zIz6*vwFgo7QXgBGLfL?4n|Rhwvgn}jClY6jpD4zR zp49@Ts?GMh{#MYB7O`_9kX__YVcss%ieL-7XRms{dX=EdPqR;p`0G$it^cmt$dG5|73nh%Th9&~aByH3y zQ;lu=UjIfJT5q@54|DG)WyW){e5X4h@`tGGNMD_3N8|Kj85p*3T)vm%4WK!G8AWT_yo2!xPXv{Nl>Qz5LSW9AU= zg{@KF^VL9m$e#*~NbVJGuJ$K-JDyG4)MA!f4xGXeO1d%96hK2!c~u@eI}S{mk5m(i zW(xdd%MUPDF6#OxHZw2%A%FP&Oar*MvhsXUM(077qc30|Ns)_fP%EwwGd7OFuCk!a zfh_IH;iFD&mxf%Xb0b+t!^%+eBU7x&15J~li(4M>r(+u88g}55*ag@sTV@SLX}8Lu z+f;%132;XP*Ct(8I^pEK0Q=fz)mZA?qe(14ROChE)w0(Lo2K$B47FmNV;`HR!>~B;uw=yS@g!p^bg6 zqA&5EBkSB{+kSvDq%|yLo3>Ti6x~lvhtbMDsp=yTM@z1#VBl9UR5$NPu9IR&5$EoW zm$x2NO$X_8A_7X$l|mObRcZIpvSLTum85_Q-&$ph&`hWgC$=dE7VRdZuM#kKNwU?< z*mmH_l(dTi^(t%$Hb?|u?VQG_O?{hrRpsWfwP{sb$>*G;)qTq^L=fWHIfarge;alG zW#|_16llvcPIwFxv(TKE%J^*8Wuc?ATa}JL5Y`lmH^RvPEUJ|Cd-T+)2jOht50J#; z9VupQk&nf`7`vR#oyS&)8nG)^s^}Id#bOK*hH7tjnr{EdRExP~Q|UV#okv|f=PKn( zI<%CL?f>{8`FGM5Z5wW}4Rn@miGZpWP(x~{N8uUt`%(L*{$CL_+ z?dIOUg>3V&YH4@i4)$5op3=w`QPomFLUDX1Aw$^y*;g>g1!GKgQtGuHIDc+G^vsP4 zr|VHMU(S;)C!recBRQ$RNBsWD^qLHzqlGy5sw2u1Cp5F^t%EdUia1m>0Sd4H-Q$mF z1D3~h8(iNyu4@$u%WEyfo6@d3vXi0FHD7-p_* zeX;d-&~}?*Z}D8R^Y!!!u`n92e9CxWqz!h&)g8d}bhOPjX%tnQlL`xc;IeWw%n557 z(LXew)9~$>^l9nSyIe<6aMo2xZSE{WL#&9Xb!|ky**9$hUW(^y@9E>IJEVF;qJl;CVvG z__WuG@@f`3v|N>J*-td>7;h`VQP>NWFN*iXdIh%+cIq{f`5w_XZn>Q;TEVrz>_(AQ zKBZSlSp+|f?2ej|=A459sk_Pce%1QeVrudZAqKMF>hX>S8bSOseys3}PF0*Af2p_6 zVt!&0F2rm^sZp4642~j_#C%ExsPCx`HrY>1?GL75g{^g3uyORjoBc2{E2I+wk=;4l zbgZ=UgS8`5hDy&v_Iu94fLAQtu+()ja=YTaPN%#;X0mZ#RThk>9){WH-L_phda=h< zIAaXja;L8|vBVfJ`PE=w?e|u*LzGcM10Vr~P96{n(N-!0cRDUECz@&42iN&u?Y~mi-Ev&50kp8G{c(`mBzWlK$y4`?9l`l8NLAmIO z$9WreLHh^|dbZa4>b(Ya&g$($_-zy7!qD%2zbrXCkAA!@0TWbMH80#>>pyl?V8avo zwUbcN7_dGkDIX^azrLgY^U&Om;=c?Eh4^S5mh406}(U@zc#~hCZyvpas z>I78>mj!D+r5qN7Q z(>duJLxH6*VLrM#u{!QqB8@}Nq+Yx&bv3fne%rP*oviLM`mY9;ifCEgR0?pH~>1#D7t~vCtsF&CQLaBvc@%sL*1* zup1Q>o|Y;>8IwTf3(rM%W574v<_({DfRI*DPLrSv0NXat@igOe0N? zI`=wiD32??T0*;OS=Ngxm0u zlvi`2b#ou6lWTz1v{o<1QB%cwi1KJ)XS15dR2L0?L%s4v$33g^1hpHb{lQ_kJ9MQF zU!J&Yb>8l`-a!TX-SYZBK!#*u>odB70#uGNMq(5di3&o1xPvrt_d-h?g{p>qM%m~z z{zV8xifpBNZ=7yXbpL8?xt~d^%&K)Uwur5qPkC$G;hY!#JhqIbR~lJWo_je|bSXOd zRS86EEc*TmrnbD5)^yC!;fdCi-^Sju>)*E?njZ?x0b4%v^hW6wQ>m7`5~K>s zR9WM~>fh|te;K;{Ptc}Grs?;(@V1StGe1CI_ntE${Qxz7<>Jrdu2xU0P4r+eyF#81 z!Km`fNoJ3=fX4z4XPE~1%GOai_( zl7!ilxjL-gb*(9OP=b@|(1B1cr zM)2|9uYOapy*HJQq;hW39x8TG;T8Hy4;Uf9Ks`!+&h47%+zA0oAwq}wzC()9#1vGN zr45`{$*^+l?{^_gn(ouV;7p~V%priC?N{AkWJ8UF4RKOC{G^6rOu=1fvNCG{?csb7 zvG9Opc!|?MHA@@zkIP7Dv9~v8don82TX8TN^1A+O?7Hb_aZM|rweE?_`r4|3#N!ob&TOO*(~yA_MnC{LpCuJ`?I?JFT4P7zx9U13bh z1Q!ovrl*5WL|3&07YKrmav$3h)AAP`Ypq@lGi@T?D)W*&7tkcQbs$S2Cp}1^5u%`MW+LPwm#~v^;$CVO@TpD zMmE{lUh<~c#gNSr;A2Y*NRi<(#fXH|cy=qiY22qrW1&@A&g|Xxn7HKfxB&>Pl~0;X z5HDAU!3e895fNpLZk)S-mo}qG9t6_2_fq|vybU_dhNfq_C*jwcpMN<;oIEpCj)6ex+z~DiQ-Zkk;LD0Uqu5DE`v#&e> zFST;HG`iZwU|QEHO~1c~;{uwyF&TYsA;aD-xDQe(u!BWXs|+)I;nwb-xiEYYpKxLD zT_&x~QukQh)a!zAZ3RU}DjjV~9TQ#wcO)!_Zb2&QGN&s($a{#5L8QQD_t0fe_SK~2 zhnBg=*|8%_={XmUHMuj*5kZxxq3Mj!20}rgj7;>~ZUFG-tQq)r@hl4_IcGUv1IO{ZlWTk6t#b!h>RUJpt8s%q3+E)pmJ zxK4uQFj~uL^W#zJBTcy*$JZWjfFB3DatAK(b-$Kml87WPzva~|QQY)sc=oi~KK}_i zUU;u~m_$9;3Tt8!S>rLs>`Xo&wp2*$T;LZh`RlL-Ty~+CbTC)6@`h)LX!YkI~&NflamU@Z%&JEJR3_K~Iclga6p-Vwc9+%<*(7(yGnc^_=8DC5$XDv?r zZ$|WgBI{q@qyq!PIW02U=owo|nhKq3jmZec?Py3z8Mr+%#aQ^ZYM<-{qg36B zDSwVL#h%DS_(UPw#Po6@Cm}cg9u_ln$nIjHO+m&SvH|Yp96uIGc(mB82*xEm@3;PP z%%Cd`{XrEEFmgqVGTphd{%#X4VZ14{IgDogkQX7iE#6QYW2EZfCrqclaQ103hl*=AztF(wh8!=#DW4_`pH2o8$C-2au_Oy)eq?R?Q((ImT|P zpVyzy`fjT0C(^uASGqX@lD1m}t$LqrEj7bH4+S$gYOcV+VhlagwaTNf zGcNR=?hjCY$L!Vtxv6ooJ~Ta-K_kIf7$FfTT&9~YbwI-!dw<2$mPTQ2T4yMBCs3A~ zo+aGnOIMh}=UOjA4_-!Gtmt(KaMcd<=Q1M#FJV>V`)vrXS`#}p`+I_=r_GMtdK#-a z&$dmq+4F7At-bcoA0XJF_VYhLCS!a4m;Lm1*xh^L)6MB4N$;A3QN5h{?jB--VFIT? z^)3SEFu^6XAQwf*g}KHawNs6^8l(>8clCY^uDj5WkHGmr%f59RuFZCgj_-l?K!*(W`Xu|QAm{wrDU2c#=wI~$IcBCJkx--}q$f8HM%PN8MzT63noadkbgVST4y%A=_&Dd6abKbW|oi_z`k(K9<_w1n0l@|)|Q`?PWlm$|`>U}#` zX2vPdR&CVB6f;0nnpcCttYfb1J+r3yB4$@rb1rU%l-OW~cg`C$;{y+Dl$#&0#rs;Q zUrH8A7jO^LPTlH!-68Y|vIsh%IhAjlkOkB}%Vf-CKgKKMNO)`;QXJlX*In@ZEp=a7 zTA9WhweMF@rJ~lVlQ=~$(B-|&47IpJu|rWoRd;Y5oJX&Zcz;9`U8z^|)Qy2yDj&=J z;$FTHR4=0N!vQ6e2%YYma`eh2SEHq0d!JKnBZ;SOxdAnirlc?eQ0i8Ihgs#mmBepf z$6QT*Rf^&DH6S`TIpxh^FB2MfJ-&T8+;g!oy;?VAXj)6V#4bpDyl!S~#*3jiKxBnB zl_hkUYVQ9KtrK4P?YN`Gx^Me3rHoo!OoMKvL7DJQjI2|y2y+nzpUj-hT))Ix374g6 z3G7u%1hV+IM{R=gE3l>q$FYE^;wvajpE|l+s-NDtpYEcucD}Y>^yXdm!_-QV9Z3iX zIhMSl&peY+m*vg zLP0FyfNEM$Pq(J%dgm zUjA4skzhzcBTrsl24g3V7ti zRJgtcb_trL%P#EQfK`dqd>miRqo;!zG)C>*5Rv}@PUKb{Pn^~Nb!zRlas zBym1m-}zXo+L`4oK)Fs&23{n4kXN*FkkOlyM+i3 z{+NIKwN}u|XALzUZFTxEPo5Pxiv5Im#~E&s&H-7dUrFTt07*XHdP(EyGdC9^^P#6- zyJtg2xA6Q!gImK%lS=yplJ-Z|?AG*OX&IYLX+ftQ8NNg9GRW8uo{w*8V^H9bhEMAz z|2d!hP3t0yFN0-0ZQGg(_k3EY4*IfhNL~%5qrqt6!Kt<~+g8o-=Ae zL*>~N+6ht@N?9)`!>lEVk+$V(t z>$)E=W_*o&JorIi*;zmI_VgDpp;d@3S0ihbfIBiy{nmg7G+D(3d{mFkQEF_;IRp1L ziylju*fS^t2i2I~GWl;L>AMx}f444EeCvvrf(mN@IRgO7pLxH2Vb$YjIfrb{h32vW zYVAkL0p@2IzZCgi!aYx*9GaF9%#LBA_Nufm#u z0*c>(Bxc{$>iE`}?2rGhiZ2UHv&;E619GX|6vZILghoC={kluu>h5lnr|X=zCD~v9 z-E2<+*b@NVtAJwTP7M=^{}v(wrO#>ZkEi56JDP4wxpaP8E!P?a9-52oxbJS8X+pQS zLoTG9WGBv5F05WjA1SoD<`cP!`T_dontb@B3EV}WLyLR2`Y73n9x805RL2zHQfS&Ph3ZFJ^D{dkiV>Ftqy)X^*@{T%s)$f z;-FMe(7D4A7OJ0A=fji-%Lt`mD9E$;Od%L`-Q}}KK+W)IAY*mC7Hb-TpMDqy#+5;S zO}a@aYrf8Y8&vZ>DdL;q)W_%CHyx+mE8sO2=-vVn%XMlHx6O{p^&-lN^6n!6bxt}B zj7z%k?P=uWEgrM#JwHG(-^V9#4Nq$aU*gmqbGf0>$_V~-+LZiJT&+@B@O=1$lY>{Q}X@93q|o6J)4zAzn)xtblvZb z{xgP(r#42eA6uZ%oIZ65Q-VRD5!fa1CcAmtYRu?QQ9g7tWewFQE0dx#KeHGoefZky zA`L6*Z$^R5!CUYmfP;_c61dXLdmqJGsXt}^FPgV1{8O08;Wwu$KK_GnG@)_(;zmx? zPr?CjhaLYA{gcqC9P)QvH+PF=E--dx>oi@Xr@<%0y8sLj=s=~&9HS#Bm^>exM;mqI zGxN(}s@JzV=uWopJ`CzAF2i$z!>i@%B901|IVIc9y+ zhMd?Hh-!Uh@@RKk`2Um#~79dyGf@Xz^?l3eC7B6Zb-bG z$Ba2x(SDSMtN+MKAWTBXF&Nht`xo2QbQ7k=)!-2_RZ?*2r(^P>}j9;q=+ZzAn_3%6EXDs7BM>%YbWR*&-+(AHo+GYaM(P5ekcB7qix)ITS$K1DjalvJ1s+CZF5lN< zqnH#ay_13N7y_^CiX&W|`@cr(QJkWuA&N?!m+4Bp- z0SCgfz^WYU6yjKuI*h*rpErE+pYvSnnx{-sd<15n^`+krHk8JRq#q&91FAP-E?4*Fc9HF`4*c;Iw= zrkK+%UYIl|7EP3Hvn*75zb)tXFl_gyvt0CNJe#d`#CLT)9%e)jO!sgBmYZ?C?kDty zVmG7;Z>F3pHe60Hb2@isYp9wfw^yFdllH)1YG3|5r>L?gXgA;8C}cV~*)FVF@7d>H z`yBPtps@%H{DI`8#UrO)1PO>Z?G4OJ;nHa!O zA#P1sFNq5n_kicm3a+@3iE2{*`93~UzEs{tz{)z^P(-#~w$jBV$-Ik>_ypG(V9yp7 zyQD?mH`*O-t{^jLzaO9D%W{`W?W~wNt7WL4Vu~Y0QRc?UxShV&c zH@*hp;4I$L!Ppyye$zZD+hS(PpmAJ`@p~todePl|9!^gB#BYz*@qDw7H@Cl@&aEDX za*xJrgT=i?4@C;fD6|YUB8zXr<+hltFY%2u)=aC=X75vkn0iKNJ^POGN#h0#L!i=Y zP*Pmu@D!<}24PBS3+Vdsjbrvx%^%d&g5IOBtZPAiAyeeBYoCC>c-#oGN(>~vA6 z$F9dSLyJE^Vc);pA^w{frfvPfsrjTxvGaK+XZqNL3JyL*6Fe{=#FyY-qEIo{maY#`kw}@VL1Y3P9EbBI|ZfB-?l(GVH%`!L9 zt@6X;bJBUq=mef&Hwp4F>{UXE1BdE&47QSK@zr|1dbO`g?1r-Y2BEB=t3)Vjy=JRn zfu27x%cB+$eQS?*8a!6|y14M|OLZkTQ(ZZ=Vt_1&@$> zKSyL0B#x-1E}`maF)(PEBZg+J`Z||fr#Au_oTzEyqNl=Lp1ba@m8i-nt!aIg$NV4c zy?0zw+4eWg%&6mtWe^b!pfgknMJWM7vnBLmNDiR{80jR`P^6=bpac^o8cJvyT1X&B z2@skiN@xNS2p~m8dJqu>6cl|jcjnH_y}x^Z&wYRIeeU~yp3j^=a?aU%pSAa1*>&x; zzKaxLht^doTLrqQ%+9FEeaG12f`B?bmFJ-@h5p%tTU(Hl^}ed~qmN1!_bojBVxw{X zq6nyM{|c;m=M52G5s|OEF~dGUM?FSelDsa#3LD_t8S6WB`p&irx}}_i!NOHK+-haZ zRMO9pp`!f!PQCs8usoNdAw!r@8=XE3E-n_s&>vzyi#V>K=dh|*~U(S(@hiD`hre1EB z0A_wFEw~`+rG5i=Ao1zWijKZ-fhjwc%{NtKu==p%kuw~ZEB zz0lc1?}t}>A(R7S+SGsyUOD#;axFA(8t0S+0Ijk|P6O7~pKf&SXuiJy8;8y| zH!B{(wue^inhw;YXJHiMu)*Q`hwTO$qRwYMu5`No#P#VrS(P*nw}fX~)Kf z_IO~R$1o`LS=oZqLpWXlhMpJ(D+(1U*S|Mw^w2o;c44EOJwe*rQ>D>0h#M6`A6W@c zO??DVh7brsqO(V!gQ~iC^p!nE?HMt6EK zVv&&paNsCR#1|VO&BOf&%X1l`@*lK^o>a<|E#EQHIy$rh=CCZkq!`F)4ik?D1k4(u0v@ zhlh{XqkXRME;+{oC}PzT&G zSwfamqf@d`%E3smkpxote(*FVWaa^BFX@4`Wnm+%AT~r*>Y%=q{NSUQy!DEr`HId# z8}(Mjsx5_y?~i;*YvJ=%JqWp5FyQ@677@lmu=|V38ZruF=R&em?0s=0|7& zRBmkH5Z;4l8eiri|BjlBd6_DY8mRIWe21;_1uEQ^9GIKPBQ=0+VL86atS;NGY7#$) z?qZb3gdDxy)O#ZK0X@y{} z<{;3C4E*GS!>E?8C1ZJ1K@|4R9l{k2me^aX`QaZc-(VgNt$oZX+5W=UF^pi*KB(Y{ z5~@6L78^Sj(n79Hc1!@zo6AZ~X z@MZP+YoRY$cNbAe0M+OxO)Ab1qiK}&R=*%JI95|KA5Zmu7O`5KE*pWR3D{aH1n8#C|ptOq<(-58=a}=WLxaJwh4c z_&qO;@x@*+o267aV-@3TjZVVr`7qa~gZk#L#>#8;>rNY?q;_;ww_d{4&NjR+Ta?94>l)(CKSsO z*U*;LCT+Ijv#{}5e6tuJuKt{8b5w;8r__7RIk40mZeN8wEg$7UC0=+_xg-Hpz}Jca zr_xSzCF*^H!!&s*gq}8FZZDAJR6XQdt>ER&a`4o8tLWkD#@}HFTv)2PhtamS>djJ& z%+YWZo(Z&EFRY&`3+-Phy04}lySmzW?!8d`tu4B^K`^CeFDt9q2;iIQscMOnZeUHz zaJXy{!{N|e$5q0b{dq_xC+PScE3lO3>IIi;>s^eilf~(h1z4|WaL33fS9s*`DdX`~ zI`a(zrN0m~5R2N(^d0aYEiA;zr-?}~#hI!j4aGvoe;8Z{?=s70S$vVpG!@V0ys8u3ZFk`SPN2RF;5I$-$69XZv>33i!xML5e`bmww@@-yI8F zqP={GY6#8dVWRKoOB{45G#u1a&xjTp)$FZZj81Pne*HrdFWP1EiHme_txM6a84zQ} z>UiO+R?>~Lu7YlEYqGiDvjck_#R(jT82~5M(=F8JZPQVH)eX2whlR0~t~u6t~s%WOyruipVR4t98PAHZ&Mhq z;MM{nB)%8$WO-rg1C^&&Z(c8;v<9~uMt82ZeDlm!LP7@Oa`t>lhO6uGR%0dZQOKvJ?tLbcWI48#QtzolV{cj^VYg$h8%uCl?_Ek(0r%s zCnnqi%i>y!u9e_;k|$mInQQf0$FW}HGS}@9Q9^t7IxwS~$lFCgx5*gxct~LQd^kb( zd@z&JO$i+_59X*r)(6g(4O-M>0TOzw0z~A6EGMH-lZGWEu-#H2&t>v^FT-9G&fPt{R!s+hA2CrvpCgAK2z;= zg3Kt0&%;<*M1PoGTMH{s93>SG4&ggKs#p13pnIP`zP0uZ$Q&W{loZ=TOre*- ztvJZ*#hJEuteTJTxaf~=+ZIF_;OW?ePMda>aKx)Fw&bx)Z>`bNQMdXrhI)AIrXY%U z(`#E zq{jwwT4pLGb?1s;yHlKoX%-8RyWGjhNjig>i)vLWs|;YqVbmQEW~zja6zU^jqW@-!(3&ndUr3!lwFX3+O~Q5~H|sobtKIa$7&4WWqs z5VgiC*=ty*fk8Pz=?(AdIB)lSiMb>-zEf4XEq9LqDb}+ zuQJi+=uML8=)GG}I@%3sY~9FQ0w-5O0+y{K$0xdAAjof$@D)q~OZ?)&C6H;$_*99B z)w;R)?s9%fOt|Zu*>yCScD^MwBrm=!SDE`5ZEKAuF5NC0&LffYbXvZqc~zNR2Qblk zxV2_g8N=`(=WDmB2AZ!DN8#WQkFoob*H^o5xFLy29n%6DDR_Y8e z>iYzmFDf{LL+C1E4YorOC#D#hec7QPp;XckvmC+lUzOx>ZAtDFN=SJz@% z6tbM9TL=bw@Kd|!&8gjKHcN$&LCNw?tXfBpsl~f8P4Jl>ZyJtakY7k>x|@UEi6-Q( z%=2H72?Iu2Ot71Tk%~J`tf60*Fd2?$k_gR-Z`t<2ITj5zRgm#SEfMap#X8|yl)f|v zCWViR2zQ}$l?cAFScsM{nEj9x6>CGF_~wM9e49ENoX9IZ}(h1&8H92-PhjLwZe z3yA3-R>*d#oDHzoki|KT`|P!+%>ZIz_(JMYbEDn-0}|^C`^_m{u_TwsED-i(0j@F$ z)m03gVYymdv`@8M9Iw$-&{j{5i;_uyMBu91YXkm)R#S($k#mV4`|LHBfW))`lMeOp zT_fU;7edC@A)As6=-plSyrEfm6H6_ zk1rOBkjMvSL*~}I2hLShI#-49py*9icd!q61-Ng1?3GTv?}ff=SY_oG5v>P2ZOrua zwN05)6cn+u8yaDL{99CQwyEqB6ip|kCo8QselIL_=X45U+(%RYoj@@t<_i2^E017PI@hEWc}vkRmAZy5Db$_Xy8y?*y~2 zr9slfB=-!3rs;Vca&wT1){0>^*jsNu>pO5>%5^Q-I^4wzWn)pgB^lfsCG&mzJW;{A zWe!D2L*cr=5+fTiX0poBU@$`#lDaVI)D)VdzGqQ_YniJulb+?rXYu&}&__#M#Hu6p zT%{!>uYykf%aZeLH*Ds`I))$}GkuaC(i%@z#V)D_RfRbVk>4Nx_0AHhZ0YO|67IE5 z|9U@D|0VZ>61?T)){b9pyPk_mz05uM{L@uo? zKGR$M|A2&#?d$N@ZXx|kiiwM1Wrd-Bqg&a^<8vxGv&_gv$capRUj6Q+80;Dj_Jpr- z`PFl_t^pWJDELX_h$UU*i!#LV1Av62=%Zidb6^auJpEwRGo*wT|7i{xUV{o+M;^i&TYREq8SuWE`{vQO zQu<6lIXzt=o%)SO+95tHG{3Y>lX-bnGB1j3cdkLSInKRVf~}T9ACJw2p+1YG#ER80 zcN@Qd;dRBx?L5^7V@Jk1WZ9?b+BrzCv&)3Gg2R;vJe^K&IuYwwc0no}XbrEZ<6yJz z%iam|le($L^}^IS_Toz*=;H_UPq_sNQH5S@nm$X^37bm25t!6UxogBbGe7b~Mn2`M z!zzU_;c+r3p#o42;zU!7b+n3vfpG5|LLfKjm@UYS+Ev!~NdU0b@G2$+W;K^~cRM=O zqQ3KBSo_XU(C zdqCy~z4?5j*ZPjLo>o{{2#>j?;EJUWGnAOaPe@k}GT!1)Q<_(+iuKOsSB)^}>P!F# zcB<9cbJ=%(4G;r3)AjAQGhwz)rH@5?TtYEZc7p>4UR@MH{P33!eU(vf^MmWp8rJu} z`%A$8Uw(d84Bf3J!=f`9{dNQUnN!7O{vnED5UDEnsb(O-LF)EYSnFCso!hY=svoPa z_L-<;T!>9k$yvi?Rs`)_b6)UDm`r+k@hjtZVWa^Q%6|3{!ZcPF%LOymRrq2Frds*l zS&yE~J@pU2i>yZPH=7?JcKHkd5mKu@A`v2O4BoD2A0%f{^iHgbc0)Cr2g~eIr^Ob( zwPK@5CsKQ~R5$%3!oF=@wexz@E;X?oZdjh;84aO!Jo#CkIlq2sDDlTYYw9A0SM8rh zTi##LdBuzwkoyB^Tx<7EXVAhrdx=vfMqz7T$)ae(5l+{r9Dee1R z0}G)_g~eq3FmxY6Tg?%3mwUAxDi)rTgC|aBwX*gs0!YEa_=bI&D+$%6J!VgfD^of; zxc#T!|G<4*4ht=ZA(UgFVo~Z^Nm~{d<1Z2jS#Y=wM&ws5^*YX$Pkms5<14j`;Gb_& z`9m?OKkUjD-je6m@?MO5SuDIw{+y~-r1a0ke{$phhPE;F!YLpJyA(UcPh633d$GLb zRc)gW@p% zE?SRD;RT7lEM#;WQR1McB#U;s(va!MXQO+P$LR+g`4*hD=R$NJLIUrh8GSYoo zJloI~T{h^?#~LwSsg>g2z^4f@-+q6eG+lnwyDPEQymPqFFE$g?7VAc!{CQ7pu2uciGGwy95xjcS1D?d^u8iC z*^Od@&b5iZi&QRjRGB5+dHnDZ?FHmPTEnZDwC=^$klD+d2IkU(!ztxZ2A^?qbs#g)rpwjq$mN(K<1K@*y+(l*%I7jq2?zO?XGP~>xS z<%J=ZqemKKWLWKN0Wr9RjXRoOb;a`bUHIMvuahJCjy zof!^cB9K9 zTp)4_cbFBX7Mlzcb~t2MzwCksau57d*D=suo+$pbc4N4GTTL&BgYxJnp_X9yDFwl1 zJ>5^fln$5`SaA@VC0TuE${yFE)QWHj-|sz?$Yy+5*bDlgD^zHy<>t2cft%$*VLoiO z$WlwK$5BjBZZ>`m*2=soG?Dl*;Y`I3G66zEe?m(-i3a$uiO$PH0CAGIW0WqKo|1>V zzppWgM;I@&7)?G?(N*U>{NtqtJLKa|7a1?rYGXY>;R(u$Ycrj4biWE{8moaJSAt3f z*zZe=R#o7*ORsf`qub#!DuBDwWvpz)0rc^&!Hu9UW~Uzb(mfo{66kRcka2e1z!TkR zG?vyhE-#7}a{t_>o_vABP1V>?yVhA9C6gH!Q@}5tAd;JzJqQ7P&}KLoXg3U*4pX}92eb&$UF4EkLeoOOS7DWATTu?}OR~eO zSw}F_febl>)-{=7spf-gkI;tDIQK)&qX5jqa(D@3>)pr)2F6$@%Km}l#c7X*y&B4a z=<|_mNWVms&#_Yt&e#A=t9QUufc##ZvI6B%jO1QZIZ#7WKhm%taqi8coZ|RuCj~+Y zHA_#yO0&|7q~vm?o@8hW(YjznI;ma#@Z^JcX78?`QD=r-FKo!tEOzi3vXn8U`}GfG zyYW7S-#mWQF8;@g$@Lhk`d+Dfwi!JckxX?xBRe&4jin!*V~S9pvsLH%G~}6-*-Yko zGZ6?eX#%74@)^-&s>9NoZW+M(Y=n8)TouWu`cN-o$(@aq#uGv$A&z0TR)HwVU33@Q zFmj}4A>CdF<}k?(c|)ZakMGU4Qr|R(+a}~0s9F3 zVwuu%q)$h$%oq-j@(l{Ef7tk#8{i@!3sKqc^f0NQgCz6e5iW1Tay|?{Z-b|r35EgD{2k2xtHJ4gj+YGnYxY%pzv}e$mku>1JQQ|qU3JDEl3+!byT=slNYGUvXqd?-=qGuXk zy&}G`UocpYf@HzF&eK0CN{Au9d9dw`qiTO!1!IR_h=#z#0 zB3=U{0grWV`s|P|`V<%tZVsJ6U{Wb?SKlVdyMw|ie$h9+dS)OrkKxoY8^iD+6k9vy z^x5cOb$9XMHZp`Tk>-OG$6jrlos3Lo7-uwKPxXN2iw;>j4!4j_4v0>X2d?v`D*)7n zg~gUwrFj53*)r^SNji)Q!z>ytbauU*GiJrs<8@C*H90++qQ(JF20DK{O|y?Skbvjj z>Ln-M`C#smZff8X8H#xW!y%>bCKkZT22kAw!ZG*_x1-R)f>S!Wu5n_}p1V$?DQ%nV zc)BfMDI}}M;FEW6-k}rEXtlX!b62HD>613++$qYY5Sds=iUEIOZz<+IQo6#XA$(0 zf-Li+%V!bh`lTFUd-#>UAQ}}G{N|f+~>XXL8#rgNSb2$k_edUR=jqqGopZ7+*pv?!u1K$^)MGnR9 z3(Nj=clqS|dt9Rv`%<1pwZ9*@^Pfrh;mRrPbZF+g*za92L_E0UO-{@VY8h@MFDwT1 zw_+fd@#|WgSnj*y9tIhYE=uni_}hRljDLv<`b)A75%E8xf9BJlZ27s>{Mj6S?Kb~n zbJ!r$%ny}Eup}!_uF3vo&=Uf?{qyT*RWG4J;&^a3*juL#&F;RI?_9Q(7CGF(F@0Zn z^!&ZMw{|ZL#dkE}rga@Z$gWhR(L{PDd|swFT3~KLAHmyC2}dWPK;fTXzhamb{B=MgTwgIWevokfmGH=J3-b$y ziBSxBfB!)AHyL1>qVT-FYPWNI`ut2+mA+-f4JWXmu($ApeY@PmWdH+_p*$ztqmPR0 z3PW3f77prL)O)9ib(P`LT#}m|X4(zqsI#ALDdbh<<N4Su!${7sP|Hg|e-uxF!HD5sxO8t`0m4KnzxX(1mT*_b)|+B*8H> zAtJ<=hZ(mMqa(d3*V6BFbx@gYf}blSfV&OCtwZmWGSky{)ytQrV6LZBSVst^456=w zf}+3h{}vcR^7M1rnzfKTRIE!stCli&j`uh zo!bARQrD3b@dW>p#t|3IIZ{&Sq-+~Ytu<$qBwlKhX2q%bTq1 zJ~~(+?_Ul8rUo0wXU(E_5>zfAK?97Uu zG&u1!FfQ;aDWv$66buq8tKh``89=J+<BE-09E;06 z`BK|V8_XH5^A5uv%FJD|3?)QSxq5M(Z_l&H#&$fb&K?_!>Dj!X8r}mpd6<<@P-BH{ zB!f?av(6UQ&MR!MowG%oJGx%&JXq}DnRaB9kZYzdzK z$Ar~&apW9w)$W5a}i(joHe9H+7clTPzwlKIySw zjoxV3b-ZA6l)t7ZN&0r?_^lfIIYfhn=-Z+=ZnBO(>F|q7H7f4xQdu+&MT`r5~AXQG#+IoT}&EF zA4Z-I<@M|)6Kld%y)=3K{g6X*X4goLg&zH`(?dZ!D$C(hNhrAOd7cX9LihGAzTtHe zSm39ex9;IcUuObH;}%7Uh|5(~2jNCSCk0a4NUY_#VMtdeXccbls+@jXMriKy-&}if zliyR`_!`aBGSYc{jb^MYq`HR~gT?>E6M9tItj@RJcYtT9K(j2emgkL4;>Ye>8uPkc z%qN-kBayy6UiKy160}=GPTpY+T63J3zMe6eU72p8IZu5_*kWYU9QsVbvsp&+GQK#h z5-ydsqZ^3u=fhVsXL?1Z&gVI-+W+(c^NpkQx@D680a2@mB($JS-rs=+!(})Moq_`ct$f@>;y{EM=}m3@k|4&5zFjS znJVIHKrG&WI*s&X2Iy&}33Xyq=BKD{zH5#>Ds-fteONH>G3aTz$6&y_3NC{tzJ3&{9qT#PuzTN^n3;Rgrv;DGm&lCwZ69=uL8P)QhY;uhDQL zmp6;f&cS8$bTEfmzLf33G}3P9*E9v2kl+!WKsX={$}tdvxAbG37r(<5Q?L8Tlc9t4 z9{DvX{f#Q5UFGaUUpX=Uqz4AKF-z)c>)6Vmig<-rZ zrM|!-3&$f{s0c-((40^R?pelfT?{r!3ENMF{P?1g63$8Me{g1g%2Qm&;YDap8{SfC zOFnd*W95ZTsVHcGo;{CCzwW;=oswp)UC~|4M}wgqnSr&gFB!7YH_5W!`Ct5Rn%4g$ zNE4`1FElKEF;w*3Vk?e^V$Hevz)>nd$$x>uI~_Kv2mnaNFXA~HV( z9gm4^JO0+@L(pfDM}@cH?1Y|~<)F;bd$Vue)jhiP5tr6M;KSi-x5bv+-bTfY&FWNa zG&L{1RS@~Rq#4RNURr%$Dc`ax78(T{6k-hPW;C~W2+iTLyPrPO1W<4oU{!Iv5KIGm zA++ur-;n(^kAPq3S};c{dU)(nRyR!UOqUW|bqdz`%UElb%BAuti zex>%$N_p$Q5;28J5xrntlbR*-|AVjpEk*bjh?sPS=Q3uYBrZaRq`Z0SM#?i{`})xj zP8Qo$%DDz%8l07G!)mj%v)hglHr$e2>}L>ANH+xqg8_inCxiL~VA*)+=KCqLdmG%; z&muK$pGDTd)&oJ4w9E<8N!QRL`-?ZSp0>V!HMxau`ru?cb)EF;C_ko;UHA!Fq2^); z5j(BgU*#8nR(cwKVg8Kck&% |Nu%rN2Q%ZKx!KKZrRPX6|d-tUB{Rl~fq2k_Rw zwvdi2k9=8(%%-MU^}{q%#r|xl1if8$Mkdd}twhexmaH%E%8MV>X!*UkQ;k>FT;DGF zoh11OeY>=7U>|T#&M*r6=^;|!=aC2@3oXD2DU?n0H_M2cl1`a$&AyWXhwjOYpJcqt z$C-Z&S($jSSrN5fli#j;Y7{w5YRLMzoX7uBIkRsED8Dgvv8wJHGm&h+uusrDH_A(p zj=d24iTO0HTxq4}vCgdxIaaab+){z)?*%jdt+ccAQWmjk!${S)>-|Z;=X~_H;)I6n zDKa)&EKQI4;QNnR-LT_lZVq-EZq=OsJ?Hk{i!<@nZ+trO5BU^$>32rB{g(5ejQH|KIyms^-} zuawS_;Dn~(Q}0I}AtZqO=C+``BFIWS5NM3(0VJ?SDE_$%G<&N)q_UF z#KT7_P`Gd>CPm^v^+5B>yu)&#(&cz4BNH{57{5KVeb1+o_}T>cr{Bu{_3st>6!pHL z&iDOLx@XMyPo;-75O#*Liz{pA5uvM;Z4nZ^ksg8zi$1H@fB!X5d+(GA&i&Gb#ip^-_Kmg7c}3}=j7;=!v{3j zt{X>$7S4lK3`T{#&12(;UnWDTUuL1q)Z$UKT>Ax?+~tqy_9k-|WT$!MP0qf`-!m%e z?d%4u=xN=CJ;VwjHVq67_6jD{Z;Dj^?IhRvqh(ki&G$tKPZU`R#qHKB3G)*|tgQ4hSrMHl)zsg>I*njaKt}p(r3y9JL>V>g{PhN(7 z=1cn;J}RRNqYZv&E{kw7mW+bzSM3AdoJ_k3Z9jUm6S~=~nXyq4SNBDJ5yB<>JC(F1bRt;R$MC1OF#*j1YtcvL!t9}n|NG!f_z-4JC2u0^NcTMe zXj>^02W;3{3Pw*^2aVxeOqnPBnO!tT_Ivk6SQ&rV6a)723|o$orx1k*9aife2p)`N zV$^xLl_N=xQ2Z)wARE}jZK)-YiR#hLAtl%|8~xH_FzkdiP3Lkz->e)9;rQbrO=4ou{Vp~7JrOruYjWK54LNo+*T zHZyO`_@5iC>HXjgnN8GZ*gMgc3X<76**j-FNod)|n|$Y_gp;TBUPH5E)Iqj;N&e+S zV_Tl~J7@D$1fNA@hdNkhMOXnR;`(4jT|t|ek^I_b?i^h8-Ia%%U4EeXNvGHWl(}uW zr1hAtu4DcaOo=i7q&ii}mt$?KeWTA>``cG9$;k{Sw7d(%A!nRzWY#3kzo-X_j*hCa zJ&moU*bO2lekh6VivN)NiKFs^3ZcYWvezBH%-jrQ-CDmRo=R-z~r zmKfu?@R!LOIRDR4`)wy1AQS0%@} zLZZdAa%7E-q{b=b^PO{@i$MYC9haSI5+m$SS5vFZrYNZ#Btt{u5n%Jwug&&67%^7r zD_CUs(FXGx?0e2FdAVFDkOHYo1m~rKax}W@zKfg?+BOwM5NJPK*Fju(2t&S}@K7@`p=aXB!lQa#b9>Hw5_Lwt3U&HirzbNYw6LU% z`gP$_s{3_QlXWKAXP$L}kn`qim1suo{6X@=57x5Cu?ZYKkC8~ERx-y_cPDi`Tyx~Noj&Pup)XV^MT5!xua*@kgW^E+PH~QlX6ynkpr28M;AmM-#2%( z?L<@5>r;l$%)w!~LdPqssw}qSo!i58ir_DDiQAW@Mg2dDi~s9-Ssv?O2AJifX1BF_$P4vTHfW?T{?-`)Y%Z> zc!ERcGgH)BpIL_Z?p@Pjux3)ku_)f8`Flv$$I z+tr*j?2d)Y*~>xt5m(D28d#c)d$;Gjlj;}~2Mdz#o4Pdggq1kQy(*O_L5MHmur#3z z^QrYJI$7LspH0I&n>FXk=2fxsDj%6WGm`KetVk>EJW>g5=*YtO==JTEn0}lpx-fr- zL@my(!O7i;Q}cgqmMTkY23|%B{foc=G`C9H#c z3}&bS@*tAq5no4pdMw8X_cSZQa!*5Nlm4@>#?;Of6jL4ZwW}D4;!dUv&dAiYirv)z zG6TYvxl!bG1%>)w9nIeH@R1{XypOW zs8fY*vzrTWQ4@c<)~*iDa^rXN6W4gT#vR%zms=eW#Cxxz)Q)Ra8%+ z^3$7av0ICKnUrg-=i`&Cjt`Kx!fMDuLv&LkR+jRLI8wiIk?q8K*?z!gyyARew}df& znd(?#%E3&hh32q{v-#&m0iE~WC7!~r`rWlqX+BZtODr|Lq<*(loWTqaTqtxb)_NUx zCzLJe(myGaZ#u|o-+f%uAa@T0?lH$C4GlefXKa=YV&)BUtMg?Q@mJSbVj5$4evcJc zxivM9+gUvm9q*r}F34^U8#m2_j!|T(ed6QoXSE+2G1V~>Xq-HOFpa{4!CvC^J5_5m z#+Y6umu_LX+*H;`|4lg{?m&e2UK`Q&E63fmd<)J7B&RLNLf_b4k?mN62^UY8^J3zI znUfEin*QN(=eNM^qUF}spf<#T7_7ZdAh)DA%D5D%1+H2}|87Kz>?lzxP>0>Qg|1>DRuWKn|vE_BTuJQ|`Uk z3h2ibzkh%%P4;KMf5Y!(1!oOysdRu2#`JKz4QAQ)eHQQPfDcNOM{-Fv7Wsvn*-naO zig_A(OnX;kViI$j8smsNI#ks#{!!D zfow4@D4`w%p?O=1d*YFJt z2HLbui$S)UUExYzYM;O^OIbBmPQ5c6UGnhBko57cZ--kW6~Jdte>o~k_b8^9J_NJX zu$4#mFNrbBn2a2WVCMbZL6oLJ_+7 zwD}&j)@ZYZ^CKp=`W%`iP(W9Sgb)BBE@cEB*J6Fae>Yjtq=T~j{9QB8Yz-bWJ_i5)mQ;Kdx-}{)Iu+VeigTC#%$|L9g=Jnu`?Sh)|)rQX^ zIvJ}e2ZVl{2e#NkMEDnW%AZAYOFjr)oKV{5uHUwBU#PNakcn}Ur||u0S+C_ChaEv} z_8!tec$!}Hh7??Mt+Fl^wYuNxy+@Xq^sK4W@ZnyFrx4uhp2e}FeR>VWHyK6Tt=jkZ zpidQtH)8L7+p8rIZdcd>gDtfH)X^RKTKH0CMN$2!TdagPl@F(>p131(#`X+0(sz~X zl!>Rk=Bj_%sX8|Mwq-CtC&n;P8&uyLZ?k-D%dn?0`Le0=540Nnm0$un-J~OBH|o)_ zee;n=H27s0YI@MNTMi(|ia;6FZm@hkSM-IL!yXn&3#`eT~EfBwZ$#~#qhw(mad*}D8& zUZ;}E(+$}~4=M{S%cMvW<|=I`{5C^k;9Ix7ce@{=~leeX3uN*3CdB;T2z62sXJq>dhcMf{V$VDfzQVhScvF{!tcJSFZT6 z-}}>Q#>e{K$^DH$@b^k^|3L1KVo9|{^LyHk_nH+xi>T}VMvwDHnc(kr4Edej-v}mu zuL1E7H^U>o$?w0_hXoz9*fCl*cyQLp z(@ViHY<|s3C5>n@H}0|x@Vj~h>&*&L^@SwJK%%++aMpE@JHuQD$pAo`%%Cb z(vL#f(vNT&qaCT8k>G@fH;y=${*Q)dzKq_@g<;^c$fbAUIw2ZYIVLp4gwv~kKY{%E z{_hl^{gW*Iqrv}%xwL2}xE4_$-3}Ex;i{k6?9`oIE&9;%gY$Oljb`Qe<;-W^FLYlXS4agugg>wbx*_BKZ~>${L;@) zBQ$$wH^D=?1lhO1mnVdy-oKz^D)RL#zFz2!HlNIQ8;PHsfW~DvUHsxo*vxw1n?o0D z+`N~+6FL8v&}5l`j3^Phxn{!A;ec6+<^B>~i1fW8^Q(Uq5&V;L&c7w_fBa2(9HAlJ z96id}Ji+`d^5x{eK`znW;3rW#7E)ZmC@UhFW9UnTOIYqzJ|0Y~P~e_)eCK)ZoU46A z|KLE!`@$HBw60+Wo7HOYjR2`QTeoXMsU-QCqs2@?Oy0|W=+ci)5G%7ShAb!a7R+h| z?kYXqpSzwugFfp9>}cpzI|Faf*EJ(WgpVv0q&c^zYi3z(R!Kp0o%CafYTK$4dIgfZ ziDKwYb)jD7^L7uz*Y(D)GIpxn3Zyg8*peNUM6}VXHAuM)m^qT9@>0`0l+`-p+l3!7f}PG1u$4y}h%%B)oF4@dcl30RD<-t;Y9{8=h6m4= zRW;B$s(*DRU5$Bn){Lvfo8^SGAIs5W9CxQeBzR{H`!y3;J{nioRei@T+r$vkbF#9< zQBb6Ex1r6KPtG!t+K=+6AQ)>z4fsmI{Wz^{HK#Lj22)zL8ZSwGbJp36e@tESVPQ$0 zF3LGeWwr#%2GJLUD$Pm?T=6SM2MKf;wFZHp;fbH(ZDs*gZ%kduVc3zuNJ;XTSj4`) ze_fqwccz}a8w>@}M;k6cCD2C|$AD>G{FOVxv>fsD8_mR57N$UXiyRjsha;?9l$ zgR#~s^x%ukVwRNWmHI)zfZ-S+Gz@SbyB2Vtv0KsJK1nKguAqMW2`009x^L z*Vbjen5n}_FGiQd)|S)y!g8JFPFpP%05_p}_Xw}8Cdc)HUtIk<09|;i=GJ}`9}dxM zKiB1BY7A=V<-f=T;4ssKqY#N34ceI3k!{4@0$j?1)ocx6seT3dStn!5xERc}Bz`GK?bV)q|l0 zv`K@DtD?=J0kzbwxjL6yk6ZrYtO9{Pycz6#BJg^^$|9BoiBqo%>T}&W^g5z@A+%nW zhR3pk8Cm3KXoR&?Ik}Qmk(An4R$*UTto@uc1asAEg=*Xq{@y;SO|>>Rv{*}g&Npq& zOWOSg5{@bFf|j8$Q-+d5E_qAlw$|I;HrQ)kz;cIAt;S^RsvqQ51;bsH1yFE@T+PFE zv_JiDb<9XdSYoLwGYMq^qYq*llf~=SKy{X0abC5pLmFQ6l2AAOmeSS5kKI&8g||x% z2O7F|2|rL+BPpptbaYQQpSeUNE7711b4m~^F9e}}Hm4a@C(1il_8}Tv?(lS|AkAT@ zSwaZ*0PV=>M;`9$Pl7{TBx}(6SWr^Zeo#!VbKwOp((;;dM1syL-&N%ervmbV0Z%A- zVn5vf_;K_9YVW(Fno9R}u`qTWL`0e;G)Vvj2?&VjNa)3oY(i(~7 zk)qUeqyz{cjwme%NJ;1_9RjEzprAN+>&%>QzH`6xt-0%-^{sQ)J^T@NcJjXOZ@>Ay z{rNo)eBK!y?BZAKAnpw^>7#6}GlinE-38`DJU&TnB(T2kZQlbVMHhb@T~0f@Vo!S+ z?|@^MJ7B$#4cXr}sn(ra<<2G~7kIwa+_yJZ!cMu9=I!EpE~SyjO2SN`CL#KLoS7r0 zZoN1XGquRS9HoQx1bofTh}t~A8J9129(50dU(PUBOy6z8G9SBQq}0@U`%EYM!wQh% z&1Q80#>d)3zL1BkcxU-Tw?3%Dhi1$32j?<$~m*QJu&g+zC<$FqgbQk}he-3_cKs1aF*{9TI;19A!R&h&<~~ zW?s-xCOHU}9e+uN+0IZ4hHB%dT@{{)yiE0!M8%2b?LB;Qg_Ij%rsI>oy4R-A&~A^A zY*+)cN*KWOB@VWyJnc8y-G?{>E{gT%GIl||o%U2=sf^&z6#F%500LWj?#GuehPKnX zqR#2JAGkN@POZG7Tqxlmuh#$0tM|IrKW{Vot_-Rw+q-;EJ9 z=5C3k%G7Rv?HWdX#7x}tnbek!h^m6yHk96PHQvcD<<(+wwLG9uxHdfUgV$`s55X3* z%xj)vrUB-NeJ4HIjbtdaI7+%jj`f_SoOGy$k@%FssX~yIW>>m>*z5hxgf*6kQjCeO z@44ikxu8PP!9{T{7Ai;U-!oU8tC?ld8*arzSk&}QEj?NvM!n-&m7tFGEWVvcZui+w z7&FamD|U$RPDvsU^5ImvjWv~5a=5^ziv(R$cz(&hn3RRhFwRs-(Ud|8Djw7u6ba(7 zEM?nIzcK1PTV{htAH=-ith=U=SIn|Cy>mZkPqJzN1TdC#icVDCYeKrrX+glo zhnkt_XW0Rh7De@E*6z%}#QZ&y?TT*$tHZLs5?+(0ZLqW+eb_Z;%)PW2xoi1C!wNr= z>xU^$!I9m4D{n|e?dQAcSxh{5rrHJoX{(U?;-Y4-m{*D^Z3s&y!OQ@bCoDnOO~sXd zCi3)NQ{!^-XNO6~^kQy4D8q$9$Rn)2J7`d@rLrqfYKFAjoBN!*<&mL#}x6EeV&WA_MT3-6!3pLC#E1*8w`d)yO&l3c5qbipz1nSbA&65`;s>MKQM*}2u@%r@Ofez9YmC6$z~EUJ)) z+#n}*d=FZ1#B@gTf`E{a@Xq*zuAe`W6Tf|)wzB0T9_zU?fyf!t@thSneYY8udURGv zlGEM_st!a`&%J$0r6SmHM-)wYAuEyXDT4OZ&P4*=BX4>5E!^SgY(DL&zC`~zWeXUFt((6 ziyapb@aq1euw%e~K^F1{oc@2LLInhH6rd7}KSLsMWXi@aLz?cPyanwwfg&JO@vHKh z3zYgL#uEDb;nw|sY}7yd;df|;)}r68s$8CDRlZwtAVD?XINq?&mPqnE*WZ70yd|&( zq=kiwI*KI3lT+ZQCwZJH8T(*=N#%&|Ds^#iU(m<~ z?+CiZ$bPmYgL3dlVJyEnpLH~G4hm9rnV*Z;DP0M)83?Dy#8^}A9TvMhtw>GGRt75A}0e{u$?O2MnjCm0f&BEx@XRfgFxA~uT%MO^#Y%A4zx$|_?%Nz&I5T6B5T zO35>^)ekm>=F9JWuM9Z2`w|r7Kvg@Yfu{|11S^vrxIC0dTVaZrQtS5J=**dI@}4vZ zKRFpKM?=DMh!eM*J{erPc(Jnld@4f*Yq$<7whY)vPEI{h#r@=t8qFT!Ts=QMbAjMM za}NPwU%(ViTsfFFzo=CRM&g(oyHt@LWNL*q&c`&jP&w=F2z&?E5ftEHhDJIRzxcb~ ze(Ul!abxO-e!Ah%H7Sb(dqNga-b|*fpF2(2c$o9x!}a{~^AkF%a1qjyehVynVMgrr z-LcpWVHwPBaavDV7ye{HvR3Vvw8<`YwopKlqnIhRv^$1-iOTl{`rcbA1}{kR_?nV! zBDQ1_t?SYEdpG%Wk^zC<6GjhiJMif?Qk(E*>IRgS+~)%r*$#OuoVe-3=3xwLlQynH zN@vG5Oy}_=u|eaN*f*KJ;%y0%**Z5=BVR7`y|;ln$L%nZc209?N@<^?229su&d#*% zuClF*HM#Etn2{5h4)v)h{seA{JQzR!^+^GH4&G z%5C_(>sf|_Lo51xzV1$jU98Uw$$h)Lx}@9nA=Ax)%I{ z;jC;->V)|nHgLuANH*6D!(xD{P90J$$+XPbtyM1ceZ6qQb=BBY4v_G%+G|1%)h&~=PZm#eMnZ#xlU#W9@3vKC_TL8mB4${od@{&p!{&-r9r>I9Dx(4VS@FqG$F4eoc?1!DD&}$B}>! z8-$D*;i<^o&D^k}OO6ZSX~0P23|xxJRv5$fn&JlUPObddd{7UtpRmj9y#t?To+1<@ zu$(^Ep6v-q7&d=edKKh--rdJ@vQ^&c%VHhmqP)?4nx!PeLfkbH*H4^@SU*Xol4u)k zP)09+LCY0nNl?Y0rh@IcOzkz;!zwoxe=})Ruyb_$g&mp zSXP_~aK_FM7aN1N=5&6LmihT$RjLfyrP@*t5kw2Zt+7?{8=6bz@|XN`uwD~c?Yn7d z*{AwlmR6O&pBXlBIM{0dhwCN9+Au(wZ)%bURa$igJx-;icOAR`l2tH#N!Z;erNhi= zs;u3BWLWeT84@9h*(TvWjX zPBV<)lZ4i9=5qDd<~4(80|_^yxYqnBEtpy1#fl#@Za3_KVAHj2P+{ZJte@vOdJ}3#vFh7<9yS^ z1=;O(&!w)_duiO&TDtaP*Tuu{cQ~FB-#5L#-cfDqWkmgx{>0{+*Kd}J;sw%${7;9D zFWvul!hcuJznP*~c-%>P%N`@yPYw_y!IU-SAH6R~Z960|N9`S=~h=l2V;$k(3aFEA^8hd(xc zCo=9!<6oIQ|EJ#R|MJaW=-&Tv30wa-ODp=@soH%s`1QZEkFNIjFgtJ$ealn(%CeQ} z;U-zc##0|$oR#WXZ%A-a3T@9(=9kchYH9#>d6jgipO(`bP*f1JTYQk5WoOCH(9(;Q ztnmiX;sAiY3O994T|S>0w`y?pk%@tkH>Ie?yp2@BZP3v8zC}F03=(weF!Xb&jqPwj zCQMR&Zn3wkC4`TKgNT&-0gT-T+xwx@KpJ-YvBq_$X4z|veVI_=L1uX z*WRu+>N?t$t;_FTYI*v>Uy_~~(IFLD5$!HvIR-p*-*$wi=JS;3ST0RuHp*eT^(K0;!{+XDqAS}hq~Qv*1pwzJ36ac3kH~>m!U>h#2#3|ZM&1L0}sc9 zgeBo=uqHI8Q*j)6_tk2n{3}p!nHIdZXS1zOEt8^Qfx}{5V=Lz*Qms^tEY)?~c#TYZ zt`)yTaOP=JX2hfEk;OM2$nu=kq@tKuYbGfqBz`m`<=8F2^f3kqNN7S4PLy=dryVb> zG_1@Mw&bZ7Rmc!*9Gi_Q-LM_@a#B>>qB}<;^XRdMcK*+Mp!e`*B2-p8EXzcO*aN@v zdNQ8p0|;@l?b+k?XKr2cQ7p?!Iv06PdcLdWJ?i7brB9zWj%S+Wek!ELmB;LqrrGM@ z!mm3p8BqnSVk}>eN?t^x?Z>pQ*_A$W5XmI*_jR0PSVTx|FJt$hVMmgqjuP}d;d(0$ zO{(=A)(|`}RF>CkoUY)*=AO?tzWu08(Qws3M<{+oGdNq@MkRe=LWlD-B*<~r_f4Tm zGfQ@8ynQh@18aNj3e9>o9L8OSci^Q6h6%Bp z@b@c+pbJ-_*t%LfiZ3ywT26&cBR&r~IWT;bny)gnuo9Z0nhit*ImNV`J2=ae_o1^I zXNw}1327?QLalj4s`bX)vi2j+jghRGbw5+j=BUphu3m45=#lx8YagIT3MeUalDdb@ zM?(-!7c1!Z;87qUa`n$6+4mMTIQ@24lr8Ru%1WW24j2id>^O4%oXRzmi^$ z6&+0+a?Pnw-Ic1(KAKq1sYeF4+kF@INg>-MV8dAp%MyF|X>(+FrJSAqL94_;(Kk}b z%v8uOXHV7g!esD~z95qvPf$anX}tdXYM>BE2&WgP&UjQdS?pECV)U31#6=*;+t)f< z1yoMm!0PoSS>43Ps5H zg0i6tXanihnwgjfB_`pi8O38pH$t^#3VtTSkh@_vGX9u+Wh#{lg^8#Tr|-_(luLbb zjb(vNjnR%Y@v^hq=1MABf*=&>A=YU8h%HYV(A7UwS*rr?ygq*_)I`dsG}d}Z6sj)Q z#H9P#=Vlgu&;*cYCt{p;G8L~kJ7pegntw`i)t&i-r708ppIm9+0lmGJ3_-L|S*qs? zoL<6UO7L=ujQvDR`fbAujK=VP#fz_kbN&Ql9RI;+Hg4+2q zu?YPU_Z)BR@l+XdrUJDi0~qk`*!wsuxvGbtkt}?|PbR ze+$rvd=rGtZzKvNA%Zi@zTeQ5K?FgUJoqa)siX09dGI%TTThs#JIv@e__pK6T>yVN<(q{jp17gXS`m! z`GqkH>}Lvyvwfb0cX>h3sk3e!-J3$}9*2WQC#r2Jhcm3P1yy~9Wmd*3Sebi*t~Q5H zPrar#WT;Y_oE;AL2!hdFvZhuWHA;Qv1*F6Yu?I02hcH}wy;qn0Oy#Nt?YA&% zqx~Wl%aE5xVOZMh8%wAsTVX>y9WjWmO)K82E@tX_F{q?$t+a8YrR4@`&a39~Q$L!q z;VP-6_9|OiU!TX~(4=!9&YI4sfeRm+axytP_Dqd>{(Gg$ zkkEr8Ud)hCwfC3yrdu}lnRW-@fDU7lNH9n^v z{rDYCiW4bZ|Mchr$Dqmljv=XwC_7wS&aP=>2?Hp?^~pVe_;vsM;+po*2W-1Mgh>Oq zkCs&`Bi8tc#6;VJTpQ4WG?0=bkKWtvb0yG@sMI`AiSD3TaU0|39t80V?&2|?%8gj2 z6j855Sl)2O>QiA?DY?|dSLRoN!)^XW1cg^H`*I_RSJES8ETnF#i!@TS|5@JH0Rx$u zHoKr3pP*h00$xheFGHWxHjzab%nvt1B;z1c*N;o6M=HjQ-SExz4sCuF7z(2u6t5Y` zYm+98`g&-#H956wnkolA)e)N}$p=s$f0mVP;;Qq>3cd$XhV#LriYl$^1woKja#nbO zBy}YGWI~sG(NF)yPxGiyuAAB(muIesoCQnt3JXI{iF&BXr(oGWBtK7%++ zH%n;~&(|U#&{kMxXuoFHi?HY&n5;Kp8}ZP2hd^?1IE^N8R64@(1MLVwU!M?RjpscT zI$Lsu=#Zak_eSK*d0z;la=~_X*d&7fQcSKK!m=$Nn)N5&;i3Vhf!fv1Lf5tgjYehUS$Ho1XWCFEP?_{zc6`F# zq1v~_oL~nt8)1cP=oDt>`Gel`i&Yz1B<(~-ADuW4XuFl*1Iwd2;^(@pNj*Np?frb& zOug({&DwId)w`kOB#D6Fvv03=Hc+E49gSl~85HE%JB5HdE;Vy(2T#*d16Fb)7duAAB%dn>^ayo#qC!G3+p0*2G<|KrY{wb$*5=y$>-OOfCMugUJVAue;@rP+Ae`G&#{ zUnyczLiJ7=aAvW(8BZ}Mr$PqCp6=}Xf)nT3Y8Up~!hqVPrE`fFk=tKMRv9o%*8IJR zc1QE2k52lDgmQIcU)TTi_+4~S#jEi;)kP5*>PcNbLui$S%EL2kBK3xkU~XfDG)? zG{t}4i(@EO*}7Ev9jI+c&1KHXi^Sn&{6dA|7d>?XCiS`slGxX!S*}&DPKnmJd)&F? zmtR=b%8#c|pvNv(n9_rVIwJ}^Xtw=z`}FLo`PAamZbOyg%#M7L`mo6?r82d{xK%xg zbI~$ez08n2LX%vmvlBCL9+<`*D(+=RY1a2}uY|=9UWNAe8kjkmGqbGYxdzm?iZjG7 z$>gElJ&QdpMvvP=%3j#HCXgzCAJ(PpwDOr9cq0i)`)yI`Oy+*wj9MvZg_Wa*wmX5D zTg6^Yx>0T99E|M1VriB+ZBH?|MsHR~=yjNe^`b~6tFOD6Z`}5hXK4~^Km#BEF){r$ znLEbs5?}~U82MGNT}IXpVy$cyU^6%-!8o(ZChv^HQ9DJi_|a5(+3CWbEAD_&+-H8R zm(_dkoXRVQuytAqlWyMonb-1l++AEr$|zV8L&Ae_Wk125;#R3T7#y2n3kte2=&8A8 z*QB7dcLTXNT&g@n^JG{I57URLU9}w))A4?DRj`0$dsQn0Uv{CDOC{*5P_pMC)G`_Y z_S0O*5A&$dO17VAoOX_4kENCBA+(Eei>nd>KzpYFb41|jo#C_dQAz!?i_J^t3C3;C z%wpGJsm}82BSasN;yi+tL!QP$mN-7cx5h)(nIGH5D_@B&XwGYUvv^ZUu9&0sGskTO zyWh8#b9iEmiGZ)%o(S;3=l>Lj9N4%}(p(sHHv4$pQauHCnYZL=7_>*bosnsG!8 zN1n|h8CaRJF${KU`mqcRQG^Mv6Wl0HsG@$b%bsp>63*q=-ZEs3Ol$N2R(0K01xx8p zMj|4@*rgvowww5TP?#tt5eTWw9iR+rb}8`%3=l#c0q0KR*}Zv^sN(MHUTG!@WXp`* zduT!{FDjG_i$b(PMo&|?h;6_786qZNaqpf6FxHsbJ!I$Y?rxe63#Nyt%}Gnr4q_YQ zXR6ORF)4Ih{IiLjtqB!qW~i_b_dsN=?FnuZ&K z+Nf&28>q0DK~%uU=KKmU8Ug5kv-(hlCTO}}pC)Q`c7#meVW))8zBW^jA5{)Ej2~R= zjA2fwg$DGSY)_Tx(Xc1f0 zBZ?2U=YN3ReB0e6CDQhyDe%GlP}76W0|To=>N-AWdV*jnZAtRSEcw<7YPgO|YD+^W z`7mba0R|1fIxmi5Bk68lwFDj)pM)*l&QadG&ak}%5}kk zS1XK;Ff@Tox#b4{!aK7swD*|F)^^X?&3XA(?_%6(Iky^o5wWdD>fVHb{_|>O>tVSH zHWlKUSf0@9@V57fgI`HeF0-g|<(*YSxN+o|_mS1FG0h22M1*Z5xsA$ap#iqkjoDLS zq+%~TCl@LnepgBm+Ec5ch0&+d9cb6w364q#`z1Y@j3yhq>v-Silc4Y@P-Vf$UDc9J~OTcA&-fdhf-FrWU7^0%8DPF42hNsGb@%jKf zIQ>bHNg==@85FK<;H5)FWXtOGvXs1!c97XY^i)V0Qib4{s*Ji*ZLO@P7IH{sNF&T7 z+&8Lom-t7@@nk?qath6%YQ#WJ>GFIp<^yod#&GPLs$FxQBn8X3BLMxmQwME?2}GS$ zKAn(mzUS69QujUu8nfGEb*OeTAQT}2H*Gh7%baf8?t3FaGOuGP4BUqi#Dc5iUlN9l zB2!0$9~4nQ%oDFl-u^_Sv`oou&)tvcJnMlOdU6C3I12#}1w@O>ylNX-XV5=z?5IXg zdt@_o@@38dIv;~o<5$pJw1KDCro3dWg8fJS`ycBH`-%;q2PvAo!qn5EtOummGSUnNoRW zblOmkinB7WYQH3tvAZ%PVscqm)1@Gwwg#VNZgQe$P%UgC>>;Wp#JBlrVBDNiTTfZv zvgVr<_t#uRN{xOSC@s68@RaA=GfVt3=F&aDBZx!sNA(+I7*XZ@IwGZmb;!t_%RD9q z#%yLXCFXX}dl$om-fAVNQ@t17)AAh8v}})!2wCP{Ms*?0WMz6(BtObn(Fy=_B>^;A|$l;gTCj5Mna&>iBOir18zUeDdBWMBJNHw^)DnsAGA{|4g!|@IWJRMMAhv zXrzp}q)4RE%9cMHyz7F{H=jTLtu_1rGEIJKEWa5H#-J9?@1+(a6v9A8mU5yPP)Z$7;f_Ds!qDy5WP*_4LLbZ<>&XRuM^4 zlugv1p7_9&e`DvpsPJC}7JuA)@*io#qWk^NV!y08)To+wPst`C3e zLbl0}QyeO|XzUo!PS9z23H?HA0(mue{Z_HRbu&kQzv)uSuXp-%A?;7435|cdr~lt2 z!&kIYx^{!uuUpeUS^ou-z8kXOeAri+M|`t2^4kx3z98;oze;m+sUnLU>-+Ts*^-p5 zpn}YEs@tdj=!O3|{qeS=OA+q14Ws5|=9|-@3H$+>Ac#+4lr5QyurCwd#Rr@&SG*hy zEUiJwoT&)QfwTcwKcw}((?cD2N~HTQy;-)ZAdsZ!(0%g||t8&`Z6 zjpV)UX4#7&IBMckasFM7+hfPpE0s#g1tfJq)*S9+al33jgH2C(Yp=rq&{*hNd+UyFow%Atxvt`pT-I(E)1{;(ZNnny6<~yrW zX$-=iXY&@Ys`8B7PuqrNRr!5IY=xd$-*T+!v^UjLGBC%{TIau+A^d}h!#}vaMHEyN z`&Gd8i%g-@rqgEf$DhqNua=a*@COt#fRLoPlg?NzBM_Ly!XwZidX0e;CQ;>uWotbR zq;#764uw|2(|7IX5v+MgaV7l$+W$9HNh8wIntH++g?yjhv z&6?uPGm@(8Ftm1^?0U8M5Kf%5yUw@qu)2FO)tcLh=a7@V3xY|M4C*P#{E|cCSw$t` zCg%yZZlsuDQ~=$IHpJ7L!l~yMS4@V>2?>dwwJ?e_>ab+sY);wwqrziI<$HV%uOXZH zwVu7THd2sBA6>hp;__8gDY1Zk5K4MXeDG30j~({B?NvZk>AIMWA)k5;g0MGT3853z zI#Fjan%6xsKl#q;XUS!lT;^--P>(y*yupbx5q|ab1-6=$uMD7Kf;z8>_nQ=^_a_3i z8FKA9E^RWTc6_!7ms`$hjHe`}s6{B2(XcW>r7 zq>wEE%W9h~w$Se`{UTo|O6yrlDE(NK5@~l92qReZLLbqFQY!a~k7_rd03~fTzg(t1 z+HaX53z#xu2RQSZl{JlWXJUcz?ERT~r9gG~IIN{**C&{uVL03k%Q2BMIL zyQ(#T)Sl1_c8iq>XERa=dcjh$ljI%NQSjEuc0fdx^2??Q)~AQQte#5gTLJcfzI1)2 zpZvJHi@xtGI1|B9_z%vX34ZR8xiPoXDrL`}J+xW@NF!04HDj$-o@^1tpHKwVa%Jlt)=pijoz|fydYCTsB;l00eR(LcIBF(N6T0aZ{I|CctU7_CYR#_#(PE$TBlA9A-M~JsjeIb%cKf~b9L>DRom}7fky2EQ zf9{(1xsuFvuwrxKv-@viQeCURbAI$dxXG+A&;+R%sDGyq^5~6zv6X1P9EJr5uillf z7Pjhfx#oO~hI3JUR#*BH$P)d){Z|2y&noc5rhW9u&mbcfW6!D0Uj;rT9NP|ZK z^=_8EJ!J=OUS#SzY*he7U#>k!87j@xVw>-#@Vl-k{IIx}<>Rgxn6EOQXxfybcJ4XDJOkdq1cX)82NT;BHkAbG)E(8$j|LbO48&= z)tAqXDu=;OBiN-8*OQDl1Oz3YY&WjXHnxp*#6%GCqR@*yQJieq&) zu|E6QD8!x=@;!j$o9?@Ubhzyt6i=hIlTJCeKjV-_d%o-AJbcT7E0|aEoB9lNS~uO- zO*s`urc0-56m6+w*%!?ybpK`aFCB?T=3}Ju4Sa?+bCHp<{zq@1Vgi2=zf!o$z!6su z^Nrmp;i~Fpwu~U^jF?$oL7r7$hj89Da)RUR-E#PmRXq$`^km|^=|-1I0j0pWop^pJ zKc!cbj+JW%a5yfH+gLKMqe`-#E+JSS9fU+6J0dVACs&~-56@mV`x-(Wo60(%(D3Tx z<#K`M9$!dNH{Yw69#d}BCM|9DQY2i%X3+(f^MqmgNRtX(YE&o>*4=femphyOuIG~C zdnE)cB}K)4N{@^O0&?PUw3XvhrvHWZJUl^5P2KeR`E$fz8vRSw4h!Pq&P75oOw`zk>BOcGWG65Z+zpP^ zuSq1N#by=rFAj7KLu$zWmYnM!SS8 zzMIqhh+qn{&q7!f9l(#~5l^wM-W?rWxBP^;{6n?GSgNa7E?Q&0m#EW)I$__Qmhnb1^jRRgD`flEkPbbAQ}7lM>EAQ@Bj1BDN`F_v zpZeCZ-*eQp*>hykjR!0S69?XIz3{C9Bu_n!-Y9((Jreym=?hY~#(Q?|&7ojRtH!pu zVr;qL%PfNv%~885<==yxkD1*7vzrB ztFQ0<5Uu*Fzz5WqkDrCTET)~l@S|Vp=zbCzZpRd2r$0uPoc;7USM=SnFO`aqKY*?; zqe~mSbs0EdA!@OM;`1Ye_`K{|B!n3@fiB+i+?>A|Y`Ja3br|Uh_bAJ4u`zo~T zp15kCzyXyW0Rck6dgh@HK``KPS$d#l{ro$Hu`}6$i(stGEj_)BJ@=0M3W}Yot9-Gj z-~A)IEO|L{!u8nXr(a4<6a#%N4OloP#~YM%uz*B*u^{$L{?J>$>?h z>!q*BUq}53*m~*1*UXxmb=yAty#5XT8Y>sbbFyS}@*V&t!+$E<%-b#pToZqbsZsux z|8K^EMm9nqVJCwBBO@~*ArNB@>oEG(qRhS)I@5cEcO(6mV~GEowa3YRwL6=8^*#uT znFbH$WD{IYQBFUu+!(gtT@AkRQ}1^kLs1oV;3B+tH3wXtt3Ao8%A>!TUf)-_>3LP7 zb>C?Z-;#N4Z~^bDYctxhyvFsuV!qaAHY{SSY5m0o6oO!_EY4| z313klS4P6CmcDfS*pDBF5nRuN?}n6V=L~lYoLBD-wHd!yl}PP6xMl5ME1`!`$v3;i ze)<=xUC@-j0EYipG8hcU-7@nbkN| zvaRdwVcM;JTekGa^WE5KEdv-9>uozIYx=oFZeans<{oXhM~62C=cQ_%|31W)O-Ld0 zu~@k(Bb2Tzt-fyeqZ+3d;ag_bA^@-;nbaG{yuKC$aMiesuLYq{VPHY3d4($inuAP{ zBwETn50>hRU!}k4nByBU4`yiK{Fem3L8R&vpx#2QHpvwPm%-9(;%T?MuPtpHq5%Gk z4BN~?@lG4bl=H@!P`D(COgYx}8b=$lcLUJXyGHjaN2=$8Ynqi+yIcO?HrqzSos$?R zDG`WD1%gTUPQ^^iUR}m<%>eVLom3%YEU*sGPSMphG|Nt{^OHVtI~l_gwHdLEuw2I_WG8TWSrz@_XV*?J-T^vEQ$Gv zra&;DB(6B-!4*$l>|5f-0zn-Lvu4`UmTB{E^~N^*9bS`)7700NEdHyR)T{cj()&L>ARMx=VF8Kv*Be^}t-9d*dw$D;aL!a4a_?|Ww zxwCL3WPcH(5JzM&V)hn{D)Q+Op@5K3ZTmdh(f;)1+M(hbs|QL7Qr_!CIdb;dd!AEW zDCwbQT~;2#PN&8(!>J?Ehb?9yFd>~A(Jl{6Cc^U)q%NmfoEhE5ePiq?*Y-)rW7%K% z+`1@zjn|~snM&-I?(H#>P7rEOp8~@;+i^jwSxa`xMtaj)S@9n%YxBTuSus2w;lu;! zmizX7^`?{5!i3baj_^XmGAz48&jPhH-*rw54JUHV{g#l(y>?~>hJ6WQ#MgN56e&7; zEO!@w+QAx1RhXYhO^Ig-mlVLtdDR}zc=o~L+}F7c%LR&z2rBF3I9BCIYmbNAv0Lac z6{25I_T@eOT61G;)Wy9)&aE|JOxqdd>zn)ZFsJw@hu^@Lt@J8%EEV!%y~%|jZAw5k zFam$Fk~I6NYUg;j@Sb@=aPG6Q1g zNTmsj=6=|Gw@&x%T`bj6t(G!dz1TNcYMs*&PNYC2LdPC{=aTL0(KHr^n}8cNfOtv* zL3g{3N;ageUFW-(rjqb4TFUX|I?F0f#2}`Skci4MCb{QUAPQrGI<$JlJh=qlfj_c} zj^GNxGh>|322FHW3@XgKQeRSr&DY&y%Q!8MI@q{N5cS30Mp4+Os7sVF`Z&MeVXYE^Cf2HvuBw(F0!1z#Km#>G8YFY zC(jk3YdpLHe4L!Wi2ouWEFuaL<-R6yQ{u)=p&Oz%{%C~Z^y$;gXPDWUnb~jfaPr*v zU%rk$0a(wRa5#lzWZ(y!U}a!rWjJa8@Es$0f|215eE)Vao;-1iiQ)8_?C6 z*u~ay3Kp%Ok@ptTB=pb~;7EI~H~8L(<0g5JyJ7?|{;U4}Z1R{i#}99@9%FOjxQkdf&ZH=a(t0n_v>SCHw4X2yp(mu@kI}tN;zbcjLbe z;-YhtjeK2|;Oi`jTQTPOxfuBsC6l~r9nT}coNeen@(9qQWVU}bUBbU7_Ga54z0b!r zSn-iR z0V3A*`SJ2(WRX(+-aB}&1SUs;1n|6pBoVoBOt9q&mwU+IdP6j_fgDOAr>vUCjr6AE zb@knC80+}eTBp?>O?$JYa;7M~{0_EOjxs|2{3BECl|Id(G5m^H-lMNuKHQIx{Uu$E z8z@_rNjY>zu(;9+@qo;;f*wRyBB#B_7(Tl_d_du2ORV8)*d!qdB=ai{aD-8gPsrKO zH-1)hgACeRFKAY!f%r_8O^(`A4cCMfz*pS z+;9k(c-fq9ez@V}TWi?FOB*IJ*hY6p(~3mQVk5X>zdS7^=sHJ1YQEwP;*v-~s4Jn< z&897|lYg$#Ev!w>BWlU!bogUv#l0V@b<)?)!>96zj%q;J7;RAXJc;IpC${J1N3Lo9 z`Xab+*XvSteVU*mUY4>U_)u%#RK>NeR3Bu`Rq%28Q02LD+OqMPhY7Ctyyd=C=nA%> z^!g0>`N?UY7aZWWkLvH61$(fE(suJP(FEL^ZyG+tul8PJG`Kd|-qRe`RY9PzKfU%& z^}$rdwbNa?SFb}N4B8?ucMM!N)f&x&Pp+Cyv?pjY92kA3cU{V7|Mgdl(tK03qlM3a zd*7Hxo?c~pu1Q*#WS_h%qV8 z#b1Gbc(=|BiLMH|ghCbqS!w7>GZX;3>+LWI}{y5YLMj$j)CU)IXc15&a zO>lFcNxLN@K{xI7OHbh;aM{pf-t>5?kF6_^rMXIC{P0dSwb+2PyqBKaj$HpGwk!5Xl3ZM%G;Y0Bsw#VxUEFI(@im@&kLbvT8lc*ydeFe({>%FL*DZkWo?X%Kt& zd9Dd1NIsD?-t8=G_%d={ALWQ!&2$=mcyao;|fk0;hH; z4zuODzrgXpY_Y1{(L-;v8euZ^sqell1&C0j0nscN5eT!=USw%01b$F$q$MnU-y*); zq6`sOnp^{a%rW;nOBQ_hc}&86U3(G)A0-fcu3SXr@XW&_0LR^aWKoV3|7GHY2wkJvPp_vCyY+^pxv zGj?jt5#a7MFex`WF+kAz!9^=8127~CIvXbM7ZEIztV58h&Y&sqhbGD8eb6Xpl z^S}Jb=6}kMXNU~FktHm3p7Si(jhi#*b-ja5dUfg8%s92y#;N%86W2G2qu`r}Zs_e| z-B`YSFNeq;FDiOs1<29l__*>tNCZYY=9LsI%)VJFt_CeXV zqSvcW^cZ4GbuYIm&Ufbw0nG)9aR>{ljRK_h+|Z&|&BgQvq6@u@2C>@FMb1y5Z_2x8 zXqO|EczZElC!YrEksCj{%b)z18wKiJyO`x>(dad{i8ITINdB={PzTgwIRLL8AaRze zUD276{iPxxe#tiZ@ACKsk18X%1j<)Ov%C;P2>ZG}o$n4ufTdigUX&cV3=HwK-5i)% zSTIf{oUZj?e%#53x+auJjVy5Alah7c4v=_y0CA35PgEuaxPDb}%6TO(KV2CW@T1X6 zq4}ehfcYs|>HflR;x5hldfN(L;vH%J)=GS}k)lpnbZFQ-JCKz2_+7AS&QMUZN?YHG zQn22^lhb13R=^H=@JNqapL#r9 zM}SCX|G_C{=Dcu076RHmW(3%>L&}OagvJWKLJ`tOFv)D~E#E7xHT7-Tv@M|=biydF z0IVhKSU%6+d$OJ=wXYLk^lD{jyxt0#wYB9lSi#-uUjj{{CpqDxwPmy^FmX&YeB9U8 zA(xzY0Isu2C>E5NRYM?rEgdIA5|04&m#-ZPNBxN5oWDsrulB{~0w2ffV>xUD&3Nw! zK;>wSrsk`EsdOj;xQump_QBV3oliRu`#u zBIzr;yqEmLov0f1ez(>ug5-AHZ{_?Fpson4(JGf}`dX1Xd6ln@&b{E2S5KUc!LwdB zRrrmSbHQPzV;CU!wv^$;6q}5J2}?F9klisK9}mBCUgPsePF{Fh;(1U^s&8&HLk!K7Wd zM^Qvy4%&4Tn}27WGl3Lt=OT%$W({K9F839(=Aq14mH4kd{mUwz^r{1D!it+(d7`|v zGkd0=o=JnAf9ZDZyoY_r5`yTk%RN&pEjVacVwggHRHpe0lPE9is;3-lS83013g!qf zLA3;p_N2BS0XBXw0W@|OcF&aOAuWbM$>O)$nB|rD+Vp(Ol|Wh%wRc(=o1_a1FE^!I zL^^==`C0XKp%UNjMb76BS!WSO-Q8jV0x@SYu$On=&WZ?=VX(7*Vfat^WlW=Mzj#>7 z+75*w2jAysSNv*j$lomMQq|3uTJXr^5<#1JaijyC1)x<#&9n{E07HI8-NSw5xjtj#BJQ!#c<~@slZlpM2m}gA ztJHe=1>dVaR;J`ZG6T4c{&rX8H2HJi$q`hNnA0ojG!9{#-?Qi@v(sAgkn|FRWEAHh+V1IJ1GB~Glis&4 z7%GI{Bn1qL@#-{FtC1VW;x_fAe;OJ-zIoXP+G7Ct zXoXmZAfk30^Cm?d$q@PaDI04NlWcO2Sb=wfX1}#@~x!q zBLM#q!2Tu^cjx>O0Qcm>mj~L1QQu#Fy6pU4zXhHz4Ra-FoCVveV|J^ycmvd)Z0@P* zS;?6__yg(3Qf-;H|7j&5Fsc;dS+HRIUP5Z>2%z`7eqPA#*-9ym^;RN*TxudgAC9rID-kvU=8YgI|A zh~U;Qb7z&sW{E^d{<9tsgYt}@=v=>`B_P9 z^#X%ASm8mfePn=wfDNX~x~n7J#)>`i1IF}L!|_!7O)DV=-F&ONB^B#O87r|#&1B54 zcr=-;^ujXp_dV+XL|r7U=-5~O;R>S}o5R{lt=||XEoW9c^`3}^)ICy=Pt5#XUO@ne zk0GTe*7`^i-MlIozLIfDr0a_kd)I^;cvW0ovdl<>ypy3)pjHq$4pPhE`Az?!$^(}B z2mRsqFqM9GrP}4JMd~7!uI@|Kd5d?IZ;~QAVHa%ypWnry=5!_O%Z; z>_Ag_PK>xNI}QkA|IN2r2l0vr_ZDlg6?6B2l^sd~5yQEu&re@`_xN6qpHXR6F^li^ zZkD$PveYl7WRQKT9^+}lv-H3vfy8tM6i4w zj;7s5uqP6(iM{g>sw(zw%63vRRn|0R(iHEEegI!^GY@_&uP#e$a9#R9;<9nEkJc?q z0A7wU=-YeXp_624lBC8(Z{+Vj2&t5zS3ZEgAC+>;?4?S^!z--SwA)oBtoEU-UF?U3 zzk=kMeU;Wit%`W#DViLptTLZO-lr7`8atZElXUQfO{B`Rv-)&x6-PyL zzXDQY^>e3Qv$8kfJ!tPY?N>ZwX;yByR$mgxM{_u{TQc5cwuU8^^?7tr)LA|4-u62b z?~(=CXtUHZl=T-kKcy83fa^?|ZfZ=Va;HB&TsXv+pwGg%%WburdIO{&Y5t-Rbq zz-XK?w6O{rG4ZzJBPdUnA}0mOA|eJIBs@28%GqRMoyY34P!*dN0d#?`pcX-CK5oeJ zktO#to8sgr9;ipwo^!WmiMWRNILWrGeRuSYzRKch_vl-Vy9KWv9%|P6 z4AxxnY{-k@e)}PvozA*4ORAEvUu>@b5D>)u<4nT`QZ3j0T?iuBf@XO!$1u?WQB$wh zJn%tObf#{BGdfS~o=$gO&d^Xtk;M+Ok~^(WWHdC4}7BC>S;ej8WzRa zbZ?eIbj=n`2Ma23r4?D(nlJ5ua52uMurBMez!@cW=P57z`i#@9uu7nLl?t+w=8YF0 z#8vT3>+>J%GT1iOC?uve0L_ff&H9GYy4@$K)!?t}+((lnO zlAjZ}(@ccQ(i)Ncd%p2~r{VuvNKnVCm-~KM*AWm+WGhNEt_u~gKN#%0HBc7ydzMfb z6;|PT58a6r$0>gS?}+Bxo|||X&JrC5%(6Jm|7q9Mu_d(xu|?Ql>$oruI0HI%e$C0# zJs&t8k<8s(u0ff>q%FhTMz}uzSK1m_&g&i!8ulJpUXYpvj&TSI(I#0svSYj19W`PJ z0F>AVmnF~w$N}vp#up{Gnlr6cE^jjWn69ffrrE~fP@{?{<1C`?*=3cZwF<0nevcQ(X;0|(^*c=ccMcXMr^Iiz2@uLWe6nCmVrA?3p|M#&1C+uW zxh;hxzwnAn8ZfnE*Qr0GRLmH-Xw>{so zgg-hjOlC{KbGTN^bh_F5q{Ax+k8CWVfyXndOyYb^*8sjFsOWZ~n9ZJ`gG+yfw$Mj8 zm}OCN&yMBD8iU{BMngx#&Kp0(hRbp2`GRrrIZQYkhPm|4j=0{-0Wf5$?Qpk1lBqiA z#`nFv$P)NKpc9%jV6oi1oYxIBef~&!9I2d)yQT6C8gBbr5?v}ht8vjGMl>CJMQ1Tk zi7T;UXJS##@7iymhs&N}6?&w{MJcaP6l?%!gf13_a&2Kkm`eDL9JKn3sZM?*KC3)h`| zBy%8+l9P{Ha#+l|$MApB;xi>?e;&Ai(3~TFzTud#^jK}3K#m{Fnr3)^1gIP1I|4kv ze+1a%@wl;YkC5f4CMX`pB$UL<))KjT=ia}zG=GOr1@sG8437}srf?M7?wHe!-*V52 zK-BC@M~nJM75#UT%c#F9jK4HPI-GEWy_X zH2a~N5*rt$gn`a;H(TgjU$BHgTwVeUMk19$=lYy6lW*P{-=VB|>w-wqzQF!6byKmj zPc=O5>U#G%+B#ESRC?s3&TL&d38!nHe3%Y1=Y{2IKN^pGV8#Oq@_3$gSt!iWY%b|B z7J*4JHaB&YOyQ}xYB#XW`vN2^0B^A&J2;O$5wfj_w>936+EN5wFcQ_bz2)Ft(oSK& z1r%`0B~l98wh>cnzYy2+&%u;C_Rqwais5a#K&0|c&jJ?fp{IB2RADdd@oxH|)C&LZ zDB-q-ZGRKHrN{h%T0t!yHlzlG*VTw*(`QUv6IHj)7ks_(&fCi|S9cBpMy0c&lk`dqdIR znfVh6%Zv_bYP0FT-u46)58&8H8%h`bejF4wIWE7<-X=a|fF74+(uOiWol?!-&iVOT zR*|Y5#sc$^TQEupMADzPbs|Z`V{>aMoqO6`pIxO9*S>5m`BDztf-T6q7?v++w~Lt;?eqZ$%=J$ZL0{>a6wEICuEy ztCdEmwwXbcm{p#QSU2o$H*_lP;_dI@lR9MD>y}O1Q6A!=i9d8A=<0b;+6Bk+`VgZz z@l<3CD4!O}UTxTkm3jeKho^|F$%R>o zy^_Eud+}OD8NQZen0v5csD!F_Nw>+T(YNU+Ib!#v{)xAzIImdCIVf$w+eSlUTvPbd zJ;+7zHfMdNpPK(iRQ^vgysNDVavH;A>AKV^Sqh!G+f+&b@gRt#0PPcE zmZeqsa{?v^ICO*(b)Py=dPVI*kbg1ErT01LAtub0-c7J33oT0Nswu?r<4N6DXCUYWZ-1{-*>^st*g0w^JH)>Rc z$$o*CU%}f0RmpBr32cYG#=TV{&z)tEcOiUOFRQ3)@fe*!HQR5}v+6V6kMwBSQHX_;^GZd=W28hn`LqwRmM8+lPsV z+-&-wXW;nK5k++@-Er_z!g_jp^3$P_g&27Vy8t$)Px?5R?+G^2Ic4>))$pIh`G3^? z|CLV9wchSaAIiZ*)j@q@i8fN7ofkB33Jn=$=y&{SgpOAye_m9-up&MA-Wski{qCf4 zZXP8Jb*_|@%syUbYb@H=2u!xW*FegMv}(})G=!wI6s}0%76QL+EAO9bc*MuW zk_wuw6Pwi5$$xgOguD(1e}6j0n6f@hM-92477H(@@RYaACmCQX_(@(v6D>Vk2kgL7oR4w6=BGND(pZlQtLBqvOzOL}YUxlxq%Z>|DcNx6sMo2ep74x&{ z+uHmkqj}NM(OREK=WOS1&G22Ec)S~pFqJaI^)SLrz-g9yNOP?-; zXD?jaP}9&k0>mWm(#~J17wmN`bw-y-%X3XRTRw{|3;^e#b+)_^Fis@h9^ublfH(qt zl|9k_vtBGuTbA*!DIgV5pnNj5rtPuR$K*5Bf3%4E1BbWDMY3^z-F$O*8UnXX@^*v# zuC5taXi*EV33{NESR;Mlwhy@jLU2a_f!@tmd!ia#*q2@@`W9G}wdr-g>nMws^bB`R zyys|fvBPB9O#Kd{#vh~lvxdk^qCF`M+9>D-+qSikGGwM*&Qnv<`&9shL{t!DQdYGs z`ilsUW}jMYei!(m{RhgwYDUyq#E6T^2moR2f<|`MoNe)NqZ>Jz!gBgoRE_gdxmaxq z){b0?@E?>rvG#M6F{ka|tq?gAn|zE|dM>R(N{JgB^~cz*re!>7y>UY7C;o!({uQDAcU3_29`98VfE&p}jFBp?5z||l z4QXL(&)?SVitjQcoU;{=ENQTlKa?bU+JT^-9;|KsU34&66(Cp7SzT5HX&P=<&Nv2fI%U-#y|$h`{y9c1@y9HQqP9TrgLdnbxN_i^U4E(D zGKlFF2ff{G91K0HBtBaJxqa&=f$X91)|o_EZF!xzFK#jItG3TV5y5hTh0Us%{?dj^%pTH{A71*8|LkJ_7_hz z`nOHAX&q4pw#pdrC{Evbetx#^0i(}=B4B?AB6Z_5R`?VdR6G~U71I-#Bp7g0! zmOoYC^;+kxP2!s#NALnL-&0P?DU%j*hDrTc0b2fGx?!-E8stPnbeFFkLC*3VUNLiT zASTEuEX}torO=tQJnIi+Yb%5M;MqfKKu@iH$_df{ncybF+TLX^KW13dzI9AynShEc zV@foh@1+-xG;MUDR_Gt>iO>z|3`6x$t(IcpTilh(} z0TrY>Wxxoe3Z3MJiNK-x`00Now?ayf63vo0$2^9+^9S63h@4fu(rJBRA)_IQg?w+E zt#cc#qClTPNx%QPyYuk&;Lh+3~ct&QPnMtZpKTFmIKMr zLy!CEpA+D}?Dl2Iq(fwAy(Zu{o(M{D zDFU0*2pQax>>-$YxJ-T`xmxS0tdkHpie+z#eqkApwfi*=D2P9s12J87ae$$tf1HI# zhe!m)=&i)*77Lg#t4>heDOW9^@|U!6!zYoPb(nw!FSOv-_4NIBr*?0T_9{CH|j zxE^4fv>t?b^wQYueqL+bdz(f~a2N?qkwW)v+QKOB>ZQ*ffArqTH$Hg<#*{}acE^AN)6Ga_hK9~V}sm@l>llzi0GGahhue~%x>QJN~{66 zeh^nDMO}!b04eO1kKJT4wD_gb$)y%itFvAMA?zwQ=aWpHLM84fbn^G{i^b=aewk;; zU8k~}jC&U%q&pJwth37d;geNTOXKKI|7maIgy@s2EST?Sb^dZR7~A@sjN52l!!wA= zD~`~SNjHaR3bkc!c1RH6g$MfL+?TwJ=rAAd3EH&pZ<1c7#;AszSp`~w~`#-ywn zFo*d$26@W$Yn6(q0TC^!nG*%MSv!@=r1d*X;>5?dzk-CnR*odoj3!E6%O{BT$hPX6 zAN!zR7qyn~_zvkm*?IJ&+%)^**)X5tk34-lac&H_RJAXFQ!ADmI0WIl=pai13?8R@*jKh?;CA!hM=2tmG`P_F3 zyhe2ylV^qyUaTI~ezCoEKozS20kk_}2#zwBJbmk=fi|2V!$lDtTPf|W5JC;db4j!p z4}q9L3AQD;rjZ=?oecOm`3O*xHAYGVfy27y+$pAvf_dE2RYN#PiX}i z?gBA zqwPk4FSSTg%laIw;Dsx^togWMBlzsh&a?OR3)#M05rV}PE~7%4NO^*^Ri(!fz-QO? z$DI1YH7M+AQ%~)EHtoVSpOO5TX-`iuc-L&r5_--q$d%V~EZ-}U=#4;Be4Qj|m2i+& za>HzP#^a_Opa;TFU2e9_=6^!5Nr{bYnM!w>I)Mtwj>8CBTU(fgvMBMZ(ZWQG2ziJa zVugyB`9V%`l#_)Ql7F$2CY4dv7R$cBuG_q;o>^R@Mzm}MT9rbFU$Ykib5TmeM&QX( zO4seb3GYAN8Cx|jd_5)o+q(I>OzisTy2)jYQ)z%({27N#KaK###(%jxjF4AtLl&)4 zvT=zqrjF!(M=P3RZfZW)4?~-iO~t@aZl~OcIZ|mqMC%~A`eGBx&uOR=n_r2*kdl2( z6Rp1ahN~5$1GE$z$v)$007cg{F=I1)yDU1ktsomYG*7Axben$=q;gBZ-Jn?JGEb8< z!lJ>>qJN7wA;&e#R2;`oj$15)R}9tN5QXt2NWeYQ@mHELp0iBv$>KieUMdynWLSsa z%CO0{-22|Hn0tUQL)G!cF1NldcdtM%mrI4%6no@g;@}V?q<{mf%=xziFEMRN5)+>; zs+G_sG!s93jx9CAU->XvT{YvmSN`?B)Ve$nC;oQJG2Ls0!-|x`@)$Dd(G!LjEn;I5 zG#wfQZ*A=!bP3nzWT*5(_0JcTvyv7rM_YkaWRkoxckVu>iwTXysN6B&amz~@t@;(0~UYs$B`w*ytaI#n4PV>aODDd)gO5KC-onHAS-D*dvR*NvR*^Gi@QiUuR> z%Lfq}Z&b6MgIr=1FTb<{kAgdE{vg8WY*$eacJXS;Se(=9x)`j(y^nJbW-Z{qoK4J0t&OBkw&A-B;h zzN7)C%?;#nKz9}ef%uLp1Q4MXlus(%iL(n+#4qvk&;nMJU`hpu6Jgi*JE1~A&5@zE zvG?1eoRw;_`44*cWU18rAWx6n_Rua*F=V0fwk3#!E>O9m`5xOVkFZA~2?XU47&Gz3 z#16s&slRlI4n=d13i1C3(Oo$vla>q zXDrQ|E!vd*Cc=clea-BpZ>iI?BfwB9trud-)~JH$uyHLR1_OGjW0yryw^(}}GrS8y z=B$g!R@LZ4q5W6|J1ZooypOvBE5+&ub8J&pH%wVFekbYdyemrP+jz7=NwIv)wkjqp zhMQMVwp(j^tgR{C|JyXv`(MaOR}$>=4=>AGO-CxaD<7!t%Z9}yKKAo;h(V;}cW5=m zy^0<>Uc%2o&a>WrrSrBgeWbvr8dXJuvL$l-CL$UIr1=6RlaEhy-BlDZ*aex2xWnvG zO3OjfLq5BsX49)Xn+ouCsO7}h!wTUZP`*i0FlAz_%+PRSVw1EqEpr6;kv4diIF3ma zjOJV}R5Sg^M^WSZfarB^7GzT!mI!F(8FXLuB=%)?vn}~7nvM{6@}?xe1tjMq%IcJ^ z-}@<>{{S#tF`aSxer{7xfOMGq^?k@wo#fY2LT6h~Y#ejsPdDu?WY^&P4X3|qF%19! zr!4u2^tM_GpRu?W%4K#vEyh%f8tm0Q!r2B+(s(wxer)1Pkv+!h zF7brIg-+nb{(8Srjfk5~{l=Pz^G2Q&ZUl3~k8=+y-nlano2wvU_mr+p;xC4)4NDIx zXt)OW!~7CaX;&wxyQ$;*e1<4QftSVWvW`9yKgqw`x|4dbiiZ!{uvv2Xw;ORWHH6IH zOW&KHR0$ME!1d`9jU4UFbUESc))K;N=K87+a~Fr+mP$6jA@JiZF@6^%HA;fG*Qsg( zxx-PhGRO^~erM-=8vbro7te%8T}Gq47+Z|opjX_f3Mon3OUm~i^?rYUA7?O-q>a&= zK~jKk7IyHJI>`vd;YjLzpWi3VeAL|K`wW(Kxl7_KrQj!8{(;cUl;f#34JaBf_G3vM zWCAdR?A6ZOk}k#m743P7J}-#9<^jSa!%V17fGE))gG1-D51d_&V+;Qc;L!V?nQ^A( z{_Dah0T`>&FMu;e2&qDW#Nn}qcV~*yR#M)zJt_5l@!tYIFYxL z0x4NcPv_XbIRcrYvR`C`-B&N zt}O5UrE>010sbNrmE+kRA?>OfFD7eF6P>Py_d-+5;mzgUgmM$IQpp?xbZhgH)&CeEk^Eu!A6k{?aTLj z3PMqKi3HTkmv^#+Yn;iWMT0oC z57FFFql-zpvXt`{PAj(0$=pIQKS~zEM@AxMNg!*Wcoyjz%l(@ZCie>`HdhDKbzQnt3=SdkFshOvP}rxj^sDH*Fj z)@olt{&bVnR!XJ)Zqlh@rvvMezT(7BB0K>(|N3m=ZLmEl$K_piN=LhCQ-k|(;4qwV z0%4|4XetEVn9NC4ReoPW*Td@Pcc3jvGb-4Tnp*SjdM#XVoM|hkz}=yWY=LWhtYXyy zR%LFM#UVF{A$-5@t7*UZz?@id>}U(eSVVqJ5PXFNS}pP0g75qQhTff!jWi55(S{mD zn`WT(`9a_As&h>ST{X3P3t!F1Bl%e21Ts=1XTOYF2Nw_N) zhpUv-7s-<>1@-$W)Z@225y`$Ll5d@8zKO|kONyvCo@^F=TtA;*;`U@m zd$_o_FWlj{GWpL$eS&B0YVl;|&69sc@ZX>PnW_JK2S0oIX9H3~4E{HGTx!pzWuEWg zKNJpmbL%e@7}5U@D*thk!Zv!6x+{Nmk(81x(T!_psGJGda5L#6Wqq1c|2>`joiw56WnSt~g&#!~P;qX2<%T^+>b>^NvR9jNj;dspyUta02m z?(H!eAd~FKhax7%x3ZwmoFVq#ff61gsMN0xr8lmFYEKf?losvZ(R7QnbX2hqszW4Rn zyxV#k$v~5@*)8E*hW917_DQ81WRXOLQB1C>%J+W#b^XyZKd3iJdqQ-k>lZHA7-|v_ z=Awv}q#I4v`R@A0?ZqMDwU^X0Cp6f5{V*VG1V|Kg&pNx83K4vO_VDUaAgSdYv^3gq zm2uQE8XW;j^f=zRo7`BO(FMu#qi^a9J8)NWx6BqA_dx>Wci?3vu<-&Gg9UnyG)j5? zeZvC(v&8`KrT*jNFyZ)1*`n6RX*V*?$t9y8`0+*g|j8f$I_xDt9nc+a-%|zPk=42 zUO;=+^mbEuMBdcQ)U1BM?^BPQCU(=F-sG$)I7dnl?#%IytzL8cN)dc%rxlHOoM1dE z`B2gkjK{M-c~K`OlF}pB`HtX;CRbHCdi0MLW$wSb=GT09pNZ=#+x$CpYowelTl?J> zp1yS0h}93Sq7BvcuTcqD`&;+h4LOXPPe99nR$dgCRUT!aitAZ=Q;J?_dY?(^&;VzPwPsSU>YyBQ!lx*DmG;+k0d@TbDh~Do?9g#=K`dSp477 zfIlBi1vyW(s7eC^I>QiO_RZqpB_9fl_>Z$I-^l?5#RsB;*LV(Y6Mkc->rH$db+=&; z>-+WY4hl)$uX*zEuQS1_a7mrWHM^INg1yHt8Z=?&Kirb5~y2U0K)mT+;f6 z&P=Vk>!OJ^Vmmhc4#hAm>JrgYqTF#7a1k{xnxre{L`+mn#%z;rd5Gvl`H~3h?zLWtblQbe)&`!FW^Qha zw&4{gy=9C^;5i}ufpK-T=TKjf+57Rf9O-23`mKi(k)geE+%ko)vfx2jY{vu6&XP!H zsguz=v#0x?{zt4oe(pxEa{7%iW2{hjJAGjvs*dUJGwNQ76gG+|*z?>n>*^y<-cKSX-Jr?fIYJ8GtM1 z>A#X)9wrkFvE${z&0Keor+Ry zU6``?OvW1vLOD3RM1n6G3wGZt_Y=AGx*#$}gWawO2+@1t9S1?_iszZFt{JCWtcCUX zFjDfHc2Lt9(HMe%QH-+ykXVEvrBaMAf#Fjsqv$sU>8WRvI8g#La!GvIInb8U5r9?D zh>n^SV$Pawe;}ho+!<|mJRQ!Owj1~U7Ykv@=^B)C`FvDqvMK75yK9hsn)r(E6%+P* zo<*ig^!(2RYd?YfDlV#J6mOUoLJemydwXDX#+}x(S@BTGKnqMKm2aF(*)v=*&Y z&}|k!U#UZ00@watY(&#jH4;SkfMiml(Kq_7jn!|IRaj0kebL}Dt|_$$?kQSEDevhw z{g_IR+{4Mu_ba$cISb)gVl~4x`CyacW=`gbk%DN9m`UCtX=ad8LAnNirw_ZGP+n^1 zzZykZ8$UVz4}1glfA0%|XIT{-vPFfG}i(0rld)4y&=1OHbyZWWzN%-4%2W@ZHpb2 z{0#&}S1>H!gq-((*w8W9D;MC+Gbkh?>!5c^%Y+3BS^EgP>s%{FjEzME-*PwXd4>7t%SBl<_;ZilbqDY zikQs}q#N>)Erhz+9XeW|r^` zY~!N$E03<2?>FG_*KNQ?w{_<2ygTsSg^qS0xkc*G@pFvdu3e1Tbd0dF4jvKh1w&wn zjz#+)LGNe(AP%>2pyThSAQEauHuRImGnK9hFSXGH@LD06#2a&w-B4b^$W~|RmE)tp z0iw}AD=_zr?>e;6k}eW(77=|bw`oNpf<}C=uGf9|=!kwjQ37kBRa&+1V4_wnIg9=- z1ZVzlg40a}^X*e8pXW=3PKYkN%kg$omDVe)bj557l$w%kV`6lgTAF3FC>@^W$2+w^ ztZn(nDpcvT;A46I54mTOhV|b0kPBZgyl>ZhwVhR-r?k?tTT$fXyiSWT6qqwY%lZ8bA~^KWs&;w_`t>Ni7!)hi+K=fi~c_RQIN#kyJ7 zvpa0K8t;cqf!Fa)O z`gWVLlW|4D8@hZ-OHN_Bi6hK^^z5fg>d^@sv+8G(zrS(nO~jN!Vawic=q*LsCk+G2 z91(fo3U6V+eLh$-+p!fJ=oZHwf15!4r1$2VO?r*u^=NsGC|n-phv4{``EpWvzn{+vu`==* z!bH})qQhRX(i!KcBCYjjee$FDA1WZw=r=u-6>T|-D&R#qy8hjHo;MqExzAU%h02xu z3N9&ZOu4iLm_PRCE7ov!Om!3av3&0^M3x9fLtBhkJSo1o0D?EvJSX+6@cN2i9tMIV zrTVrOx>Z;0Z`?`W32Qf>LlVSu)-*I4z=r74&JhUZxJa6~of2~ptHg}5uSu~yIbnhJ;Qr7`>Lx8`iy`tLi#f@ihN>(SH3`84 zmo_3_p8zW?r(X(a^CDuJ@d0X%h>$a!ZoKXRktHK~~+(lp*a;PcQ6{D!!#539e;VeLJ6Mc-ROh+CP#932KP5~>tcLYLEO z`73rK7mVk3kOKDZ(^aj$qz&k*=0tXNuJ^?uce~~|YT%QL^5)!6qK^|bPacshe5-rK zGO+pBRV=F{)jA&Z2&^w9Wp>5#5m*mMj62KioZ-^&O$_huhPv~y3MW`Vd`FBHE;zls zUp?bI+kf9I$V<&;F}~>tAm;095$CzFb3J%KPOPV^(<|7eNaD@eMN*Hb=#;(hbV46^ zK9zC_;ob)|*j}ztQL6nW7wlBV>n*!M;IA&G3fqk0VK0q*%LInZiHmf3NYQ4i`$Wta zRJnvB>g!wf+Dl};xn7xY*G@b2y_|RyDf|12%jC2d>NRFENre<%#60O>13HQhRaRBz z$<-tIRs_XS_}xlr@^~P^eRax?*+N+1^UFam+y=OzvfJkP$N^66o>lA>gNEocgCL5D zv==LTf1qs|he#49_;Z3e$75|B++cfqR}tBJiw~=h;uZh$Tu!GuNkU%${n9R5QoY1l zZBFw7BmMkYEINvB#T$!Sc?i}8vd62R?Kmg13c>pJ$TaI1rv$Y3;4V<5`=aW#2K)P6M- z*a^wdl}A`7HXn2HbKfuPz3)dC2Kjq(qu9|e?QsXlEz+quq@%Jsr5-Hk?{6 z9OBzJ>LC{Ll)WLI>}^?uPG{}!Fv@DGWgT@Dh!y>k2;R_2_NZF%ihG_UlR|l&+tcN! zYd_^Av}^9()HGXz-|{Z8EK+xBO41eT#S~oBJB6|G1!0=NIGusCX}6KF2UEU3`V>xc zKfeoKfyjKC6GX_Ugth$-_TB@mschXF$KDV`ngYU@P$V=#f&>8_A@m}U(2G%j{937{_*%wJ>6Z*BbtR3Mc9qd<)yz@C1=8&BAdeIZ^(P} z_qdd_s(AFnN9-I;o>W2dv~SH`p6Nx%awhMBb~_c+TpuB?OMjqqez@!N?{!zH?l;~* zzE8L_YapB+emt4KOG+N>t!eFp?kaXTa+{1!qE{#t$Sb@=ozQ8i=B;Sr z7DnOjkv-KKV?#b|Z9P4+quR>Z!9wI2+_XgH9^E6yO09AH@dj)$LAeZQ!ou_6i4}4+ zWV8!@+cnaePc9{vGuWZx`2bUbh~Pk^z-0XstOvP4{*I=Fk)Zr@WB-nxw9eA_Jbhp? zkn0&N3LN-BTzj^GnK0%v%pGh1(>5_X?D9AqvuMJKXq|pHb>D_^wxgSxZg)B}nZ?#` zLRr;{+Ww+=^x`rRbeWk;(H~4214qN-#iNVpV3P@26@L}0p2?3gD^IDe-PKQXIQoEL z?&|K-s^a}3oJ+)FlYln?4yH}UDLtA7r+DUpdhN>aDv^^3O$bc z@1m2LZzZ#C`YJq+U;hQXh$s1}! zIPj^>NhGOhJ5t&`mkj{|t-v-etgO5zM;w$Y&19|N9#eb0lgAf_Z_tLrT!hCMCg57w+9Y6qK-=prqAbbT6 zV`3th@oE6~`df)o3l*#6`NlkQD6wGPbpEZJ@EVU?taxSeaxJVL2CqG-m$P;j3bZQ3 zr)-+E2gUOh^bE0!;+=2-E9Mk;uzBd@7%~tflUS5rkZ4$uTUTFSZeeA@+)qBrJFU?J zgV>`DU$JmejakfsHdAB3%FwACv-<`fkEwDIb>a>{0|oxFr_j`_coMx)!1)(GZLYzp zxbKZR9a#6opn4&^RF!4-_pvn_gm46efTC(*Gqbi;Ll7&dy(_F35=Ct?JK5HMRaKWQ z%TLD5?>-z#$2MB9GyQbblN@-i){zL}&m^XKUE|#z+|4k_QdS{fgt=!PsVH7KG&i$J zOrp4pB(}2nyqev4f67~MK_&I!W1E%-nJ+x)q#7~#-o~^@c$JZ)80F*ev6I~+>-Vyn z6>GG$s8DDSlebyUON;LcbHrJrGLi9B0;$wNN&6K1gJ0~Ei_N2o;)KW?&T(1W4^wnf7ZuE7Y&X5qc7SvG%yC{g=d%rG0W$0tk-0tu< zn$fV?1kc_P=wOUs#}$$~fVCPc5 zY+h>G>D;U_D-Ym=bAQfAxCBgDnI!NF03d)7{bRCraIQ>7NVYP<1Q(ioChYq|Iy^%_%+UzQ!|dXC z7T1+^3)t5UefIK!4@y<}vbpj6|4ats#FrQM>X#RH{e!T=&kXDW6AZt;xz*K@4X1692Knc-<=hrE$M;OPjMlJT|$ zV}Y?}R9-`miGQmmpAmyU_hKOSMUCD_99bFM=gl!Qm1#16*Iv~?v!EW_W&<`KNQJXF z2OL|tzy0$tqrHmYZe}S0Y4;T2UAP+EocDH6Ps~Y3shGjhUpLs$2BR+(Y$}_~*r+(9 zB@t!c`jHUYOWNyA=ZJSU;J8z;BZWl$2Zo|m1=OA{XS?JDD%F476 z@7ql7#COjaalO5-r%>man}&)(ZIe!y9ak5CJ8d2#gyn;W$2w`^>N< zl91g8vrH6rgGaoymJU-2?$*yyePwf{1Nyw}LRRTQX6fYX@2`rZQgw~Ch8lGhVXZlu z*pw2qF93qWh53QLkQ@HfHQVpk`JdXO`$1Z298LJ@|9Iv2S9A9pcD`#}C3%31ce`0; z;MLCz`25Y?17GU=JLmYpO_9=4Ob*Nob{zj!&+dlkLApT}sV9&i2ukmEoQYNHBV3G! zUrd0oLl#On-l!e#@rhKGw6jsbp>V{ey$Hj*T~`$>hk{p(tezZ*1DcF!hc!?JU@9w9 zgjjy4sz5&uPyHK#{5v26JXwKa=@~16k|AT{dj_OLVQW zp~Qs9n3U+diIy|^CK-v20M*neM>I*Cnv-1xvwRew#iQX zH)8seL+L0;S4*VKo+u$(cr30r^m5bjd3)m9=?GVuQrXSIXr2ijuE#?^Oe|h~WLJ((nob=vaCbvH1%}!T9n(Z|=jDGAr6&C>&r3GRE4)5x{ z^?37#d!P7MqFyJUxXXBi~WXbIh43kAe|G&6q()7lWuk{$Ale8Dk>F= zmb=(!b4!v+CO>O#36`suEYUVhHIa5vwj>lyFB!~ic(Rcr&>NRkMaC=NRY>H2=Y}GMQ2=(BNqfeZ^+?4aFxypi+bu-2uicw?2Hcf0uVaHbHuoK z!^WVLGibtM`*N1KcNC?(Eja&ZWGdTx6AO7JWA2k_hMF|E+g?h?$OpRnrtvOqj(5h8 zzm>GG0$0fij?i$oE!8|_l)1j+wvdT&a|omQ|jtZSp|ZI zuUdv!^B#Tnv>}?T9sj{ZAm-`gE?XiAXx@nDwlNkDDd5 z##QrzlRycwefqH0)Lgt^6 z8G7Y>U@hs&`OG^=$)=`qJ{C{72$Mmws+UC!L4raX`{l>b@nP=VVC3R}iRqR)ds5Fr zI%eq1!W+Iz?=EvSF|%54BcN7Lb?zalhONT88Om^rpYwervRyql3i5r5*fo5cRBIpU zsS&1H2E^>}o;nxMrMVR@9SaOR7FY5>j51Z#8yL7oo_=GA5tK`VREKoHVwYQ7Iz`Jb zIK7a{UY@#I)m5*q3+I95sj5*D7L80Awjl1iY3TjUh>jna<9$w5uiAV%PE4&ox3Ce@ zskToxrrt)f8#qM__g4|NF13zkSR03P2P=t)OtqpQK(8ZFB2h`X(4xai^S2o?og+7?hh(aX}i5QdZUYc+dU* z=$`P#y<=^L!I^s*HD~QpNErknwY)5EUkiiI{zgC3x?EmtYSeM$^FY=b$GI!ku=vhp zmy`j*&hy=lLz6*{vPUnXZOLo;;14Js1prKi;Neb4_1TGOqEkPd3Ndo)I}KGPmr!Mi zgd%&S9vYd~K=CVnXtcBp-*{p3cXj-;1P?`7lxgU4G4_c`LVsPmpqVSK4%}2#x}3LW zyeA?}dw#iFb7q#~p>T{0HZ^)n#ld-h(hWKl@aDj~FBq0F36s5m*pVi_< z-B)a-)a0+E31|O~E#aW>UOO@t<`nJb_E?y$D&kme;G^$Tf6%0}^asobLx9k*3Z4G^ zV(XZ3tG}dDnW8Xz1Jy{1Q*O2wmD#}*^=(3*5$}0qlCCGnoIg%{~JUS1zAYzwCVzxj26!dMDre7%{|Js@adpF@U=IYQK`H}80 zP*o+)OR5!JAY`C?qP_VM5Q+BCuk%Utmr3RG6^+HF93=0CvlOJ^<5k2bIBX(zO5{Gm z=W-*cZ^JM%x(IkkUZhbQ|)5F5?papD+ zc-hsWRpk1ip>L}l(U(EBxzz)5RgUmBmqk}i+g`)>XN~(eWl}i$jmlyMqFJ)5td<^` zew*Lo^-+0k!;W*7J@6YuFLx96uz=V-u{~@;{C(4z(Hh?9V&7~Kja4{62y@~6hoXgJ zO5bE)U}T0YjSS{g*W;e>>%73PwTktyoPhwz;Bfx;+(tf#aPBe9;MR;WZKiuVRq- zK6@5m_&y=lzx9Wi@_$dpjqP*K+%vjBLPts?YX~z5#MWDz-wZ4aExC*?G17iZIdl)n z^4C!H?bG|8UVhh6TeB;-(<4fwwG57i=R=~9__ zvCuW&N3##Uiy5&3im_q8g_SvOau5>PkWGV@(u~jx<)N=d^|3km;sj+|Z&@j|)xj;_ zP2bG&)5G6XPg@HPztVWCYK(0eDCmE;UXopp1rC8aOZZ!q*3V!)vRU;B`7fr<0l+`T!+iNZk)x!JCW z{RaPcRlIcY`%(*?z2HBX5qcN0r^o%vP=iV9aZa%+52cXTt?^ClVy?=b2i`) z&xkP;MfE6&4dNoq1hSA$rp@ysm9MW}`?jzHsUHU?qU8w-B^JQ`84Qvh)&Ziwh>##g z#sX?GteD$j`O0%Kbm+a%;P_jf~&K`3fus8hZpIeRTI`Dl} zQvJpsRwbYRWk&aQB1y`(us|p(&-9*BMuyruL(u z(OmoKkRTwoNL#3&aPR#YP@b{HBIF0dcJ`xRk0irh;oT5C6-= zKq;BC)z5;xhKMp;>hp$aism2I){G3gh>o>6U3z|DBq-?b~X0?ouldxG}M5OGo&_ zSsPS~MFEmaRQ}woTjN};^1)Bv9#UOf+Y+cx10`!Sx&=oo07{y|ADkjw9eL#Zy(>EL z*9u_}R;p||!ST51DTtnhcWPELy{pstH?JUs#}lrX0QDM|Z^?hxdeE|F!pSUqheVqM zi+-7mE=_)olCU{t&4BlT$v1|+4-b@UT%N_|tIG9IIJes!GMre~3*C6dv=e$~(m|a} z&i9X_#}Ki9GyJ_FyUJLzwG^Ki*2eAFt0=UR04tUpR*w~2bS`S1Z6S3+LZ>q7%nGw| zl&8t<5jSM~zUjF1MqIKvn2JfeU(ZI5&}Jpl$%V>sx60)fV!qGB{H#^Mw2**(YVC5( zauKNqXO}6A{mh`fuXgm~w?@eX3o01yLNe2lt9iCDFher#HJQ^swlq^ARw7#`Jp4gB z2j%(dn+lSMD?LUM2zUN_J^xyH|5B^}vl&`~$h>!3`Hnn21nl5zRrroc)y;hd%$G$? z)=0X8k|Xx3gT>`vC#nZ>WB0XR6m|4*?F3P19|IWZ-joas18kbLtQvqD2ktQ(Al9$k z8CfrD8#ORG<%p)1 zfB;wt!sfiK7aC4FE_H$a?jUeY@k`bhgln4UH8-+GWa~UQmRWO;>nXnWWeiCm0wnx{ zd()ip2Y<4r#W1l#!cSlakW>!O8vkJYe*AjGhpoKwcJVH{AH=Km;$*1UArtxMuZyJE zG)ubGCe-?3wp>6TvrLwkuFC)g%miO$+0^X zxbkhTbIVoB3kn_Ynr0>fw2;8j%or#phCK8-tZt4FE3>Tt*IbsHZr8RXM`{#07etg= zh{mXzg`66mby@>AMsO6FZoZ$Vqtvr=C#8D3eTvQ}RHma-k<0ZCJGYaaKbQtI4-VTD zujh5#-jF^WuDuF_It|63T0=l955zAewW%__m6UTNaKz`D;5W@tef}-(D~5SI`!CSN zxdsK)Q3gN8;g$-G1S&az;Cnrt*-m?Gi7h&`TVos1V7;2TbLV;` z;#;$vsI1u-*#yR*I{j{4l;8z{Q#7@i+5RZCY$MG!O%qN;L$>C<@u0`HM^c^R78x=6 zc~>2pW)bIa6l9w%q?*+y>`zY536B?eooPi%Jvotz-k>xiyeni9W2Z)Hp|GlJ!sk}T zo+ZR>1qBgYoK_aEXN%vilnBs@t0r_kwMi?-q>J5bmPSdvdcxMQmEUJ z*c3y3xzZ{LveCm2c9=G^ z)yXA=+;{g+a1ic}5(YCc@xK6pCVUP+^9i^n_MzEc?DF#3w(9oNhgLfDX?QgFd49I5 zMl0jeQ$s$QUgteu(YdSJ|O z)agsdI!vj0`2OHogjM;6$7T;1|BV&X?w3C^V=jL&V=Bp|+C6O>p} z|6}3iP-CO4S%~5h;mf6I0lKLe)9@&3Ztkoyjde{SeqAXEreltYe12Z%^R|j^UD9RS zAUmVfdgLzg^p+SUwdq_!(#JIk_BouNE)NA9snRecz|-c5He5qC*XkKyv#APm=?mZP zokM+>`2DvG6SuSl?9r@Q{jgz#`=| zlQ8sF?;V3`sjO#C=Gz3`Ua8@VpGjh2WJyZpIh^B~3{Q(zt&&`tDK4_I z&Hc6DS3zaHYQ-R8SyJ6A6|#yHvOSWyix=+E=e}ZYkF~coEuPpg2e4@qaH=K8m>%tuI>!_xum7)Xtt4@>J3zmcmLwM z+z0Gc*H*4&eG^hv;t3Qf9AjT@pxi?!$+9VdvqmlgezBG=tl_snUO03v#*@;5_FgK_ zqFAcT%P4NnU9tV(`qF{%%fHkw$475h|FhQ?2F}hNFrq01dbf`Fsb8Sezw6DUR+Vw5Qig8QD`lyyA_ol?cp;+ zNyj(6Wzv6fD&fSmx|x21&491yXncgF+izkQ5WGSW_fz9%B1)VXpz7&DQHBMW5fHvAxuE;td}4HPR+}ML7@PV zZRO3XCA!N&;pmo8+PCud7x(XfmcESm8R zXvE`+o#BotjSo;Z^k7A1hd%V^eW~A}7dektZ}ikZvA^a*EF2`$zmd&SZ2Qcq8{Hap zA_)z8$b!1uoOu$F{&HNGXw$UweTiS1Y5oSLn>Vmh5k4X7vn^Bd)_d@EMxkq*^& zY|rHel;eu-6^i| zN4Vm7=;><$PqNJb4rM>t+QyCq(_3~(k< zU6+S$twN~eB35j$_ux7yt8L$utz~*D{ME)rn1GD1$Q4$x+c-5^K51AjSJz@+W$Rm; z-A!cIiWBg3L*aQyIc9?11DpPpW!d|I`LyNOnyr@U8#7(b^Q3qSiMQ;WpX%1WmGcO@ zk>4?TzxD8vyJo>muah;mmz9+JY)dmsYAdpl<8YmNAyr)uP-@ax|KvU94`YMV+3FsR zEES)ZIKNe_6Yqo( zLTT3yRu?~@SBH;U&_c5EW~)9N#7}MvPpzWjg;Qbb4w0LCM#Svp67lbn_=~aRzdE^L zu1e(UjMb~9FLw^lP?1YfNu|dD#f65oIv<|>LfP_#yX9{f5l;J8%{lK^2EXZT{*=)^ zFmv<`{mbZRAJ!6RY)qUNGwC3lGGW|$7VOu&_A}^7kCMXp#gvt{BezT&?v&eI8HT+1 zJ-=R-{YNKy-Y@Mw(+6>4^gOBaA9zOKN3w$b>gdIiPQE15^XSDq9>X|D=Rcfh+zP+< z&n~Hs4_vD}9M8BFFNwYb$iExMKL1Csncl^}cLzHeinkwobRgX98u!cQJGP!u47M?C zbu(WvUXRXA=I50%7J6T$2UCPNFS!0BmW=Lb_+NQ4N)q3g-O<2s3{^)MGDTIcEBs$t z(Lb~#3(MJtP~;K=0}QGO0=)W?^XivFJoNVb@k<-qG1Px)PkVYBpJ$N#()j1;Z5(`q z(VY(PJNm!yWM(}yv=qz|c$A?kb>c@~85-#9CQL!^EP=Xb)Wg6(7(X~! zq`y`LvuFs+E)3F$r`!W_WQI;MCrG9EPdnc-xzjGlLLt>Vi7X0DGn-(8Mi zS9%O1rBj(bo&B@yY20ZaLx~@*v5s|&QNYCtR$+NTOiJi+W2ogE?R5_!U8RY}?{=;K z(?>p=mpIeXOCS!RcceVtyoMML*QD6t!#=?R&=7EVE+IHM*9>g$EAyG*R6?o5c^}`r zuEH0bSdUbUjk`nU@D`U9d7(jGy1(Xy!=((=)f(JTZ0ZD^__PU!r@MY(>A|QfPnBJ( ziGvGr&B-}BAqHz>;(u9`EOGP7imY6O+T1f72<5w%UAH_ZOt2;r?DP%Zkfp$TVqBI> zI=Gl|R~1cNqx$e}$(cG4 zJKKb^}r&JYM4vF?Zl+>Hz14p(o~2TIA=nB$YJ=#<8{P)R%1jeWRZo#d z?XoeJ+2hktn3>$B(!#dC2NZe=gt%ZJ1(r$o&?8LF+l@RjroRDx`yG+D%+X+!!Detd zBGS=>>|hZWg#fi!NRp(yK?qgJ1X)7v=&3mCtA=6qs1kchcSjJ)q5Ftm`{AF0f`7lm z|J?sS;|KbSw;=<=j`XKPD_Y=l6lr)In4AnYRWB?ZZ@DQib#(mfTd?oduF9h##>6G41U+o=)G6I(_1vJ0 zZ1l6z{se-WcdlCn2~VeUG~j#ux?wmXJomg)RAh#b94KfzJng_ue{N=6?fKAlO*Zl~ z`5}WD=YgN~uN*cPlo7eRYIyp&#8Lzkh7A1k4Eh_J>kv{KdXur z8>T+Mvu=vmRDx?}@A|V}$;|Y)ef&b}L{c@JL=rzW)w{y3{ z1)woltcpUr7q!_Atta19nQx&!$`yx~2a8hft6Ql}XgqI$()pYxNmf*^jUTuYg4o9z z(beo0P;*JO=j%)OfWwPVEfPKwy;Etb5E*VS(*2S0)9b}OVC`|SeqYF(2@jYloa+G? ze@$kr&({TVc2Sie)|()VtM!MVsm;2vPkYGOS;0mkdrHzRPG&%J;`4d1kC52u@GGPK zhS$$tJM&H;5|Pkkf=|y*fmO&UEEG2Tf^qWzA@=E3*h#B9fqhro|-0@YLn4i=6Bsx8)D9TG$1? z14!`tnahHNY5F6w!3X2ed?6;<#jq|BeAk_Ybg-uJGtKmv1$Q9arO|ZB&m0_e%O{g5 z2UCy-w^3)zii~_krLO=D(J+um@}P6B(~Ih!qUn2bxhIV>E`crz)nY4~vk25GRT0efN!ss0CjLg3{>>qCa__j$ zb6$nI%e#A~VJESl8JIF-PH@;6NnSySZ|2RC=uk6YDL{JnVompLujhecD;#1^t=TS@ z5>ism4atX6a2S%-EI-_aZbkyKqAcJQENmx@Wfb(TqRxkmRtjd;#|!f5$>r&@sZH2o zRV-`~Ai!doQcOf`U)8qup^W<5!9GqP3;n)}G5AJq0eA7?c79DPK!Q+zN00?=}>(zKx;?n2xbu*5&u&%=DL1q4vY96k=vj)a`rV?O4?P5baUl zbHaHl?Qo3^_0&j-z3SC|+&pB~Pi&`;X06DS`=YwImP4?g;Lv_;(nZclbZ0gOjZidt z7%dYO-F*|me8R@-oF|+;-m^HF@l0(<9#6Phk2DnSp6Na$>wbe5yuu{#qiSBr7?bOz z=QFujR0L$fJ(IJ-EFd`ZdV0g&1*9z-SsE7jLmf@jb@Xr(d zmMu^a=jSwAXzRz#{7D6bP9{U)kxS&_;l`z#qDOzK2`8qRbgp|!yXM>Aaq4NwIsN^r zB0l$4oe3)=OD`Y%n>PQ81BhEe>7LT*vHb zG0WvWj(<1jvHjtv6~~qMw2Ulb%6V=rbaL|{3#OWIBcL7^(hjO~_oY=`9*=M4ne>cz zPR8{)d=-$3XdKO=$o1rAg`QX-M+tuUNKmLGkC9i@Sz-bz3ay-L{Qk}mr|`fn@_?bG zVu6mg+{vobGP$e-TpiBrS=U=twlmOc0KaKJ_>xCfnW(CGmk?go7p?o#r}oz~AjcKc zBrZlN0vG)=06)N{wys*DFYOl}R7xG8xTjO)e&AF23rYP=@}Gg6e={zfe*BxY>GUId zq0_%1yFp{;(BeFGl_ZCnUJDj;1a8mj3A|FJ+JdVjoK|Gz>-XsWPgWLTUAG#qfKu|Z zZe`8oAh03^$+5kzDeJ)Mygi9C{`6t~-ADgVV?FQU4+1K|iK;T(AaiJo1Fp zaD-Pejq}q)d+ntGsF>Lu^s)*n$HOAP7led=`KpK zWs_b};$KVUzTzG4X?f}B8!rE=HN}>|nCH~vIhdv%vYsB?)%D+0{+}WXL0AE*^P^x- zRn1dyaTM`wAj8+ioiD5H27|xPD2~PdzJj9g`*h;b!`~PD{&m{d`PF~1K+YP1XuLyO zp%dtM*VU_$yAij|-Zifuy27bemz^|){YYte5|8%0G!15_vjf8wM%4e+9A6~xKdhhh zV1$v2FxVFFFL^9vaTP4xAKUroH$Kxq{Uw<4R@@YQQC*8)*-MF~0Tlx$MShv0ADH@m ziA?wR`T0xUZ)>Dmzt80Vb=t4yxc@SN=<2J`dl=O!^vj3_z}ucpfzxB1a1p{>HE#?3 zc_@zGong2SSxM|BUUz%R{bj7>zWsH?<#`{$4ZP5^x%!}HZ^w_ zDHF?Ea{e|?j@x-Y+&ERZ&64e5VBP%Il=yB@=VXjxSg19n_=zNF+@mv=mOnUPHG=P; zhyMy*rXx)r1nob(zW2t%Ql`MYx4RK89VT#UI#>w8(M8rXuxC*iERGu?>o|9-5p=JiPG+8^~F#CcN z$aES^^i-)ZTQ@jyuSxy#`l}{dx(@egxXuNJomR`WA9b(s?B1Q_V-v+ZLptXgciYQC zWF-WyR$huR?CdZ|?N%_ZOVe`dr+5?thzS$4ZBya{av%45T;pRCoO-(M)l2olDfE(WiOgWVa=B}4Kxt8Qr!Mw+ z@>4y?^TnY) zj-U#mkou9e(!40wdkd*>!r<0@_{Q2nf6I*YyR%&m?K;y{4q7j+GT}q^fMq8@?CeCNb8Sg`!$u0t zc2(jhADQGN5JXL5q}TG=>D=O8g%zwh)csD*t(%1>;BW&AIIw>c%C;({S;2#RD{D50 zpQDFlc};uJ0K&6hxK)3x6<7>rM^TiwEL=6ULljZQG0_d##;a5PQ*HQ#dpEi% zm6e1X{xo7TP_rb$-%kwF_=&Qg+cIn|RjM{jvy1K``?q2eLGPDtc^&cBacy2(>H!fN zvhzFP@W4|+BQ)!RwKu)l%D64{J0Ky!+??U18|Hs(i@)1HV3<=Xr~b-r(Ze?CiTWps zCF-HN)ddUX&Nsaf%(6*PKe6+~bg*2g1zU@1-2{moV3Tru_LcK3$@AR$4WUQfzZrq; zzfc=zTn;>?$nd-UHx2gU(fjvvM~gU9@*QpMr0DTP_AmsI!I%xtZHzTLSVv_6Dl(Ho z@!$jO^rK?;yLSZZgawK2)5XVbs#=+l_8Z>Z(??-0^Qy13Ingv3xXgCs-(Q z^pAdKxLQ2)cWv~?1j*>8hLov1%PA}Y49P8SIFRKH)-HqctIY~v22MX^Z70t|b>ZlE zi|4SGtN??HHZTRz=>b#>!(R}0Sd2CGjDF&-L0k~Q8opU~Mr$rWObe}O+F@1F?OhpV zwVv#g+8I<*Q?@Y9>ojMB?DLv19y+Z_wZuTO3<(Pa zfu*qKt&Izo!lNygMb*VJ=T*x(WS)j@SdbqiBT04ShggtE%wAW+(VyOJrwHho{j6@H z^vnomm`1**M(&j1RO!u3v}Wh%@spLflK)W^pAhlRi?u5YDPo6eq+fXF4zXk-ybc=O-0F21>34gpBVyAa3OTZb|qNG)}uQ0yGL6yO^m11b}R~Tz4Yjt zeW%Ha5dArglDXHu{dP~lzTi#wh6)yzhcAQ(E5|}XxOrc`{4Kwz_8Jfe=wIT*A3*r?8U(n1g%Eah*?$O|&|$nfYR6`EL0s(=KF zg%tg%a%_Pu<(l#ZaTemhh-y74fRfl1ym@3QRTh{+Y!5R+xyvDvfahz0?`G~pUTU0^ z$|Pcl4=5V-KnyM=V-Wz_$m6n(Ta-XbUcHsZ__9Xwq;EaNWhbr9|loKdaSeo10aWbT@#M;oiMeg^JuAI`a8W5j{M^rFJ)` zPjqL2|C@?1$MaB?ZK-h4q!TuXLK!;wq7=i)mFvQZ%uYF|@~C0s|3~ETmu;$amN@0H zfEzGFUS?1d8xY~MH>6V^M|k`{t1(|CWi1_#Q9#>nCQ+3~j2XT{ng6}Wg6|#m@Ai(E z{u<)8Ir`UFD35-lkU6f7tGc>>YU73s1KWzZPT28OFH0dfyVx}!(a6%qWkG<8T!ggN ze0k2HsLu?N<-pGjys6Zn*MHd0t0LXm5$b}iIIgJ@vyKUUvh`8@q@YTVU1j};-G%s1 z>g!RpnXSAzuX$eVphx+(H+YHrA>a4DUPYJVg|u(!8~328t3L;gQ+#tb`M&M(Holm6 zp)POg-#IsbfR1^L8)o^;5TQ3$)khBY8L0M2w)1>0+saQpd(ZCfN0C^pIdaxR+0{`o zGOk&DHO}+Kp(Agg<+bmFo?IFREPZ05)55H9K3Gom2`k&aMJM#Av3UOuJtqEPl+F?3 zCix)fq*^a90Yzu>*?9SdcHzJ6_aCyI{pC~r^Y%X0aD6)(uFGhxhAK2|yAF)VeZlvc zVT>LcI_7fjr@Wwb*@Zy5luIH$d}_N+kH>lXMe-Xt=0gTQ!pDvZD7(Z1Q0nogB4>dK zR2U*NH=<*UCc&1~AJ7(@*W}q*dfv3gFV$D_A^kfGc+L4#t#OaQ*hy2+EJsc_!J%4! zwJ-i|uUM>o;VO;uNu~%-j^trjG1-o7X&@M4UU!WESQd^D$QYF1dZ9S#L?XFew+6aL0(B$SEMs1S^r$8wR7k+q#+a)KJtQfW=#Y%V|Hj3XB(aZvFi_y zT(SYU-0MdGhFIL1=3j*e=Dw}E;NojD%{?$KV`e#c5R0YhH+dT$y8A-c2sr6Q4}_^g zdh;6Siuwp{HQO2~O_G~2+n{uk`0{dk5lN(jTaa^_Z_)gEn>@gRZ%e5<6XFN@2uX6Ai;L1h0!cZx)>`G^~4JX zwO}ZQYHnTFr8fXxT@4>d5AJAIwQ=d}qrX(T|N4yow$4!hnPEGI4sCoU@|hvaYM)8* zjsItc#ngA?PoU*RX<2q^t1~yVv()RI+!2Jyvd0MaVg0@3n_L;pLZB+yC z1sI%-BnKIDheWtZ8x($K5Z<$(o7Qx9L$5alaRyaKeZuli`7R~+>8NdlsE6l<^z&bNou3*7*9#w62#ops3EK-!L4D$tnB>0T3;H=K;**Elb z>9o|zfejai+cYJXGpi=AhgFTzd%NDT9o?}H+wMV>n^F!ltGXhV3L4#DTRoF?k75Uz zIJ-d*2q2-p?@fT693E) zBDAk8Q_22Octtu8P(78EVXq*6g=AD=8D@vTuI`7~&+oc&(D4xRm6viDlTx?scg<0` z^mw-+0VlKs9qc4*=pFvmTG2~~qzHah5uRr-RkJ6#`ux!|Nj6^lO@0ZV8SF+B;i!O^ zo-y!!inftb94$YoM6FD-{zdGtXI#+JJPkg^-qBl>&WsgrvNxQI0wXOe>j>F?DLMLU zaSI_@&ZRxb^A}_HP0%&~A|0p*voup2pY8J#3J({AXSpV4E4$YRyy%~Ln7gTSp8j`G zA7oWb9}7P0Yh1BfNb)-b$Ly34h}m89_xS(od(K~9@jq<1`qNAOQzqg+E%o1;_#g4e z{|68DPvh_WgR=i!S>@0Fy&iA>=!yQVUGG?4g-tXGEMZ5=gvm3QzmM@FBXDqh7GdOi7XXxD)8{FAfloN@kBE zaB5*(cKj!2;Fdyhnkjn%NojhFong6S13fugq62WBUYtCbYeScQ)q1K z))+JLZuZgES0+h$l@ErSo@t8hTYhG+692*LWv}6wl!5iK&Wx>#a^m%!hk{P#gz|o> z<;jT+uPydG9^EsqjMVlN<0*KcSz)wOk-a-Wysf?}`pwWGZL|$lHbCk8X^>XE%tte+ zT)r7}<6e|c5UaPy&Dr-%nu)+v!<7wxJx4 z(QREcDC%&=)8v2g(GtjORKr;9*OM^JtIjE{oc&}__9v&nJS~Jb8!Ig5~)zIVUFrHYzD|p z9mU&kqg}u}8|>@1lzRZq{BWiy7x+U7jM(1e!egw==(I>#cDiL}wUyrmYD9o!tFsfg z0{q0r>yz7>QViP|!5oTzxr)kasQU)?}n$rfM2wHW9$W?m^td z*avu31$tq7+;~&e*$JyQz)#5e7&>8*BJ2q(HE}*V!L!eCU7CEkkdTl9dT6Muo{BTV zim;+Pjgw?OL=1fLqHo43G#fR%ah+%b zH3r+}TNnG_uv@e&=zRo0!|e>bG%`aY37pq@>bAGpol=1MAb~=o>4+>HNg< zN{Q}K=-TIGPI3KVAp66+P15Sbi@qVj%RQ|+QT&(dwmk#};ZLC&k9=Cz$_el`_p-2=YC#qiHSi)nh~%BDvfAAk_~gp3Hx}U_-K4w z`ReVmc}G*a2`3!MGmSNIE;P)8d;aitWruhlhE0hQLI`O@6d)V*#O~x(1l;hXhv}rx zlDdBlzis_B%x&Cn@Qhcle+O**8}`ya?NA2{|45j1`^2wHtvAp9jt)5eIH&$S@bYw) zl%eDtF7!Eu{J}k!*g{p-^auHKLk~tA73nhWVcBD@=k4_r}&kCl#nVluX=ZWa)wU&w%=Vmi0Y5^RbX2&x$ zaQY7ibDSwHaDTOWnw`Z{l8cGxBz&T#h?I9IoSV30j&cAI~Dnm36d#m5GwW4&=S+G#zYy%!;n5LbEW}#iiilxv! zfJO&&xWUfeyC|nSI=p^!>*gG*lkXzmFW9W+L1WST;^hW za4i-{|4i`mfz{$mlKj;%$@lu>QW}R|&%7j&IEZl{KCLtmU(bVCQMnJU%-l0lIIl#8 zO5fM18AJM~7aex@(2c-9OgwXxl^p(bF&;NV<#Z&(LUkXn%`D5(ploqoJXv@=X6Bh1 zJDOIJL$p&#FJ9S?z%t0vUecB)q5R7*(R{cF>A?d9^U!s;E{OgUQZ(1_QP!=NjGKr( zIW&bRL0%98mM>zAv&;I2B7_pQtn}V9$eOzy&5Eqz#t)>JOuWqJKINYaHPHhqTRLqS zWq;!DBa3c7;{`YGE|Hc@bFS$xDJen=Ot ziL7SD2|8mMrE?0@d0flj_1CA}XP`M^6rmWiu(y@V_yl{egUF zlNE36coIC^cMd?blc07QjW0zb&h z4jus|4Il?$^=F>%?9MRL+=VfRkygNVb7cYD^r&uWGYhk_93@*W&ju)u9n4BWCu$dY zAfjZze68P_5MjG@{8EN5hFsdPtQ5@5GS~;de#Gc*94qlj zp2juAok78RP-Sj(HK`|iEV@$Sj;hOv+AiSndYdqTl~+&p)6EA(&6-0Bo8+Z`_a_HW zxA*LlV07s{jV*aleKtX5FA?iuIZW<%Enk`yh=br68FFPllGw7W9~Lhi*7v`t|3bEC zJH&zPZHPCHMF(+1h1w{eT4G@zYP=S;LidYvmK1uIput0WT4IpY=j|en48cKx8JVJ1 zOaP#6ff00(Td_e86%#4+(cA;57@K~*hNfXqsDItG)Qn8P3kLtEM@JC>t$5a z`iRw~tWPrC>XAzBI=hZ$zvewk;6J~3_pIGiSpiJqRGJPVLLGz?gH-If=*ij@)i-eo!#A(;lhu@<4TW7isK3K=&7) ze!0VA)~wemefmCTuzO8)z#D_A45#oN_gIKg=NEX5nPP9n7WGEulc|&dE1|WyM}&@H zP=Gl2KV!PLQdIxn>n)%E_znOEK;Os4>!3XswnRY-3eLwI1j2?dyC@uEA7H%)9vxX^ z)1OBFT+6I6G~hvgjD9FsXt(CVm1kpzB{!2~D$DOqNX4WJ0@DI#dM+a;$+`6!q5HHZ zP5L|zQJECM;8eALYz(cI3yWj%0iwyAWgc<5@MhS;8__*SpGvoH8+%a+bdyj0{@IZc za8$#);`&--lAlixj~KZKe>uKnN_xj!G8eHSxq<3wKO=ETh|#g+ANy4!TwcPi|32EE z89rhOPk13^F#h2dO>LGi$GCCsc4ZL!0D&i!hXx8IW~DQ@vpapM%?G0q$U zZ-aw*o=6&Jkx&hYYus&o;p2H75-Hz!Zstsjy5ErM)OK~=;pv&rgR4}PG!<~Z1(`>g2};bc-iLa%N~@ve4R$lk*0PBko26N_C=q>VD@~ujW1^CdrTI{Nv zX3j6gt93C4;()5Qk_^*lEC>54w1AmIq=7UoFtKmORvpYYWI0z$Rd3^-I#eZ61!=^H z#KE;}>_p!9?OP$8mK)k3k(BpjVB^Si43<&1t}N;NUN_c8TVo{Q3amDx9znO==4X?deYV&X z2Ua5e%h^(P&IdPJwy*o9U;=!^3ny=WXKm^6>z#xJ22e64n^C~dQ(rVH%@Ozr76d+( NrGU+P?veMy{{-ZhFed;2 literal 0 HcmV?d00001 diff --git a/docs/财务系统开发交付总方案.md b/docs/财务系统开发交付总方案.md new file mode 100644 index 0000000..0522300 --- /dev/null +++ b/docs/财务系统开发交付总方案.md @@ -0,0 +1,3166 @@ +# 财务项目基础系统开发交付总方案 + +> 文档编号:KD-FIN-DELIVERY-001
+> 版本:V3.25(Preview.8 首次安装向导与部署收口)
+> 状态:第一版 Preview 候选包已具备 22 页功能框架、核心业务链和可部署制品;PAGE-13/14 已通过模块验收,PAGE-12/16/17/18/19/20/21/22 为 `IMPLEMENTED_PENDING_ACCEPTANCE`,PAGE-15 正式业务矩阵仍为 `IN_PROGRESS`;整套 R1 尚未达到第 12.9 节 Definition of Done,不得把 Preview 上线等同于甲方最终验收
+> 编制依据:2026-08-05 腾讯会议转写、14 张 OA 表单、财务岗位职责资料、项目管理流程图
+> 最新项目决策:按既定 Java/TDesign 技术栈交付第一版 Preview;2026-08-17 已完成 PAGE-08 来源、记账、档案双向穿透,PAGE-20 审计稳定排序/筛选/脱敏导出、PAGE-21 权限与配置、PAGE-22 私有 Gitea 签名在线更新、OA-04/OA-06 动态附件矩阵、OA-06 财务终审付款投影、Release/SBOM/依赖门禁、Linux i386/i486/i586/i686 外部 MySQL 8.4.x 单命令安装、更新请求持久领取/中断恢复/数据库备份校验/回滚健康复核,以及隔离超级管理员全部功能权限和全局数据范围;本轮新增 PAGE-23 首次安装向导、无业务数据库启动上下文、MySQL 8.4 DDL/DML 预检、一次性安装码和安装状态锁定,数据库基线新增 `V072`。远端仓库已固定为 `https://git.awaioi.com/ERP-Team/kaidi.git`;配置 Gitea Actions 签名 Secret、公钥指纹 Variable、可用 runner 和生产只读 Token 后,推送符合 SemVer 的 `v*` tag 即生成 Release;PAGE-15 完整业务矩阵继续保持 `DRAFT`,须经 D-02/D-09 确认后才允许作为正式规则发布
+> 实际开发技术:Java 17 + Spring Boot 3.5.16 + MyBatis 3.0.5 + MySQL 8.4 + TDesign Vue Next Starter;前端仍只允许在现有 `frontend/` Starter 中原位增加菜单、页面、组件和真实功能 +> 文档性质:本项目唯一 PRD、SPEC、开发任务书、测试验收与交付基线
+> 文档用途:甲方需求确认、产品设计、开发拆分、测试验收和交付培训
+> 阅读方式:第 0~10 章说明“做什么和业务逻辑”,第 11~12 章说明“怎么开发”,第 13~19 章说明“怎么验收、交付和控制风险” + +## 0. 给非技术人员的一页说明 + +### 0.1 这次要做的系统 + +首期做一套“项目财务基础系统”,把资料从进入系统到可查询的全过程串起来: + +```text +项目/业务人员提供资料 + → 系统校验并留存附件 + → 项目或财务人员审批、退回补件 + → 形成收款、发票、成本、付款等财务记录 + → 生成记账准备资料并登记人工记账结果 + → 资料人员检查、归档 + → 按项目、合同、金额、状态和附件查询分析 +``` + +### 0.2 第一轮必须交付的“完整” + +完整不是“把所有财务和税务都做完”,而是第 12.9 节定义的 R1 全部完成:22 个页面、14 类表单、真实 Java/MySQL 数据、三类业务身份和独立系统管理员、审批、财务台账、记账准备、档案、报表、权限、审计、测试、部署和交付材料都能实际使用。银行网银/U 盾、电子税务局、自然人税务系统、第三方账号/API、RPA 和自动付款/报税/过账明确不在 R1。 + +### 0.3 前端唯一规则 + +前端只能在现有 `frontend/` TDesign Vue Next Starter 中增加菜单、路由、页面和业务组件。按钮、表单、表格、弹窗、抽屉、分页、标签、图标和反馈**必须使用 TDesign 或经批准的 TDesign 组合组件**;不能新建第二个前端、引入 Ant Design/Element Plus 等第二套 UI、用原生控件替代 TDesign,或用静态页面/Mock 冒充真实功能。具体例外和批准流程见 11.2 节。 + +### 0.4 开发团队的责任 + +开发执行期由开发团队负责“实现 → 自测 → 联调 → 定位 → 修复 → 同类扫描 → 回归 → 部署 → 证据 → 验收”的完整闭环。甲方只确认业务规则和验收结果,不负责替开发人员查代码、改数据库、补接口或发现基础缺陷。 + +### 0.5 当前状态 + +项目已按 2026-08-11 最新指令进入 R1 开发执行阶段。当前采用“模块逐项验收、整套系统统一交付”的口径:一个页面只有真实前端、Java 接口、MySQL 持久化、权限/状态/金额控制、自动化测试和浏览器实操都通过,才可标记为模块级完成;模块级完成不等于整套 R1 已交付。 + +| 层级 | 当前状态 | 准确结论 | +| --- | --- | --- | +| Preview 工程实现 | `100%` | PAGE-01~22、核心业务链、构建制品、签名安装/更新框架和自动化发布门禁均已具备;该比例只表示第一版可预览框架,不表示全部业务规则已由甲方确认 | +| 整体 R1 工程实现估算 | `约 95%` | 按当前已冻结范围估算代码、页面、接口、迁移和开发自验证进度;正式 UAT、部署恢复、性能、培训、规则确认和签字仍单独阻断 R1 交付 | +| 整体 R1 | `IN_PROGRESS` | PAGE-01~22 尚未全部完成,第 12.9 节 DoD 尚未满足,不得对甲方宣称整套系统交付完成 | +| PAGE-12 主数据中心 | `IMPLEMENTED_PENDING_ACCEPTANCE` | 已新增部门主数据表、共享字典权限范围升级回填、完整草稿/编辑/提交/异人复核/生效/停用链路、重复编码/版本冲突/审计断言、八类资源版本记录/版本对比、旧编号映射和 TDesign 部门页签;`MasterDataIntegrationTest` `8/8`、PAGE-12 三档行为测试 `3/3` 通过。正式异常矩阵、Chrome/Edge UAT 和甲方证据包仍未完成 | +| PAGE-13 合同与成本 | `MODULE_ACCEPTED` | 合同额度、变更、结算、开票、应付、付款汇总与下钻已连接真实接口;应付登记/确认职责分离、OA-04/OA-11 已批准来源和金额口径已通过自动化及浏览器验收 | +| PAGE-14 收款与发票 | `MODULE_ACCEPTED` | 收款草稿/确认/核销/作废、正式应收、开票申请/审核/人工结果/作废/红冲、合同额度、权限范围、金额上限、SOD、乐观锁、查询分页和 CSV 导出已接真实接口并通过本节记录的验收 | +| OA-04/05/08 来源投影 | `IMPLEMENTED_PENDING_ACCEPTANCE` | 财务终审已分别生成已确认应付、已确认收款和开票候选;来源专项自动化已通过;OA-08 已审批候选退回后的“来源更正→新版本→重新终审”闭环、正式三档浏览器 UAT、迁移升级预检和证据包仍待补齐 | +| PAGE-15 进度款与付款 | `IN_PROGRESS` | 付款主链、OA-06 v2 稳定引用及财务终审 `OFFLINE_PENDING` 付款投影、成本类型附件规则、同项目并发余额控制、独立逐笔资金台账和例外申请/复核/消费机制已实现并通过定向验证;正式矩阵仍为 `DRAFT`,正式规则确认和浏览器证据仍未完成 | +| PAGE-16 记账准备 | `IMPLEMENTED_PENDING_ACCEPTANCE` | 财务事件、凭证草稿、编辑、复核、导出、人工结果登记/异人复核、已完成结果冲销/异人重开、分轮证据和历史下载、历史外部凭证号唯一、授权额度/数据范围、SOD、乐观锁、幂等和审计已实现;`AccountingIntegrationTest` `18/18`、PAGE-16 三档专项 `12/12` 通过,正式外部导入样例、Chrome/Edge 业务 UAT 和甲方证据包仍未完成 | +| PAGE-17 项目归档 | `IMPLEMENTED_PENDING_ACCEPTANCE` | 已实现财务资料完整确认、档案包生成门禁、缺件清单、对象快照、检查、提交、异人审核、退回补件、归档、冻结及保管到期状态提示;正式 Chrome/Edge 三档全流程、异常状态 fixture 和甲方证据包仍未完成 | +| PAGE-18 文件检索与借阅 | `IMPLEMENTED_PENDING_ACCEPTANCE` | 检索、扫描/归档状态、详情、受控预览、隔离文件可恢复重扫、异人批准借阅/归还及范围外直链下载回归已贯通;正式 Chrome/Edge 三档全流程、axe 和甲方证据包仍未完成 | +| PAGE-19 查询与报表 | `IMPLEMENTED_PENDING_ACCEPTANCE` | 六类报表、汇总、同权限下钻、筛选快照及两阶段导出已实现,MySQL 8.4 全量集成回归已通过;D-08、日期口径、流程时长样本和正式 UAT 仍阻断验收 | +| S3 财务收支阶段 | `IN_PROGRESS` | PAGE-13/14 已完成模块级验收;PAGE-15 核心付款链已通过定向验证,但模块级阻断项仍未清零,S3 付款与例外任务继续按各自证据单独判断 | +| S0 决策与合同 | 并行收口 | D-01~D-11、全量机器合同和其他页面 fixture 仍要继续完成;未确认规则继续阻断其影响到的具体模块,不回滚已通过验证的 PAGE-14 事实 | +| 范围外事项 | `OUT_OF_SCOPE` | 网银/U 盾、税务平台、第三方权限/API、RPA、自动付款、自动报税、自动缴税和自动过账继续不做 | + +**剩余时间估算(以甲方规则、样本和环境按时到位为前提)**:第一版 Preview 候选包可在 Git 仓库和 Release 地址配置完成后当天发布;剩余代码与内部验证约 `1~2` 个工作日,正式 Chrome/Edge UAT、异常/权限证据、staging/恢复/性能、培训与签字约 `5~8` 个工作日。并行推进时,整套 R1 预计还需 `5~8` 个工作日;D-02/D-07/D-08/D-09、正式样本、生产域名/证书或甲方验收人延迟的时间不计入该估算并顺延交付日期。 + +### 0.6 V3.22 本次修订记录 + +| 修订项 | 本版固定结果 | +| --- | --- | +| 页面层级 | PAGE-03~21 以路由合同面包屑作为静态页面名称的唯一可见来源;工作台、列表、台账、报表和配置页删除内容区重复的模块名、页面名及功能说明,只保留读屏标题和紧凑操作栏。项目详情、表单详情继续显示不与面包屑重复的业务编号、对象名称和状态 | +| Preview.6 | 在 Preview.5 基础上,窄屏表格固定列统一降级为横向滚动列,顶部账号文本增加省略保护;通过 TypeScript、ESLint、Stylelint、Vitest `10 files / 56 tests`、production build/hygiene、完整 Playwright `198/198`(含 390px 固定列几何回归);真实 Edge 复测权限与配置、系统更新、财务工作台和记账准备页面无整体横向溢出或控件重叠 | +| Preview.8 安装向导 | 新增 PAGE-23 独立无库启动上下文;首次访问 `/setup` 以一次性安装码验证 MySQL 8.4 连接及 DDL/DML,执行 Flyway、创建自定义 `SYSTEM_ADMIN` 和全局权限,写入受限运行时配置并锁定向导;32 位 curl 路径不再把数据库密码放进命令行,安装完成后的修复重装优先读取运行时覆盖文件;通过 Java 编译、无库 HTTP smoke、TDesign 输入可访问性和 setup wizard Playwright 回归 | +| 超级管理员权限 | V071 将 `SYSTEM_ADMIN` 固定为隔离超级管理员,逐条授予全部 `iam_permission` 和无额度 `GLOBAL` scope,并由启动同步补齐后续权限;前端菜单、直链和按钮统一放行全部 PAGE-04~21。管理员可管理跨人员表单/导入/付款草稿,PAGE-15 增加仅管理员可见的“全部付款”,三类业务工作台显示跨人员项目、资金、档案和全部待办;审批实际处理人、SOD、状态机、资金、附件安全和审计继续强制执行 | +| OA-06 付款投影 | V070 发布 OA-06 v2,新增生效合同、供应商和已确认应付稳定引用;财务终审在来源审批事务内生成 `OFFLINE_PENDING` 付款,执行付款检查、冻结项目资金并追加资金流水。余额不足等投影失败时来源状态、审批任务、付款、资金控制和流水整体回滚;不产生银行付款指令,不接网银、U 盾或第三方平台 | +| OA 动态附件矩阵 | OA-04/OA-06 的条件附件行支持 TDesign 布尔开关、长文本、不涉及原因和嵌套文件上传;仅 `required=true` 的行要求文件或原因,可选空行不阻断。上传状态按字段和表格单元隔离,上传期间锁定刷新、保存、校验和提交;更新请求不再携带创建专用字段,校验前自动保存当前草稿 | +| 本轮运行验收 | 最近完整后端 MySQL 8.4/Testcontainers 基线 `213/213`,Failures/Errors/Skipped 均为 0,迁移基线扩展至 `V072`;本轮新增 setup context smoke、安装器/包装器夹具和 PAGE-23 Playwright 回归;最新签名候选为 `1.0.0-preview.8`,正式 Release 仍须在干净 Git tag 上由 Gitea Actions 重新生成安装器摘要 | +| 第一版 Preview | PAGE-01~22 页面和菜单框架齐备,真实前后端核心链保持可用;Preview 上线与整套 R1 最终验收分开计量 | +| 来源表格校验 | V068 为 14 类 OA 的 33 个 TABLE 字段补齐嵌套类型、必填和文件引用规则;V069 保留 OA-02 v1 历史账户表格契约,避免 v2 字段追溯污染 | +| PAGE-20 审计 | 增加不可变事件序号、四种稳定排序、URL 查询状态、同排序 CSV 导出、脱敏详情及 OpenAPI `422` 参数门禁 | +| PAGE-22 在线更新 | 更新源固定为私有 Gitea Latest Release API;系统管理员点击“获取更新”后自动检查并下载,验签缓存进入 `READY` 后显示“立即重启”。安装健康检查成功后页面从 10 秒倒计时自动刷新;刷新或短暂断线发生在安装中时恢复轮询。后台只写固定结构请求,由 root oneshot 服务验签、验哈希、备份、切换和回滚,不接受页面传入地址、Token 或命令 | +| Release 工程 | `.gitea/workflows/release.yml` 监听严格 SemVer `v*` tag;Maven `revision`、npm、JAR、前后端 SBOM、签名 manifest 与 tag 必须同版本。工作流执行 Java/前端/OpenAPI/Playwright/依赖审计,使用至少 3072 位 RSA 密钥生成并独立验收恰好 9 个资产;先创建不可见草稿、逐项上传并核对名称/大小,最后发布为可被 `/releases/latest` 读取的正式 Release | +| Linux 32 位 | i386/i486/i586/i686 下载 Java 17 i686 JRE;JRE 元数据和归档均使用仅允许 HTTPS/TLS 1.2 及以上的受限下载器;由于 MySQL 8.4 无对应服务端镜像,要求预置外部 MySQL 8.4.x,curl 安装只开启 `/setup` 向导,数据库密码在浏览器中提交并完成连接、版本及 DDL/DML 预检 | +| 更新可靠性 | 下载和安装拆为两个持久动作;下载阶段不停止业务服务,安装阶段只接受同版本 `READY` 缓存并重新验签。私有 Gitea Token 仅从权限为 `0600` 的 root 配置/临时文件读取,不进入 `curl` 参数、页面、状态或日志,且只允许同源 API/资产使用;请求原子领取到持久 `processing`,systemd 限制失败重试;`mysqldump`、新旧版本健康和回滚均有独立门禁,私有 Gitea、跨来源拒绝、成功、健康回滚、下载失败、备份失败和不安全请求夹具纳入 CI | +| 阶段口径 | 把“开发前冻结”更新为“R1 开发中”;明确模块级验收与整套 R1 交付是两个层级,避免一页完成后虚报整套系统完成 | +| PAGE-13 页面 | 完成合同执行汇总、筛选、服务端分页、来源下钻、CSV 导出、合同详情及变更/结算/应付操作;页面仅使用现有 TDesign Starter 和 TDesign 组件 | +| PAGE-13 后端 | 新增 `contractcost:payable:create`,项目经理可登记但不可确认;同人登记/确认返回 `422 SOD_VIOLATION`;应付必须关联已批准 OA-04,结算必须关联已批准 OA-11,同一来源不得重复消费 | +| PAGE-13 金额口径 | 展示层分列“待确认应付”和“已应付”;待确认应付仍预占合同额度,避免连续登记绕过限额 | +| PAGE-14 页面 | 在唯一 TDesign Starter 内完成收款/发票双页签、筛选、服务端分页、详情抽屉、草稿录入/编辑、状态动作、金额格式化、CSV 导出及加载/空态/错误反馈 | +| PAGE-14 后端 | 完成正式应收、收款确认与多对多核销、收款作废、开票提交/复核/人工结果、发票作废/红冲、自动生成应收与财务事件,并由 Java 后端执行权限范围、金额阈值、SOD、状态机和版本冲突控制 | +| OA-04/05/08 审批投影 | 财务终审在同一事务内分别生成 `CONFIRMED` 应付、`CONFIRMED` 收款和 `DRAFT` 开票候选;来源版本、公司/项目/客户/合同快照、额度、SOD、资金台账、会计事件和重复消费由 Java/MySQL 共同约束 | +| OA-08 合同口径 | 投影前锁定合同控制行,分别复核合同金额快照、已开票快照和包含 `DRAFT/REVIEWING` 候选的在途预占额;本次申请与在途候选累计超过合同额度,或快照过期时,终审整体回滚,不能留下已批准但无法继续复核的超额候选 | +| PAGE-15 页面 | 在唯一 TDesign Starter 的付款申请弹窗中,把 OA 来源自由文本改为按当前项目加载的 TDesign 下拉;只显示已批准 OA-06,切换项目清空并重载,编辑历史草稿可回显已关联来源 | +| PAGE-15 后端 | OA 来源保持可选;一旦选择,保存草稿和财务批准时均要求 OA-06、同公司、同项目、当前版本存在 `APPROVED` 审批实例,并允许来源进入批准后的结果/归档后续状态;批准前发生版本替换会阻断付款 | +| PAGE-15 付款主链 | 付款申请、项目/合同/应付/账户/通用附件/精确重复/项目风险检查、线下结果登记、异人复核、部分支付、失败、退汇和重付已实现;不产生银行付款指令,不接网银、U 盾或第三方平台 | +| PAGE-15 条件附件 | 应付成本分类命中生效矩阵后,TDesign 页面逐项显示齐全/缺失/文件检查中并要求每个文件选择资料类型;Java 在保存时固化参数版本和分类规则快照,批准时按快照阻断缺件、矩阵外类型及未通过检查的文件 | +| 附件兼容与发布 | 历史付款及无生效规则路径继续按通用附件检查;生效矩阵下旧 `attachmentFileIds` 在保存时明确拒绝,必须改用结构化 `attachments`;包含 `allowNotApplicable=true` 的矩阵在例外复核闭环完成前禁止发布 | +| PAGE-15 并发资金控制 | 资金控制行存在时以冲突更新先获取排他锁,再读取余额并冻结;两笔各 600 元付款并发竞争 1,000 元项目余额时,固定只有一笔批准、一笔返回 `422 FUNDS_INSUFFICIENT`,最终冻结额为 600 元,不出现超支或 500 死锁错误 | +| 数据库 | 当前最高迁移为 `V071`;V064 完成八类主数据旧编号映射,V065 完成人工记账结果冲销/重开,V066 增加审计全局时间线索引,V067 增加系统更新查看/执行权限,V068 补齐来源表格嵌套列的类型、必填和文件校验元数据,V069 保留 OA-02 v1 历史账户表格契约,V070 完成 OA-06 v2 与批准来源付款投影约束,V071 完成超级管理员全权限和全局范围回填;V062/V063 的部门及字典范围升级兼容继续保留 | +| PAGE-12 | 部门复用 `masterdata:dictionary:*` 权限和全局范围,后端覆盖创建、列表/详情、编辑、提交、异人复核、停用、重复编码、版本冲突和审计;八类资源新增独立授权的版本分页接口,快照清除执行人相关 `allowedActions`;TDesign 页面新增部门页签、版本记录和双版本字段差异;`MasterDataIntegrationTest` `8/8`、`FinanceOperationsIntegrationTest` `10/10`、三档 PAGE-12 行为测试 `3/3` 通过,两个 Testcontainers 测试库均从空库迁移至 `V063` | +| PAGE-11 | 待办列表新增项目、任务状态、到达日期、逾期和排序服务端筛选,返回申请人、关键金额和当前节点;列表退回先读取最新任务详情,再按详情版本与字段退回点提交;`workflow:task:view` 按公司/项目数据范围校验;OpenAPI 已重新导出并通过合同门禁,真实财务账号组合查询无 Console 错误;后端查询专项 `1/1`、退回三档 E2E `3/3` 通过 | +| PAGE-16 | 状态机扩展为 `COMPLETED → RESULT_REVERSED → EXPORTED`;冲销恢复来源事件、重开递增结果轮次并清空当前结果,新旧证据和备注按轮次隔离,历史外部凭证号不得复用;两命令分别执行权限、公司/项目/金额范围、异人 SOD、版本、幂等和审计控制,理由按去空格后的至少 2 个有效字符校验;`AccountingIntegrationTest` `18/18`、三档 PAGE-16 专项 `12/12` 通过,测试库从空库迁移至 `V065` | +| PAGE-17 | 财务管理人员在 PAGE-08 确认项目财务资料完整后,PAGE-17 才允许生成档案包;服务端执行权限、范围、状态、版本和审计校验,页面展示保管到期日及 `PENDING/ACTIVE/DUE_SOON/EXPIRED/FROZEN` 状态,到期仅提示、不自动删除;`ProjectIntegrationTest#financeConfirmationIsRequiredBeforeArchiveGenerationAndUsesScopeVersionAndAudit` `1/1` 通过,测试库从空库迁移至 `V061` | +| 兼容查询合同 | `/api/v1/contracts`、`/costs`、`/payables` 保留现有 `keyword/status/sort/page/size` 合同;未知 query 参数固定返回 `400 QUERY_PARAMETER_UNKNOWN`,非法排序格式、字段、方向、重复项或超过 3 项固定返回 `400 SORT_FIELD_NOT_ALLOWED`,不再混用笼统错误码 | +| 生产密钥门禁 | 新增 production profile 回归:缺失 `FIELD_ENCRYPTION_KEY` 或使用仓库公开的本地开发默认值时,敏感字段组件初始化失败;注入 Base64 编码的 256 位密钥后,银行账户和来源敏感值两个加密组件均可初始化 | +| PAGE-15 逐笔资金台账 | 核对 `V037` 追加式 `fund_ledger`、付款冻结/支付和收款变动写入、来源事件幂等键、范围权限、审计、`/api/v1/funds/ledger` 查询及 TDesign 资金流水抽屉;OpenAPI operationId 已统一为 `listFundLedger`,真实 MySQL 8.4 数据查询已验证 | +| PAGE-18 | 扫描器不可用时,隔离文件仍建立带公司/项目范围的 `file_link`;重扫采用“持久化意图 → 同 FileStore 原子移动 → 独立事务确认”流程,崩溃后可从源或目标恢复;同幂等键保持首次 503/200/422 结果,成功后转为 `AVAILABLE` 并清空转存意图 | +| PAGE-18 借阅闭环 | 资料身份申请、财务身份批准、资料身份登记归还的真实浏览器流程已完成;申请、批准、归还分别形成 `ARCHIVE_BORROW_APPLY/APPROVE/RETURN` 成功审计,申请人与审批人不同,最终状态为 `RETURNED` | +| PAGE-19 | 导出改为“服务端预检冻结 → 页面显示预计行数/排序/筛选/脱敏说明 → 用户确认 → 服务端重新校验并生成文件”;一次 POST 直接导出入口已移除 | +| 前端三档门禁 | Playwright + axe 合同驱动覆盖 PAGE-01~22 的 `1366×768`、`1440×900`、`1920×1080`;完整 Playwright 套件已扩展为 `198/198`,另含 390px 固定列几何回归和超级管理员逐页打开全部业务页面,无 serious/critical WCAG 违规、整体横向溢出或 Console error | +| 本轮验证 | 2026-08-17 最近完整后端全量基线 `213/213`(Failures/Errors/Skipped 均为 0),MySQL 8.4 Testcontainers 在 9 分 19 秒内完成全量回归并从空库执行 71 个 Flyway 迁移至 `V071`;OA-06 成功链、`FUNDS_INSUFFICIENT` 回滚链及超级管理员跨人员数据可见/审批处理人拒绝已通过集成测试;前端 ESLint/Stylelint、Vitest `10 files / 56 tests`、TypeScript、production build/hygiene、npm 完整依赖树 `0 vulnerabilities`、Playwright `198/198`、在线更新专项 `15/15`、真实 Edge 管理员 390px/1440px 页面复测、OpenAPI、ShellCheck、Actionlint、安装器/更新/Gitea 发布夹具均通过 | +| 证据文件一致性 | 三张本地浏览器证据按真实 JPEG 格式改为 `.jpg`;重扫图按实际像素命名为 `archive-rescan-1366x612.jpg`,批准/归还图保持实际 `1366×768`,总文档引用已同步 | +| 浏览器验收 | PAGE-13 在真实 Java/MySQL 环境验证 OA-04 应付来源与 OA-11 结算来源的 TDesign 下拉、项目范围筛选及必填提示,未提交验收业务数据;默认桌面宽度和 `1440×900` 无整体横向溢出、按钮重叠或控制台错误 | +| PAGE-15 浏览器验收 | 财务身份打开新建付款申请,选择 `P-DEMO-001` 后 OA-06 下拉只显示临时已批准来源,选择后正确回显;未保存草稿,桌面弹窗无控件重叠,Console 无 warning/error,临时付款和来源数据已清理 | +| PAGE-15 剩余阻断 | D-02/D-09 正式矩阵确认和发布、正式三档浏览器/异常状态证据和甲方验收仍未完成;PAGE-15 不升级为 `MODULE_ACCEPTED` | +| 剩余质量项 | Starter 演示页面/API/组件/资产已从 production 源码移除,全仓 ESLint、Vitest、release build、source/dist hygiene 及 Chromium 三档 Playwright + axe 基线已通过;真实 API 关键动作、键盘焦点、Edge、异常状态矩阵和正式 FE-GATE 证据仍未完成 | +| 前端硬规范 | 把“使用 TDesign”落成 Starter 实际布局基线、组件来源、菜单/页面新增步骤、原生控件、图标、Token、例外批准和机器合同规则;页面必须在唯一 Starter 内原位实现 | +| 新增功能规则 | 新增菜单、路由、页面、组件、按钮、筛选、图表时,必须同步页面合同、`COMP-*`/`OPS-*`、OpenAPI、权限、状态、审计和测试,禁止只摆界面后再补功能 | +| 页面与功能 | 保留 PAGE-01~PAGE-22、14 类表单和既有页面合同;新增页面、菜单或平行入口必须走变更,不得绕开本文件 | +| 第一轮交付 | 明确 R1 是前端、Java、MySQL、权限、流程、文件、审计、测试、部署和交付材料的完整闭环,不是静态页面或局部接口 | +| 修复责任 | 增加“复现 → 定位 → 修复 → 同类扫描 → 回归 → 部署 → 证据”的开发团队闭环,甲方不承担基础调试和补实现责任 | +| 范围边界 | 继续排除银行/U 盾、电子税务局、自然人税务系统、第三方权限/API、RPA 和自动执行;不因“完整系统”而扩回范围 | +| 资料状态 | 已从微信正式文件目录找回并复核 14 张 OA 表单图和 12 份财务岗位职责表,记录三份原始资料 SHA-256;项目管理流程图仍未找回,须在 D-03 前补齐或书面确认 | +| 当前基线复核 | PAGE-12 已补部门主数据、升级库字典范围兼容和旧编号映射,PAGE-13/14 维持模块级已验收;OA-04/05/06/08 来源到财务对象的事务投影已实现并通过来源审批专项回归,但正式三档浏览器 UAT 和证据包仍待补齐;PAGE-15 的核心主链、OA-06 自动付款投影和条件附件技术能力已验证,但正式业务规则仍未发布;PAGE-16 已补已完成结果冲销/重开和分轮历史;PAGE-17 已补财务确认门禁和保管到期状态,PAGE-18 已补可恢复重扫和异人借阅闭环,PAGE-19/20/21 已形成 Preview 实现并等待正式验收;`SYSTEM_ADMIN` 已升级为全部页面/权限/全局数据可见且保留业务控制的隔离超级管理员。数据库当前最高迁移为 `V071` | +| 基线材料 | 把签字版总方案、21 页原型、14 类字段/附件字典、fixture、状态/数据/OpenAPI 合同、D-01~D-11 和任务追溯表合并定义为唯一《R1 基线包》;材料不齐时,对应未确认模块不得通过模块验收或进入发布 | + +## 1. 先说结论 + +### 1.1 甲方现在真正要的东西 + +甲方暂时不是要一套把所有财务、税务、银行和自动化都做完的大系统,而是要一套**看得懂、用得起来、能把项目资料和财务流程串起来的基础系统**。 + +系统首期只解决这一条主线: + +```text +业务人员提交资料 + → 项目/财务人员审批和复核 + → 形成收款、发票、成本、付款等财务数据 + → 形成可供记账的凭证资料 + → 项目完成后归档 + → 按项目、合同、金额和状态查询 +``` + +会议原文明确了四个方向: + +1. 先做简单版本,原有系统继续保留,避免用户一开始看不懂。 +2. 登录后先选择身份,首期分为项目管理人员、财务管理人员、资料管理人员三类。 +3. 14 个 OA 表单不是 14 个孤立页面,而是要按角色放进一条项目财务数据流。 +4. 报税、网银/U 盾、第三方权限和跨系统自动化暂时不做;记账先做成可供现有系统或人工使用的结果。 + +### 1.2 我们最终要交付什么 + +| 交付类别 | 交付内容 | 甲方得到的结果 | +| --- | --- | --- | +| 可运行系统 | 一个 Java Web 系统,可部署、可登录、可演示 | 三类人员从各自入口开展工作 | +| 项目业务 | 项目、供应商、合同、进度、收款、发票、付款等基础数据 | 项目资料不再散落在表格和聊天记录里 | +| 审批复核 | 表单提交、待办、审核、退回、驳回、状态跟踪 | 每个决定都有操作人、时间和意见 | +| 记账准备 | 财务事件、凭证草稿或标准导出、人工登记结果 | 财务可以接着现有记账系统工作 | +| 资料归档 | 项目档案包、附件版本、检索、下载和归档审核 | 项目结束后资料可查、可追溯 | +| 查询分析 | 项目台账、合同执行、收付款和流程进度 | 管理人员能看到项目处于什么阶段、钱和资料在哪里 | +| 交付材料 | 源代码、数据库、部署包、测试报告、模板、操作手册 | 甲方能够部署、验收和后续维护 | + +### 1.3 首期不交付什么 + +以下内容明确排除在本期之外: + +- 不登录、不控制、不保存网银、银企直连、U 盾、电子税务局、自然人税务系统。 +- 不申请 OA、T+、HR 或其他第三方账号、Token、证书、Webhook、SSO 权限。 +- 不做 RPA、自动付款、自动报税、自动缴税、自动过账和无人值守定时任务。 +- 不做完整税务申报模块;税务资料以后可作为扩展模块。 +- 不替换现有法定总账系统;首期输出记账准备数据和人工登记结果。 +- 不首期建设完整人力资源、工资、固定资产、预算、融资和合并报表系统。 + +### 1.4 V2.8 冻结基线与 V2.9 执行关系 + +V2.8 冻结的范围、页面、技术和质量规则继续有效;V2.9 只把项目阶段更新为开发执行,并记录已经形成客观证据的 PAGE-14,不降低整套 R1 的完成标准。 + +1. 当前状态更新为“R1 开发中”;Starter 导入本身仍不算业务完成,只有通过模块级代码、数据、测试和浏览器验收的功能才计入实际进度。 +2. 前端继续锁定为现有 TDesign Vue Next Starter 原位开发,只能在其中加菜单、页面、组件和真实功能;文件级保留、重写、删除和新增范围按 11.2 节执行,不引入第二套 UI 或平行前端。 +3. 21 个页面补齐默认进入状态、主操作顺序、完成标志、TDesign 组件、路由、查询语义、接口组和验收条件。 +4. 已逐张复核原始压缩包中的 14 张 OA 表单图,并回填可见字段、重复明细和人材机/进度款条件附件;原图没有给出的必填条件、枚举、金额公式、审批时限和下游映射仍按第 18 节资料可复核性规则在开工前签字,不把设计推导当成原表事实。 +5. 权限判定、权限码、分域状态码、状态按钮、数据库列/约束/索引基线、R1 接口 DTO 规则、幂等并发、文件状态、安全参数和测试判定已统一。 +6. 岗位职责资料按“R1 吸收、仅留内部记录、后续单独立项”分类,不把网银/U 盾、税务、工资社保或自动化执行重新带回第一轮。 +7. D-01~D-11 和 S0 技术合同继续并行冻结;未签字、未形成字段字典、OpenAPI 快照和数据字典时,受影响模块不得验收或发布,不允许边开发边猜关键规则。 +8. 每一个前端菜单、页面和可见操作都必须同时绑定本地路由、TDesign 组件、权限码、对象状态、OpenAPI operationId、成功/失败结果、审计事件和自动化测试;缺少任一项不得合并,也不得计入完成率。 +9. “第一轮完整系统”的完成口径统一为第 12.9 节 R1 Definition of Done 全部满足,包含 PAGE-01~PAGE-21、OA-01~OA-14、真实前后端闭环、P0/P1 缺陷清零和可独立部署;阶段演示、单条样例或局部接口不得替代完整交付。 +10. 前端增加唯一工程、唯一入口、依赖白名单、ECharts 使用边界、组件批准记录、逐操作状态矩阵、可访问性和验收证据包门禁,使“使用 Starter 和 TDesign”能够由 CI 与 UAT 客观检查,不依赖口头判断。 +11. 每个页面和功能固定按“需求/页面合同 → 数据合同 → OpenAPI → Java 实现 → Starter/TDesign 页面 → 联调测试 → 证据验收”顺序完成;`COMP-*`、`OPS-*` 和路由页面合同在 S1 落为可由 CI 校验的机器文件,但不新增第二份 PRD/SPEC。 +12. 会议原话、后续项目决策、方案设计基线和待确认事项按 2.3 节分级记录;21 个页面、完整 R1、Java 技术栈和 TDesign 唯一前端属于后续项目决策与设计冻结,不冒充 2026-08-05 会议逐字结论。 +13. 开发团队在开工后承担“实现 → 自测 → 联调 → 修复 → 回归 → 交付”的完整责任,不能把局部页面、局部接口或甲方代测当成完成。 +14. 仓库中的 Java、数据库、部署和接口代码按模块逐项评审:PAGE-13/14 已有完整证据的部分按本版记录为模块级完成;其他代码在通过所属任务评审和验收前仍不得计入 S1~S5 完成率。 +15. 三类业务身份与超级管理员身份不混授:项目、财务、资料是普通业务身份;仅隔离的 `SYSTEM_ADMIN` 账号在 PAGE-02 显示“超级管理员”入口并默认进入 PAGE-21。进入系统后,超级管理员可访问全部业务菜单、页面、功能权限和全局数据,但不会被伪装成普通业务角色,也不能绕过审批处理人、异人复核、状态机、资金硬控制和审计。 +16. 正式页面不得出现“页面已接入”“功能建设中”“后续实现”等占位提示,也不得保留空按钮、空表格或共用占位组件;这类内容只可存在于隔离原型,不能进入 production 或计入页面完成率。 +17. Starter 的 `side + light` 布局、侧栏/顶部/内容区基线、TDesign Token、组件与图标来源、页面密度和目标分辨率已经冻结;新增菜单、页面、组件和功能必须执行 11.2 节七步流程和逐项变更表。 +18. 第 3.6 节 A~F 统一称为《R1 基线包》;当前开发已启动,但未完成的基线材料必须继续收口,且总方案、页面、业务、技术合同、样本测试和执行计划未全部确认前,整套 R1 不得进入发布或整体验收。 + +### 1.5 当前是否可以进入正式开发 + +**结论:第一轮正式开发已经开始,但整套 R1 尚未完成。** PAGE-13/14 已通过模块级验收;技术栈、前端实现方式、R1 总范围和交付责任继续按本文件执行。D-01~D-11、21 页原型、14 类字段/附件字典、完整 OpenAPI 快照、数据字典及其他页面实现仍须按第 3.6 节完成和确认。 + +| 判断项 | 当前结论 | 接下来要做什么 | +| --- | --- | --- | +| 产品方向 | 已冻结 | 按“来源 → 审批 → 财务处理 → 记账准备 → 归档 → 查询”做完整 R1 | +| 前端规范 | 已冻结 | 当前及后续只在现有 `frontend/` Starter 原位增加菜单、页面、组件和真实功能 | +| 技术架构 | 已冻结 | 使用第 11 章 Java 模块化单体、MySQL、Flyway、OpenAPI 和测试规范 | +| 业务细节 | 尚待甲方签字 | 完成 D-01~D-11,尤其是字段、金额公式、审批节点、导出格式和报表口径 | +| 当前开发状态 | 已启动 | 按“一个功能一条真实纵向闭环”继续开发;未通过证据验收的代码不计完成 | +| 当前已验收模块 | PAGE-14 | 按 8.15.1、11.6.3、11.7.3.1、12.5 和 13.1.1 的实际记录维护 | +| 整体发布 | 尚未触发 | 《R1 基线包》和第 12.9 节全部通过后,才能形成唯一 R1 整体验收结论 | + +“已进入第一轮开发”不表示可以跳过未完成的 S0 基线工作。每个后续模块仍须从已冻结的页面、数据、接口和测试合同开始;缺少关键决策时只阻断受影响模块,不允许开发人员自行猜测。 + +### 1.6 第一轮开发执行口径 + +这一节给甲方、产品、开发和测试统一使用,避免把“马上开发”“完整系统”和“使用 TDesign”理解成不同事情。 + +| 问题 | 固定答案 | +| --- | --- | +| 现在是否已经正式开发 | 已经开始。当前 R1 为 `IN_PROGRESS`,PAGE-14 为 `MODULE_ACCEPTED`,整套系统仍未交付 | +| 后续模块什么时候可以开始 | 其页面、数据、接口、权限、状态、fixture 和验收人已经明确时即可进入实现;关键规则未确认则阻断该模块 | +| 前端在哪里开发 | 只在仓库现有 `frontend/` TDesign Vue Next Starter 中原位增加和修改,不新建第二工程、不换模板、不用 iframe 拼装 | +| 前端怎么做 | 按 7.3 节加菜单和路由,按 8.24~8.26 节逐页实现,通用控件和图标只使用 TDesign;业务组合组件也必须由 TDesign 组件组成 | +| 每个功能怎么完成 | 同一个功能按“页面/业务规则 → 数据库 → OpenAPI → Java → TDesign 页面 → 联调测试 → 验收证据”纵向做完,不把空页面、空接口或 Mock 留到最后补 | +| 第一轮交付什么 | 一次性交付第 12.9 节定义的完整 R1:21 个页面、14 类表单、真实数据闭环、权限、文件、测试、部署和交付材料全部可用 | +| 谁负责修改和修复 | 开发团队负责实现、自测、联调、定位、修复、回归和部署;甲方负责确认业务规则和验收业务结果,不替开发团队查代码、改库或补测试 | +| 哪些仍然不做 | 银行/U 盾、税务平台、第三方账号/API/SSO、RPA、自动付款、自动报税、自动缴税和自动过账继续排除 | + +按 S1~S5 分阶段开发和内部演示,但对甲方只有一个“第一轮完成”结论:完整 R1 通过验收。PAGE-14 的模块级完成可以作为阶段进度和复用基线,不能单独称为已交付系统。 + +## 2. 会议结论转成产品规则 + +### 2.1 会议内容和系统决策 + +下表左列是会议原文的业务方向,右列是为便于开发而做的产品化表达。产品化表达包含必要设计推导,不等于会议逐字确认;其确认等级和生效规则统一按 2.3 节执行。 + +| 会议说法 | 转成开发规则 | +| --- | --- | +| “先做简单的,后面再做加法” | 首期只做 P0 闭环;每个功能必须有明确页面、输入、输出和验收,不把未来模块混进首期 | +| “用户看不懂,做出来给他们看” | 先做可演示原型和一家公司/一个项目样例,再用真实操作反馈迭代 | +| “登录后选择身份” | 登录后显示当前账号被授权的角色卡片;选择角色后进入对应工作台,服务端仍然再次校验权限 | +| “至少两类,后面加资料审核” | 首期固定三类普通业务身份:项目管理、财务管理、资料管理;`SYSTEM_ADMIN` 是隔离的超级管理员身份,只对纯管理员账号显示入口,不与业务身份混授,但自动拥有全部功能权限和全局数据范围 | +| “14 个界面分成两类” | 14 个表单按发起/财务审核/资料归档分配;页面可合并,但表单代码和数据关系不能混淆 | +| “记账可以用现有系统做” | 本系统生成凭证草稿/标准文件并登记人工记账结果,不调用现有系统接口 | +| “报税先不管” | 首期不开发申报执行;只保留税务字段扩展位,不把报税作为 MVP 验收条件 | +| “把数据流打通” | 每条记录必须能从来源单号追到审批、财务结果、记账准备和档案附件 | + +### 2.2 用通俗话解释系统逻辑 + +- **项目管理人员**:把项目资料填进来,补附件,跟进审批,查看自己的项目进度。 +- **财务管理人员**:检查项目、合同、供应商、金额、收款、发票和付款资料,确认哪些数据可以进入财务台账和记账准备。 +- **资料管理人员**:项目完成后检查资料是否齐全、文件是否可读、档案包是否完整,然后归档。 +- **超级管理员**:使用独立账号查看和管理全部业务及系统配置;系统自动展开全部权限码和 `GLOBAL` 数据范围。它仍以 `SYSTEM_ADMIN` 身份留痕,不冒充审批节点处理人,同人登记/复核、状态机、资金余额、附件和审计控制继续生效。 +- **系统**:负责记录谁在什么时候做了什么,不替人登录外部系统,也不替人自动付款或报税。 + +### 2.3 需求来源与确认等级 + +为了让普通使用者、产品、开发和测试都能分清“甲方说过什么、方案补充了什么、还有什么没定”,本文件中的要求按以下四级管理: + +| 等级 | 含义 | 本项目中的典型内容 | 是否允许开发自行修改 | +| --- | --- | --- | --- | +| `CONFIRMED-MEETING` | 2026-08-05 会议明确提出的业务方向 | 先做基础版本、原系统保留、按身份进入、14 类表单进入数据流、报税先不做 | 不允许;变化必须走需求变更 | +| `CONFIRMED-PROJECT` | 会议后由项目方继续明确的实施决定 | Java 技术栈、现有 TDesign Starter 唯一前端、第一轮交付完整 R1、2026-08-11 进入开发执行、第三方权限和自动化排除 | 不允许;变化必须由项目负责人书面确认 | +| `DESIGN-BASELINE` | 为把业务要求变成可开发系统而形成的产品/技术设计 | PAGE-01~21、菜单路由、模块边界、接口、数据表、权限、状态机、测试和部署规则 | 文档批准后即为开发基线;只能按变更流程修改 | +| `PENDING-DECISION` | 仍需甲方拿样例和选项确认的具体规则 | D-01~D-11 的人员、字段、金额、流程、导出、报表、文件、容量和品牌 | 不允许猜测;未确认时阻塞受影响模块验收和整套 R1 发布 | + +本文件对来源的处理规则如下: + +1. 会议原文优先用于确认业务目标和范围方向,不从含糊表述中擅自推导具体金额、状态、字段或接口规则。 +2. 2026-08-10 后续项目决策明确:第一轮不是只交原型或局部模块,而是交付第 12.9 节定义的完整 R1,前端必须采用当前已拉取的 TDesign Vue Next Starter;2026-08-11 最新执行决定进一步启动 R1 开发,并要求已完成模块按客观证据写回本文件。上述内容均属于 `CONFIRMED-PROJECT`,不是会议逐字原话。 +3. PAGE-01~PAGE-21、六类报表、权限码、SOD、审计、文件版本和工程门禁属于 `DESIGN-BASELINE`。甲方批准本文件后即具有约束力,但不能写成“会议已经逐项确认”。 +4. 记账首期固定为“凭证草稿/标准导出 + 人工登记和复核结果”,不接现有记账系统接口,不自动过账;其最终文件样式和字段仍由 D-07 确认。 +5. 需求冲突时按“最新书面项目决策 → 已批准的本文件 → 会议原始方向 → 开发个人理解”处理;如冲突影响范围、权限、金额、状态或交付日期,先停止相关实现并完成变更确认。 + +## 3. 首期范围和后续范围 + +### 3.1 MVP 必须做完的闭环 + +MVP 范围必须全部完成。“一家公司、一个项目、一组完整样例”只是最低端到端验收数据集,不表示只开发一家公司、一个项目或只实现样例触发的功能。该数据集必须能跑通: + +1. 登录并选择三类身份。 +2. 创建或导入项目、供应商、合同基础资料。 +3. 提交并审批项目立项、供应商准入、成本合同等表单。 +4. 记录人材机成本确认、收款确认、发票申请、进度款支付。 +5. 财务人员审核金额、附件、合同和项目关联关系。 +6. 生成财务台账和凭证草稿/记账导出文件,人工登记记账结果。 +7. 项目资料归档,资料人员完成归档审核。 +8. 按项目查询全过程和未完成事项。 + +### 3.2 后续再做的加法 + +| 阶段 | 扩展方向 | 本期处理方式 | +| --- | --- | --- | +| V1 | 银行流水人工导入、对账、更多凭证规则、项目成本分析 | 预留数据结构,首期可先用人工台账 | +| V1 | 工资、社保、固定资产、折旧摊销 | 只接收人工结果,不做完整子系统 | +| V2 | 税务日历、税会核对、申报资料包 | 不接税务平台,不自动申报 | +| V2 | 报表、经营分析、移动端 | 先完成基础查询和导出 | +| 单独项目 | OA/T+/银行/税务 API、RPA、自动化执行 | 重新立项、重新评估第三方权限和安全责任 | + +### 3.3 现有系统如何处理 + +旧系统不删除、不替换、不要求一次性迁移。首期新系统作为“项目财务资料和流程中台”: + +- 旧系统继续承担已有工作。 +- 新系统接收人工导出的数据和附件。 +- 新系统把财务审核、台账、记账准备、归档和查询做清楚。 +- 未来要不要替换旧系统,等 MVP 使用稳定后再决定。 + +### 3.4 当前模块是否齐全 + +**结论:整套财务系统的模块仍未齐全,但已经不再是全部待开发。** PAGE-14 收款与发票已通过模块级验收;其余页面必须按各自实现和证据判断。当前仍不能证明 PAGE-01~21 和完整端到端业务闭环已经全部实现,也不能把“Starter 能打开”“接口能返回”“有一张表格”或“某个页面完成”扩大解释为整套系统完成。 + +| 业务区域 | 当前材料能证明什么 | 当前缺口判断 | 本期要开发的结果 | +| --- | --- | --- | --- | +| 14 类 OA 表单 | 有表单样式、字段和审批业务基础 | 表单之间仍是分散数据,未形成统一编号、版本和下游关系 | 模板化录入/导入、校验、审批快照、内部处理和归档 | +| 人员岗位职责 | 有财务岗位分工资料 | 系统角色、数据范围、金额权限和岗位互斥尚未落地 | 三类业务身份、管理员账号和服务端权限控制 | +| 项目与主数据 | 表单中反复出现项目、供应商、合同、账户 | 重复录入、编码和历史版本没有统一控制 | 公司、项目、往来单位、合同、账户和字典中心 | +| 审批与财务复核 | OA 可提供原审批痕迹 | 来源审批、内部财务复核和补件状态未串成一条链 | 来源快照、待办、退回/驳回、财务确认和审计记录 | +| 收款、发票、成本、付款 | PAGE-13/14 已完成合同成本与收款发票模块级验收;付款仍须按 PAGE-15 单独验收 | PAGE-13/14 不再是缺口;PAGE-15 和跨模块全链路仍未据此自动完成 | 保持 PAGE-13/14 回归稳定,继续完成付款检查、线下结果登记及异人复核的独立验收 | +| 记账 | 现有记账系统可继续使用 | 业务单据到记账资料之间仍需人工重新整理 | 财务事件、凭证草稿、标准导出和人工凭证结果登记 | +| 资料归档 | 有附件和资料岗位职责 | 缺少统一档案包、文件版本、检索、借阅和下载留痕 | 项目档案包、缺件检查、归档审核、检索和借阅 | +| 查询分析 | 主要依赖表格临时汇总 | 数据口径、下钻和权限不统一 | 项目、合同、收付款、流程和资料完整性报表 | +| 银行、税务和外部自动化 | 业务客观存在 | 涉及第三方权限和执行责任 | 本期不开发,只保留人工结果和附件的内部记录 | + +### 3.5 第一轮开发必须交付什么 + +第一轮开发不是只交一套页面、一个原型或几个接口,而是交付一个**前端、后端、数据库、文件、权限、测试和部署全部可用的完整 R1 系统**。这里的“完整”指第 12.9 节 R1 Definition of Done 全部完成,包括 P0/MVP 全范围、P0/P1 缺陷清零和完整交付证据;不代表把明确排除的银行、税务平台和第三方自动化重新纳入范围。 + +| 完整性维度 | 第一轮必须达到的结果 | +| --- | --- | +| 前端完整 | 基于 TDesign Vue Next Starter 完成 PAGE-01~PAGE-21,菜单、按钮、表单、列表、详情和异常状态都可操作 | +| 后端完整 | Java 模块、权限、状态机、金额规则、审计、文件和导出接口全部实现,不用 Mock 代替正式业务 | +| 数据完整 | Flyway 可从空库建立数据库;主数据、14 类表单、审批、财务记录、记账准备和档案关系可持续保存 | +| 流程完整 | “来源资料 → 审核 → 财务处理 → 记账准备 → 归档 → 查询”能用真实接口端到端跑通 | +| 权限完整 | 项目、财务、资料三类身份及系统管理员按功能、公司/项目、金额和流程节点生效,越权由后端拒绝 | +| 交付完整 | 可在 staging/production 部署,具备初始化、备份恢复、日志监控、测试报告、操作手册和验收证据 | + +第一轮内部仍按 S1~S5 分阶段开发和演示,但这些阶段不是五个可以单独验收的残缺产品。对甲方的第一轮正式交付只有一个:S1~S5 全部完成后的 R1 完整系统。 + +以下情况不算第一轮完成:只有静态页面;页面仍调用 Mock;按钮没有后端逻辑;只完成部分 OA 表单;权限只隐藏菜单;数据刷新后丢失;关键流程靠开发人员改数据库;不能独立部署;P0 或 P1 缺陷未清零。 + +R1 继续排除:网银/U 盾、银企直连、电子税务局、自然人税务系统、第三方账号/API/Token/SSO、RPA、自动付款、自动报税、自动缴税和自动过账。它们不是“完整系统”的缺口,而是已经签字确认的范围外事项。 + +### 3.6 当前阶段和第一轮基线门槛 + +当前处于**R1 开发执行阶段**。`frontend/` 是唯一 TDesign Vue Next Starter 前端,Java/MySQL 和业务页面按模块纵向实现。PAGE-13/14 已有模块级验收事实,但它们不代表 PAGE-01~PAGE-21、全部 Java 模块、数据库关系和端到端业务闭环已经完成。 + +仓库代码统一按证据分级:通过页面、接口、数据、权限、状态、自动化测试和浏览器验收的模块可记录为 `MODULE_ACCEPTED`;只具备局部页面、局部接口或技术可行性的代码仍按“待评审实现”处理。任何模块在通过所属任务验收前,不能据此把整个阶段或 R1 标记为完成。 + +低保真原型继续只用于确认尚未开发页面的结构和业务理解,必须与 production 真实链路明确区分;已进入正式开发的页面不得再使用 Mock、静态占位或原型状态冒充业务结果。 + +以下六项现在作为各模块验收和 R1 整体发布的共同基线;缺项时阻断受影响模块验收,并阻断整套 R1 发布: + +1. D-01~D-11 全部有书面结论和确认人,不残留 `TBD`、口头规则或“开发中再定”的临时默认值。 +2. PAGE-01~PAGE-21 的低保真原型使用 TDesign Starter 框架完成,三类身份分别走查并签字。 +3. OA-01~OA-14 每类具备脱敏的成功、必填失败、重复、越权四类 fixture 和正式字段字典;付款、凭证、档案另具备成功、业务阻断、并发冲突和 SOD fixture。每个 fixture 都已写清前置数据、执行身份、输入、预期页面/HTTP/业务状态、金额、下游对象和审计事件。 +4. 关键金额公式、状态机、岗位互斥、附件矩阵和记账导出格式已经冻结。 +5. 前后端目录、逐表数据字典、分域状态机、OpenAPI operation/DTO、Flyway 规则、错误码、测试门槛和发布方式按第 11 章冻结。 +6. R1 范围、负向范围、Definition of Done 和 UAT-001~UAT-035 进入项目管理工具并关联负责人。 + +六项基线全部通过后,项目经理形成书面《R1 基线确认》,至少记录文档版本、决策项版本、样本版本、原型/页面评审结论、开发启动日期和各模块负责人。当前已启动开发不免除这项工作;没有完整确认时,整套 R1 不得进入 production 发布或最终验收。 + +为避免开发和验收时分散寻找材料,上述六项统一组成一份《R1 基线包》,只允许引用这一份总方案中的编号,不再另写一套含义不同的 PRD/SPEC: + +| 基线包分册 | 必须包含 | 通过标志 | +| --- | --- | --- | +| A. 需求基线 | 本文件 V2.9 签字版、R1 正向/负向范围、PAGE/F/OA 编号和变更记录 | 甲方负责人、产品、技术和测试签字,文档哈希固定 | +| B. 页面基线 | PAGE-01~PAGE-21 低保真原型、三类业务身份与系统管理员逐页走查、菜单/路由/模板/组件清单 | 每页有进入状态、主操作、完成结果、异常状态和验收人 | +| C. 业务基线 | OA-01~OA-14 字段/附件字典、状态机、金额公式、权限/SOD、D-01~D-11 结论 | 没有 `TBD`、口头默认值或“开发时再定” | +| D. 技术合同 | 逐表数据字典、ER 关系、OpenAPI 快照、错误码、`routes/components/operations` 机器合同设计 | 前端、后端、DBA、测试共同评审,无互相矛盾字段 | +| E. 样本和测试 | 脱敏 fixture、UAT-001~035 前置数据、账号/身份/范围、预期状态、金额和审计断言 | 样本可重复初始化,成功、失败、重复、越权和并发均有明确预期 | +| F. 执行计划 | S1~S5 任务、负责人、主责闭环人、前置项、环境、证据位置、缺陷处理和发布时间 | 每项可进入 `READY`,且所有关键路径都有负责人和验收人 | + +《R1 基线确认》只在 A~F 全部通过后签发。若变更页面数量、表单含义、金额公式、权限、状态或范围,必须先更新本文件和受影响合同,再继续受影响实现;开发人员不能用聊天记录或临时口头决定覆盖基线包。 + +任何人口头提出的新功能先登记为变更,不直接插入当前迭代。影响 R1 范围、金额口径、权限或状态机的变更,必须评估页面、接口、数据迁移、测试和交付日期后,由甲方和项目负责人共同确认。 + +#### 开发执行期允许和禁止的工作 + +| 类别 | 当前允许 | 当前禁止 | +| --- | --- | --- | +| 需求 | 修改本文件、补字段字典、流程、页面、权限、状态、验收和决策项 | 口头规则不落文档就直接开发 | +| 原型 | 为尚未实现页面制作与 production 链路隔离的低保真走查稿,明确标记“原型” | 用 Mock、静态 JSON、硬编码数据或原型页面宣称正式业务功能完成 | +| 样本 | 整理、脱敏、编号 OA-01~OA-14 成功/失败/重复/越权样本 | 把原始账号、密码、身份证、银行卡等敏感资料提交到仓库 | +| 技术 | 按页面/数据/OpenAPI 合同实现 Java、Flyway、TDesign 页面并完成真实联调 | 绕过合同、权限、状态、测试或审计直接实现;把局部成功扩大为阶段完成 | +| 项目管理 | 建立 TODO、负责人、前置项、验收人和证据位置;按 `MODULE_ACCEPTED` 记录已通过模块 | 没有完整证据就把任务、阶段或 R1 标为已完成 | + +本阶段的过程交付物包括本文件、原型、字段/附件字典、脱敏样本、D-01~D-11 决策记录,以及通过模块验收的源代码、迁移、测试和证据。开发团队负责从实现、联调、测试、缺陷修复一直处理到第 12.9 节 Definition of Done;甲方负责确认业务规则和验收结果,不负责替开发团队定位代码、改数据库或补齐未实现按钮。 + +### 3.7 当前工程差距基线 + +截至 2026-08-17,仓库使用唯一 `frontend/` Starter,R1 已进入开发。PAGE-12 已补部门主数据、升级库字典范围兼容、旧编号映射和三档动作回归;PAGE-13 已按 8.14.1 和 13.1.2、PAGE-14 已按 8.15.1 和 13.1.1 形成模块级验收;OA-04/05/06/08 来源终审投影已完成代码和专项回归,但正式页面 UAT 仍未签字;PAGE-15 已按 8.16.1、13.1.3 和 13.1.9 完成核心付款链、条件附件技术能力及 OA-06 付款投影定向验证,但仍为 `IN_PROGRESS`;PAGE-16 已形成 `COMPLETED → RESULT_REVERSED → EXPORTED` 冲销/重开闭环;数据库当前最高迁移为 `V071`,并完成隔离超级管理员全部权限、全局数据范围和三类业务工作台跨人员数据可见。PAGE-17 已补齐财务确认门禁、归档状态机和保管到期提示,PAGE-18 已补齐隔离文件可恢复重扫和本地异人借阅/归还闭环,PAGE-19/20/21 已进入 Preview 阶段验收,相关页面均未达到 `MODULE_ACCEPTED`。其他页面仍按下表逐项评审,任何“已有页面/接口/测试”都不自动等于模块完成,更不等于整套 R1 完成。 + +| 当前现状 | 与 R1 的差距 | 对应处理 | +| --- | --- | --- | +| PAGE-01~06 已出现 Session/CSRF、身份选择和工作台真实接口实现 | 尚缺正式 OpenAPI、权限/状态矩阵、全异常状态和对应模块自动化验收的完整证据 | `TODO-S1-007`~`TODO-S1-012`、`TODO-S1-019`~`TODO-S1-023` 逐项评审,不直接标记完成 | +| PAGE-01~21 已声明本地路由和菜单,已建立 `routes.json`、`components.json`、`operations.json` 初版机器合同,并由 Vitest 校验页面、运行时路由及 API wrapper 映射 | Starter 演示源码清理和 source/dist hygiene 门禁已完成;机器合同尚未覆盖全量 query 白名单、逐操作状态/fixture/拒绝码,逐操作 `allowedActions`、权限版本失效测试和全量正向/越权证据仍未完成 | `TODO-S0-017`、`TODO-S1-022`、`TODO-S1-028`、`UAT-029`、`UAT-035` | +| PAGE-07~08 已有独立项目列表/详情实验;PAGE-09~11 已有表单中心、动态填写/导入和审批任务实验,并接入来源表单、两阶段导入及审批命令 | PAGE-07/08 尚缺正式十页签、导入导出和全异常验收;PAGE-09~11 尚缺经 D-02/D-03 冻结的 14 类完整字段/附件/路线、逐操作合同、正式 OpenAPI 和全部 fixture。已有正常路线不能代表 S2 完成 | `TODO-S2-006`~`TODO-S2-028`、8.24~8.26 节、`UAT-004`~`UAT-010`、`UAT-026` | +| PAGE-14 已有独立 `ReceiptsInvoicesPage`、真实 API、分域 DTO、服务端 `allowedActions`、应收/核销/发票命令、V017 数据结构增量、专项测试和浏览器证据 | 模块级功能已经验收;初版机器合同已建立,但全量操作字段、前端组件覆盖率和三档正式桌面 UAT 证据包尚未完成,因此不满足整套 R1 DoD | 维持 `MODULE_ACCEPTED`,按 8.15.1、11.6.3、11.7.3.1、13.1.1 回归;全局门禁归 `TODO-S1-023/028`、`TODO-S5-018/019/021` 收口 | +| PAGE-13 已有独立 `ContractCostLedgerPage`、真实 API、分域 DTO、服务端 `allowedActions`、合同/变更/结算/应付命令、V034/V035 迁移、专项/全量测试和浏览器证据 | 模块级功能已经验收;三档 Chrome/Edge 标准证据包仍归全局 UAT 收口,不因本页完成而满足整套 R1 DoD | 维持 `MODULE_ACCEPTED`,按 8.14.1、13.1.2 回归;继续 PAGE-15 和 `TODO-S3-011/015` | +| PAGE-18 已有独立 TDesign 文件检索/借阅页、空条件不查敏感文件、扫描/归档状态筛选、版本详情、受控水印预览和隔离文件重扫;工作台“隔离文件”已按 `scanStatus=QUARANTINED` 精确跳转 | 本地浏览器已验证检索、详情、水印预览、重扫、异人批准借阅和归还;范围外直链下载回归及 Chromium 三档空态 axe/溢出门禁已通过。真实数据 Chrome/Edge 三档全流程、正式负向 fixture 和标准证据包仍待完成 | 状态为 `IMPLEMENTED_PENDING_ACCEPTANCE`;保持 `TODO-S4-011/012` 未完成,按 13.1.4、UAT-020~022 收口 | +| PAGE-19 已有独立 TDesign 报表页、六类报表、后端汇总、同筛选下钻、中文状态、两位小数和权限范围哈希;已实现服务端预检、冻结排序/筛选/列/预计行数、页面确认及确认时重新鉴权的两阶段导出,MySQL 8.4 全量集成回归已通过 | D-08 尚未确认;日期口径仍混合业务日期与更新时间;流程时长真实样本、三档真实数据浏览器和标准证据包未完成 | 状态为 `IMPLEMENTED_PENDING_ACCEPTANCE`;保持 `TODO-S4-013/014/015` 未完成,按 13.1.4、UAT-023 收口 | +| PAGE-12、PAGE-15~21 已分别使用 `MasterDataPage`、`PaymentsPage`、`AccountingPage`、`ProjectArchivesPage`、`ArchiveFilesPage`、`ReportsPage`、`AuditLogsPage`、`SystemSettingsPage` | 独立页面和初版 API 合同不等于验收完成;正式 DTO、详情、下钻、受控导出、逐页筛选、全量数据范围、失败状态和 UAT 证据仍须逐页核验 | `TODO-S2-001`~`TODO-S2-005`、`TODO-S3-008`~`TODO-S3-017`、`TODO-S4-001`~`TODO-S4-016` | +| PAGE-15 已有独立 `PaymentsPage`、分域前端 API/机器操作合同、`payment`/`fund` 后端模块、V012/V036~V041/V053/V054/V070 数据结构和 `PaymentIntegrationTest`;核心付款链、OA-06 当前批准版本及终审付款投影、条件附件规则/快照、并发余额控制、逐笔资金台账及例外机制已通过付款 `29/29`、来源审批 `37/37` 专项和页面/HTTP 验证 | 正式附件矩阵仍待 D-02/D-09 确认和发布;三档浏览器、异常状态和标准证据包尚未完成 | 维持 `IN_PROGRESS`,按 8.16.1、13.1.3、13.1.9 和 `TODO-S3-011/015` 继续收口 | +| 已新增基于 TDesign `Input` 和 `decimal.js@10.6.0` 的 `MoneyInput`,PAGE-14 已使用十进制字符串 | PAGE-14 金额由 Java `BigDecimal` 最终复算并通过专项回归;跨页面 `COMP-*`、组件测试、可访问性和全部金额页面迁移仍未完成 | PAGE-14 保持回归,组件级全局工作归 `TODO-S1-020`、`TODO-S1-028` | +| 新增运营查询服务目前使用 `Map` 读模型,并由前端做 snake_case 到 camelCase 归一化 | 尚未达到 11.7.4 合同:后端应返回分域 DTO,财务金额应为十进制字符串,筛选/排序字段应逐项枚举;公司/项目数据范围过滤尚未对所有查询分支形成完整证据 | `TODO-S0-024`、`TODO-S1-019`、`TODO-S4-014`、`UAT-003`、`UAT-023` | +| Starter 演示 API、页面、组件、资产及 `/dashboard/base` 死链已从 production 源码移除,source/dist hygiene 门禁、release build 和 Chromium 三档 Playwright + axe 路由基线已通过 | 仍需把真实 API 动作、Chrome/Edge、键盘、Network/Storage 和逐按钮证据纳入正式 FE-GATE 包 | `TODO-S1-023`、`TODO-S1-028`、`TODO-S5-018`~`021`、`UAT-028`、`UAT-034`、`UAT-035` | +| `npm test` 已接入 Vitest,当前 `9 files / 53 tests` 通过;Playwright + axe 已接入 `test:e2e/test:a11y`,合同驱动三档路由基线 `63/63`、完整 Playwright 套件 `183/183` 通过 | 当前 Playwright fixture 已覆盖路由基线及 PAGE-07/08/09/10/11/12/13/14/15/16/17/18/20/21 定向行为,但仍不替代真实 API 全业务矩阵、正式 Chrome/Edge UAT、键盘路径和逐操作权限证据 | `TODO-S1-023`、`TODO-S5-001`、`TODO-S5-018~020` | +| `frontend/contracts/routes.json`、`components.json`、`operations.json` 已形成初版并由 Vitest 校验;OpenAPI 生成客户端尚未形成 | 初版合同已能校验页面、路由、基础组件要求和 API wrapper,但完整 OPS 状态矩阵、query 合同、OpenAPI 类型及生成无差异门禁仍待完成 | `TODO-S0-017`、`TODO-S0-024`、`TODO-S1-019`、`TODO-S1-028` | +| 前端生产构建可完成,但当前主包仍明显超过 11.11 节预算 | 页面和图表尚未完成代码拆分,也未生成正式 `FE-GATE` 证据 | `TODO-S1-023`、`TODO-S1-027`、`UAT-034`、`UAT-035` | +| 仓库已包含 IAM、项目、主数据、来源表单、审批、文件、工作台、运营查询、合同成本、PAGE-14、付款、记账、档案、报表、审计、在线更新及 V001~V071 迁移 | 当前编译和已执行的定向验证只证明对应基线可回归,不证明每个页面均满足 PAGE 合同、浏览器 UAT 和 R1 DoD | PAGE-13/14 之外继续按第 11~13 章逐项形成“保留/重写/补齐”结论和验收证据 | + +后续工程基线评审必须逐项给出“保留/重写/补齐”结论、负责人和验证证据。除明确标记 `MODULE_ACCEPTED` 的 PAGE-14 外,表中代码即使能够登录、返回数据或通过局部测试,也不能自动标记页面完成;在第 12.9 节全部满足前,不得称为“第一轮完整系统”。 + +## 4. 三类业务身份与系统管理权限 + +### 4.1 登录流程 + +```mermaid +flowchart LR + A["输入账号密码"] --> B["校验账号是否启用"] + B --> C["显示该账号可用的角色卡片"] + C --> D["选择项目管理人员"] + C --> E["选择财务管理人员"] + C --> F["选择资料管理人员"] + C --> J["选择系统管理"] + D --> G["项目管理工作台"] + E --> H["财务管理工作台"] + F --> I["资料管理工作台"] + J --> K["系统配置"] +``` + +“选择身份”只是让用户进入容易理解的工作界面,不是绕过权限。比如一个人同时被授予项目管理和财务管理角色,必须分别进入两个工作台;服务端按角色、公司、项目和流程节点再次判断能否操作。 + +R1 固定行为:所有账号每次新登录都先进入 PAGE-02。单身份账号只显示一张可用身份卡,用户点击“进入系统”后进入对应工作台;多业务身份账号显示全部已授权业务卡片供选择。仅超级管理员账号显示一张“超级管理员”卡并默认进入 PAGE-21,不显示三类业务卡,也不得与任何业务身份授予同一账号;该卡只是隔离身份入口,不是页面权限上限,进入后侧栏展示全部业务和治理菜单。系统不因只有一个身份而自动跳过 PAGE-02,避免用户不清楚当前身份。会话未失效时刷新页面保留当前身份;主动切换身份必须重新经过 PAGE-02,并清空上一身份的页签、页面缓存和待办查询。 + +### 4.2 角色权限矩阵 + +| 功能 | 项目管理人员 | 财务管理人员 | 资料管理人员 | 超级管理员 | +| --- | --- | --- | --- | --- | +| 查看本人/授权项目 | 可查看 | 可查看授权公司/项目 | 可查看授权归档范围 | 查看全部公司、项目和跨人员记录 | +| 新建项目和业务表单 | 可新建、补件、提交 | 可代录入但需留痕 | 不负责 | 可新建并管理跨人员草稿/导入,全部动作留痕 | +| 项目立项/供应商/合同业务审核 | 查看进度,不审批本人 | 审核财务条件和金额 | 只审核资料完整性 | 可查看全部任务;流程审批仍只允许实际节点处理人 | +| 收款、发票、付款财务处理 | 查看状态 | 负责审核和登记 | 不负责 | 具有全部功能权限;同人登记/复核和资金硬控制继续生效 | +| 记账准备/凭证草稿 | 不可修改 | 生成、复核、导出、登记结果 | 只读关联资料 | 可访问全部功能;制单/复核和登记/复核仍须异人 | +| 项目完成归档 | 提交归档 | 确认财务资料完整 | 接收、审核、归档 | 可访问全部档案功能;同人提交/审核限制继续生效 | +| 删除正式数据 | 只能删除草稿 | 只能作废/冲销 | 不能删除 | 不增加物理删除正式数据能力,仍按作废/冲销/版本处理 | +| 用户和权限配置 | 不可 | 不可 | 不可 | 可管理用户、角色、范围、模板、参数和在线更新 | + +超级管理员的“全部权限”指功能权限、菜单页面和数据可见范围完整,不等于跳过业务完整性控制。Java 后端仍逐项执行状态、版本、幂等、实际审批处理人、SOD、合同/应付/资金余额、附件安全和审计规则。 + +### 4.3 权限判断的四个问题 + +每次查询或操作都必须回答: + +1. 这个人有没有这个功能权限? +2. 他能看哪家公司、哪个项目? +3. 这笔金额是否超过他的授权额度? +4. 当前流程是否已经到他可以处理的节点? + +### 4.4 三类入口与实际岗位权限的关系 + +三类身份是让普通用户容易理解的**工作台入口**,不是把所有财务人员都授予同一套万能权限。后台仍按甲方实际人员逐人组合岗位权限、公司/项目范围、金额阈值和流程节点。首期至少预置以下岗位权限模板: + +| 岗位权限模板 | 进入的身份工作台 | 首期主要权限 | 必须禁止 | +| --- | --- | --- | --- | +| 项目经办/负责人 | 项目管理 | 发起项目、14 类业务表单、补件、查看本人项目 | 审批本人申请、登记付款/记账结果 | +| 成本/应付审核 | 财务管理 | 审合同、成本、应付、发票和付款附件 | 登记并复核同一笔线下付款结果 | +| 收款/开票经办 | 财务管理 | 确认收款、应收核销、开票申请和人工开票结果 | 自批本人录入的例外和结果 | +| 线下付款结果登记 | 财务管理 | 对已批准单据登记线下付款结果和回单 | 在系统内发起银行指令;复核本人登记结果 | +| 结算/资金复核 | 财务管理 | 复核项目余额、合同可付额、付款结果和退汇 | 修改已批准来源金额、代替记账复核 | +| 记账会计 | 财务管理 | 生成/编辑凭证草稿、导出、登记外部凭证号 | 同时制单和复核同一张凭证 | +| 财务负责人 | 财务管理 | 金额阈值内终审、批准允许例外、查看汇总报表 | 绕过永不允许例外的账户/SOD/重复硬控制 | +| 资料管理员 | 资料管理 | 缺件检查、档案审核、检索、借阅和归还 | 修改合同、金额、付款和凭证状态 | +| 超级管理员 | PAGE-02 的隔离入口,默认进入 PAGE-21,侧栏展示全部页面 | 全部功能权限、全局数据范围、跨人员草稿/导入管理和系统治理 | 与任一业务身份共用账号;冒充审批节点处理人;绕过 SOD、状态机、资金、附件或审计控制 | + +一个业务账号可以被授予多个业务岗位模板,但每次只能在一个业务身份上下文中工作;互斥模板不能同时对同一业务对象生效。超级管理员必须使用独立账号,不能与任何业务身份或岗位模板组合;系统通过 `SYSTEM_ADMIN` 自动展开全部权限码和全局范围,不通过混授普通角色实现。是否展示按钮由当前身份和权限摘要共同决定,是否允许执行永远由 Java 后端重新判断。D-01、D-03、D-05 必须把实际姓名、岗位模板、公司/项目范围、金额阈值和互斥关系确认到人。 + +### 4.5 服务端授权判定顺序 + +每个查询、下载和写命令都按同一顺序判断,任一步不满足即拒绝,默认规则是“没有明确授权就是无权”: + +1. Session 有效、账号启用、密码不处于强制修改状态。 +2. 当前会话已经选择一个有效身份,且该身份仍在账号授权范围内。 +3. 当前身份具有本动作的权限码。 +4. 请求对象所属公司/项目落在该权限授权的数据范围内。 +5. 涉及金额时,金额不大于该动作在该公司/项目下的授权阈值;等于阈值允许,大于阈值进入更高审批节点或返回 `403`,不能由前端自行放行。 +6. 业务对象当前状态和流程节点允许该动作,当前用户是节点处理人或明确授权的候补处理人。 +7. 岗位互斥、自审自批、同人登记/复核、制单/复核等禁止规则没有命中。 +8. 写命令的版本号、幂等键、前置数据和附件仍然有效。 + +同一普通身份下可以组合多个岗位模板:功能和数据范围只合并明确授予的正向权限;禁止规则、岗位互斥和对象级拒绝优先于允许。金额阈值必须绑定“权限码 + 公司/项目范围”,不能把一个项目的高额度带到另一个项目。`SYSTEM_ADMIN` 在第 3~5 步自动按“全部权限码 + `GLOBAL` + 无 IAM 人员授权额度上限”判定,但第 6~8 步的节点处理人、SOD、状态、版本、幂等、业务余额和附件规则完全不变。超级管理员身份和普通业务身份禁止同时存在于同一账号;授权变更保存成功后立即使相关旧会话失效,用户重新登录后生效。每次拒绝至少记录用户、当前身份、权限码、对象、请求编号和拒绝原因,但响应不泄露无权对象的敏感内容。 + +### 4.6 权限码目录 + +权限码固定使用 `模块:资源:动作`,不使用页面名称或中文作为持久化权限值。OpenAPI、路由 `meta.permission`、按钮指令、后端鉴权和测试样本必须引用同一权限码。 + +| 权限域 | R1 权限码动作集合 | 主要页面 | +| --- | --- | --- | +| 项目 | `project:project:view/create/edit/submit/export`、`project:risk-flag:view/create/resolve` | PAGE-04、07、08 | +| 来源表单 | `source:form:view/create/edit/import/validate/submit/withdraw/void` | PAGE-09、10 | +| 审批任务 | `workflow:task:view/approve/return/reject/reassign` | PAGE-11 | +| 主数据 | `masterdata:company:*`、`project:*`、`counterparty:*`、`bank-account:*`、`contract:*`、`dictionary:*`;动作限 `view/create/edit/submit/review/activate/disable` | PAGE-12 | +| 合同成本 | `contractcost:ledger:view/export`、`contractcost:payable:view/create/confirm` | PAGE-13 | +| 收款发票 | `receivable:receipt:view/create/edit/confirm/allocate/void/export`、`receivable:invoice:view/create/edit/submit/review/record-result/void/red-letter/export` | PAGE-14 | +| 付款 | `payment:request:view/create/review/approve/record-result/verify-result/refund/export` | PAGE-15 | +| 记账准备 | `accounting:event:view/generate`、`accounting:voucher:view/edit/review/export/record-result/verify-result/reverse-result/reopen-result/void` | PAGE-16 | +| 档案文件 | `archive:package:view/create/submit/review/archive`、`archive:file:view/preview/download`、`archive:borrow:apply/approve/return` | PAGE-17、18 | +| 查询报表 | `report:finance:view/export` | PAGE-19 | +| 审计 | `audit:log:view/export` | PAGE-20 | +| 系统治理 | `admin:user:*`、`admin:role:*`、`admin:scope:*`、`admin:template:*`、`admin:parameter:*`;动作限 `view/create/edit/enable/disable/publish`;在线更新固定使用 `admin:update:view/execute` | PAGE-21、PAGE-22 | +| 敏感字段 | `sensitive:identity:view`、`sensitive:bank-account:view`、`sensitive:payroll:view` | PAGE-08、12、15、18 | + +表中的 `*` 只是在本文件中压缩展示,数据库和接口中禁止保存或判断通配符权限;S0 冻结时必须展开成逐条权限码。查询默认只授予脱敏视图;查看完整敏感字段、下载、导出、审批和结果登记必须分别授权,不能因为拥有 `view` 就自动获得其他动作。 + +`SYSTEM_ADMIN` 不使用数据库通配符。V071 把当前 `iam_permission` 的每一条真实权限码和对应无额度 `GLOBAL` scope 授予隔离超级管理员账号,启动同步负责补齐后续新增权限;会话返回展开后的真实权限码清单,前端菜单、直链和按钮统一把 `SYSTEM_ADMIN` 识别为超级身份。禁用账号或角色仍会阻断登录。 + +**权限常量不可自行改名**:业务身份代码固定为 `PROJECT_MANAGER`、`FINANCE_MANAGER`、`ARCHIVE_MANAGER`,独立系统管理员固定为 `SYSTEM_ADMIN`。`ADMIN`、`all`、`project:read`、`workflow:task:read` 等历史试验代码、缩写或通配写法均不是 R1 合法值;正式路由、OpenAPI、Java 鉴权、按钮指令、fixture 和测试必须引用本节展开后的同一权限码。发现旧值时先做映射和迁移评审,再改代码和测试,不能在前端兼容多个别名后继续发布。 + +## 5. 总体业务流程和数据流 + +### 5.1 一条主数据流 + +```mermaid +flowchart TB + S["来源资料\nOA导出/Excel/人工录入/附件"] --> V["资料校验\n必填、格式、主数据、重复"] + V -->|"错误"| R["错误清单\n补资料后重新提交"] + R --> S + V -->|"通过"| P["项目业务确认\n内部审批或只读 OA 审批快照"] + P -->|"退回/驳回"| R + P -->|"通过"| F["财务管理复核"] + F -->|"退回/例外"| R + F --> E["财务业务记录\n成本/收款/发票/付款"] + E --> B["记账准备\n凭证草稿/导出文件/人工结果"] + E --> A["项目档案\n附件、版本、归档包"] + B --> A + A --> Q["查询和分析\n项目、合同、金额、状态"] +``` + +### 5.2 三种来源如何进入审批 + +同一张表单允许三种来源,但进入系统后的处理路线必须明确,避免把 OA 已完成的审批再审批一遍,也避免历史数据未经确认就直接成为正式财务记录。 + +| 来源方式 | 适用场景 | 系统处理路线 | 必须保留的证据 | +| --- | --- | --- | --- | +| 系统在线填写 | 新业务直接在本系统发起 | 草稿 → 项目审核 → 财务复核 → 形成业务记录 | 表单版本、每次审批动作、附件版本 | +| OA 已审批资料导入 | OA 已完成业务审批,本系统接收结果 | 来源校验 → 保存只读 OA 审批快照 → 财务接收复核 → 形成业务记录 | OA 单号、审批链、意见、时间、原始导出文件和哈希 | +| Excel/人工历史导入 | 期初数据或旧资料迁移 | 导入批次 → 错误修正 → 业务确认 → 财务确认 → 形成期初/历史记录 | 导入人、确认人、批次、原始文件、差异说明和对账结果 | + +OA 导入路线不重新判断原 OA 的业务审批结论,但财务金额、主数据、附件和重复性检查仍必须通过。历史导入不得直接把数据标成“已付款”或“已记账”;必须同时提供回单、凭证号等证据并由财务确认。单纯补充附件时,要关联原单据并生成附件新版本,只重新进入受影响的财务或资料节点。 + +### 5.3 每条记录都要带的“身份证” + +不论是表单、合同、收款还是附件,都必须至少带: + +`内部编号 + 来源单号 + 所属公司 + 项目 + 业务日期 + 金额(如有)+ 当前状态 + 当前版本 + 创建人/时间 + 审核记录` + +这样财务人员点开一笔付款时,可以反查:它来自哪个表单、谁审批过、使用了哪个合同、有哪些发票和附件、是否已经生成记账资料、是否已经归档。 + +### 5.4 状态的通用规则 + +所有业务对象都遵循: + +```text +草稿 → 提交 → 审核中 → 通过/退回/驳回 → 形成财务记录 → 记账准备 → 归档 +``` + +正式数据不直接删除。错误通过退回、作废、冲销或新版本修正;每个动作留下操作人、时间、意见和前后状态。 + +## 6. 系统总体功能拆分 + +### 6.1 功能总览 + +| 功能编号 | 功能组 | 要解决的问题 | MVP | +| --- | --- | --- | --- | +| F-01 | 登录与身份选择 | 用户不知道从哪里进入、权限混在一起 | 必做 | +| F-02 | 项目工作台 | 项目资料、待办和进度分散 | 必做 | +| F-03 | 财务工作台 | 财务审核任务找不到、口径不统一 | 必做 | +| F-04 | 资料工作台 | 项目结束后资料无法归档和检索 | 必做 | +| F-05 | 项目与主数据 | 项目、供应商、合同重复录入 | 必做 | +| F-06 | 14 类来源表单 | OA 数据无法进入财务流程 | 必做 | +| F-07 | 审批与复核 | 审批结果和财务审核断开 | 必做 | +| F-08 | 成本与合同 | 合同额度、成本和付款无法核对 | 必做 | +| F-09 | 收款与发票 | 收款、开票和项目关联不清 | 必做 | +| F-10 | 进度款与付款 | 付款前缺少金额、附件和责任控制 | 必做 | +| F-11 | 记账准备 | 业务数据无法变成可记账资料 | 必做 | +| F-12 | 档案管理 | 文件散落、版本不清、找不到依据 | 必做 | +| F-13 | 查询和导出 | 管理人员无法快速查项目和财务状态 | 必做 | +| F-14 | 审计和运行管理 | 出问题无法还原谁改了什么 | 必做 | +| F-15 | 银行对账 | 银行资料无法勾稽 | 后续加法 | +| F-16 | 税务准备 | 申报资料缺少统一底稿 | 后续加法 | + +### 6.2 首期最小可用闭环 + +首期不要求 14 个表单都产生复杂会计分录,但必须做到: + +- 14 个表单都能收进系统、查看、审批、退回、归档。 +- 财务核心表单能产生结构化业务记录。 +- 每条结构化业务记录能进入项目台账和记账准备队列。 +- 所有附件能从项目或单据反向查到。 + +## 7. 页面结构总览 + +### 7.1 页面导航 + +```text +登录 +└── 身份选择 + ├── 项目管理工作台 + │ ├── 我的项目 + │ ├── 待办审批 + │ ├── 14类表单 + │ ├── 项目详情 + │ └── 项目进度 + ├── 财务管理工作台 + │ ├── 财务待办 + │ ├── 项目/合同/成本 + │ ├── 收款确认 + │ ├── 发票申请 + │ ├── 进度款/付款 + │ ├── 记账准备 + │ └── 财务查询 + └── 资料管理工作台 + ├── 待归档项目 + ├── 档案包审核 + ├── 文件检索 + └── 借阅/归还记录 +``` + +上图是业务功能视图,不代表每个叶节点都要做成左侧菜单。第一轮真正的左侧菜单、隐藏路由和页面数量只以 7.3 节为准:例如“我的项目”“财务待办”“档案包审核”是对应工作台的卡片、页签或带初始筛选的快捷入口,不新增平行页面。工作台快捷入口必须跳转到 7.3 节既有页面,并通过 URL query 保存非敏感筛选条件,以便刷新和返回时结果一致。 + +### 7.2 页面数量和合并原则 + +首期固定交付 **18 个业务页面 + 3 个公共页面,共 21 个页面**。14 张 OA 表单在页面上可以按业务分组,但每张表单必须保留独立的表单代码、字段校验、审批路线和数据映射。 + +公共页面:登录、身份选择、个人中心。
+业务页面:三类工作台、项目、表单、审批、财务、记账、档案、查询、审计等。 + +页面分类固定为:公共 PAGE-01~03;工作台 PAGE-04~06;项目 PAGE-07~08;表单审批 PAGE-09~11;财务 PAGE-12~16;资料档案 PAGE-17~18;查询分析 PAGE-19;系统治理 PAGE-20~21。PAGE-01、02 使用公共入口,PAGE-03 使用顶部头像入口,PAGE-08、10 是隐藏业务路由,其余业务页面按 7.3 节进入左侧菜单。工作台卡片、页签、抽屉、对话框和项目详情的十个页签都不是新增页面,不得据此增加 PAGE 编号或平行菜单。 + +### 7.3 Starter 菜单、路由与页面映射 + +登录后的整体框架直接使用 TDesign Starter 的左侧菜单、顶部栏、面包屑和内容区。菜单名称、顺序和路由在第一轮固定如下;详情、编辑等隐藏路由不出现在左侧菜单,但必须保留面包屑和返回路径。 + +| 一级菜单/入口 | 二级菜单 | 页面 | 路由 | 默认可见身份 | +| --- | --- | --- | --- | --- | +| 公共入口 | 登录 | PAGE-01 | `/login` | 未登录用户 | +| 公共入口 | 身份选择 | PAGE-02 | `/role-select` | 已登录用户 | +| 顶部头像菜单 | 个人中心 | PAGE-03 | `/profile` | 全部已登录用户 | +| 工作台 | 项目管理工作台 | PAGE-04 | `/workbench/project` | 项目管理 | +| 工作台 | 财务管理工作台 | PAGE-05 | `/workbench/finance` | 财务管理 | +| 工作台 | 资料管理工作台 | PAGE-06 | `/workbench/archive` | 资料管理 | +| 项目管理 | 项目列表 | PAGE-07 | `/projects` | 项目/财务/资料 | +| 隐藏详情路由 | 项目详情 | PAGE-08 | `/projects/:projectId` | 按项目数据范围 | +| 表单审批 | 表单中心 | PAGE-09 | `/forms` | 项目/财务 | +| 隐藏编辑路由 | 表单填写/导入 | PAGE-10 | `/forms/:formType/:documentId?` | 项目/财务按动作权限 | +| 表单审批 | 待办审批 | PAGE-11 | `/tasks` | 当前节点处理人 | +| 财务管理 | 主数据中心 | PAGE-12 | `/finance/master-data` | 授权财务/管理员只读配置 | +| 财务管理 | 合同与成本 | PAGE-13 | `/finance/contracts-costs` | 项目/财务 | +| 财务管理 | 收款与发票 | PAGE-14 | `/finance/receipts-invoices` | 财务 | +| 财务管理 | 进度款与付款 | PAGE-15 | `/finance/payments` | 项目/财务 | +| 财务管理 | 记账准备 | PAGE-16 | `/finance/accounting` | 财务 | +| 资料档案 | 项目归档 | PAGE-17 | `/archives/projects` | 项目/财务/资料按动作权限 | +| 资料档案 | 文件检索与借阅 | PAGE-18 | `/archives/files` | 三类身份中的授权查询人 | +| 查询分析 | 查询与报表 | PAGE-19 | `/reports` | 三类身份按数据范围 | +| 系统治理 | 审计日志 | PAGE-20 | `/governance/audit` | 授权审计/管理员 | +| 系统治理 | 权限与配置 | PAGE-21 | `/governance/settings` | 系统管理员 | +| 系统治理 | 系统更新 | PAGE-22 | `/governance/update` | 系统管理员 | + +本地路由表固定声明组件映射,后端只返回当前身份、权限码和数据范围,前端据此过滤菜单和按钮;后端不得下发任意组件文件路径。业务页面路由的 `meta` 至少包含 `pageId`、`pageType`、`pageTemplate`、`title`、`permission`、`requiresAuth` 和面包屑信息;出现在菜单中的路由组沿用 Starter 的 `orderNo` 排序字段,不再另造 `menuOrder`。S0 页面追溯表还要逐路由冻结本地 `componentPath`、默认入口、URL query 白名单、首个读取 operationId(没有接口时明确记 `N/A` 和原因)及证据 ID;菜单、路由和页面不得出现三份不同配置。 + +同一账号切换身份时必须清空当前身份的页签、页面缓存、待办查询和按钮权限,再加载新身份菜单;不能沿用上一个身份打开的详情页。手工输入隐藏路由或无权路由时统一进入 Starter 的 403 页面,后端接口同时返回 `403` 并写审计日志。 + +## 8. 页面清单与每页功能 + +### 8.1 页面总表 + +| 页面 ID | 页面名称 | 主要用户 | 主要功能 | +| --- | --- | --- | --- | +| PAGE-01 | 登录页 | 全部用户 | 登录、失败提示、密码重置入口、账号锁定说明 | +| PAGE-02 | 身份选择页 | 全部已登录用户(含单身份) | 显示已授权角色卡片、角色说明、进入对应工作台 | +| PAGE-03 | 个人中心 | 全部用户 | 查看账号、修改密码、切换身份、退出登录 | +| PAGE-04 | 项目管理工作台 | 项目管理人员 | 我的项目、我的待办、被退回表单、项目进度和快捷发起 | +| PAGE-05 | 财务管理工作台 | 财务管理人员 | 财务待办、异常、待记账、付款资料、项目资金概览 | +| PAGE-06 | 资料管理工作台 | 资料管理人员 | 待归档、缺资料、借阅到期和已归档统计 | +| PAGE-07 | 项目列表 | 项目/财务/资料人员 | 按公司、项目、负责人、阶段和状态筛选项目 | +| PAGE-08 | 项目详情 | 三类角色 | 项目总览、表单、合同、收付款、进度、人员、附件和操作时间线 | +| PAGE-09 | 表单中心 | 项目和财务人员 | 14 类表单分组、模板下载、创建、导入、草稿和状态查询 | +| PAGE-10 | 表单填写/导入页 | 项目和财务人员 | 动态字段、附件、Excel 导入、校验、预览和提交 | +| PAGE-11 | 待办审批页 | 当前节点处理人 | 待我处理、通过、退回、驳回、意见和审批记录 | +| PAGE-12 | 主数据中心 | 授权财务人员 | 公司、项目、供应商、账户、合同、成本类别维护和版本审核 | +| PAGE-13 | 合同与成本台账 | 项目和财务人员 | 合同额、变更、结算、应付、已付、开票和成本分类 | +| PAGE-14 | 收款与发票页 | 财务人员 | 收款确认、应收核销、开票申请、人工开票结果和异常 | +| PAGE-15 | 进度款与付款页 | 项目和财务人员 | 付款申请、额度检查、附件检查、结果登记、退汇和回单 | +| PAGE-16 | 记账准备中心 | 财务人员 | 财务事件、凭证草稿、复核、导出和人工记账结果 | +| PAGE-17 | 项目归档页 | 项目、财务和资料人员按动作权限 | 生成档案包、缺件检查、归档审核和项目关闭 | +| PAGE-18 | 文件检索与借阅页 | 三类身份中的授权查询人员 | 条件检索、预览、下载、借阅、归还和水印 | +| PAGE-19 | 查询与报表页 | 三类角色按权限 | 项目台账、合同、收付款、流程、附件完整性和导出 | +| PAGE-20 | 审计日志页 | 审计/授权管理员 | 查询登录、查看、修改、审批、导入、导出和结果登记记录 | +| PAGE-21 | 权限与配置页 | 系统管理员 | 用户、角色、数据范围、模板和参数版本 | +| PAGE-22 | 系统更新页 | 系统管理员 | 当前/最新版本、更新包获取、立即重启和历史更新记录 | + +### 8.2 PAGE-01 登录页 + +**页面目的**:让用户安全进入系统,同时把错误原因说清楚。 + +页面内容: + +- 账号、密码、登录按钮。 +- 密码是否可见的切换按钮。 +- 账号锁定、停用、密码错误和会话超时提示。 +- 首次登录/管理员重置后的强制改密入口。 + +页面规则: + +- 连续输错密码后临时锁定。 +- 停用或离职账号立即停止登录。 +- 密码重置由用户发起申请、系统管理员执行重置并要求下次登录改密;首期不依赖短信、邮件或第三方身份服务。 +- 登录成功后不直接进入复杂首页,而是进入 PAGE-02 身份选择页。 +- 登录成功、失败、锁定和退出全部留审计记录。 + +验收结果:正确账号可登录;错误、锁定和停用账号得到不同提示;旧会话在账号停用后失效。 + +### 8.3 PAGE-02 身份选择页 + +**页面目的**:用当前账号被授权的入口卡片告诉用户“你现在要以什么身份工作”。业务账号显示三类业务卡中的一张或多张;纯超级管理员账号只显示隔离的超级管理员卡。 + +角色卡片: + +- 项目管理人员:发起项目业务、补资料、看项目进度。 +- 财务管理人员:做财务审核、付款检查和记账准备。 +- 资料管理人员:检查资料、归档、检索和借阅。 +- 超级管理员:只对独立的 `SYSTEM_ADMIN` 账号显示,默认进入 PAGE-21;进入后可通过侧栏访问全部业务和治理页面。 + +页面规则: + +- 只显示当前账号已授权的角色。 +- 单角色账号只显示一张身份卡,并由用户点击“进入系统”;不自动跳过本页。 +- 系统管理卡不得与三类业务卡同时出现在同一账号;发生冲突时登录后阻断进入并记录配置错误审计。 +- 切换身份不会扩大数据范围;未经授权的角色 URL 直接访问也要被服务端拒绝。 + +### 8.4 PAGE-03 个人中心 + +功能:查看姓名、部门、当前角色和授权公司/项目;修改密码;切换身份;退出全部会话;查看最近登录记录。 + +不提供:自行增加角色、扩大项目范围、查看其他人的敏感信息。 + +### 8.5 PAGE-04 项目管理工作台 + +**首页要回答五个问题**:我有哪些项目?有什么待办?哪些被退回?哪些资料缺失?项目现在到哪一步? + +页面区块: + +1. 数字概览:进行中项目、待我处理、被退回、即将到期。 +2. 我的待办:表单名称、项目、当前节点、提交时间、剩余时限。 +3. 我的项目:项目名称、负责人、阶段、合同额、最近动作。 +4. 快捷发起:项目立项、供应商准入、合同会签、收款确认、进度款申请等。 +5. 异常提示:缺附件、超期、合同/供应商未生效、表单版本过旧。 + +可操作:新建、继续编辑草稿、补件、撤回未处理单据、查看审批进度。 + +不可操作:财务终审、修改已通过金额、登记付款结果、生成正式凭证。 + +### 8.6 PAGE-05 财务管理工作台 + +**首页要回答六个问题**:今天要审什么?哪些金额异常?哪些付款待处理?哪些数据待记账?哪些项目资料不齐?哪些事情快超时? + +页面区块: + +1. 财务待办:按立项、供应商、合同、收款、发票、应付、付款分类。 +2. 阻断异常:余额不足、合同超额、账户未生效、疑似重复、附件缺失。 +3. 待记账:已财务确认但未生成凭证草稿的业务。 +4. 待登记结果:需要人工登记付款、开票或记账结果的事项。 +5. 项目资金概览:收款、成本、已付、待付和可用余额。 +6. 月度工作:本月已完成、待完成和超时事项。 + +可操作:审核、退回、发起例外、确认财务事件、生成记账草稿、导出和登记人工结果。 + +### 8.7 PAGE-06 资料管理工作台 + +页面区块:待接收档案包、缺失附件、待复核原件、借阅到期、已归档项目和近期下载。 + +可操作:检查文件、标注原件类型和存放位置、退回补件、确认档案包、借阅/归还登记。 + +不可操作:改项目金额、改合同额度、审批付款、修改凭证。 + +### 8.8 PAGE-07 项目列表 + +筛选条件:公司、项目编号/名称、负责人、客户、项目阶段、财务状态、归档状态、开始/结束日期。 + +列表字段:项目编号、项目名称、公司、负责人、合同额、已收、已付、当前阶段、待办数、资料完整率、状态。 + +页面操作:新建项目、导入、查看详情、导出当前筛选结果。导出必须使用和页面相同的权限及脱敏规则。 + +### 8.9 PAGE-08 项目详情 + +这是系统最重要的“总账页”,不是会计总账,而是一个项目的全过程总览。 + +顶部信息:项目编号、名称、公司、客户、负责人、状态、合同额、项目起止时间。 + +页签: + +1. 总览:关键金额、当前阶段、异常和待办。 +2. 业务表单:14 类表单及状态。 +3. 合同与成本:合同、变更、结算、成本分类。 +4. 收款与发票:应收、收款、开票和核销。 +5. 付款:应付、申请、结果、回单和退汇。 +6. 进度与验收:履约进度、任务、照片和验收。 +7. 人员:中标人员、班组人员、变更和押证费。 +8. 记账准备:财务事件、凭证草稿和人工结果。 +9. 档案:附件、版本、原件位置和档案包。 +10. 时间线:谁在何时提交、审核、修改、退回、作废和归档。 + +### 8.10 PAGE-09 表单中心 + +表单按业务阶段分组展示: + +- 项目准备:OA-01、OA-02、OA-03。 +- 收支执行:OA-04、OA-05、OA-06、OA-08、OA-13。 +- 项目过程:OA-09、OA-10、OA-14。 +- 结束归档:OA-11、OA-12。 +- 银行资料:OA-07。 + +每张表单卡片显示:名称、用途、适用角色、当前模板版本、我的草稿数、待处理数和新建按钮。 + +支持操作:在线填写、下载标准模板、上传 Excel/CSV、查看草稿和历史版本。 + +### 8.11 PAGE-10 表单填写/导入页 + +页面区块: + +1. 基本信息:公司、项目、来源单号、版本、申请人和日期。 +2. 表单字段:根据 OA-01~OA-14 动态显示。 +3. 明细表格:人员、支付明细、附件矩阵等重复行。 +4. 附件区:文件类型、是否必需、是否齐全、“不涉及”原因。 +5. 校验结果:错误、警告和可提交项。 +6. 审批快照:原 OA 节点、人员、意见和时间。 + +操作顺序:保存草稿 → 校验 → 预览 → 提交。Excel 导入使用“上传校验 → 查看错误 → 确认入库”两步,错误数据不进入正式表。 + +### 8.12 PAGE-11 待办审批页 + +列表分类:待我处理、我已处理、我发起、被退回、已完成。 + +详情区同时展示:原始表单、关键金额、附件、历史意见、项目/合同/供应商摘要和系统校验结果。 + +允许动作:通过、退回补件、驳回、撤回、作废、提交例外申请。所有动作必须记录意见;退回时选择需要修改的字段或附件。 + +### 8.13 PAGE-12 主数据中心 + +对象:公司、部门、项目、客户/供应商、供应商账户、公司银行账户、合同、成本类别、科目、税率字典。 + +功能:新增、编辑草稿、提交复核、版本对比、生效、停用、旧编号映射和重复检查。 + +关键规则: + +- 项目编号在同一公司内唯一。 +- 供应商按证照号去重。 +- 银行账户新增或变更必须由另一名财务人员复核后生效。 +- 停用主数据不影响历史单据,但不能用于新单据。 + +### 8.14 PAGE-13 合同与成本台账 + +合同列表字段:合同编号、项目、供应商、原合同额、批准变更、结算金额、已开票、已应付、已付款、可付余额、状态。 + +成本视图:人工、材料、机械、专业分包、运输、设备、间接费和其他成本。 + +支持下钻:点击“已付款”能看到付款单和回单;点击“已开票”能看到发票和来源表单。 + +#### 8.14.1 PAGE-13 已实现页面与业务合同(2026-08-13) + +PAGE-13 当前为 `MODULE_ACCEPTED`。本结论只覆盖合同执行、结算、应付及联动汇总,不代表 PAGE-15、整个 S3 或整套 R1 完成。 + +- 合同列表、全量筛选汇总、详情下钻和 CSV 导出均使用服务端权限范围、分页和金额重算。 +- “待确认应付”仅统计 `PENDING`;“已应付”统计 `CONFIRMED/PART_PAID/PAID`;待确认记录仍参与合同额度预占。 +- 项目经理使用 `contractcost:payable:create` 登记应付,财务使用 `contractcost:payable:confirm` 确认;登记人不得确认同一笔应付。 +- 应付来源必须是同公司、同项目、当前版本已批准的 OA-04;结算来源同理必须是 OA-11。两类来源均不接受自由文本输入,也不允许重复生成业务记录。 +- OA-04 财务终审现在直接在同一事务中生成 `CONFIRMED` 应付,写入合同额度占用、含税发票汇总和唯一 `PAYABLE` 会计事件;不是先生成一个待确认空壳再依赖人工补确认。历史/人工登记入口仍按原状态机执行,来源单据不得重复消费。 +- 前端使用 TDesign `Select`、`Form`、`Table`、`Tabs`、`Statistic`、对话框和抽屉;来源下拉只查询当前项目已批准单据,最终约束仍由 Java 后端执行。 + +### 8.15 PAGE-14 收款与发票页 + +收款功能:登记/接收收款确认单、匹配项目和客户、分配到应收、处理暂收款、查看已收/未收。 + +发票功能:接收开票申请、检查合同额度和已开票金额、登记人工开票结果、关联已上传并通过检查的票据文件、处理红冲/作废;直接上传交互归统一 `FileUploader` 全局任务收口。 + +首期不做:连接发票平台、自动验真、自动开票。 + +#### 8.15.1 PAGE-14 已实现页面与业务合同(2026-08-11) + +PAGE-14 当前为 `MODULE_ACCEPTED`。该结论只覆盖本节列出的收款、应收和发票闭环,不代表 PAGE-15、整个 S3 或整套 R1 已完成。PAGE-13 的独立模块结论见 8.14.1 和 13.1.2。 + +**页面和功能** + +| 区域 | 已实现内容 | 用户得到的结果 | +| --- | --- | --- | +| 公共查询 | 收款/开票双页签;关键词、公司、项目、客户/开票对象、状态、业务日期和排序筛选;20/50/100 服务端分页;当前筛选 CSV 导出 | 页面刷新和翻页后从 Java/MySQL 重新查询,金额不由前端临时合计 | +| 收款列表与详情 | 收款编号、公司、项目、客户、日期、金额、已分配、未分配、状态;详情抽屉展示流水号、确认人、作废原因和逐笔核销明细 | 可从一笔收款反查核销到哪些应收、项目和合同,以及每笔应收剩余金额 | +| 收款动作 | 新建/编辑草稿、异人确认、分配到应收、未分配收款作废;项目和客户可暂不选择以形成待匹配暂收款 | 支持一收多应收和一应收多次收款;分配后收款与应收余额同步更新 | +| 发票列表与详情 | 开票业务号、公司、项目、对象、申请日期、金额、税率、发票号和状态;详情显示合同额度、累计占用、剩余额度、提交/复核/结果人员、意见、原票和终止原因 | 审核人员能在一个抽屉内核对申请、额度、人员分工和后续结果 | +| 发票动作 | 新建/编辑草稿、提交复核、通过/退回/驳回、登记人工开票结果、作废、登记红冲;可关联已通过本地检查的结果文件 | 不连接外部发票平台;人工结果、原票/红字票关系、终止原因和责任人可追溯 | +| OA 来源结果 | OA-05 财务终审直接生成 `CONFIRMED` 收款、项目资金控制增量、资金台账和待处理 `RECEIPT` 会计事件;OA-08 财务终审生成带来源版本的 `DRAFT` 开票候选,后续仍由 PAGE-14 人工提交、异人复核和登记开票结果 | 来源审批和 PAGE-14 人工处理不重复生成收款/发票;失败时来源状态、工作流任务、财务对象、台账和会计事件整体回滚 | +| 页面反馈 | TDesign `Tabs/Table/Form/Drawer/Dialog/Tag/Alert/Empty/Pagination`;金额使用 `MoneyInput` 和 `decimal.js` 字符串;必填意见/发票号/日期/原因校验;401/403/404/409/422/5xx 分级提示 | 重复点击、版本冲突或业务阻断不会在前端伪造成功;写成功后重新读取列表、引用项和已打开详情 | + +**状态和硬控制** + +| 对象 | 已实现状态主线 | 后端硬控制 | +| --- | --- | --- | +| 收款 | `DRAFT → CONFIRMED → PARTIALLY_ALLOCATED → ALLOCATED`;未发生分配的 `DRAFT/CONFIRMED` 可进入 `VOID` | 登记人与确认人必须不同;分配额不得超过收款未分配额和应收未收额;公司、项目、客户、币种必须匹配;重复公司+流水号拒绝;每次命令校验权限范围、金额上限和版本 | +| 应收 | 人工开票结果自动生成 `OPEN`;核销后进入 `PARTIALLY_SETTLED/SETTLED`;未核销发票被作废或红冲时进入 `VOID` | 一张有效发票只生成一条应收;未收余额保持在 0~原额;核销使用数据库行锁和乐观锁,禁止超收 | +| 发票 | `DRAFT/RETURNED → REVIEWING → RESULT_PENDING → ISSUED`;审核可进入 `RETURNED/REJECTED`;终止可进入 `VOID/RED_LETTERED` | 提交人与复核人必须不同;审核通过和登记结果都复算合同可开票额;同公司发票号唯一;已核销应收不得直接作废/红冲;红冲生成关联原票的红字票并按净额进入合同/报表统计 | + +**实际权限码** + +`receivable:receipt:view/create/edit/confirm/allocate/void/export`;`receivable:invoice:view/create/edit/submit/review/record-result/void/red-letter/export`。页面按钮来自服务端 `allowedActions`;Java 后端再次执行功能权限、公司/项目范围、金额阈值、对象状态、SOD 和版本校验。 + +### 8.16 PAGE-15 进度款与付款页 + +页面分区:付款申请列表、待财务检查、待线下付款、待登记结果、已支付、失败/退汇。 + +付款前检查:项目可用余额、合同可付余额、应付余额、供应商账户版本、发票和附件、重复风险、审批是否完成。 + +结果登记:由经办人填写线下付款日期、金额、结果和回单;另一名财务人员复核。同一人不能同时登记和复核。 + +首期不做:系统发起银行付款、读取 U 盾、保存网银密码或 PIN。 + +#### 8.16.1 PAGE-15 当前实现边界(2026-08-13) + +PAGE-15 当前为 `IN_PROGRESS`。下表用于区分“已经能用的核心付款链”和“模块验收前必须补齐的控制”,不能把存在页面或接口等同于整页完成。 + +| 区域 | 当前已经实现 | 仍须完成 | +| --- | --- | --- | +| 页面与查询 | 六个业务页签;关键词、公司、项目、供应商、状态、风险、日期、排序筛选;服务端分页、详情抽屉、检查清单、CSV 导出;新建/编辑及各状态动作均使用 TDesign | 三档 Chrome/Edge 正式证据包、全部失败/无权/慢请求状态和包体预算仍归 S5 收口 | +| 付款申请 | 选择公司、项目、合同、供应商、可选应付、生效收款账户、金额、日期、币种和附件;应付分类命中生效规则时,页面逐项显示必需资料的齐全/缺失/文件检查中状态,每个文件必须选择矩阵内资料类型;后端复核对象归属、数据范围、金额权限并保存规则版本和分类快照;附件“不涉及”申请已接入异人财务复核和消费审计 | D-02/D-09 确认并发布正式完整矩阵;正式矩阵的必需项与“不涉及”范围未确认前禁止发布 | +| OA 来源 | OA-06 来源为可选;选择后页面只查询当前项目已批准 OA-06,后端在保存和批准时复核表单类型、公司、项目、当前版本及其 `APPROVED` 实例;OA-06 v2 财务终审已在同一事务内生成 `OFFLINE_PENDING` 付款、冻结资金并写入流水 | 自动投影只形成待线下付款记录,不产生银行指令;PAGE-15 正式矩阵、三档浏览器证据和甲方验收仍按本页独立收口 | +| 批准前检查 | 项目可用余额、合同余额、应付余额、生效账户、通用或按成本类型的附件完整性/文件状态、精确/近似重复付款、OA 来源和项目阻断风险;批准事务内锁资金控制行、合同、应付和付款;同项目余额并发竞争只允许余额覆盖的付款成功;历史无规则快照付款继续按通用附件语义检查;独立逐笔资金台账已记录并可查询 | D-02/D-09 正式矩阵和业务口径确认 | +| 线下结果 | 登记成功/部分成功/失败及回单;另一人复核;部分支付保留剩余冻结额;失败释放未支付额;退汇逆转资金、应付和会计事件;重付关联原付款和序号 | 正式桌面端全状态业务证据包及跨模块记账/档案追溯仍须补齐 | +| 例外 | `DUPLICATE`、`PROJECT_RISK` 支持一次性申请;申请人与财务复核人必须分离,最多 7 天有效,批准时重算指纹并在付款批准事务内原子消费;附件“不涉及”同样要求理由、异人复核和消费审计 | 账户未生效、同人登记/复核、项目资金/合同/应付额度、附件硬要求、OA 来源、重复有效来源/事件等硬控制永不允许例外;正式矩阵仍须 D-02/D-09 确认 | + +**当前主要状态主线**:`DRAFT/RETURNED → REVIEWING → OFFLINE_PENDING → RESULT_RECORDED → PART_PAID/PAID`;批准或线下阶段可按规则进入 `FAILED/VOID`,已复核结果可登记 `REFUNDED` 并创建关联重付。所有写动作继续由 Java 后端校验权限、范围、金额、状态、SOD、幂等键和版本,前端不得自行推进状态。 + +### 8.17 PAGE-16 记账准备中心 + +页面分区:待生成、草稿、待复核、已导出、待登记结果、已完成、已冲销、已作废。 + +功能: + +- 从已确认的收款、应付、付款、发票等财务记录生成凭证草稿。 +- 显示借方、贷方、摘要、项目、往来单位和来源依据。 +- 财务人员修改科目或金额时填写原因。 +- 借贷不平或缺辅助信息时阻止复核。 +- 导出标准文件,人工导入现有记账系统后登记凭证号和结果。 +- 已复核完成但外部结果错误时,由非原登记人填写原因执行冲销;冲销后来源事件回到可更正状态,旧凭证号、登记/复核信息和证据保留在不可变历史中。 +- 已冲销结果由另一名具备权限的财务人员重开,进入已导出待重新登记状态;新一轮结果与旧轮证据隔离,旧外部凭证号不得再次使用。 +- 冲销和重开都校验独立权限、公司/项目/金额范围、当前版本、状态、岗位分离和幂等键,并记录操作人、时间、原因、前后版本及审计日志。 + +首期不做:调用 T+ 接口、自动过账、自动修改外部账套。 + +### 8.18 PAGE-17 项目归档页 + +流程:财务管理人员在 PAGE-08 对授权项目执行“确认财务完整” → 项目财务状态变为 `COMPLETED` → 用户在 PAGE-17 主动生成档案包 → 系统检查缺件 → 财务提交 → 资料人员异人审核 → 退回补件或确认归档。未完成财务确认时,服务端拒绝生成档案包;确认命令同时校验权限、公司/项目范围、项目状态、财务状态和版本,并记录 `PROJECT_FINANCE_CONFIRM` 审计。 + +档案包应包含:项目基础资料、14 类相关表单、审批记录、合同、发票、收付款资料、记账准备结果、验收资料和文件清单。 + +每个文件保存 SHA-256 哈希,用来判断文件是否改变;替换文件生成新版本,旧版本继续保留。 + +档案记录保管期限、纸质原件位置和冻结标记。归档后计算 `retentionExpiresOn`,保管状态固定为 `PENDING`、`ACTIVE`、`DUE_SOON`(90 天内)、`EXPIRED`、`FROZEN`。到期只在页面提示,不自动删除或销毁;处于审计、争议或其他冻结状态的档案禁止处理。档案销毁审批不纳入 MVP。 + +### 8.19 PAGE-18 文件检索与借阅页 + +检索条件:公司、项目、合同、供应商、表单、文件名、上传人、日期、原件类型、归档状态和扫描状态。 + +操作:预览、下载、查看版本、申请借阅、批准借阅、登记归还。下载显示用户和时间水印并记录审计。 + +当前实现状态(2026-08-15):本地真实浏览器已验证首次进入不自动查询、项目条件检索、文件版本详情、受控水印预览,以及工作台“隔离文件”按扫描状态精确跳转。扫描器不可用时,系统保留 `QUARANTINED` 文件并建立带公司/项目范围的 `file_link`;用户可在扫描服务恢复后重扫,成功时文件进入 `AVAILABLE`、正式路径存在、隔离源消失、转存意图清空,503/200/422 幂等结果保持首次语义。资料身份申请、财务身份批准、资料身份归还的异人流程已在真实页面和数据库贯通,范围外档案直链下载回归已通过。移动 `390×844` 只作为补充视口检查,不属于 R1 正式支持范围;Chrome/Edge 三档全流程、axe、正式负向 fixture 和甲方证据包仍须按 `TODO-S4-011/012`、UAT-020~022 收口,PAGE-18 当前不是 `MODULE_ACCEPTED`。 + +### 8.20 PAGE-19 查询与报表页 + +首期报表: + +1. 项目综合台账:项目、合同、收款、成本、付款、记账和归档状态。 +2. 合同执行台账:合同额、变更、结算、开票、应付和已付。 +3. 收款/开票台账:本次及累计收款、开票和未收。 +4. 付款进度台账:申请、审核、线下结果、回单和记账状态。 +5. 流程时效:各节点停留时间、退回次数和未处理任务。 +6. 资料完整性:缺失、待补、不涉及和已齐全。 + +所有汇总数据必须能点击下钻到明细、来源表单、审批记录和附件。 + +当前实现状态(2026-08-16):六类报表、服务端汇总、筛选快照和同权限下钻已实现;金额按两位小数显示,业务状态中文化,下钻四类子查询再次复用当前用户、角色、权限和完整父行筛选。导出已改为两阶段:服务端预检冻结人员/身份/权限范围指纹、口径版本、筛选、列、排序和预计行数,页面展示筛选快照与脱敏说明后由用户确认,服务端再次鉴权和校验全部快照后才生成文件。MySQL 8.4 全量集成回归已通过;D-08、各指标日期口径、流程时长真实样本和正式浏览器证据尚未完成,PAGE-19 当前只能标记 `IMPLEMENTED_PENDING_ACCEPTANCE`,不得标记 `MODULE_ACCEPTED`。 + +### 8.21 PAGE-20 审计日志页 + +可查询:登录、敏感字段查看、创建、修改、提交、审批、退回、导入、导出、下载、结果登记、作废、归档和权限变化。 + +日志显示:用户、角色、公司/项目范围、操作时间、对象、动作、前后值、原因和请求编号。普通业务人员不能删除或修改审计日志。 + +### 8.22 PAGE-21 权限与配置页 + +配置对象:用户、角色、公司/项目数据范围、金额阈值、互斥规则、表单模板、字典、附件矩阵、审批路线和版本。 + +本页只管理权限和业务配置,不配置系统名称、系统域名,也不承载在线更新。用户、角色和数据范围变更后,受影响账号的既有会话失效,必须重新登录获取最新权限。 + +PAGE-21 只向超级管理员开放。超级管理员账号不得混授普通业务角色,但其 `SYSTEM_ADMIN` 身份本身自动拥有全部财务和治理功能权限;审批处理人、异人复核、状态机、资金和审计控制继续由后端强制执行。 + +### 8.22.1 PAGE-22 系统更新页 + +系统更新使用独立入口 `/governance/update`,同时显示当前版本、最新版本、更新状态、四步进度和最近更新记录。拥有 `admin:update:view` 的隔离系统管理员可进入页面;下载与重启安装仍由 `admin:update:execute` 和服务端 `allowedActions` 共同控制。未启用或 Release 地址无效时显示明确禁用状态并禁用“获取更新”。 + +更新固定分两阶段。管理员点击“获取更新”后,前端先检查最新版本,再自动提交 `DOWNLOAD` 请求;root oneshot 从固定 Gitea Latest Release API 下载 manifest、签名和应用包,验签/验哈希后写入 root-only 版本缓存并进入 `READY`,期间当前业务版本继续运行。进入 `READY` 后页面显示“立即重启”,点击后只允许安装同版本缓存,并在切换前重新验证。健康检查成功后页面显示 10 秒倒计时并自动刷新;若页面在 `INSTALL_QUEUED/BACKING_UP/INSTALLING/RUNNING` 时刷新或短暂断线,重新进入后自动恢复 3 秒轮询。失败时保留明确状态并恢复已验证的上一版本。 + +页面只提交目标版本和原因,不接收下载地址、Token、脚本或任意命令。Gitea 只读 Token 仅存在于权限为 `0600` 的 root 环境文件和临时 Header 文件,不进入进程参数、DTO、状态 JSON、审计参数或页面响应;API 和资产必须同 scheme、host、port,认证请求不跟随跨来源重定向。文件锁和 inbox/processing 双路径防止并发覆盖,独立 root oneshot 执行 RSA/SHA-256 校验、可验证数据库备份、持久事务、应用与运维文件原子切换、新旧版本健康检查和失败回滚。 + +### 8.23 页面通用要求 + +- 列表有搜索、筛选、分页、排序和列显示设置。 +- 详情页固定显示业务编号、状态、版本、创建人和更新时间。 +- 按钮只显示当前状态允许的操作,但后端仍必须做权限校验。 +- 删除只用于草稿;正式数据使用作废、冲销或新版本。 +- 保存和提交后给出明确结果,失败信息定位到字段或附件。 +- 页面上的金额使用千分位和两位小数;日期、状态和颜色保持一致。 +- 所有页面在常见办公电脑分辨率下不横向溢出;复杂明细允许表格内部滚动。 + +### 8.24 页面开发交付矩阵 + +下表是前端、后端和测试共同使用的页面级开发合同。组件列表示必须优先采用的 TDesign 组件基线,不是允许开发人员另做一套同类控件;接口组最终以评审通过的 OpenAPI 为准。 + +| 页面 | Starter 内实现位置 | 主要 TDesign 组件 | 必须接通的真实接口组 | 核心权限/完成结果 | +| --- | --- | --- | --- | --- | +| PAGE-01 登录 | `pages/login` | `Form`、`Input`、`Button`、`Alert` | `/api/v1/auth/login`、`session`、`logout`、`csrf` | 未登录可访问;成功后获得服务端会话,失败/锁定/停用分别反馈并留痕 | +| PAGE-02 身份选择 | `pages/role-select` | `Card`、`Button`、`Tag`、`Alert` | `/api/v1/auth/roles`、`select-role` | 只显示已授权入口;业务账号显示业务身份,纯管理员只显示系统管理卡;选择后清空旧身份状态并进入对应页面 | +| PAGE-03 个人中心 | `pages/profile` | `Descriptions`、`Form`、`Timeline`、`Dialog` | `/api/v1/auth/profile`、`password`、`sessions` | 只能处理本人账号;切换身份、改密和退出全部会话真实生效 | +| PAGE-04 项目工作台 | `pages/workbench/project` | `Statistic`、`Table`、`Tag`、`Timeline` | `/api/v1/workbenches/project` | 仅汇总本人授权项目、待办、退回和缺件;数字均可下钻 | +| PAGE-05 财务工作台 | `pages/workbench/finance` | `Statistic`、`Table`、`Tabs`、`Alert` | `/api/v1/workbenches/finance` | 展示授权范围内财务待办、阻断异常、待记账和待登记结果 | +| PAGE-06 资料工作台 | `pages/workbench/archive` | `Statistic`、`Table`、`Tag`、`Timeline` | `/api/v1/workbenches/archive` | 展示待归档、缺件、借阅到期和归档统计,不提供金额修改入口 | +| PAGE-07 项目列表 | `pages/projects/list` | `Form`、`Table`、`Pagination`、`Dropdown` | `/api/v1/projects`、`/api/v1/projects/imports`、`/api/v1/projects/exports` | 服务端筛选/分页/排序;查看和导出执行同一数据范围与脱敏规则 | +| PAGE-08 项目详情 | `pages/projects/detail` | `Descriptions`、`Tabs`、`Table`、`Timeline` | `/api/v1/projects/{id}` 及表单、财务、风险标记等详情子资源 | 十个页签可从项目穿透到表单、财务、记账、档案和审计证据;人工风险标记只向授权人员开放 | +| PAGE-09 表单中心 | `pages/forms/index` | `Tabs`、`Card`、`Table`、`Tag` | `/api/v1/source/templates`、`/api/v1/source/forms` | 14 类模板、版本、草稿、待办和历史状态完整;按身份显示发起能力 | +| PAGE-10 表单填写/导入 | `pages/forms/editor` | `Form`、`Table`、`Upload`、`Alert`、`Steps` | `/api/v1/source/forms`、`validate`、`imports`、`submit` | 保存草稿、校验、预览、提交和两阶段导入均真实落库且可定位错误 | +| PAGE-11 待办审批 | `pages/tasks` | `Table`、`Descriptions`、`Timeline`、`Dialog` | `/api/v1/workflow/tasks` 及 `approve/return/reject/withdraw/void` | 只允许当前节点处理人操作;意见、字段/附件退回点和状态迁移完整 | +| PAGE-12 主数据 | `pages/finance/master-data` | `Tabs`、`Table`、`Form`、`Dialog`、`Descriptions` | `/api/v1/masterdata/*` | 新增、变更、版本对比、复核、生效和停用完整;提交人与复核人分离 | +| PAGE-13 合同与成本 | `pages/finance/contracts-costs` | `Form`、`Table`、`Tabs`、`Statistic` | `/api/v1/contracts/*`、`/api/v1/costs/*` | 合同原额/变更/结算、开票/应付/已付和成本分类可计算、下钻、导出 | +| PAGE-14 收款与发票 | `pages/finance/receipts-invoices` | `Tabs`、`Table`、`Form`、`Drawer`、`Dialog` | `/api/v1/receivables/*`、`/api/v1/receipts/*`、`/api/v1/invoices/*` | 收款分配、暂收、正式应收、开票申请及人工开票/红冲/作废结果可追溯;当前已通过模块级验收 | +| PAGE-15 进度款与付款 | `pages/finance/payments` | `Tabs`、`Table`、`Descriptions`、`Dialog`、`Alert` | `/api/v1/payments/*` | 额度/账户/附件/重复/SOD 检查生效;只登记和复核线下结果,不产生银行指令 | +| PAGE-16 记账准备 | `pages/finance/accounting` | `Tabs`、`Table`、`Form`、`Dialog` | `/api/v1/accounting/*` | 财务事件、借贷草稿、复核、导出、人工结果登记/异人复核、结果冲销/异人重开和分轮证据完整且幂等 | +| PAGE-17 项目归档 | `pages/archives/projects` | `Table`、`Steps`、`Timeline`、`Drawer`、`Dialog` | `/api/v1/projects/{id}/finance-complete`、`/api/v1/archive/packages/*` | 仅项目财务状态为 `COMPLETED` 后允许主动生成档案包;缺件、退回补件、复核、冻结版本和保管到期状态完整 | +| PAGE-18 文件检索借阅 | `pages/archives/files` | `Form`、`Table`、`Pagination`、`Dialog` | `/api/v1/archive/files/*`、`/api/v1/archive/borrows/*` | 受控预览/下载/版本/借阅/归还;敏感访问、水印和审计有效 | +| PAGE-19 查询报表 | `pages/reports` | `Tabs`、`Form`、`Table`、按需 `ECharts` | `/api/v1/reports/{reportCode}`、`drilldown`、`exports` | 六类报表同口径汇总、下钻、导出;不以硬编码数字或前端合计冒充结果 | +| PAGE-20 审计日志 | `pages/governance/audit` | `Form`、`Table`、`Drawer`、`Tag` | `/api/v1/audit/logs`、`/api/v1/audit/exports` | 授权审计人员按请求编号还原关键动作;日志不可由业务页面修改/删除 | +| PAGE-21 权限与配置 | `pages/governance/settings` | `Tabs`、`Tree`、`Table`、`Form`、`Dialog` | `/api/v1/admin/users`、`roles`、`scopes`、`templates`、`parameters` | 仅超级管理员可用;用户、角色、范围、模板和参数按版本与动作白名单管理 | +| PAGE-22 系统更新 | `pages/governance/update` | `Steps`、`Table`、`Alert`、`Tag`、`Button` | `/api/v1/admin/system-update/{check,download,install}`、`/api/v1/audit/logs` | 获取更新自动完成检查和下载;验签完成后显示立即重启;健康后 10 秒刷新;历史操作来自不可变审计日志 | + +每个页面只有同时满足以下条件才算完成: + +1. 路由、菜单、面包屑、返回路径、页签标题和当前身份规则正确。 +2. 页面所有可见数据和操作均接真实 OpenAPI;不存在 Mock、静态 JSON、空按钮或“后续实现”。 +3. 菜单、按钮、接口、数据范围、状态、金额阈值和岗位互斥都通过正向及越权测试。 +4. 首次加载、刷新、空结果、字段错误、401、403、404、409、422、500 和网络失败均有确定界面。 +5. 正常、失败、重复点击、并发冲突、未保存离开和刷新恢复测试通过。 +6. Chrome、Edge 三档目标分辨率截图通过评审,无重叠、截断、异常跳动和页面整体横向溢出。 + +### 8.25 页面进入、主操作和完成结果 + +下表锁定“进入页面先看到什么、用户按什么顺序操作、做到什么算这次操作完成”。它与 8.24 节共同组成页面合同;开发不能只把路由和空壳组件做出来。 + +| 页面 | 默认进入状态 | 主操作顺序 | 本次操作完成标志 | +| --- | --- | --- | --- | +| PAGE-01 | 未登录表单,账号为空、密码为空 | 获取 CSRF → 输入账号密码 → 登录 | 服务端建立 Session,跳转 PAGE-02;失败停留本页并显示稳定错误码 | +| PAGE-02 | 显示当前账号全部已授权入口;单业务身份或纯管理员也只显示一张卡 | 选择身份卡 → 点击“进入系统” | 服务端保存当前身份并返回权限摘要;业务身份进入对应工作台,`SYSTEM_ADMIN` 进入 PAGE-21 | +| PAGE-03 | 本人资料、当前身份、授权范围和最近登录 | 改密/切换身份/退出指定或全部会话 → 确认 | 服务端返回新会话状态;切换身份后旧页签和缓存清空 | +| PAGE-04 | 授权项目的概览、待办、退回和缺件,更新时间倒序 | 点击指标/待办/项目/快捷发起 | 进入既有目标页面并带初始筛选;返回后保持非敏感筛选条件 | +| PAGE-05 | 财务待办、阻断异常、待记账、待登记结果 | 点击分类 → 打开对应单据 → 执行授权动作 | 服务端状态改变并重新计算工作台数字;数字与明细数量一致 | +| PAGE-06 | 待接收档案、缺件、借阅到期和近期下载 | 打开档案包/文件/借阅记录 → 处理 | 对象进入新状态,工作台计数和对应列表同步更新 | +| PAGE-07 | 当前身份全部授权项目,按最近更新时间倒序 | 筛选/排序/分页 → 查看、新建、导入或导出 | 详情路由可刷新;导出使用相同筛选、权限和脱敏口径 | +| PAGE-08 | 项目总览页签和项目摘要,保留来源列表返回路径 | 在十个页签查看/下钻 → 执行当前身份允许的业务入口 | 每个金额、状态、表单和附件能回到唯一来源;对象不存在显示业务 404 | +| PAGE-09 | 14 类模板按业务阶段分组,显示可用版本和本人数量 | 选模板 → 新建、导入、继续草稿或查看历史 | 进入 PAGE-10 并携带表单类型和已有单据 ID;无发起权时只读 | +| PAGE-10 | 新建空表单、已有草稿或导入校验批次三种模式之一 | 保存草稿 → 完整校验 → 预览 → 提交;导入为上传校验 → 确认入库 | 获得唯一业务编号和版本,状态进入 `SUBMITTED`;错误批次不产生正式数据 | +| PAGE-11 | 默认“待我处理”,按到达时间升序 | 打开任务 → 核对表单/金额/附件/历史 → 通过、退回或驳回 | 当前任务关闭并生成下一任务或终态;意见、退回点和审计记录可查 | +| PAGE-12 | 默认显示公司/项目等主数据页签及生效状态 | 新增/变更草稿 → 提交复核 → 另一人复核 → 生效或停用 | 新版本生效且历史单据仍引用原版本;同人复核被拒绝 | +| PAGE-13 | 合同执行台账,默认授权范围、最近更新排序 | 筛选合同/项目 → 查看额度与成本 → 下钻或导出 | 汇总值与来源明细一致,合同可付额由后端重算 | +| PAGE-14 | 默认进入“收款记录”页签,按最近更新倒序加载当前范围数据,可切换“开票申请” | 收款新建/编辑 → 异人确认 → 分配/作废;或开票新建/编辑 → 提交 → 异人审核 → 登记人工结果/作废/红冲 | 形成可追溯收款、应收分配或发票结果并重读服务端详情;不调用外部发票平台 | +| PAGE-15 | 项目身份进入“我的申请”,财务身份进入“待财务检查” | 申请 → 检查额度/账户/附件/重复 → 审批 → 登记线下结果 → 另一人复核 | 状态到 `PAID`、`FAILED` 或 `REFUNDED`,回单和人员分工完整;不产生银行指令 | +| PAGE-16 | 默认“待生成”,显示已确认财务事件 | 生成草稿 → 编辑分录 → 另一人复核 → 导出 → 登记并复核人工记账结果;结果有误时由异人冲销 → 再由异人重开 → 登记新一轮结果 | 借贷平衡、导出批次/哈希、当前结果和分轮历史完整;旧凭证号及旧证据可追溯且不与新轮混合 | +| PAGE-17 | 项目人员显示财务确认状态和待生成/待提交,财务显示待检查,资料人员显示待审核 | 财务确认完整 → 用户主动生成包 → 缺件检查 → 提交 → 资料异人审核/退回 → 归档 | 未确认时生成被拒绝;成功生成不可静默覆盖的归档版本;已归档包只允许受控后续动作 | +| PAGE-18 | 空查询条件,不自动加载完整敏感文件全集 | 输入条件 → 查询 → 预览/下载或发起借阅 → 审批/归还 | 每次敏感访问带水印和审计;无权对象不出现在结果中 | +| PAGE-19 | 默认项目综合台账和当前月份/授权范围 | 筛选 → 汇总 → 点击金额下钻 → 导出 | 汇总、明细和导出同一口径;每个数字可到来源对象 | +| PAGE-20 | 默认最近 24 小时授权范围内审计记录 | 按用户/对象/动作/请求编号查询 → 查看前后值或导出 | 可用请求编号还原完整动作链;前后值按敏感规则脱敏 | +| PAGE-21 | 默认用户管理页签,只向系统管理员开放 | 编辑草稿 → 校验冲突 → 保存/发布新配置版本 | 新版本有生效时间和发布人;权限变更使受影响旧会话立即失效 | +| PAGE-22 | 当前版本、最新版本、更新状态和最近更新记录 | 获取更新 → 自动下载验签 → 立即重启 → 等待健康检查 | 健康检查成功后倒计时 10 秒自动刷新;失败状态和审计历史可查询 | + +所有列表默认每页 20 条;默认排序已在上表写明,未单独写明时使用 `updatedAt,desc`。无功能权限的动作隐藏;有功能权限但当前状态、资料或岗位互斥不允许的动作禁用并说明原因。任何写操作成功后都必须重新读取服务端对象和允许动作,不能只修改前端状态;失败时保留尚未提交成功的用户输入。 + +### 8.26 列表查询、列和动作白名单 + +列表查询条件之间默认使用 AND;同一个多选条件内部使用 OR。空字符串、空数组和未传参数都表示“不限制”,但 `false`、`0` 和明确的空值筛选不能被后端当成未传。日期范围含首尾日期,页面按北京时间选择,后端换算为 UTC 半开区间。URL 只保存下表允许的非敏感条件;身份证、手机号、银行账号、工资和自由审批意见不得写入 URL。 + +| 页面/默认视图 | 允许筛选参数 | 允许排序字段 | 固定主列与动作 | +| --- | --- | --- | --- | +| PAGE-04 项目工作台 | `owner=current`、`taskStatus`、`projectStage`、`issueType` | 卡片明细仅 `dueAt/updatedAt` | 指标、待办、项目、异常;点击只跳 `/projects`、`/forms`、`/tasks` 的既有路由并携带白名单 query | +| PAGE-05 财务工作台 | `taskType`、`blockingCode`、`accountingStatus`、`resultStatus`、`month` | `dueAt/amount/updatedAt` | 财务待办、阻断异常、待记账、待登记结果、项目资金;动作进入 PAGE-11/13/14/15/16 | +| PAGE-06 资料工作台 | `packageStatus`、`missingType`、`borrowStatus`、`dueBefore` | `dueAt/updatedAt` | 待归档、缺件、借阅到期、近期下载;动作进入 PAGE-17/18 | +| PAGE-07 项目列表 | `companyId`、`keyword`、`ownerId`、`customerId`、`stage`、`financeStatus`、`archiveStatus`、`startDateFrom/To`、`endDateFrom/To` | `updatedAt/businessNo/name/contractAmount/status` | 项目编号、名称、公司、负责人、合同额、已收、已付、阶段、待办数、资料完整率、状态;新建、导入、详情、导出 | +| PAGE-09 表单中心 | `groupCode`、`formType`、`templateVersion`、`sourceSystem`、`status`、`createdByMe`、`keyword`、`createdDateFrom/To` | `updatedAt/createdAt/businessNo/status` | 表单名称、来源、项目、版本、状态、当前节点、更新时间;新建、导入、继续草稿、查看历史 | +| PAGE-11 待办审批 | `view=todo/processed/created/returned/completed`、`formType`、`projectId`、`status`、`arrivedDateFrom/To`、`overdue` | `arrivedAt/dueAt/updatedAt` | 任务、表单号、项目、申请人、关键金额、当前节点、到达/到期时间;查看、通过、退回、驳回,其他动作按状态进入“更多” | +| PAGE-12 主数据 | `resource`、`companyId`、`keyword`、`status`、`versionStatus` | `businessNo/name/status/updatedAt` | 各资源固定编号/名称/所属/版本/状态/更新人;新增、编辑草稿、提交复核、版本对比、生效、停用 | +| PAGE-13 合同与成本 | `companyId`、`projectId`、`counterpartyId`、`contractKeyword`、`contractStatus`、`costCategory`、`businessDateFrom/To` | `updatedAt/originalAmount/settlementAmount/pendingPayableAmount/payableAmount/paidAmount` | 11.6 节合同金额列、已开票/待确认应付/已应付/已付/可付余额;查看、下钻、导出 | +| PAGE-14 收款与发票 | 页面页签 `receipts/invoices`;接口参数 `companyId`、`projectId`、`counterpartyId`、`keyword`、`status`、`dateFrom/To`、`page`、`size` | 收款:`updatedAt/receiptDate`;发票:`updatedAt/requestedDate/amount` | 收款/开票业务号、公司、项目、客户/对象、金额、分配或发票号、状态、日期;新建/编辑、确认/分配、审核、登记结果、红冲/作废、导出 | +| PAGE-15 进度款与付款 | `tab=my/finance-check/offline/result/paid/failed`、`companyId`、`projectId`、`supplierId`、`status`、`riskCode`、`submittedDateFrom/To` | `submittedAt/amount/dueAt/updatedAt` | 付款号、项目、合同、供应商、申请额、可付额、账户版本、阻断数、状态、申请时间;申请、检查、审核、登记/复核线下结果、退汇/重付 | +| PAGE-16 记账准备 | `tab=pending/draft/review/exported/result/completed/reversed/void`、`period`、`projectId`、`eventType`、`status`、`externalVoucherNo` | `period/businessDate/amount/updatedAt` | 事件/凭证号、来源、项目、期间、借/贷合计、状态、导出批次、外部凭证号、当前结果轮次;生成、编辑、复核、导出、登记/复核结果、冲销、重开、历史证据下载、作废 | +| PAGE-17 项目归档 | `view=prepare/check/returned/archived`、`companyId`、`projectId`、`status`、`completeness`、`frozen` | `updatedAt/completeness/archivedAt` | 项目、档案包版本、完整率、缺件数、状态、保管到期日、保管状态、提交/审核人;财务确认后生成、检查、提交、退回、补件、归档、冻结/解冻 | +| PAGE-18 文件检索与借阅 | `companyId`、`projectId`、`contractId`、`counterpartyId`、`formType`、`fileName`、`uploadedBy`、`uploadedDateFrom/To`、`originalType`、`archiveStatus`、`scanStatus` | `uploadedAt/displayName/sizeBytes` | 首次进入不查询;至少填写一个有效条件后返回文件名、类型、项目/对象、版本、大小、上传人、扫描/归档/借阅状态;预览、下载、版本、借阅、归还 | +| PAGE-19 查询与报表 | `reportCode` 加该报表冻结的公司/项目/对象/状态/日期条件;默认当前月 | 每个报表只开放其列代码,最终补业务编号 | 六类报表列以 D-08 为准;汇总、下钻、导出必须使用同一 filter hash 和口径版本 | +| PAGE-20 审计日志 | `occurredFrom/To`(默认近 24 小时)、`actorId`、`identityCode`、`objectType`、`objectId`、`action`、`requestId`、`result` | `occurredAt`(默认倒序)、`eventSequence` | 时间、用户、身份、范围摘要、对象、动作、结果、原因、请求编号;查看脱敏前后值、导出;没有修改/删除 | +| PAGE-21 权限与配置 | `resource`、`keyword`、`status`、`versionStatus`、`effectiveDate` | `name/code/status/updatedAt/effectiveAt` | 用户/角色/范围/模板/参数的代码、名称、版本、状态、生效时间、发布人;新增、编辑、启停、发布,按资源动作白名单执行 | +| PAGE-22 系统更新 | 固定读取当前状态与 `objectType=SYSTEM_UPDATE` 历史 | 历史按 `occurredAt,desc` | 当前/最新版本、状态、发布说明、操作时间、目标版本、动作、操作人和结果;获取更新、立即重启 | + +列代码、筛选参数、排序字段和导出字段在 OpenAPI 中逐项枚举,前端不得把任意对象属性透传为查询字段。金额汇总由后端在同一筛选下返回,前端只格式化显示;当前页合计与全量合计必须使用不同标签。每个工作台快捷入口都要有 Playwright 断言,证明目标路由、初始筛选、返回恢复和数据数量一致。 + +## 9. 14 个表单怎么落地 + +### 9.1 统一表单骨架 + +每张表单都由五部分组成,不把 OA 原样截图当成唯一数据: + +1. **来源信息**:来源系统、来源单号、来源版本、导入批次、原始文件哈希。 +2. **业务信息**:公司、项目、合同、往来单位、日期、金额和业务字段。 +3. **附件信息**:附件名称、类型、是否必需、版本、哈希和“不涉及”说明。 +4. **审批信息**:节点、人员、意见、时间、动作和前后状态。 +5. **财务结果**:财务确认、财务事件号、记账准备号、归档状态。 + +### 9.2 表单分工和输出 + +| 编号 | 表单 | 主要发起角色 | 财务处理角色 | 首期结构化输出 | 首期完成标志 | +| --- | --- | --- | --- | --- | --- | +| OA-01 | 项目立项审批表 | 项目管理 | 财务管理审核预算/付款约定 | 项目主数据、预算基线、项目事件 | 项目生效且审批快照完整 | +| OA-02 | 供应商准入申请 | 项目管理 | 财务管理审核税号、账户和资质 | 往来单位、账户待生效版本 | 供应商和账户分别复核 | +| OA-03 | 项目成本合同会签审批单 | 项目管理 | 财务管理审核合同额、清单和额度 | 成本合同、合同额度控制 | 合同生效、变更可追溯 | +| OA-04 | 人材机合同应付资金确认单 | 项目管理 | 财务管理审核应付、发票和附件 | 财务终审直接生成 `CONFIRMED` 应付、合同额度占用和 `PAYABLE` 会计事件 | 应付确认且附件齐全 | +| OA-05 | 项目收款确认单 | 项目管理 | 财务管理确认收款和应收核销 | 财务终审直接生成 `CONFIRMED` 收款、项目资金台账和 `RECEIPT` 会计事件;后续再做应收核销 | 收款证据和审核记录齐全 | +| OA-06 | 项目进度款支付 | 项目管理 | 财务管理检查额度、附件和付款 | 付款申请、冻结金额、付款任务 | 通过财务检查并待线下处理 | +| OA-07 | 银行账户开户审批及账户信息表 | 项目/财务人员 | 财务管理复核账户资料 | 公司银行账户待生效版本 | 独立复核后可供新业务选择 | +| OA-08 | 发票开具申请表 | 项目管理 | 财务管理审核合同额度和开票条件 | 带来源版本的 `DRAFT` 销项开票候选 | 人工开票结果登记或退回 | +| OA-09 | 农民工班组实名入场 | 项目管理 | 财务管理检查付款附件引用 | 人员名册版本、工资资料来源 | 资料归档,人员字段受限访问 | +| OA-10 | 项目履约进度申报表 | 项目管理 | 财务管理复核产值/成本依据 | 进度版本、收入/成本建议事件 | 进度审核完成、照片归档 | +| OA-11 | 项目成本合同结算审批单 | 项目管理 | 财务管理确认结算和付款上限 | 合同结算版本、付款上限 | 结算附件和审核意见齐全 | +| OA-12 | 项目验收信息登记表 | 项目管理 | 财务管理查看关闭前置项 | 验收事件、归档触发信号 | 验收结论和附件完成 | +| OA-13 | 项目押证费用收取明细表 | 项目管理 | 财务管理审核费用口径 | 扣费/应收候选事件 | 作为资料和费用依据归档 | +| OA-14 | 中标项目人员信息表 | 项目管理 | 财务管理审核与工资/押证资料关联 | 项目人员名册版本 | 新增/变更/驻派记录可追溯 | + +### 9.3 14 个表单的关键字段 + +下表是对 14 张原始 OA 截图逐张复核后的**源字段清单**。它说明源资料中有什么,不表示截图上的每一格都必填;必填条件、下拉值、长度、金额精度和审批路线必须按 9.3.1 节冻结后才能开发对应正式模板。 + +| 编号 | 源图可见的基本字段 | 重复明细、附件和审批信息 | +| --- | --- | --- | +| OA-01 项目立项 | 项目所属公司、申请人/部门、中标时间、项目名称/编号、建设单位、项目地址、建设单位联系人/联系方式、中标金额、项目归属、代理公司/联系方式、是否 EPC、联合体单位 | 中标人员:岗位、姓名、类别、电话、投标社保要求时段、证件类型、是否上岗;设计:中标设计负责人、设计费、设计资质、联系人/电话;施工:合同名称、对方、金额、资金来源、工程内容、监理方/总监、开竣工日、工期、工程/合同类别、签订/归档时间、合同文本、工程资料;成本:原额/增量、预算总成本、人工/材料/机械/安全文明/间接/税金/不可预见/暂列/暂估/其他成本;财务:付款约定、建设方账号、结算方式、付款描述/比率/金额;审批意见 | +| OA-02 供应商准入 | 公司名称、供应商名称、联系人、电话、产品、税号、采购额、营业执照、通用附件、供应商类型、纳税人资格类型、诚信资料、账期、是否开通银行承兑、是否主材 | 账户明细:户名、开户行、账号、准入原因;资质明细:资质、证件号码、有效期、附件;审批意见 | +| OA-03 成本合同会签 | 公司、是否集采/研发、申请人/部门/时间、项目编号/名称、系统编号、成本合同名称/编号/金额/占比、合同文本及台账、标的物简写、合同类型/子类、供应商、户名、开户行、账号、账龄截止日期 | 资质附件:营业执照、资质证书、安全生产许可;项目实时成本:目标合同金额、累计签订/支付/收款/扣费、项目余额;量价审核:数量、单价、成本计划、预计超清单、超清单总额、核查情况/审核意见/备注;各节点审批意见 | +| OA-04 人材机应付确认 | 公司、单据号、填报人/部门/时间、项目编号/名称、成本合同编号/名称/金额/类型/子类、供应商、合同附件/备注、累计应付/已付/已开票、发票文件、含税/不含税金额、税额/税率、发票类型/号码/日期、实际供货时间、本次费用成本类型、本次应付金额、账套、借贷方科目、往来单位 | 按材料、机械、劳务、业务、运输、临时用工、专业分包、设备采购等成本类型显示条件附件,详见 9.3.2;审批节点和财务意见 | +| OA-05 项目收款确认 | 到款公司、项目名称/编号、工程类型/地址、业主单位、收款方式、开票申请流程、开票金额、结算方式、本次收款金额、收款账户/账号、收款内容描述 | 收款证据附件、填表部门/人员/时间;结算会计、财务负责人、董事长审核意见 | +| OA-06 项目进度款支付 | 公司、申请人/部门/时间、涉诉纠纷、项目编号/名称、合同金额、收款/支付累计、可用资金、合同预计/累计支付、可支付余额、材料/机械/专业分包/人工/其他计划占比与实际占比、是否分包付款、发票附件是否已入账、回款预测附件 | 支付资料:成本合同编号、标的物、总价、累计应付/已付、已开发票、累计欠付、本次申请/大写金额、供应商、合同类型/子类、附件矩阵;收款账户户名/开户行/账号、其他支付事由/对象/类型/附件;支付明细:日期、方式、公司付款账号、金额;账套、借贷科目、往来单位;审批检查和意见 | +| OA-07 银行账户开户 | 公司、申请人/部门/时间、开户事由、对账周期、户名、开户行、账号、账户类别、项目编号/名称、开户时间 | 开户资料是否齐全:总公司营业执照正/副本、法人身份证原件、基本户开户许可证、公司公章/法人私章、股东身份证复印件;预留印鉴附件;审批意见。公司附件按原表要求打码后上传;本表只管理内部账户资料,不连接网银或 U 盾 | +| OA-08 发票开具申请 | 公司、申请人/日期、项目名称/编号/地址、总承包合同金额、已开票金额、设计费总额/已开票金额、是否设计费、本次开票所属结算方式、客户全称、预计到账时间、联系电话 | 开票抬头、纳税识别号、注册地址/电话、开户行/账号、开票金额、税率、开票内容、进度表/施工合同关键页附件、发票类型;普通/专票条件必填和审批意见 | +| OA-09 农民工实名入场 | 填报人/部门/时间、项目编号/名称、班组名称/负责人/联系方式 | 人员明细:姓名、身份证号、联系方式、合同薪酬标准(月度)、工种、进场时间、紧急联系方式、工资卡开户行、工资卡账号、劳动合同、身份证;审批意见 | +| OA-10 履约进度 | 公司、填报人/部门/时间、项目编号/名称、施工单位、业主单位、合同额/增量、预算总成本、人工/材料/机械/安全文明/间接/其他成本、合同工期/日历天数、实际开竣工日、交底完成情况 | 报告年/季度、本期及累计完成产值、本期及累计投入成本、本期应确认增值税收入、完成进度、剩余工期、进度概述、业主审批附件;累计成本分类;4 张项目形象照片;任务明细:名称、开始/完成日、进度、文件、负责人;审批意见 | +| OA-11 成本合同结算 | 公司、填制人/部门/时间、项目编号/名称、成本合同编号/名称、供应商、合同金额、结算金额、累计已开票、累计已付款 | 合同结算单/结算单/合同扫描件/发票扫描件/现场照片;质安、成本、财务、工程负责人和盖章情况 | +| OA-12 项目验收 | 公司、填制人/部门/时间、项目编号/名称 | 验收明细:阶段、时间、结论、附件、备注;资料室、工程负责人、董事长助理、档案室意见 | +| OA-13 押证费用 | 公司、申请人/部门/时间、项目名称/编号、收费时间 | 费用明细按建造师、技术负责人、施工员、质量员、安全员、资料员、标准员记录姓名、人员归属、证件开始时间、收费标准、本月收费金额;公司/办事处每月应收费用;审批意见 | +| OA-14 中标人员 | 公司、项目名称、是否办理施工许可证、经营负责人姓名/电话、项目负责人姓名/电话、中标金额、合同工期、中标公示日、合同竣工日、实际验收日 | 证件人员新增:岗位、姓名、类别、电话、地址、证件类型、备注、是否上网/上岗;人员变更:岗位、姓名、电话、原因、日期、备注、是否上网/上岗;驻派人员:姓名、电话、任命/调离时间、工资、备注;资料、工程、结算会计和结算出纳意见 | + +#### 9.3.1 字段元数据冻结规则 + +每个字段在本文件冻结记录中必须具备:稳定字段代码、中文名称、所属表单/分组、数据类型、最大长度、是否多行、必填条件 `requiredWhen`、默认值、枚举代码和值、金额/比例精度、单位、导入列名、重复行规则、跨字段校验、敏感等级、脱敏方式、附件关系、下游对象映射和错误文案。源图没有明确给出的内容不得由开发人员猜测;统一进入 D-02,由甲方在原型和样本上确认。 + +公共字段由系统统一提供,不要求 14 个模板重复配置:`documentNo`、`formType`、`templateVersion`、`sourceSystem`、`sourceNo`、`sourceVersion`、`companyId`、`projectId`、`status`、`version`、创建/修改人和时间。人员、账户、合同、供应商和项目必须优先引用生效主数据;确需保留来源原文时,同时保存主数据 ID 和只读来源快照。 + +字段代码一经正式模板发布不得改义或复用。更名只修改显示名称;类型、精度、必填条件或下游含义变化必须发布新模板版本。导入模板的列头、在线表单字段和 OpenAPI 字段都映射到同一字段代码,错误必须定位到表单、分组、行号、列名和字段代码。 + +#### 9.3.2 OA-04/OA-06 条件附件矩阵基线 + +现有需求整理稿显示,同一附件可能在不同成本类型下重复出现,系统必须做成“成本类型 → 附件类型 → 必填条件”的版本化矩阵,不能照截图硬编码一张超长表。以下是整理稿中的首版附件集合,待原图重新归档后复核;D-02/D-09 还需要确认哪些必需、哪些允许“不涉及”: + +| 成本类型 | 首版条件附件集合 | +| --- | --- | +| 材料 | 材料发票、送货单、结算单、入库单、出库单、领料单、现场验收照片、材料检验检测报告/合格证;进口/经销场景增加贸易商进货证明、个人经营所得完税证明、资源税完税或免税/完税证明 | +| 建筑/机械租赁或服务 | 建筑服务/机械租赁发票、机械设备进出场验收表、机械结算表、机械费用结算单、机械设备权属证明或租赁服务进项证明、机械设备现场照片、个人经营所得完税证明 | +| 劳务 | 劳务发票、考勤表、工资表(签字加按手印)、个人所得税申报表及完税证明、人工成本确认单、工资发放记录、质安审批核签表 | +| 业务服务 | 业务发票、结算单、业务报告 | +| 运输 | 运输服务发票、运输结算单、运输车辆权属证明、运输资质、运输现场照片 | +| 临时用工 | 临时用工花名册、临时用工协议(签字加按手印)、项目日报表、考勤表、个人所得税申报表、质安审批核签表 | +| 专业分包 | 工程服务发票、专业分包工程量清单、专业分包进度款审批单、专业分包结算单/验收单、专业分包验收报告、专业分包结算报告 | +| 设备采购 | 设备采购发票、发货单、送货单、结算单、现场验收设备照片、设备检验检测报告/合格证、设备安装照片;经销场景增加特许经销证明 | + +附件矩阵每次发布保存版本号和生效日期,单据保存提交时使用的矩阵快照。选择“不涉及”必须填写原因并由财务复核;已通过单据更换正式附件时生成新版本并重新进入受影响节点。 + +### 9.4 来源表单状态 + +以下状态码只属于“来源表单”状态域,不与付款、凭证、档案或项目管理 ToDo 状态混用: + +```text +DRAFT 草稿 +SUBMITTED 已提交 +PROJECT_REVIEW 项目审核中 +FINANCE_REVIEW 财务审核中 +RETURNED 退回补件 +REJECTED 驳回 +APPROVED 已通过 +RESULT_PENDING 待登记结果 +ARCHIVE_PENDING 待归档 +ARCHIVED 已归档 +VOID 已作废 +``` + +只有当前节点的角色可以执行动作;“通过”不等于“已付款”或“已记账”。 + +### 9.5 关键业务对象状态和允许动作 + +表单、付款、发票、凭证和档案分别保存自己的状态,不能用一个字段表示全部进度。比如 OA-06 已通过,只能说明付款申请审核完成,不能直接把付款结果改成“已支付”。 + +| 业务对象 | 状态主线 | 关键动作和规则 | +| --- | --- | --- | +| 主数据 | `DRAFT → REVIEWING → ACTIVE → DISABLED`;退回为 `RETURNED` | 新增/变更由另一名授权人员复核;停用后只影响新业务 | +| 来源表单 | `DRAFT → SUBMITTED → PROJECT_REVIEW → FINANCE_REVIEW → APPROVED → RESULT_PENDING → ARCHIVE_PENDING → ARCHIVED`;分支为 `RETURNED/REJECTED/VOID` | 可退回后重提;驳回、作废为终止状态;审批通过后生成下游对象 | +| 财务事件 | `PENDING_CONFIRM → CONFIRMED → ACCOUNTING_PENDING → COMPLETED`;终止为 `VOID` | 只能从已批准来源生成;错误通过作废/更正事件处理 | +| 收款记录 | `PENDING_CONFIRM → CONFIRMED → PARTIALLY_ALLOCATED → ALLOCATED`;终止为 `VOID` | 暂收款允许待分配;核销不得超过确认收款金额 | +| 开票申请 | 新命令主线为 `DRAFT/RETURNED → REVIEWING → RESULT_PENDING → ISSUED`;分支为 `RETURNED/REJECTED/VOID/RED_LETTERED`;`APPROVED` 仅保留为既有数据兼容状态 | 复核通过直接进入待登记人工结果;作废/红冲记录原因,红字票必须关联原发票 | +| 付款申请 | `DRAFT → REVIEWING → APPROVED → OFFLINE_PENDING → RESULT_RECORDED → RESULT_VERIFIED → PAID`;分支为 `RETURNED/REJECTED/FAILED/REFUNDED/VOID` | 失败或退汇进入对应分支,重付必须创建并关联新记录 | +| 凭证草稿 | `DRAFT → REVIEWING → APPROVED → EXPORTED → RESULT_RECORDED → COMPLETED`;分支为 `RETURNED/VOID` | 制单与复核分离;作废后可按规则重新生成一张有效草稿 | +| 项目财务状态 | `PENDING/IN_PROGRESS/BLOCKED → COMPLETED` | 仅财务管理人员在授权范围内执行确认;项目状态须为 `DRAFT/ACTIVE/SUSPENDED/CLOSED`;成功后作为生成档案包前置条件 | +| 档案包 | `PREPARING → CHECKED → REVIEWING → ARCHIVED`;分支为 `RETURNED/FROZEN` | 退回后补件形成新版本;已归档只允许借阅、版本查看和受控下载;冻结时禁止到期处置 | + +所有状态迁移都通过后端命令完成,并校验当前状态、角色、数据范围和版本号。前端只展示允许的按钮,不允许提交任意目标状态。一次动作必须同时写入业务状态、流程任务和审计记录;任一步失败时整笔事务回滚。 + +状态码按对象分域保存;同名状态只表示相近语义,不表示可以跨对象直接复制迁移规则。第 12.8 节 `BACKLOG/READY/IN_PROGRESS/REVIEW/TESTING/ACCEPTED/BLOCKED` 是项目任务状态,不得写入业务表。每个对象的全部合法状态、中文显示、允许来源状态、目标状态、命令、权限码和错误码必须在 `TODO-S0-023` 中冻结并生成状态机测试。 + +### 9.6 关键状态、按钮和处理人矩阵 + +| 对象/当前状态 | 有权处理人 | 页面按钮和前置条件 | 成功结果 | +| --- | --- | --- | --- | +| 来源表单 `DRAFT` | 创建人或明确代录人 | 保存、删除草稿、校验、预览、提交;提交前完整校验并二次确认 | `SUBMITTED`,创建流程实例和首个任务 | +| 来源表单审核中 | 当前任务处理人 | 通过、退回补件、驳回;三者都要意见,退回必须选字段/附件;不提供批量审批金额单据 | 关闭当前任务并进入下一节点、`RETURNED` 或 `REJECTED` | +| 来源表单 `RETURNED` | 原创建人/代录人 | 编辑被退回项、补附件、重新校验、重提或作废 | 新版本重提并保留旧版本和退回意见,或进入 `VOID` | +| 来源表单已提交但首节点未处理 | 发起人 | 撤回,二次确认并填写原因 | 回到可编辑草稿/新版本,原任务关闭并留痕 | +| 主数据 `DRAFT/REVIEWING` | 提交人/另一名复核人 | 提交复核;复核人通过或退回,同人按钮禁用并解释岗位冲突 | 新版本 `ACTIVE` 或退回草稿 | +| 收款 `PENDING_CONFIRM/CONFIRMED` | 收款经办/财务复核 | 确认收款、建立分配;金额和证据齐全,分配总额不超确认金额 | `PARTIALLY_ALLOCATED` 或 `ALLOCATED`,生成财务事件 | +| 开票 `REVIEWING/RESULT_PENDING/ISSUED` | 财务审核/开票结果登记人 | 审核、退回;批准后登记人工开票结果;作废/红冲从当前原票发起并填写原因,可关联已通过检查的结果文件 | `ISSUED`、`VOID`,或原票 `RED_LETTERED` 并生成关联红字票 | +| 付款 `REVIEWING/APPROVED` | 当前审批人/线下付款经办 | 审核或退回;批准后只能进入待线下处理,额度、账户、附件、重复和 SOD 全部通过 | `OFFLINE_PENDING`,冻结金额但不发银行指令 | +| 付款 `OFFLINE_PENDING/RESULT_RECORDED` | 结果登记人/另一名复核人 | 登记成功、失败或退汇及回单;复核人不得与登记人相同 | `PAID`、`FAILED` 或 `REFUNDED`,释放/结转冻结并生成对应事件 | +| 凭证 `DRAFT/REVIEWING` | 记账会计/另一名复核人 | 编辑分录并填修改原因、提交复核、批准;借贷不平或维度缺失时禁用批准 | `APPROVED`,允许生成一个导出批次 | +| 凭证 `EXPORTED/RESULT_RECORDED` | 结果登记人/另一名复核人 | 登记外部凭证号和结果、复核;关联导出批次和哈希 | `COMPLETED`,项目详情和档案可下钻查看 | +| 项目财务 `PENDING/IN_PROGRESS/BLOCKED` | 授权财务管理人员 | PAGE-08 显示“确认财务完整”;服务端校验权限、范围、项目/财务状态和版本 | `COMPLETED`,写审计并允许进入 PAGE-17 生成档案包 | +| 档案 `PREPARING/CHECKED/REVIEWING` | 已完成财务确认的项目范围内,财务提交人/资料审核人 | 生成前要求 `financeStatus=COMPLETED`;检查后无缺件才提交;资料人员异人退回或归档,退回必须列缺件 | 新版本 `RETURNED` 或 `ARCHIVED` | +| 借阅申请待审 | 借阅申请人/资料批准人 | 申请填写用途和归还日;批准/拒绝;归还登记需确认文件状态 | 借阅、拒绝或已归还记录,全部写审计 | + +无功能权限的按钮隐藏;有功能权限但当前状态、资料、金额或岗位互斥不满足时按钮禁用并显示原因。只有低风险、同类且不含金额判断的动作才允许批量处理;R1 的审批、付款结果、凭证复核、敏感下载和归档确认默认逐笔处理。 + +### 9.7 岗位职责资料如何进入 R1 + +岗位职责资料已经对原始压缩包中的 12 份 Excel 完成名称和文本复核,用于识别真实分工、风险点和后续扩展;这不代表 12 份岗位材料中的全部日常工作都必须在第一轮实现。会议原文明确“先做最基础的数据来源、审批、记账准备、存档和查询”,因此按下表取舍;未列为“R1 实现”的职责不得自行扩入第一轮。 + +| 岗位资料中的职责 | R1 处理级别 | 第一轮系统行为 | 明确不做 | +| --- | --- | --- | --- | +| 项目经办、财务审核、资料归档三类分工 | R1 实现 | 以三类身份工作台进入,再用岗位模板、数据范围、金额阈值和流程节点细分权限 | 不为每份岗位职责另建一套首页或平行系统 | +| 成本/应付审核、收款/开票经办、付款结果登记与复核、制单与凭证复核 | R1 实现 | 按 4.4、9.6 节落实同人禁止和服务端权限;财务身份内部仍按岗位模板授权 | 不把“财务管理人员”做成万能角色 | +| 项目可用资金、合同可付额、附件、重复、涉诉/罚款风险 | R1 实现 | 涉诉和未结罚款作为项目人工风险标记,由授权人员录入证据和有效期;付款检查命中有效阻断标记时返回稳定错误码 | 不连接法院、银行或其他外部平台自动查询风险 | +| 主办出纳准备资料、结算出纳线下处理、另一人复核 | R1 只实现内部留痕 | 系统保存付款资料准备人、线下结果登记人和结果复核人;后两者不得相同,实际付款发生在系统外 | 不设置 `payment:execute`,不在系统内下发、确认或重放银行指令 | +| 公司/供应商账户、专户和账户责任范围 | R1 实现基础主数据 | 保存账户类别、所属公司/项目、用途、脱敏账号和生效版本;历史单据保留使用时快照 | 不保存 U 盾、UK、网银密码、PIN、印鉴领取密码或银行登录材料,不做 U 盾清点台账 | +| OA-09 工资卡、薪酬标准等来源字段 | R1 仅作为受限来源资料 | 按模板保存、加密、脱敏、审批和归档,只有独立权限可查看 | 不开发工资计算、工资发放、社保、公积金、个税或自然人税务子系统 | +| 税金、报税、银企对账、发票验真 | 后续单独立项 | R1 只允许上传已经取得的人工结果/依据,并关联原单据 | 不登录税务/银行平台,不自动申报、缴税、对账或验真 | +| 借支计息、滚动预算、预算偏差、三流一致、月结调账和账实核对 | 后续评审 | 只保留来源附件、备注和扩展位;若未来纳入,单独补页面、状态、权限、公式和 UAT | 不在 R1 中暗含利息计提、预算管理或完整总账结账功能 | +| 到期催收、季度提醒、自动清点和无人值守处理 | 本期不自动执行 | 页面按当前日期计算并展示已有记录的到期/超期状态,用户进入页面后人工处理 | 不使用 Scheduler、Quartz、消息消费者或 RPA 自动执行业务 | + +R1 可以吸收岗位资料中的“内部控制”,但不能吸收已排除的“外部执行”。任何新增岗位规则先判断是否能落在现有 21 页、14 表单和 F-01~F-14;需要新子系统、第三方权限、自动任务或新增法定财务处理的,统一走后续变更,不改变第一轮完整交付边界。 + +原始岗位包中的《收集到财务岗位需求》含 OA、T+、网银、U 盾和 RPA 设想,以及不应进入需求文档、源码、样本或日志的外部系统访问信息。R1 只吸收其中“项目余额不足阻断付款、核对收款方和账户、保存回单、制单与复核分离”等内部业务规则;任何账号、密码、地址、证书、U 盾控制和自动登录/点击方案一律不复制、不实现,并由资料负责人限制原件访问。 + +## 10. 关键业务规则 + +### 10.1 数据不能重复 + +来源表单唯一键:`表单类型 + 来源系统 + 规范化来源单号 + 规范化来源版本`。 + +同一份资料重复上传时,系统要提示已有记录并给出链接,不能重复生成项目、应付、付款或凭证草稿。 + +### 10.2 正式数据不能直接覆盖 + +- 草稿可以编辑和删除。 +- 已提交数据修改后要增加版本。 +- 已通过数据修改关键字段时重新审核。 +- 已付款、已记账或已归档数据不能直接改金额或附件,只能作废、冲销、退回或创建新版本。 + +### 10.3 项目资金口径 + +第一版建议公式: + +```text +项目账面余额 = 已确认收款 - 已支付金额 - 已确认扣费 ± 已批准调整 +项目可用余额 = 项目账面余额 - 已冻结金额 - 已审批未支付金额 +``` + +通俗理解:已经批了但还没付的钱,也不能再拿去批第二次。 + +资金与付款模块验收前,财务负责人需要签字确认:哪些收款算“已确认”、哪些状态进入冻结、哪些业务允许负余额、例外由谁审批。 + +### 10.4 合同付款上限 + +- 结算前累计付款不能超过原合同额加已批准变更。 +- 结算后累计付款不能超过生效结算金额。 +- 本次可付金额不能超过项目可用余额、合同可付余额和应付余额中的最小值。 +- 无合同付款要用独立业务类型、附件清单和审批路线。 + +### 10.5 供应商账户 + +- 付款不能自由手填收款账号,只能选择已复核生效的账户版本。 +- 账户新增/变更提交人与复核人不能是同一人。 +- 历史付款继续显示当时使用的账户版本。 + +### 10.6 发票和附件 + +- 发票号、税号、金额、日期用于发现疑似重复,不自动判断真伪。 +- 材料、机械、劳务、运输、设备等成本类型对应不同附件清单。 +- 选择“不涉及”时必须填写原因,并由财务人员复核。 +- 已通过后替换附件会生成新版本并重新进入资料/财务审核。 + +### 10.7 付款岗位分离 + +同一笔付款至少要分开:申请、财务审核、线下结果登记、结果复核。结果登记人与复核人必须不同。 + +系统只记录线下付款结果,不发起银行操作。 + +### 10.8 记账准备 + +- 一个财务事件只能有一张有效凭证草稿,明确拆分的情况除外。 +- 借方合计必须等于贷方合计。 +- 修改系统建议的科目、金额或项目维度要填写原因。 +- 导出后保存导出批次、文件哈希和凭证范围。 +- 外部记账完成后由用户登记凭证号和结果,另一名财务人员复核。 + +### 10.9 附件和档案 + +每个文件保存文件名、类型、大小、SHA-256、上传人、上传时间、业务关系和版本。文件替换不覆盖旧文件;正式档案不能由普通页面物理删除。 + +### 10.10 敏感信息 + +身份证、手机号、工资和银行账号按字段加密和脱敏。只有在对应业务节点有权限的人能查看完整值;查看和导出都留审计记录。 + +### 10.11 例外审批不是通用绕过 + +余额暂时不足、无合同业务、非硬性附件缺失或超金额阈值等业务情况,可以按甲方确认的规则发起独立例外申请。例外记录必须包含:例外编号、触发规则、原单据、公司/项目、金额、原因、证据附件、申请人、审批人、决定、有效期和使用结果。 + +以下控制不允许通过例外放行:未授权访问、供应商账户未生效、同一人登记并复核付款、重复的有效来源/财务事件、借贷不平、恶意文件或审计记录写入失败。 + +例外被批准后只放行指定单据上的指定规则,并且只能使用一次;其他校验继续执行。例外被退回或驳回时,原业务保持阻断。系统管理员不提供“跳过全部检查”的按钮。 + +## 11. Java 技术实现方案 + +### 11.1 技术选型 + +| 层次 | 技术 | 通俗解释 | +| --- | --- | --- | +| 前端 | 官方 `Tencent/tdesign-vue-next-starter` | 直接使用 TDesign Vue Next 中后台 Starter,再按财务业务裁剪和改造 | +| 前端运行环境 | Node.js `>=22.12.0`、npm、`package-lock.json` | 与 Starter 当前要求一致,使用 `npm ci` 保证依赖可重复安装 | +| 后端语言/构建 | Eclipse Temurin/兼容发行版 Java 17 LTS、Maven Wrapper 3.9.16 | 发布字节码和生产运行时固定 Java 17;CI 使用 Java 17 与仓库内 Maven Wrapper | +| Web 框架 | Spring Boot 3.5.16、Spring MVC | 负责页面请求、接口、登录和业务逻辑;补丁版本变更必须跑全量回归 | +| 权限 | Spring Security、Spring Session JDBC | 负责本地账号、服务端会话、CSRF 和接口权限,不引入外部身份平台 | +| 数据访问 | MyBatis Spring Boot Starter 3.0.5 | SQL 写清楚,财务查询更方便审查;不同时引入 JPA 形成两套持久化方式 | +| 数据库 | MySQL 8.4.x LTS、Connector/J 由 Spring Boot BOM 锁定 | 保存结构化业务数据、状态和审计记录,staging 与 production 使用同一大版本 | +| 数据库升级 | Flyway Core + Flyway MySQL | 每次改表都有版本;正式建表前必须锁定一个明确支持 MySQL 8.4 的版本,启动出现“数据库版本未测试”警告即不通过基线评审 | +| 文件解析 | Apache POI、Commons CSV | 读取 Excel 和 CSV 模板 | +| 测试 | JUnit 5、MockMvc、Testcontainers、Vitest、Playwright | 自动验证业务规则、接口、真实数据库和浏览器主流程 | +| 架构检查 | ArchUnit、JaCoCo | 防止模块互相乱调,检查测试覆盖率 | + +### 11.2 TDesign Starter 前端实施规范 + +**前端硬性结论已经冻结**:第一轮不再重新选 UI 框架、不另画一套后台、不另建前端工程。前端开发的工作方式只有一种:在现有 `frontend/` TDesign Vue Next Starter 中增加或修改菜单和路由,在 `pages/` 增加财务页面,用 `tdesign-vue-next` 与 `tdesign-icons-vue-next` 组合业务组件,接通本文件定义的 Java API,再按页面合同自行联调、测试和修复到验收通过。“做成腾讯这个样子”在本项目中的准确含义是遵守 TDesign 的布局、组件、Design Token 和交互习惯,不是复制腾讯商标、产品名称或演示数据。 + +**生效时点**:本节是当前 R1 开发的唯一正式前端实现方式。PAGE-14 已按该技术方向完成模块级实现;后续任何路由、API、Store、权限或业务组件改动都必须继续接受本节合同和证据门禁,不得因开发已启动而降级。 + +#### 前端硬规范一页判定表 + +普通使用者不需要理解全部工程细节,只要按下表即可判断前端是否符合要求;任一“硬性结果”不满足,页面不得合并、不得演示为完成功能,也不得计入 R1 完成率。 + +| 判定项 | 硬性结果 | 一眼可见或可自动检查的证据 | +| --- | --- | --- | +| 唯一工程 | 只在现有 `frontend/` 内开发,不新增第二个前端、第二入口、第二套后台或 iframe 拼装 | 仓库只有一个生产 `package.json`、`vite.config.ts`、`src/main.ts` 和根挂载 | +| 唯一视觉体系 | 页面使用当前 TDesign Vue Next Starter 的 side 布局、间距、Token、信息密度和交互规则 | PAGE-03~21 共用同一侧栏、顶部栏、面包屑、页签和内容区 | +| 唯一控件体系 | 通用控件只用 `tdesign-vue-next`;图标使用 Starter 的 `t-icon` 或 `tdesign-icons-vue-next` 导出组件,两者都属于 TDesign 图标体系 | 依赖和源码扫描无第二 UI 库、自绘同类图标或复制私改的 TDesign 源码 | +| 业务组合组件 | 只有跨页面业务语义才建立 `components/business`,且内部仍只组合 TDesign 组件 | 每个新增组合组件有 `COMP-###`、用途、测试、可访问性和批准记录 | +| 菜单与页面 | 菜单、顺序、路由和 PAGE-01~21 只按 7.3、8.24~8.26 节实现 | 本地路由、菜单、`pageId`、权限码和 `routes.json` 一致,无演示菜单或死入口 | +| 真实功能 | 所有正式页面和按钮连接 Java 真实 API、MySQL、权限、状态和审计 | production Network 无 Mock/静态 JSON;刷新、重登后数据仍在;失败和越权被服务端拒绝 | +| 腾讯样式边界 | “腾讯样式”只指 TDesign 规范,不使用腾讯 Logo、名称、产品文案或远程素材 | D-11 前使用临时文字和 TDesign 默认主色;production 无腾讯商标和 Starter 演示品牌 | +| 财务后台形态 | 页面安静、紧凑、便于扫描和重复录入,不做营销首页 | 无 Hero、渐变背景、装饰插画、嵌套卡片、大标题宣传文案或无业务意义动效 | +| 状态与异常 | 每页完整处理加载、空态、无权、冲突、校验失败、网络失败和写入结果 | 401/403/404/409/422/500、重复点击、会话失效均有自动化用例和可理解反馈 | +| 交付质量 | 开发团队自行完成实现、联调、修复和回归,不把基础问题留给甲方 UAT | Vitest、Playwright、axe、production 构建、三档分辨率和 `FE-GATE` 全部通过 | + +#### 前端强制组件规则(不可变) + +以下规则是“使用 TDesign”在代码评审中的可执行定义,适用于 PAGE-01~PAGE-21 的正式页面: + +1. **业务页面不得直接替代 TDesign 控件**:不得用原生 `